// Copyright (c) 2026 Tigera, Inc. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"slices"
	"strings"

	"github.com/Masterminds/semver/v3"
	"github.com/sirupsen/logrus"
	"github.com/urfave/cli/v3"
)

const (
	// bundleDir is where 'operator-sdk generate bundle' leaves its output, and
	// where the rearranged, versioned bundle is written, relative to the
	// operator directory that --operator-dir names.
	bundleDir = "bundle"

	// bundleDockerfile is the Dockerfile that 'operator-sdk generate bundle'
	// writes, relative to the operator directory.
	bundleDockerfile = "bundle.Dockerfile"

	// csvName is the name of the ClusterServiceVersion within a bundle.
	csvName = "tigera-operator.clusterserviceversion.yaml"

	// operatorName is what the operator's Deployment, its container and its
	// relatedImages entry are all called.
	operatorName = "tigera-operator"

	// noPreviousVersion is the value PREV_VERSION takes when this version
	// replaces nothing.
	noPreviousVersion = "0.0.0"
)

// unwantedAnnotation matches the operator-sdk metrics and scorecard test
// annotations, which a certified bundle must not carry.
var unwantedAnnotation = regexp.MustCompile(`operators\.operatorframework\.io\.(metrics|test)\.`)

// openShiftVersionsAnnotation matches the supported-versions annotation that
// updateAnnotations writes, so that an existing one is replaced rather than
// duplicated.
var openShiftVersionsAnnotation = regexp.MustCompile(`^\s*com\.redhat\.openshift\.versions\s*:`)

// openShiftVersionsLabel matches the Dockerfile form of the same key, for the
// same reason.
var openShiftVersionsLabel = regexp.MustCompile(`^LABEL\s+com\.redhat\.openshift\.versions=`)

// bundleCopy matches the COPY directives that point at the pre-rearrange bundle
// layout, which are replaced with ones pointing at the versioned directories.
var bundleCopy = regexp.MustCompile(`^COPY bundle/(manifests|metadata)`)

var updateBundleCommand = &cli.Command{
	Name:  "update-bundle",
	Usage: "Apply the build-time values to a bundle generated by 'operator-sdk generate bundle' so that we can certify it",
	Description: "Everything static lives in config/manifests/bases/" + csvName + ", which operator-sdk reads as the base\n" +
		"for the generated CSV. Only values that cannot be known until build time belong here: image digests, the version,\n" +
		"what it replaces, the build timestamp, and the architectures the image was built for. If you find yourself adding\n" +
		"a constant, put it in the base instead. The capability level is the exception: it is the claim we are certified\n" +
		"against, so it is set here, behind --capabilities, rather than being editable in the base.",
	Flags: []cli.Flag{
		operatorDirFlag, versionFlag, prevVersionFlag, capabilitiesFlag, openShiftVersionsFlag,
		imageFlag, imageInspectFlag, manifestInspectFlag,
	},
	Action: func(ctx context.Context, c *cli.Command) error {
		version := c.String(versionFlag.Name)
		repository := c.String(imageFlag.Name)
		inspected, err := inspectImage(ctx, repository, fmt.Sprintf("%s:v%s", repository, version),
			c.String(imageInspectFlag.Name), c.String(manifestInspectFlag.Name))
		if err != nil {
			return err
		}
		return updateBundle(bundleConfig{
			operatorDir:       c.String(operatorDirFlag.Name),
			version:           version,
			prevVersion:       c.String(prevVersionFlag.Name),
			capabilities:      c.String(capabilitiesFlag.Name),
			openShiftVersions: c.String(openShiftVersionsFlag.Name),
		}, inspected)
	},
}

// image holds the build-time facts about the operator image that the CSV records.
type image struct {
	// digest is the digest-pinned reference to the operator image.
	digest string
	// created is the time the image was built.
	created string
	// architectures are the architectures the image was built for.
	architectures []string
}

