// 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 (
	"context"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"strings"

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

// The manifests and Calico CRDs the bundle ships are read from the working tree,
// so a bundle carries the manifests it was built alongside. Every path below is relative to the repository root
// that --repo-root names, so none of them depend on the directory gen-bundle
// happens to be run from.
const (
	// ocpManifestDir holds the OpenShift manifests. They are generated from
	// charts/ by 'make gen-manifests' at the repository root and committed.
	ocpManifestDir = "manifests"

	// calicoCRDDir holds the Calico CRDs, the same ones the operator embeds and
	// installs at runtime.
	calicoCRDDir = "libcalico-go/config/crd"

	// operatorCRDDir holds the committed operator CRDs. CI fails a PR that
	// leaves them stale, so the bundle ships them as they are.
	operatorCRDDir = "operator/pkg/crds/operator"

	// Relative to ocpManifestDir.
	secretsRole        = "ocp/02-role-tigera-operator-secrets.yaml"
	secretsRoleBinding = "ocp/02-rolebinding-tigera-operator-secrets.yaml"
)

// calicoResources are the Calico CRDs the bundle ships. The CSV base has to own
// exactly the CRDs staged from here and operatorCRDs, which a unit test checks:
// a CRD staged but not described only raises a validation warning, and one
// described but not staged is advertised without being installed.
var calicoResources = []string{
	"bgpconfigurations",
	"bgppeers",
	"blockaffinities",
	"caliconodestatuses",
	"clusterinformations",
	"felixconfigurations",
	"globalnetworkpolicies",
	"globalnetworksets",
	"hostendpoints",
	"ipamblocks",
	"ipamconfigs",
	"ipamhandles",
	"ippools",
	"ipreservations",
	"kubecontrollersconfigurations",
	"networkpolicies",
	"networksets",
}

// operatorCRDs are the operator CRDs the bundle ships, relative to the
// committed CRD directory.
var operatorCRDs = []string{
	"operator.tigera.io_installations.yaml",
	"operator.tigera.io_tigerastatuses.yaml",
	"operator.tigera.io_imagesets.yaml",
}

// sampleCRs become the CSV's alm-examples annotation, which is what OperatorHub
// offers users as a starting point in its "Create instance" forms. Only kinds
// whose CRD the bundle ships belong here - an example for a kind the CSV does
// not own is dropped, and every owned CRD without an example is reported by
// 'operator-sdk bundle validate'. Each entry is the canonical copy of that
// example: the Installation one is the OpenShift install-time CR itself, so
// that what OperatorHub offers and what the OpenShift install instructions
// tell users to apply cannot drift apart.
var sampleCRs = []string{
	ocpManifestDir + "/ocp/03-cr-installation.yaml",
	"operator/config/samples/operator_v1_imageset.yaml",
}

var getManifestsCommand = &cli.Command{
	Name:  "get-manifests",
	Usage: "Stage the Calico and operator manifests that 'operator-sdk generate bundle' reads to build a ClusterServiceVersion",
	Flags: []cli.Flag{repoRootFlag, crdDirFlag, deployDirFlag},
	Action: func(_ context.Context, c *cli.Command) error {
		return getManifests(c.String(repoRootFlag.Name), c.String(crdDirFlag.Name), c.String(deployDirFlag.Name))
	},
}

func getManifests(repoRoot, crdDir, deployDir string) error {
	// Start from empty staging directories. Leftovers from an earlier run would
	// otherwise be picked up by operator-sdk and end up in the bundle - for
	// example a CRD that has since been dropped from calicoResources.
	for _, dir := range []string{crdDir, deployDir} {
		if err := resetStagingDir(dir); err != nil {
			return err
		}
	}

	logrus.Infof("Building bundle from %s, with the manifests in %s", deployDir, repoRoot)

	if err := copyOperatorManifests(repoRoot, deployDir); err != nil {
		return err
	}
	if err := copySampleCRs(repoRoot, deployDir); err != nil {
		return err
	}
	if err := copyOperatorCRDs(repoRoot, crdDir); err != nil {
		return err
	}
	return copyCalicoCRDs(repoRoot, crdDir)
}

// resetStagingDir empties a staging directory. The directory comes from a flag
// or the environment, so it is only cleared if it looks like one we staged -
// flat YAML files and nothing else - and a mis-set path fails rather than
// deleting a source tree.
func resetStagingDir(dir string) error {
	entries, err := os.ReadDir(dir)
	if err != nil && !errors.Is(err, fs.ErrNotExist) {
		return fmt.Errorf("reading %s: %w", dir, err)
	}
	for _, entry := range entries {
		if !entry.Type().IsRegular() || filepath.Ext(entry.Name()) != ".yaml" {
			return fmt.Errorf("not clearing %s: %s is not a staged manifest, so it may not be a staging directory", dir, entry.Name())
		}
	}
	if err := os.RemoveAll(dir); err != nil {
		return fmt.Errorf("clearing %s: %w", dir, err)
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("creating %s: %w", dir, err)
	}
	return nil
}

