// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of Cilium

// Copyright The Helm Authors.

package helm

import (
	"bytes"
	"context"
	"crypto/sha256"
	"errors"
	"fmt"
	"io/fs"
	"maps"
	"os"
	"path"
	"regexp"
	"slices"
	"strings"
	"time"

	"github.com/blang/semver/v4"
	helm "github.com/cilium/charts"
	"helm.sh/helm/v4/pkg/action"
	"helm.sh/helm/v4/pkg/chart"
	"helm.sh/helm/v4/pkg/chart/loader"
	c2load "helm.sh/helm/v4/pkg/chart/v2/loader"
	"helm.sh/helm/v4/pkg/cli"
	"helm.sh/helm/v4/pkg/cli/values"
	"helm.sh/helm/v4/pkg/getter"
	"helm.sh/helm/v4/pkg/kube"
	"helm.sh/helm/v4/pkg/registry"
	"helm.sh/helm/v4/pkg/release"
	"helm.sh/helm/v4/pkg/strvals"

	"github.com/cilium/cilium/cilium-cli/defaults"
	"github.com/cilium/cilium/pkg/versioncheck"
)

var settings = cli.New()

// Merge maps recursively merges the values of b into a copy of a, preferring the values from b
func mergeMaps(a, b map[string]any) map[string]any {
	out := maps.Clone(a)
	for k, v := range b {
		if v, ok := v.(map[string]any); ok {
			if bv, ok := out[k]; ok {
				if bv, ok := bv.(map[string]any); ok {
					out[k] = mergeMaps(bv, v)
					continue
				}
			}
		}
		out[k] = v
	}
	return out
}

func newChartFromEmbeddedFile(ciliumVersion semver.Version) (chart.Charter, error) {
	helmTgz, err := helm.HelmFS.ReadFile(fmt.Sprintf("cilium-%s.tgz", ciliumVersion))
	if err != nil {
		return nil, fmt.Errorf("cilium version not found: %w", err)
	}

	// Check chart dependencies to make sure all are present in /charts
	return c2load.LoadArchive(bytes.NewReader(helmTgz))
}

func newChartFromDirectory(directory string) (chart.Charter, error) {
	return loader.LoadDir(directory)
}

// newChartFromRemoteWithCache fetches the chart from remote repository, the chart file
// is then stored in the local cache directory for future usage.
func newChartFromRemoteWithCache(ciliumVersion semver.Version, repository string) (chart.Charter, error) {
	cacheDir, err := ciliumCacheDir()
	if err != nil {
		return nil, err
	}

	hashID := sha256.Sum256([]byte(repository))
	file := path.Join(cacheDir, fmt.Sprintf("cilium-%s-%x.tgz", ciliumVersion, hashID[:defaults.HelmRepoIDLen]))
	if _, err = os.Stat(file); err != nil {
		if !errors.Is(err, fs.ErrNotExist) {
			return nil, err
		}

		// Download the chart from remote repository
		actionConfig := new(action.Configuration)
		pull := action.NewPull(action.WithConfig(actionConfig))
		pull.Settings = settings
		pull.Version = ciliumVersion.String()
		pull.DestDir = cacheDir
		chartRef := "cilium"
		if registry.IsOCI(repository) {
			// For OCI repositories, Pull action expects the full repository name as the
			// chartRef argument, and RepoURL must be kept unspecified.
			chartRef = repository
			// OCI repos need RegistryClient for some reason. Set it here.
			registryClient, err := registry.NewClient()
			if err != nil {
				return nil, err
			}
			actionConfig.RegistryClient = registryClient
		} else {
			pull.RepoURL = repository
		}
		if _, err = pull.Run(chartRef); err != nil {
			return nil, err
		}

		downloadedFile := path.Join(cacheDir, fmt.Sprintf("cilium-%s.tgz", ciliumVersion))
		if err := os.Rename(downloadedFile, file); err != nil {
			return nil, fmt.Errorf("failed to rename downloaded chart: %w", err)
		}
	}

	f, err := os.Open(file)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	return c2load.LoadArchive(f)
}

