/*
Copyright (c) Edgeless Systems GmbH

SPDX-License-Identifier: AGPL-3.0-only
*/

package helm

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/edgelesssys/constellation/v2/internal/compatibility"
	"github.com/edgelesssys/constellation/v2/internal/constants"
	"github.com/edgelesssys/constellation/v2/internal/semver"
	"helm.sh/helm/v3/pkg/action"
	"helm.sh/helm/v3/pkg/chart"
)

// ErrConfirmationMissing signals that an action requires user confirmation.
var ErrConfirmationMissing = errors.New("action requires user confirmation")

var errReleaseNotFound = errors.New("release not found")

type actionFactory struct {
	versionLister releaseVersionLister
	cfg           *action.Configuration
	kubeClient    crdClient
	log           debugLog
}

type crdClient interface {
	ApplyCRD(ctx context.Context, rawCRD []byte) error
}

// newActionFactory creates a new action factory for managing helm releases.
func newActionFactory(kubeClient crdClient, lister releaseVersionLister, actionConfig *action.Configuration, log debugLog) *actionFactory {
	return &actionFactory{
		versionLister: lister,
		cfg:           actionConfig,
		kubeClient:    kubeClient,
		log:           log,
	}
}

// GetActions returns a list of actions to apply the given releases.
func (a actionFactory) GetActions(releases []Release, configTargetVersion semver.Semver, force, allowDestructive bool) (actions []applyAction, includesUpgrade bool, err error) {
	upgradeErrs := []error{}
	for _, release := range releases {
		err := a.appendNewAction(release, configTargetVersion, force, allowDestructive, &actions)
		var invalidUpgrade *compatibility.InvalidUpgradeError
		if errors.As(err, &invalidUpgrade) {
			upgradeErrs = append(upgradeErrs, err)
			continue
		}
		if err != nil {
			return actions, includesUpgrade, fmt.Errorf("creating action for %s: %w", release.ReleaseName, err)
		}
	}
	for _, action := range actions {
		if _, ok := action.(*upgradeAction); ok {
			includesUpgrade = true
			break
		}
	}
	return actions, includesUpgrade, errors.Join(upgradeErrs...)
}

func (a actionFactory) appendNewAction(release Release, configTargetVersion semver.Semver, force, allowDestructive bool, actions *[]applyAction) error {
	newVersion, err := semver.New(release.Chart.Metadata.Version)
	if err != nil {
		return fmt.Errorf("parsing chart version: %w", err)
	}
	cliSupportsConfigVersion := configTargetVersion.Compare(newVersion) != 0

	currentVersion, err := a.versionLister.currentVersion(release.ReleaseName)
	if errors.Is(err, errReleaseNotFound) {
		// Don't install a new release if the user's config specifies a different version than the CLI offers.
		if !force && isCLIVersionedRelease(release.ReleaseName) && cliSupportsConfigVersion {
			return compatibility.NewInvalidUpgradeError(
				currentVersion.String(),
				configTargetVersion.String(),
				fmt.Errorf("this CLI only supports installing microservice version %s", newVersion),
			)
		}

		a.log.Debugf("Release %s not found, adding to new releases...", release.ReleaseName)
		*actions = append(*actions, a.newInstall(release))
		return nil
	}
	if err != nil {
		return fmt.Errorf("getting version for %s: %w", release.ReleaseName, err)
	}
	a.log.Debugf("Current %s version: %s", release.ReleaseName, currentVersion)
	a.log.Debugf("New %s version: %s", release.ReleaseName, newVersion)

	if !force {
		// For charts we package ourselves, the version is equal to the CLI version (charts are embedded in the binary).
		// We need to make sure this matches with the version in a user's config, if an upgrade should be applied.
		if isCLIVersionedRelease(release.ReleaseName) {
			// If target version is not a valid upgrade, don't upgrade any charts.
			if err := configTargetVersion.IsUpgradeTo(currentVersion); err != nil {
				return fmt.Errorf("invalid upgrade for %s: %w", release.ReleaseName, err)
			}
			// Target version is newer than current version, so we should perform an upgrade.
			// Now make sure the target version is equal to the the CLI version.
			if cliSupportsConfigVersion {
				return compatibility.NewInvalidUpgradeError(
					currentVersion.String(),
					configTargetVersion.String(),
					fmt.Errorf("this CLI only supports upgrading to microservice version %s", newVersion),
				)
			}
		} else {
			// This may break for external chart dependencies if we decide to upgrade more than one minor version at a time.
			if err := newVersion.IsUpgradeTo(currentVersion); err != nil {
				return fmt.Errorf("invalid upgrade for %s: %w", release.ReleaseName, err)
			}
		}
	}

	if !allowDestructive &&
		release.ReleaseName == certManagerInfo.releaseName {
		return ErrConfirmationMissing
	}
	a.log.Debugf("Upgrading %s from %s to %s", release.ReleaseName, currentVersion, newVersion)
	*actions = append(*actions, a.newUpgrade(release))
	return nil
}

