package generate

import (
	"bytes"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"time"

	"github.com/hexops/gotextdiff"
	"github.com/hexops/gotextdiff/myers"
	"github.com/hexops/gotextdiff/span"
	"gopkg.in/yaml.v3"

	"github.com/budimanjojo/talhelper/v3/pkg/config"
	"github.com/budimanjojo/talhelper/v3/pkg/decrypt"
	"github.com/budimanjojo/talhelper/v3/pkg/patcher"
	"github.com/budimanjojo/talhelper/v3/pkg/talos"
	"github.com/budimanjojo/talhelper/v3/pkg/templating"
	tconfig "github.com/siderolabs/talos/pkg/machinery/config/config"
	"github.com/siderolabs/talos/pkg/machinery/config/container"
	"github.com/siderolabs/talos/pkg/machinery/config/encoder"
	"github.com/siderolabs/talos/pkg/machinery/config/types/k8s"
)

// GenerateConfig takes `TalhelperConfig` and path to encrypted `secretFile` and generates
// Talos `machineconfig` files and a `talosconfig` file in `outDir`.
// It returns an error, if any.
func GenerateConfig(c *config.TalhelperConfig, dryRun bool, outDir, secretFile, mode string, offlineMode bool, disableNodesSection bool, crtTTL time.Duration) error {
	input, err := talos.NewClusterInput(c, secretFile, mode)
	if err != nil {
		return err
	}

	vc := input.Options.VersionContract

	for _, node := range c.Nodes {
		fileName, err := node.GetOutputFileName(c)
		if err != nil {
			return err
		}
		cfgFile := outDir + "/" + fileName
		slog.Debug(fmt.Sprintf("generating %s for node %s", cfgFile, node.Hostname))

		rawcfg, err := talos.GenerateNodeConfig(&node, input, c.GetImageFactory(), offlineMode)
		if err != nil {
			return err
		}

		if c.CNIConfig != nil {
			switch vc.MultidocKubernetesConfigSupported() {
			case true:
				// we remove the autogenerated KubeFlannelConfig first
				docs := slices.DeleteFunc(rawcfg.Documents(), func(doc tconfig.Document) bool {
					if _, ok := doc.(*k8s.KubeFlannelCNIConfigV1Alpha1); ok {
						return true
					}
					return false
				})

				if c.CNIConfig.CNIName == "flannel" {
					// we create our own here
					flannelCfg := k8s.NewKubeFlannelCNIConfigV1Alpha1()
					flannelCfg.FlannelBackendType = "vxlan"
					flannelCfg.FlannelBackendPort = 4789
					if c.CNIConfig.CNIFlannel != nil {
						flannelCfg.FlannelExtraArgs = c.CNIConfig.CNIFlannel.FlanneldExtraArgs
						flannelCfg.FlannelKubeNetworkPoliciesEnabled = c.CNIConfig.CNIFlannel.FlannelKubeNetworkPoliciesEnabled
					}
					docs = append(docs, flannelCfg)
				}

				if c.CNIConfig.CNIName == "custom" {
					for _, url := range c.CNIConfig.CNIUrls {
						rawcfg.RawV1Alpha1().ClusterConfig.ExtraManifests = append(rawcfg.RawV1Alpha1().ClusterConfig.ExtraManifests, url)
					}
				}

				// repack the rawCfg back to next process
				rawcfg, _ = container.New(docs...)

			case false:
				//nolint:staticcheck
				rawcfg.RawV1Alpha1().ClusterConfig.ClusterNetwork.CNI = c.CNIConfig
			}
		}

		// this is needed because the upstream cluster input doesn't handle inline manifests and some others so we need to do it ourselves
		if len(c.ClusterInlineManifests) > 0 {
			rawcfg.RawV1Alpha1().ClusterConfig.ClusterInlineManifests = *c.ClusterInlineManifests.GetIMs()
		}

		cfg, err := rawcfg.EncodeBytes(encoder.WithComments(encoder.CommentsDisabled))
		if err != nil {
			return err
		}

		cfg, err = talos.AddMultiDocs(&node, mode, cfg, vc)
		if err != nil {
			return err
		}

		if len(node.Patches) != 0 {
			slog.Debug(fmt.Sprintf("applying node specific patches to %s", node.Hostname))
			cfg, err = patcher.PatchesPatcher(node.Patches, cfg)
			if err != nil {
				return err
			}
		}

		if len(c.Patches) > 0 {
			slog.Debug(fmt.Sprintf("applying global patches to %s", node.Hostname))
			cfg, err = patcher.PatchesPatcher(c.Patches, cfg)
			if err != nil {
				return err
			}
		}

		if len(node.ExtraManifests) > 0 {
			slog.Debug(fmt.Sprintf("generating extra manifests for %s", node.Hostname))
			content, err := combineExtraManifests(node.ExtraManifests, cfg)
			if err != nil {
				return err
			}
			cfg = append(cfg, content...)
		}

		err = talos.ValidateConfigFromBytes(cfg, mode)
		if err != nil {
			return err
		}

		cfg, err = reencodeYaml(cfg)
		if err != nil {
			return err
		}

		if !dryRun {
			slog.Debug(fmt.Sprintf("dumping machineconfig file for %s to %s", node.Hostname, cfgFile))
			err = dumpFile(cfgFile, cfg)
			if err != nil {
				return err
			}

			fmt.Printf("generated config for %s in %s\n", node.Hostname, cfgFile)
		} else {
			slog.Debug("showing diff from previous run")
			absCfgFile, err := filepath.Abs(cfgFile)
			if err != nil {
				return err
			}

			before, err := getFileContent(absCfgFile)
			if err != nil {
				return err
			}

			diff := computeDiff(absCfgFile, before, string(cfg))
			if diff != "" {
				fmt.Println(diff)
			} else {
				fmt.Printf("no changes found on %s\n", cfgFile)
			}
		}
	}

	if !dryRun {
		clientCfg, err := talos.GenerateClientConfigBytes(c, input, disableNodesSection, crtTTL)
		if err != nil {
			return err
		}

		fileName := "talosconfig"

		slog.Debug(fmt.Sprintf("dumping talosconfig file to %s", outDir+"/"+fileName))
		err = dumpFile(outDir+"/"+fileName, clientCfg)
		if err != nil {
			return err
		}

		fmt.Printf("generated client config in %s\n", outDir+"/"+fileName)
	}

	return nil
}