func ciliumCacheDir() (string, error) {
	cacheDir, err := os.UserCacheDir()
	if err != nil {
		return "", err
	}

	res := path.Join(cacheDir, "cilium-cli")
	err = os.MkdirAll(res, 0755)
	if err != nil && !os.IsExist(err) {
		return "", err
	}

	return res, nil
}

// MergeVals merges all values from flag options ('helmFlagOpts') and
// auto-generated helm options based on environment ('helmMapOpts'),
// and returns a single map with all of these options merged.
// 'helmMapOpts' can be nil.
func MergeVals(
	helmFlagOpts values.Options,
	helmMapOpts map[string]string,
) (map[string]any, error) {

	// Create helm values from helmMapOpts
	var helmOpts []string
	for k, v := range helmMapOpts {
		helmOpts = append(helmOpts, fmt.Sprintf("%s=%s", k, v))
	}

	helmOptsStr := strings.Join(helmOpts, ",")

	helmValues := map[string]any{}
	err := strvals.ParseInto(helmOptsStr, helmValues)
	if err != nil {
		return nil, fmt.Errorf("error parsing helm options %q: %w", helmOptsStr, err)
	}

	// Get the user-defined helm options passed by flag
	p := getter.All(settings)
	userVals, err := helmFlagOpts.MergeValues(p)
	if err != nil {
		return nil, err
	}

	// User-defined helm options will overwrite the default cilium-cli helm options
	return mergeMaps(helmValues, userVals), nil
}

// ParseVals takes a slice of Helm values of the form
// ["some.chart.value=val1", "some.other.value=val2"]
// and returns a deeply nested map of Values of the form
// expected by Helm actions.
func ParseVals(helmStrValues []string) (map[string]any, error) {
	helmValStr := strings.Join(helmStrValues, ",")
	helmValues := map[string]any{}
	err := strvals.ParseInto(helmValStr, helmValues)
	if err != nil {
		return nil, fmt.Errorf("error parsing helm options %q: %w", helmValStr, err)
	}
	return helmValues, nil
}

// ListVersions returns a list of available Helm chart versions sorted by semver in ascending order.
func ListVersions() ([]semver.Version, error) {
	var versions []semver.Version
	re := regexp.MustCompile(`^cilium-(.+)\.tgz$`)
	entries, err := helm.HelmFS.ReadDir(".")
	if err != nil {
		return nil, err
	}
	for _, entry := range entries {
		match := re.FindStringSubmatch(entry.Name())
		if len(match) == 2 {
			version, err := semver.Parse(match[1])
			if err != nil {
				// Ignore old charts that don't follow semver (v1.{6,7,8,9}-dev).
				continue
			}
			versions = append(versions, version)
		}
	}
	semver.Sort(versions)
	return versions, nil
}

// GetDefaultVersionString returns the default Cilium version to install.
func GetDefaultVersionString() string {
	versions, err := ListVersions()
	if err != nil {
		// Can't do much if cilium-cli can't find Cilium versions. Time to panic.
		panic(err)
	}
	// Start from the latest version
	for _, v := range slices.Backward(versions) {
		// Skip pre-releases
		if v.Pre != nil {
			continue
		}
		return fmt.Sprintf("v%s", v.String())
	}
	panic("there is no Cilium version to install")
}

// ResolveHelmChartVersion resolves Helm chart version based on --version, --chart-directory, and --repository flags.
func ResolveHelmChartVersion(versionFlag, chartDirectoryFlag, repository string) (semver.Version, chart.Charter, error) {
	// If repository is empty, set it to the default Helm repository ("https://helm.cilium.io") for backward compatibility.
	if repository == "" {
		repository = defaults.HelmRepository
	}
	if chartDirectoryFlag == "" {
		// If --chart-directory flag is not specified, use the version specified with --version flag.
		return resolveChartVersion(versionFlag, repository)
	}

	// Get the chart version from the local Helm chart specified with --chart-directory flag.
	localChart, err := newChartFromDirectory(chartDirectoryFlag)
	if err != nil {
		return semver.Version{}, nil, fmt.Errorf("failed to load Helm chart directory %s: %w", chartDirectoryFlag, err)
	}
	accessor, err := chart.NewAccessor(localChart)
	if err != nil {
		return semver.Version{}, nil, fmt.Errorf("failed to create chart accessor for chart directory %s: %w", chartDirectoryFlag, err)
	}
	metadata := accessor.MetadataAsMap()
	versionStr, ok := metadata["Version"].(string)
	if !ok {
		versionStr, ok = metadata["version"].(string)
		if !ok {
			return semver.Version{}, nil, fmt.Errorf("chart metadata does not contain version nor Version field")
		}
	}
	return versioncheck.MustVersion(versionStr), localChart, nil
}