// copyOperatorManifests stages the OpenShift manifests that the CSV's install
// spec is built from. For CSV generation we use a version of the operator
// deployment manifest that doesn't include an init container and volumes for
// creating install-time resources.
func copyOperatorManifests(repoRoot, deployDir string) error {
	manifests := []struct{ path, name string }{
		{"ocp-tigera-operator-no-resource-loading.yaml", "operator.yaml"},
		{"ocp/02-role-tigera-operator.yaml", "role.yaml"},
		// 'operator-sdk generate bundle' only turns a ClusterRole into the CSV's
		// spec.install.clusterPermissions when a binding ties it to a service
		// account, so the binding has to be staged too.
		{"ocp/02-rolebinding-tigera-operator.yaml", "rolebinding-tigera-operator.yaml"},
		// Staged unbound so that it ships as a standalone ClusterRole under this
		// name, which the operator binds by name wherever it writes secrets.
		{secretsRole, "role-tigera-operator-secrets.yaml"},
	}
	for _, m := range manifests {
		if err := copyFile(filepath.Join(repoRoot, ocpManifestDir, m.path), filepath.Join(deployDir, m.name)); err != nil {
			return err
		}
	}
	return copyOperatorSecretsRole(repoRoot, deployDir)
}

// copyOperatorSecretsRole stages the operator's grant to write secrets in its own
// namespace.
//
// Staged as is, the binding would claim the ClusterRole: operator-sdk folds a
// bound role into the CSV's permissions instead of shipping it, and OLM names
// what it creates from those itself. Binding a namespaced copy instead leaves the
// ClusterRole standalone. operator-sdk matches roles to bindings by name alone,
// so the copy needs its own name as well as its own kind.
func copyOperatorSecretsRole(repoRoot, deployDir string) error {
	const name, namespacedName = "  name: tigera-operator-secrets", "  name: tigera-operator-secrets-namespaced"
	stages := []struct {
		path, name string
		edits      []lineEdit
	}{
		{secretsRole, "role-tigera-operator-secrets-namespaced.yaml", []lineEdit{
			{"kind: ClusterRole", "kind: Role", 1},
			{name, namespacedName, 1},
		}},
		// The binding's own name and its roleRef's are the same line.
		{secretsRoleBinding, "rolebinding-tigera-operator-secrets.yaml", []lineEdit{
			{"  kind: ClusterRole", "  kind: Role", 1},
			{name, namespacedName, 2},
		}},
	}
	for _, s := range stages {
		dst := filepath.Join(deployDir, s.name)
		if err := copyFile(filepath.Join(repoRoot, ocpManifestDir, s.path), dst); err != nil {
			return err
		}
		if err := replaceLines(dst, s.edits...); err != nil {
			return err
		}
	}
	return nil
}

// lineEdit replaces every line that is exactly from, of which there must be
// count.
type lineEdit struct {
	from, to string
	count    int
}

// replaceLines applies the edits to a file.
//
// A miscount means the source has changed shape, which would otherwise ship a
// binding that silently still claims the ClusterRole.
func replaceLines(path string, edits ...lineEdit) error {
	replaced := make([]int, len(edits))
	err := editLines(path, func(lines []string) []string {
		for i, line := range lines {
			for j, e := range edits {
				if line == e.from {
					lines[i] = e.to
					replaced[j]++
					break
				}
			}
		}
		return lines
	})
	if err != nil {
		return err
	}
	for j, e := range edits {
		if replaced[j] != e.count {
			return fmt.Errorf("editing %s: want %d %q lines, found %d", path, e.count, e.from, replaced[j])
		}
	}
	return nil
}

func copySampleCRs(repoRoot, deployDir string) error {
	for _, sample := range sampleCRs {
		if err := copyFile(filepath.Join(repoRoot, sample), filepath.Join(deployDir, filepath.Base(sample))); err != nil {
			return err
		}
	}
	return nil
}

// copyOperatorCRDs copies over the operator CRDs required for Calico. They are
// shipped as committed: 'make gen-files' writes one document per file with no
// separators, which is what operator-sdk expects.
func copyOperatorCRDs(repoRoot, crdDir string) error {
	for _, crd := range operatorCRDs {
		if err := copyFile(filepath.Join(repoRoot, operatorCRDDir, crd), filepath.Join(crdDir, crd)); err != nil {
			return err
		}
	}
	return nil
}

func copyCalicoCRDs(repoRoot, crdDir string) error {
	for _, resource := range calicoResources {
		logrus.Infof("Copying libcalico-go CRD %s", resource)
		name := fmt.Sprintf("crd.projectcalico.org_%s.yaml", resource)
		if err := copyFile(filepath.Join(repoRoot, calicoCRDDir, name), filepath.Join(crdDir, name)); err != nil {
			return err
		}
	}
	return nil
}

func copyFile(src, dst string) error {
	logrus.Debugf("Copying %s to %s", src, dst)

	content, err := os.ReadFile(src)
	if err != nil {
		return fmt.Errorf("reading %s: %w", src, err)
	}
	if err := os.WriteFile(dst, content, 0o644); err != nil {
		return fmt.Errorf("writing %s: %w", dst, err)
	}
	return nil
}

// editLines rewrites a file through the given transform, one element per line.
func editLines(path string, transform func([]string) []string) error {
	content, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("reading %s: %w", path, err)
	}
	lines := strings.Split(strings.TrimSuffix(string(content), "\n"), "\n")
	out := strings.Join(transform(lines), "\n") + "\n"
	if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
		return fmt.Errorf("writing %s: %w", path, err)
	}
	return nil
}

// filterLines drops every line the predicate matches.
func filterLines(lines []string, drop func(string) bool) []string {
	kept := make([]string, 0, len(lines))
	for _, line := range lines {
		if !drop(line) {
			kept = append(kept, line)
		}
	}
	return kept
}