// inspectImage asks docker about the operator image the bundle is being built
// for. The two inspections can be passed in instead, which is how the tests run
// without a registry, and how the command can be run by hand against an image
// docker cannot pull.
func inspectImage(ctx context.Context, repository, ref, imageInspect, manifestInspect string) (image, error) {
	// 'docker image inspect' reads the local copy, so that has to be pulled
	// first; 'docker manifest inspect' asks the registry itself.
	if imageInspect == "" {
		logrus.Infof("Pulling operator image %s", ref)
		if _, err := docker(ctx, "pull", ref); err != nil {
			return image{}, err
		}
		logrus.Infof("Getting operator image digest from %s", ref)
		out, err := docker(ctx, "image", "inspect", ref)
		if err != nil {
			return image{}, err
		}
		imageInspect = out
	}
	img, err := parseImageInspect(imageInspect, repository)
	if err != nil {
		return image{}, err
	}

	if manifestInspect == "" {
		logrus.Infof("Getting operator image architectures from %s", ref)
		out, err := docker(ctx, "manifest", "inspect", ref)
		if err != nil {
			return image{}, err
		}
		manifestInspect = out
	}
	img.architectures, err = parseArchitectures(manifestInspect)
	if err != nil {
		return image{}, err
	}
	return img, nil
}

// docker runs a docker command and returns its standard output. The commands
// run on the host rather than in the build container, which has no
// docker-in-docker.
func docker(ctx context.Context, args ...string) (string, error) {
	var stdout, stderr bytes.Buffer
	cmd := exec.CommandContext(ctx, "docker", args...)
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return "", fmt.Errorf("docker %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
	}
	return stdout.String(), nil
}

// bundleConfig is what update-bundle was asked to build.
type bundleConfig struct {
	operatorDir       string
	version           string
	prevVersion       string
	capabilities      string
	openShiftVersions string
}

func updateBundle(cfg bundleConfig, img image) error {
	bundleRoot := filepath.Join(cfg.operatorDir, bundleDir)
	versionDir := filepath.Join(bundleRoot, cfg.version)
	if err := rearrangeBundle(bundleRoot, versionDir); err != nil {
		return err
	}
	if err := updateCSV(filepath.Join(versionDir, "manifests", csvName), cfg.version, cfg.prevVersion, cfg.capabilities, img); err != nil {
		return err
	}
	if err := updateDockerfile(cfg.operatorDir, cfg.version, cfg.openShiftVersions); err != nil {
		return err
	}
	return updateAnnotations(filepath.Join(versionDir, "metadata", "annotations.yaml"), cfg.openShiftVersions)
}

// rearrangeBundle moves the manifests and metadata that operator-sdk generated
// into a directory named for the version they belong to.
func rearrangeBundle(bundleRoot, versionDir string) error {
	if err := os.RemoveAll(versionDir); err != nil {
		return fmt.Errorf("clearing %s: %w", versionDir, err)
	}
	if err := os.MkdirAll(versionDir, 0o755); err != nil {
		return fmt.Errorf("creating %s: %w", versionDir, err)
	}
	for _, name := range []string{"manifests", "metadata"} {
		src := filepath.Join(bundleRoot, name)
		if err := os.Rename(src, filepath.Join(versionDir, name)); err != nil {
			return fmt.Errorf("moving %s into %s: %w", src, versionDir, err)
		}
	}
	return nil
}

// csvUpdate is one build-time value written into the generated CSV.
type csvUpdate struct {
	path  []any
	value string
}

