package helm

import (
	"context"
	"errors"
	"fmt"
	"strconv"
	"time"

	hashstructure "github.com/mitchellh/hashstructure/v2"
	"github.com/tidbcloud/infra-provider/pkg/dataplane"
	"github.com/tidbcloud/infra-provider/pkg/k8s"
	"helm.sh/helm/v3/pkg/action"
	"helm.sh/helm/v3/pkg/chart"
	"helm.sh/helm/v3/pkg/release"
	"helm.sh/helm/v3/pkg/storage/driver"
	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/cli-runtime/pkg/resource"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/yaml"
)

const (
	Namespace = "helm"

	valuesIdKey = "helm-values-id"
	chartIdKey  = "helm-chart-id"
)

type Package interface {
	GetName() string
	GetCRDs() ([]*apiextensionsv1.CustomResourceDefinition, error)
	GetNamespace() string
	ToHelmChart() (*chart.Chart, error)
	ToHelmValues() map[string]interface{}
}

type DryRunablePackage interface {
	Package
	DryRun() bool
}

// Applier makes unit-test easier for helm.Apply.
type Applier interface {
	Apply(ctx context.Context, getter k8s.ClientGetter, p Package, opts ...ApplyOption) error
}

type ApplyFunc func(ctx context.Context, getter k8s.ClientGetter, p Package, opts ...ApplyOption) error

func (f ApplyFunc) Apply(ctx context.Context, getter k8s.ClientGetter, p Package, opts ...ApplyOption) error {
	return f(ctx, getter, p, opts...)
}

type clientGetterWrapper struct {
	i dataplane.EnvProvider
}

func (c *clientGetterWrapper) GetClient(ctx context.Context) (client.Client, error) {
	return c.i.GetClient(), nil
}

func (c *clientGetterWrapper) GetHelmConfig(ctx context.Context) (*action.Configuration, error) {
	return c.i.GetHelmConfig(), nil
}

var _ k8s.ClientGetter = &clientGetterWrapper{}

func NewClientGetterWrapper(getter dataplane.EnvProvider) k8s.ClientGetter {
	return &clientGetterWrapper{i: getter}
}

type applyOptions struct {
	autoUnlockPending       bool
	autoUnlockMinPendingAge time.Duration
}

type ApplyOption func(*applyOptions)

func WithAutoUnlockPending(minPendingAge time.Duration) ApplyOption {
	return func(o *applyOptions) {
		o.autoUnlockPending = true
		o.autoUnlockMinPendingAge = minPendingAge
	}
}

func Apply(ctx context.Context, getter k8s.ClientGetter, p Package, opts ...ApplyOption) error {
	options := applyOptions{}
	for _, opt := range opts {
		if opt == nil {
			continue
		}
		opt(&options)
	}

	dryRun := false
	dp, ok := p.(DryRunablePackage)
	if ok {
		dryRun = dp.DryRun()
	}
	// Apply the CRD first in case the chart needs it
	crds, err := p.GetCRDs()
	if err != nil {
		return fmt.Errorf("failed to get CRDs: %w", err)
	}
	cli, err := getter.GetClient(ctx)
	if err != nil {
		return fmt.Errorf("failed to get Kubernetes client: %w", err)
	}

	for _, crd := range crds {
		obj := &apiextensionsv1.CustomResourceDefinition{}
		if err = cli.Get(ctx, client.ObjectKeyFromObject(crd), obj); err != nil {
			if !apierrors.IsNotFound(err) {
				return fmt.Errorf("failed to get CRD %s: %w", crd.Name, err)
			}
			if err = cli.Create(ctx, crd); err != nil {
				return fmt.Errorf("failed to create CRD %s: %w", crd.Name, err)
			}
		} else {
			obj.Spec = crd.Spec
			obj.Status = crd.Status
			if err = cli.Update(ctx, obj); err != nil {
				return fmt.Errorf("failed to update CRD %s: %w", crd.Name, err)
			}
		}
	}

	chart, err := p.ToHelmChart()
	if err != nil {
		return fmt.Errorf("failed to get helm chart: %w", err)
	}
	values := p.ToHelmValues()
	if chart.Metadata.Annotations == nil {
		chart.Metadata.Annotations = map[string]string{}
	}

	valuesId, chartId := Hash(values), Hash(chart)
	chart.Metadata.Annotations[valuesIdKey] = valuesId
	chart.Metadata.Annotations[chartIdKey] = chartId

	config, err := getter.GetHelmConfig(ctx)
	if err != nil {
		return fmt.Errorf("failed to get Helm config: %w", err)
	}
	getclient := action.NewGet(config)

	rel, err := getclient.Run(p.GetName())
	if err != nil {
		// The release does not exist
		installclient := action.NewInstall(config)
		installclient.Namespace = p.GetNamespace()
		installclient.ReleaseName = p.GetName()
		installclient.Wait = false
		installclient.WaitForJobs = false
		installclient.DryRun = dryRun
		_, err = installclient.Run(chart, values)
		return err
	}

	// Auto unlock pending release if enabled
	// It's only used in a special case that we want to auto unlock pending release if it's stuck for a long time.
	if options.autoUnlockPending {
		if err := tryAutoUnlockPendingRelease(config, rel, options.autoUnlockMinPendingAge); err != nil {
			return err
		}
	}

	var appliedValuesId, appliedChartId string
	if rel.Chart.Metadata.Annotations != nil {
		appliedValuesId = rel.Chart.Metadata.Annotations[valuesIdKey]
		appliedChartId = rel.Chart.Metadata.Annotations[chartIdKey]
	}
	// If release deploy failed, we should upgrade it
	fmt.Println("helm chart:", appliedValuesId, valuesId, appliedChartId, chartId, rel.Info.Status)
	if appliedValuesId == valuesId && appliedChartId == chartId && rel.Info.Status == release.StatusDeployed {
		return nil
	}

	client := action.NewUpgrade(config)
	client.Namespace = p.GetNamespace()
	client.Install = true
	client.Wait = false
	client.WaitForJobs = false
	client.MaxHistory = 5
	client.CleanupOnFail = true
	client.DryRun = dryRun
	rel, err = client.Run(p.GetName(), chart, values)
	if err != nil {
		return fmt.Errorf("failed to apply helm chart: %w", err)
	}
	// check release info
	if rel == nil || rel.Info == nil {
		return fmt.Errorf("failed to retrieve helm release info for chart '%s'", p.GetName())
	}
	if rel.Info.Status == release.StatusDeployed {
		return nil
	}
	return fmt.Errorf("helm release '%s' deploy status: %s", p.GetName(), rel.Info.Status.String())
}