func resolveChartVersion(versionFlag string, repository string) (semver.Version, chart.Charter, error) {
	version, err := semver.ParseTolerant(versionFlag)
	if err != nil {
		return semver.Version{}, nil, err
	}

	// If the repository is the default repository ("https://helm.cilium.io"), check embedded charts first.
	if repository == defaults.HelmRepository {
		helmChart, err := newChartFromEmbeddedFile(version)
		if err == nil {
			return version, helmChart, nil
		}

		if !errors.Is(err, fs.ErrNotExist) {
			return semver.Version{}, nil, err
		}
	}

	helmChart, err := newChartFromRemoteWithCache(version, repository)
	if err != nil {
		return semver.Version{}, nil, err
	}
	return version, helmChart, nil
}

// UpgradeParameters contains parameters for helm upgrade operation.
type UpgradeParameters struct {
	// Namespace in which the Helm release is installed.
	Namespace string
	// Name of the Helm release to upgrade.
	Name string
	// Chart is the Helm chart to use for the release
	Chart chart.Charter
	// Helm values to pass during upgrade.
	Values map[string]any
	// --reset-values flag from Helm upgrade. See https://helm.sh/docs/helm/helm_upgrade/ for details.
	ResetValues bool
	// --reuse-values flag from Helm upgrade. See https://helm.sh/docs/helm/helm_upgrade/ for details.
	ReuseValues bool
	// --reset-then-reuse-values flag from Helm upgrade. See https://helm.sh/docs/helm/helm_upgrade/ for details.
	ResetThenReuseValues bool
	// Wait determines if Helm actions will wait for completion
	Wait bool
	// WaitDuration is the timeout for helm operations
	WaitDuration time.Duration
	// DryRun writes resources to be installed to stdout without actually installing them. For Helm
	// installation mode only.
	DryRun bool
	// DryRunHelmValues writes non-default Helm values to stdout without performing the actual installation.
	// For Helm installation mode only.
	DryRunHelmValues bool
	// MaxHistory is the maximum number of releases to keep in the release history
	MaxHistory int
}

func (p *UpgradeParameters) IsDryRun() bool {
	return p.DryRun || p.DryRunHelmValues
}

// Upgrade upgrades the existing Helm release with the given Helm chart and values
func Upgrade(
	ctx context.Context,
	actionConfig *action.Configuration,
	params UpgradeParameters,
) (release.Releaser, error) {
	if params.Chart == nil {
		currentRelease, err := actionConfig.Releases.Last(params.Name)
		if err != nil {
			return nil, err
		}
		accessor, err := release.NewAccessor(currentRelease)
		if err != nil {
			return nil, fmt.Errorf("failed to create release accessor: %w", err)
		}
		params.Chart = accessor.Chart()
	}

	helmClient := action.NewUpgrade(actionConfig)
	helmClient.ServerSideApply = "false"
	helmClient.Namespace = params.Namespace
	helmClient.ResetThenReuseValues = params.ResetThenReuseValues
	helmClient.ResetValues = params.ResetValues
	helmClient.ReuseValues = params.ReuseValues
	if params.Wait {
		helmClient.WaitStrategy = kube.StatusWatcherStrategy
	} else {
		// Helm v4 always requires a WaitStrategy. HookOnlyStrategy waits only
		// for hooks to complete without waiting for chart resources to be ready,
		// which preserves the old Wait:false behavior.
		helmClient.WaitStrategy = kube.HookOnlyStrategy
	}
	helmClient.Timeout = params.WaitDuration
	if params.IsDryRun() {
		helmClient.DryRunStrategy = action.DryRunClient
	} else {
		helmClient.DryRunStrategy = action.DryRunNone
	}
	helmClient.MaxHistory = params.MaxHistory

	return helmClient.RunWithContext(ctx, params.Name, params.Chart, params.Values)
}