func updateCSV(path, version, prevVersion, capabilities string, img image) error {
	csv, err := loadDocument(path)
	if err != nil {
		return err
	}

	// Record the digest-pinned operator image and the time it was built, and set
	// the CSV to ignore previous versions when updating. The capability level is
	// forced rather than taken from the base so that every bundle we publish
	// makes the same claim unless --capabilities says otherwise.
	updates := []csvUpdate{
		{[]any{"metadata", "annotations", "capabilities"}, capabilities},
		{[]any{"metadata", "annotations", "containerImage"}, img.digest},
		{[]any{"metadata", "annotations", "createdAt"}, img.created},
		{[]any{"metadata", "annotations", "olm.skipRange"}, "<" + version},
	}

	// Add labels for each supported architecture, e.g.
	// operatorframework.io/arch.arm64: supported. At least amd64 is needed so
	// that we can use the SHA256 of the manifest image rather than the specific
	// SHA of the amd64 image.
	for _, arch := range img.architectures {
		updates = append(updates, csvUpdate{[]any{"metadata", "labels", "operatorframework.io/arch." + arch}, "supported"})
	}

	// The display name carries the release stream so that the channels are
	// distinguishable in OperatorHub, e.g. "Tigera Operator v3.34" for 3.34.0.
	updates = append(updates, csvUpdate{[]any{"spec", "displayName"}, "Tigera Operator v" + releaseStream(version)})

	if prevVersion != noPreviousVersion {
		updates = append(updates, csvUpdate{[]any{"spec", "replaces"}, "tigera-operator.v" + prevVersion})
	}

	// Pin the operator image by digest in the 'relatedImages' list that a
	// certified bundle must carry. The entry is found by name, since operator-sdk
	// lists other images there when the deployment has RELATED_IMAGE_ variables.
	relatedImages := []any{"spec", "relatedImages"}
	related, err := csv.findOrNext(relatedImages, "name", operatorName)
	if err != nil {
		return fmt.Errorf("updating %s: %w", path, err)
	}
	updates = append(updates,
		csvUpdate{append(slices.Clone(relatedImages), related, "name"), operatorName},
		csvUpdate{append(slices.Clone(relatedImages), related, "image"), img.digest},
	)

	for _, u := range updates {
		logrus.Debugf("Setting %s = %q", pathString(u.path), u.value)
		if err := csv.set(u.value, u.path...); err != nil {
			return fmt.Errorf("updating %s: %w", path, err)
		}
	}

	// Pin the same digest in the deployment spec that operator-sdk embedded in
	// the CSV from the staged deploy directory. That whole path is generated
	// rather than ours to create, so nothing along it is filled in: a missing
	// deployment would otherwise be written as one with no name and no selector,
	// and pinning the digest onto whichever container happens to come first
	// would install the wrong image. Both are found by name instead, and either
	// being absent fails the build.
	if err := pinOperatorImage(csv, img.digest); err != nil {
		return fmt.Errorf("updating %s: %w", path, err)
	}

	// Drop the permissions if operator-sdk left an empty one behind, since CSV
	// validation fails on that. Non-empty ones are the operator's grant in its own
	// namespace and stay.
	permissions := []any{"spec", "install", "spec", "permissions"}
	empty, err := csv.isEmpty(permissions...)
	if err != nil {
		return fmt.Errorf("updating %s: %w", path, err)
	}
	if empty {
		if err := csv.delete(permissions...); err != nil {
			return fmt.Errorf("updating %s: %w", path, err)
		}
	}

	logrus.Infof("Updated %s", path)
	return csv.save()
}

// pinOperatorImage rewrites the operator container's image in the deployment
// that the CSV installs.
func pinOperatorImage(csv *document, digest string) error {
	deployments := []any{"spec", "install", "spec", "deployments"}
	deployment, err := csv.find(deployments, "name", operatorName)
	if err != nil {
		return err
	}

	containers := append(slices.Clone(deployments), deployment, "spec", "template", "spec", "containers")
	container, err := csv.find(containers, "name", operatorName)
	if err != nil {
		return err
	}

	imagePath := append(slices.Clone(containers), container, "image")
	logrus.Debugf("Setting %s = %q", pathString(imagePath), digest)
	return csv.mustSet(digest, imagePath...)
}

// releaseStream returns the X.Y stream that a version belongs to. A prerelease
// such as 3.34.0-rc.1 belongs to 3.34 too.
func releaseStream(version string) string {
	v, err := semver.StrictNewVersion(version)
	if err != nil {
		return version
	}
	return fmt.Sprintf("%d.%d", v.Major(), v.Minor())
}

func updateDockerfile(operatorDir, version, openShiftVersions string) error {
	src := filepath.Join(operatorDir, bundleDockerfile)
	err := editLines(src, func(lines []string) []string {
		// The COPYs point at the layout before rearrangeBundle, so they are
		// replaced rather than edited. operator-sdk's comments are dropped too,
		// since they head sections that the edits leave empty or reorder.
		lines = filterLines(lines, func(line string) bool {
			return line == "" || strings.HasPrefix(line, "#") || unwantedAnnotation.MatchString(line) ||
				bundleCopy.MatchString(line) || openShiftVersionsLabel.MatchString(line)
		})
		return append(lines,
			fmt.Sprintf("LABEL com.redhat.openshift.versions=%q", openShiftVersions),
			"LABEL com.redhat.delivery.backport=true",
			"LABEL com.redhat.delivery.operator.bundle=true",
			fmt.Sprintf("COPY %s/manifests /manifests/", version),
			fmt.Sprintf("COPY %s/metadata /metadata/", version),
		)
	})
	if err != nil {
		return err
	}

	dst := filepath.Join(operatorDir, bundleDir, fmt.Sprintf("bundle-v%s.Dockerfile", version))
	if err := os.Rename(src, dst); err != nil {
		return fmt.Errorf("moving %s to %s: %w", src, dst, err)
	}
	logrus.Infof("Updated %s", dst)
	return nil
}