func (a actionFactory) newInstall(release Release) *installAction {
	action := &installAction{helmAction: newHelmInstallAction(a.cfg, release), release: release, log: a.log}
	if action.ReleaseName() == ciliumInfo.releaseName {
		action.postInstall = func(ctx context.Context) error {
			return ciliumPostInstall(ctx, a.log)
		}
	}
	return action
}

func ciliumPostInstall(ctx context.Context, log debugLog) error {
	log.Debugf("Waiting for Cilium to become ready")
	helper, err := newK8sCiliumHelper(constants.AdminConfFilename)
	if err != nil {
		return fmt.Errorf("creating Kubernetes client: %w", err)
	}
	timeToStartWaiting := time.Now()
	// TODO(3u13r): Reduce the timeout when we switched the package repository - this is only this high because we once
	// saw polling times of ~16 minutes when hitting a slow PoP from Fastly (GitHub's / ghcr.io CDN).
	if err := helper.WaitForDS(ctx, "kube-system", "cilium", log); err != nil {
		return fmt.Errorf("waiting for Cilium to become healthy: %w", err)
	}
	timeUntilFinishedWaiting := time.Since(timeToStartWaiting)
	log.Debugf("Cilium became healthy after %s", timeUntilFinishedWaiting.String())

	log.Debugf("Fix Cilium through restart")
	if err := helper.RestartDS("kube-system", "cilium"); err != nil {
		return fmt.Errorf("restarting Cilium: %w", err)
	}
	return nil
}

func (a actionFactory) newUpgrade(release Release) *upgradeAction {
	action := &upgradeAction{helmAction: newHelmUpgradeAction(a.cfg), release: release, log: a.log}
	if release.ReleaseName == constellationOperatorsInfo.releaseName {
		action.preUpgrade = func(ctx context.Context) error {
			if err := a.updateCRDs(ctx, release.Chart); err != nil {
				return fmt.Errorf("updating operator CRDs: %w", err)
			}
			return nil
		}
	}
	return action
}

// updateCRDs walks through the dependencies of the given chart and applies
// the files in the dependencie's 'crds' folder.
// This function is NOT recursive!
func (a actionFactory) updateCRDs(ctx context.Context, chart *chart.Chart) error {
	for _, dep := range chart.Dependencies() {
		for _, crdFile := range dep.Files {
			if strings.HasPrefix(crdFile.Name, "crds/") {
				a.log.Debugf("Updating crd: %s", crdFile.Name)
				err := a.kubeClient.ApplyCRD(ctx, crdFile.Data)
				if err != nil {
					return err
				}
			}
		}
	}
	return nil
}

// isCLIVersionedRelease checks if the given release is versioned by the CLI,
// meaning that the version of the Helm release is equal to the version of the CLI that installed it.
func isCLIVersionedRelease(releaseName string) bool {
	return releaseName == constellationOperatorsInfo.releaseName ||
		releaseName == constellationServicesInfo.releaseName ||
		releaseName == csiInfo.releaseName
}

// releaseVersionLister can list the versions of a helm release.
type releaseVersionLister interface {
	currentVersion(release string) (semver.Semver, error)
}