// getFileContent returns content of file as string.
// It also returns an error, if any
func getFileContent(path string) (string, error) {
	content, err := getFileContentByte(path)
	return string(content), err
}

// getFileContentByte returns content of file. It also returns an error,
// if any
func getFileContentByte(path string) ([]byte, error) {
	if _, osErr := os.Stat(path); osErr == nil {
		return decrypt.DecryptFileWithSops(path)
	} else if errors.Is(osErr, os.ErrNotExist) {
		return nil, nil
	} else {
		return nil, osErr
	}
}

// combineExtraManifests takes list of filepaths, parse go template using data from
// main config manifest, combines them into a single file in bytes with `---\n` prepended.
// It also returns an error, if any
func combineExtraManifests(extraFiles []string, templateData []byte) ([]byte, error) {
	var result [][]byte
	dataCfg, err := talos.LoadTalosConfig(templateData)
	if err != nil {
		return nil, err
	}

	for _, file := range extraFiles {
		content, err := getFileContentByte(strings.TrimPrefix(file, "@"))
		if err != nil {
			return nil, err
		}

		content, err = templating.RenderTemplate[[]byte](string(content), dataCfg.RawV1Alpha1())
		if err != nil {
			return nil, err
		}
		result = append(result, content)
	}
	return talos.CombineYamlBytes(result), nil
}

// computeDiff returns diff between before and after string
// using Myers diff algorithm
func computeDiff(path, before, after string) string {
	edits := myers.ComputeEdits(span.URIFromPath(path), before, after)
	diff := gotextdiff.ToUnified("a"+path, "b"+path, before, edits)
	return fmt.Sprint(diff)
}

// dumpFile creates file in `path` and dumps the content of bytes into
// the path. It returns an error, if any.
func dumpFile(path string, file []byte) error {
	dirName := filepath.Dir(path)

	_, err := os.Stat(dirName)
	if err != nil {
		err := os.MkdirAll(dirName, 0o700)
		if err != nil {
			return err
		}
	}

	err = os.WriteFile(path, file, 0o600)
	if err != nil {
		return err
	}

	return nil
}

// reencodeYaml re encode yaml bytes but the indentation is set to 2.
// It returns and error, if any.
func reencodeYaml(input []byte) ([]byte, error) {
	dec := yaml.NewDecoder(bytes.NewReader(input))

	var buf bytes.Buffer
	enc := yaml.NewEncoder(&buf)
	enc.SetIndent(2)
	defer enc.Close()

	for {
		var node yaml.Node
		if err := dec.Decode(&node); err != nil {
			if err.Error() == "EOF" {
				break
			}
			return nil, err
		}

		if err := enc.Encode(&node); err != nil {
			return nil, err
		}
	}

	return buf.Bytes(), nil
}