func updateAnnotations(path, openShiftVersions string) error {
	err := editLines(path, func(lines []string) []string {
		// Remove unneeded labels and empty lines. Any openshift.versions already
		// there goes too, so that re-running against the same file - or an
		// operator-sdk that starts emitting the key itself - leaves one copy of it
		// rather than a duplicate key.
		lines = filterLines(lines, func(line string) bool {
			return line == "" || unwantedAnnotation.MatchString(line) || openShiftVersionsAnnotation.MatchString(line)
		})
		// Add required com.redhat.openshift.versions.
		return append(lines, fmt.Sprintf("  com.redhat.openshift.versions: %s", openShiftVersions))
	})
	if err != nil {
		return err
	}
	logrus.Infof("Updated %s", path)
	return nil
}

// parseImageInspect pulls the digest-pinned operator image and its build time
// out of 'docker image inspect' output, which looks like:
//
//	[
//	    {
//	        "Id": "sha256:...",
//	        "RepoTags": [
//	            "quay.io/tigera/operator:v1.3.1",
//	            "..."
//	        ],
//	        "RepoDigests": [
//	            "quay.io/tigera/operator@sha256:5e1d551b5a711592472f4a...",
//	            "..."
//	        ],
//	        ...
//	    }
//	]
//
// RepoDigests may have more than one entry, so we filter for the repository the
// bundle is being built from. The match is on the whole repository up to the
// '@', since a prefix match would also accept a repository the bundle is not
// being built from, e.g. quay.io/tigera/operator-foo for quay.io/tigera/operator.
func parseImageInspect(content, repository string) (image, error) {
	var inspected []struct {
		Created     string   `json:"Created"`
		RepoDigests []string `json:"RepoDigests"`
	}
	if err := parseJSON(content, &inspected); err != nil {
		return image{}, fmt.Errorf("reading image inspect output: %w", err)
	}
	if len(inspected) == 0 {
		return image{}, fmt.Errorf("image inspect output is empty")
	}
	var digests []string
	for _, digest := range inspected[0].RepoDigests {
		if repo, _, found := strings.Cut(digest, "@"); found && repo == repository && !slices.Contains(digests, digest) {
			digests = append(digests, digest)
		}
	}
	switch len(digests) {
	case 0:
		return image{}, fmt.Errorf("no %s digest in image inspect output", repository)
	case 1:
		return image{digest: digests[0], created: inspected[0].Created}, nil
	default:
		// Docker does not order RepoDigests, and nothing here says which one the
		// tag points at now, so picking one could pin a stale index.
		return image{}, fmt.Errorf("%s has more than one digest in image inspect output (%s); remove the local image and pull it again",
			repository, strings.Join(digests, ", "))
	}
}

// parseArchitectures lists the architectures the image was built for, from
// 'docker manifest inspect' output.
func parseArchitectures(content string) ([]string, error) {
	var inspected struct {
		Manifests []struct {
			Platform struct {
				Architecture string `json:"architecture"`
			} `json:"platform"`
		} `json:"manifests"`
	}
	if err := parseJSON(content, &inspected); err != nil {
		return nil, fmt.Errorf("reading manifest inspect output: %w", err)
	}
	architectures := make([]string, 0, len(inspected.Manifests))
	for _, manifest := range inspected.Manifests {
		// Build attestations sit in the list as platform unknown/unknown, and
		// would otherwise be claimed as a supported architecture.
		arch := manifest.Platform.Architecture
		if arch == "" || arch == "unknown" {
			continue
		}
		architectures = append(architectures, arch)
	}
	if len(architectures) == 0 {
		// A single-architecture image has a plain manifest rather than a list,
		// which is the usual way to end up here with a locally built image.
		return nil, fmt.Errorf("no architectures in manifest inspect output (not a multi-arch manifest list?)")
	}
	return architectures, nil
}

// parseJSON reads docker inspect output, which an override may have passed
// base64-encoded so that it survives a trip through the environment.
func parseJSON(content string, into any) error {
	content = strings.TrimSpace(content)
	if !strings.HasPrefix(content, "[") && !strings.HasPrefix(content, "{") {
		decoded, err := base64.StdEncoding.DecodeString(content)
		if err != nil {
			return err
		}
		content = string(decoded)
	}
	return json.Unmarshal([]byte(content), into)
}