func tryAutoUnlockPendingRelease(config *action.Configuration, rel *release.Release, minPendingDuration time.Duration) error {
	if rel == nil || rel.Info == nil {
		return nil
	}
	if !isHelmPendingStatus(rel.Info.Status) {
		return nil
	}

	if minPendingDuration > 0 && !rel.Info.LastDeployed.IsZero() {
		age := time.Since(rel.Info.LastDeployed.Time)
		if age < minPendingDuration {
			return nil
		}
	}

	rel.Info.Status = release.StatusSuperseded
	if rel.Info.Description == "" {
		rel.Info.Description = "auto-unlocked from pending status"
	}

	if config == nil || config.Releases == nil {
		return fmt.Errorf("failed to auto-unlock helm release: helm storage is not initialized")
	}
	if err := config.Releases.Update(rel); err != nil {
		return fmt.Errorf("failed to auto-unlock helm release: %w", err)
	}
	return nil
}

func isHelmPendingStatus(s release.Status) bool {
	switch s {
	case release.StatusPendingInstall, release.StatusPendingUpgrade, release.StatusPendingRollback:
		return true
	default:
		return false
	}
}

// TODO: refactor it to combine with Apply
// ApplyV2 is a temporary solution that supports specify release name, namespace, and helm values.
// Because MetricsAgent have to be deployed in different tidb namespace with different release name,
// they can not share the same Package.
func ApplyV2(ctx context.Context, getter k8s.ClientGetter, p Package, releaseName, namespace string, values map[string]interface{}) error {
	dryRun := false
	dp, ok := p.(DryRunablePackage)
	if ok {
		dryRun = dp.DryRun()
	}
	// Apply the CRD first in case the chart needs it
	crds, err := p.GetCRDs()
	if err != nil {
		return fmt.Errorf("failed to get CRDs: %w", err)
	}
	cli, err := getter.GetClient(ctx)
	if err != nil {
		return fmt.Errorf("failed to get Kubernetes client: %w", err)
	}

	for _, crd := range crds {
		if err = cli.Create(ctx, crd); err != nil && !apierrors.IsAlreadyExists(err) {
			return fmt.Errorf("failed to create CRD: %w", err)
		}
	}

	chart, err := p.ToHelmChart()
	if err != nil {
		return fmt.Errorf("failed to get helm chart: %w", err)
	}

	if chart.Metadata.Annotations == nil {
		chart.Metadata.Annotations = map[string]string{}
	}

	valuesId, chartId := Hash(values), Hash(chart)
	chart.Metadata.Annotations[valuesIdKey] = valuesId
	chart.Metadata.Annotations[chartIdKey] = chartId

	config, err := getter.GetHelmConfig(ctx)
	if err != nil {
		return fmt.Errorf("failed to get Helm config: %w", err)
	}
	getclient := action.NewGet(config)

	rel, err := getclient.Run(releaseName)
	if err != nil {
		// The release does not exist
		installclient := action.NewInstall(config)
		installclient.Namespace = namespace
		installclient.ReleaseName = releaseName
		installclient.Wait = false
		installclient.WaitForJobs = false
		installclient.DryRun = dryRun
		_, err = installclient.Run(chart, values)
		return err
	}

	var appliedValuesId, appliedChartId string
	if rel.Chart.Metadata.Annotations != nil {
		appliedValuesId = rel.Chart.Metadata.Annotations[valuesIdKey]
		appliedChartId = rel.Chart.Metadata.Annotations[chartIdKey]
	}
	if appliedValuesId == valuesId && appliedChartId == chartId && rel.Info.Status == release.StatusDeployed {
		return nil
	}

	client := action.NewUpgrade(config)
	client.Namespace = namespace
	client.Install = true
	client.Wait = false
	client.WaitForJobs = false
	client.MaxHistory = 5
	client.CleanupOnFail = true
	client.DryRun = dryRun
	rel, err = client.Run(releaseName, chart, values)
	if err != nil {
		return fmt.Errorf("failed to apply helm chart: %w", err)
	}
	// check release info
	if rel == nil || rel.Info == nil {
		return fmt.Errorf("failed to retrieve helm release info for chart '%s'", releaseName)
	}
	if rel.Info.Status == release.StatusDeployed {
		return nil
	}
	return fmt.Errorf("helm release '%s' deploy status: %s", releaseName, rel.Info.Status.String())
}

// TODO: refactor it to avoid using releaseName
// Uninstall executes `helm uninstall`.
// It will return nil if the release does not exist.
func Uninstall(ctx context.Context, getter k8s.ClientGetter, p Package, releaseName string) error {
	dryRun := false
	dp, ok := p.(DryRunablePackage)
	if ok {
		dryRun = dp.DryRun()
	}

	config, err := getter.GetHelmConfig(ctx)
	if err != nil {
		return fmt.Errorf("failed to get Helm config: %v", err)
	}

	getclient := action.NewGet(config)
	_, err = getclient.Run(releaseName)
	if err != nil {
		// The release does not exist
		if errors.Is(err, driver.ErrReleaseNotFound) {
			return nil
		}
	}

	uninstallClient := action.NewUninstall(config)
	uninstallClient.DryRun = dryRun
	_, err = uninstallClient.Run(releaseName)
	return err
}

func RemoveIfExists(ctx context.Context, getter k8s.ClientGetter, p Package) error {
	dryRun := false
	dp, ok := p.(DryRunablePackage)
	if ok {
		dryRun = dp.DryRun()
	}
	config, err := getter.GetHelmConfig(ctx)
	if err != nil {
		return fmt.Errorf("failed to get Helm config: %v", err)
	}

	getclient := action.NewGet(config)
	_, err = getclient.Run(p.GetName())
	if err != nil {
		if errors.Is(err, driver.ErrReleaseNotFound) {
			return nil
		}

		return err
	}

	uninstallClient := action.NewUninstall(config)
	uninstallClient.DryRun = dryRun
	_, err = uninstallClient.Run(p.GetName())
	return err
}

func Hash(p interface{}) string {
	h, _ := hashstructure.Hash(p, hashstructure.FormatV2, nil)
	return strconv.FormatUint(h, 10)
}

func LoadCRD(path string) ([]*apiextensionsv1.CustomResourceDefinition, error) {
	// The CRD file can be a single YAML document or multiple YAML documents.
	// Use the library from kubectl to handle all the cases.
	scheme := runtime.NewScheme()
	err := apiextensionsv1.AddToScheme(scheme)
	if err != nil {
		return nil, err
	}

	res := resource.
		NewLocalBuilder().
		WithScheme(scheme, apiextensionsv1.SchemeGroupVersion).
		Path(false, path).
		Do()
	infos, err := res.Infos()
	if err != nil {
		return nil, err
	}
	var crds []*apiextensionsv1.CustomResourceDefinition
	for _, info := range infos {
		crd, ok := info.Object.(*apiextensionsv1.CustomResourceDefinition)
		if ok {
			crds = append(crds, crd)
		}
	}
	return crds, nil
}

// LoadValuesFromYAML deal with value maps from yaml string.
// refer to: https://github.com/helm/helm/blob/v3.9.3/pkg/cli/values/options.go#L44-L58
func LoadValuesFromYAML(valuesYamlData string) (map[string]interface{}, error) {
	base := map[string]interface{}{}

	currentMap := map[string]interface{}{}

	if err := yaml.Unmarshal([]byte(valuesYamlData), &currentMap); err != nil {
		return nil, fmt.Errorf("failed to unmarshal yaml, %v", err)
	}
	// Merge with the previous map
	base = mergeValueMaps(base, currentMap)
	return base, nil
}

func mergeValueMaps(a, b map[string]interface{}) map[string]interface{} {
	out := make(map[string]interface{}, len(a))
	for k, v := range a {
		out[k] = v
	}
	for k, v := range b {
		if v, ok := v.(map[string]interface{}); ok {
			if bv, ok := out[k]; ok {
				if bv, ok := bv.(map[string]interface{}); ok {
					out[k] = mergeValueMaps(bv, v)
					continue
				}
			}
		}
		out[k] = v
	}
	return out
}
