package terraform

import (
	"context"
	"encoding/json"
	"io"
	"io/fs"
	"os"
	"os/exec"
	"path/filepath"
	"slices"
	"strings"

	cp9errors "github.com/tidbcloud/controlplane9/internal/errors"
)

type LookPathFunc func(file string) (string, error)

type CommandRunner func(ctx context.Context, dir string, name string, args ...string) ([]byte, []byte, error)

type Runner struct {
	LookPath     LookPathFunc
	Run          CommandRunner
	AllowEKSPlan bool
	Parallelism  int
}

func (r Runner) verifyBinary() error {
	lookPath := r.LookPath
	if lookPath == nil {
		lookPath = exec.LookPath
	}
	if _, err := lookPath("terraform"); err != nil {
		return cp9errors.Newf(cp9errors.CategoryPermanent, "terraform binary not found: %v", err)
	}
	return nil
}

// VerifyGitRef confirms an optional terraform.ref exists in the configured repo checkout.
func (r Runner) VerifyGitRef(ctx context.Context, repo, ref string) error {
	ref = strings.TrimSpace(ref)
	if ref == "" {
		return nil
	}
	if err := requireGitRepository(repo); err != nil {
		return err
	}
	run := r.commandRunner()
	_, stderr, err := run(ctx, repo, "git", "rev-parse", "--verify", ref+"^{commit}")
	if err != nil {
		msg := strings.TrimSpace(string(stderr))
		if msg == "" {
			msg = err.Error()
		}
		return cp9errors.Newf(cp9errors.CategoryConfig, "terraform.ref %q is not available in %s: %s", ref, repo, msg)
	}
	return nil
}

func requireGitRepository(repo string) error {
	repo = strings.TrimSpace(repo)
	if repo == "" {
		return nil
	}
	info, err := os.Stat(filepath.Join(repo, ".git"))
	if err != nil {
		if os.IsNotExist(err) {
			return cp9errors.Newf(
				cp9errors.CategoryConfig,
				"terraform.repo %q is not a git repository while terraform.ref is set; set this to the repository checkout root that contains that ref",
				repo,
			)
		}
		return cp9errors.Newf(cp9errors.CategoryConfig, "terraform.repo %q git metadata check failed: %v", repo, err)
	}
	if !info.IsDir() && !info.Mode().IsRegular() {
		return cp9errors.Newf(
			cp9errors.CategoryConfig,
			"terraform.repo %q git metadata %q is unsupported; expected .git file or directory",
			repo, filepath.Join(repo, ".git"),
		)
	}
	return nil
}

func (r Runner) commandRunner() CommandRunner {
	if r.Run != nil {
		return r.Run
	}
	return defaultCommandRunner
}

func defaultCommandRunner(
	ctx context.Context,
	dir string,
	name string,
	args ...string,
) (stdout, stderr []byte, err error) {
	cmd := exec.CommandContext(ctx, name, args...)
	cmd.Dir = dir
	var stdoutBuf, stderrBuf strings.Builder
	cmd.Stdout = &stdoutBuf
	cmd.Stderr = &stderrBuf
	err = cmd.Run()
	return []byte(stdoutBuf.String()), []byte(stderrBuf.String()), err
}

func MaterializeSource(repo, modulePath, dest string) error {
	moduleDir, err := moduleRoot(repo, modulePath)
	if err != nil {
		return err
	}
	if err := os.RemoveAll(dest); err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "clear terraform source dir %q: %v", dest, err)
	}
	if err := copyTree(moduleDir, dest); err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "materialize terraform source: %v", err)
	}
	return nil
}

func moduleRoot(repo, modulePath string) (string, error) {
	repo = filepath.Clean(strings.TrimSpace(repo))
	modulePath = filepath.Clean(strings.TrimSpace(modulePath))
	if repo == "." || repo == "" {
		return "", cp9errors.New(cp9errors.CategoryConfig, "terraform.repo is required")
	}
	if modulePath == "." || modulePath == "" {
		return "", cp9errors.New(cp9errors.CategoryConfig, "terraform.path is required")
	}
	if filepath.IsAbs(modulePath) {
		return "", cp9errors.Newf(cp9errors.CategoryConfig, "terraform.path %q must be relative to terraform.repo", modulePath)
	}
	if modulePath == ".." || strings.HasPrefix(modulePath, ".."+string(filepath.Separator)) {
		return "", cp9errors.Newf(cp9errors.CategoryConfig, "terraform.path %q must stay within terraform.repo", modulePath)
	}
	root := filepath.Join(repo, modulePath)
	info, err := os.Stat(root)
	if err != nil {
		return "", cp9errors.Newf(cp9errors.CategoryConfig, "terraform.path %q not found under terraform.repo %q: %v", modulePath, repo, err)
	}
	if !info.IsDir() {
		return "", cp9errors.Newf(cp9errors.CategoryConfig, "terraform.path %q under terraform.repo %q is not a directory", modulePath, repo)
	}
	return root, nil
}

func copyTree(src, dest string) error {
	return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() && d.Name() == ".terraform" {
			return filepath.SkipDir
		}
		rel, err := filepath.Rel(src, path)
		if err != nil {
			return err
		}
		target := filepath.Join(dest, rel)
		if d.IsDir() {
			return os.MkdirAll(target, 0o755)
		}
		info, err := d.Info()
		if err != nil {
			return err
		}
		return copyFile(path, target, info.Mode())
	})
}

func copyFile(src, dest string, mode fs.FileMode) error {
	if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
		return err
	}
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	out, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode.Perm())
	if err != nil {
		return err
	}
	if _, err := io.Copy(out, in); err != nil {
		_ = out.Close()
		return err
	}
	return out.Close()
}

type planChange struct {
	Actions []string `json:"actions"`
}

type planResourceChange struct {
	Type   string     `json:"type"`
	Change planChange `json:"change"`
}

type planJSON struct {
	ResourceChanges []planResourceChange `json:"resource_changes"`
}

// GuardPlanEKS rejects EKS creation unless the caller is the Premium Starter
// remote regional substrate lifecycle.
func GuardPlanEKS(planData []byte, allowEKS bool) error {
	var plan planJSON
	if err := json.Unmarshal(planData, &plan); err != nil {
		return cp9errors.Newf(cp9errors.CategoryConfig, "parse terraform plan JSON: %v", err)
	}
	if allowEKS {
		return nil
	}
	for _, change := range plan.ResourceChanges {
		if change.Type != "aws_eks_cluster" && change.Type != "aws_eks_node_group" {
			continue
		}
		if hasCreateAction(change.Change.Actions) {
			return cp9errors.New(
				cp9errors.CategoryConfig,
				"cp9 substrate must not create control-plane EKS or customer/dataplane EKS",
			)
		}
	}
	return nil
}

// GuardPlanNoEKS rejects substrate plans that attempt to create EKS clusters or nodegroups.
func GuardPlanNoEKS(planData []byte) error {
	return GuardPlanEKS(planData, false)
}

func hasCreateAction(actions []string) bool {
	return slices.Contains(actions, "create")
}

func ParseOutputs(data []byte) (map[string]OutputValue, *OutputContract, error) {
	var outputs map[string]OutputValue
	if err := json.Unmarshal(data, &outputs); err != nil {
		return nil, nil, cp9errors.Newf(cp9errors.CategoryConfig, "parse terraform outputs JSON: %v", err)
	}
	contract := &OutputContract{
		Raw: map[string]json.RawMessage{},
	}
	for name, output := range outputs {
		rawValue, err := json.Marshal(output.Value)
		if err != nil {
			return nil, nil, cp9errors.Newf(cp9errors.CategoryConfig, "marshal terraform output %q: %v", name, err)
		}
		contract.Raw[name] = rawValue
	}
	contract.IAM = extractIAM(outputs)
	contract.Metering = extractMetering(outputs)
	contract.RemoteEC2Runtime = extractRemoteEC2Runtime(outputs)
	contract.MetaDB = extractMetaDB(outputs)
	contract.RemoteEKS = extractRemoteEKS(outputs)
	contract.SessionCMK = extractSessionCMK(outputs)
	contract.SessionProductPCA = extractSessionProductPCA(outputs)
	contract.DedicatedPublicDNS = extractDedicatedPublicDNS(outputs)
	contract.O11yStub = extractO11yStub(outputs)
	contract.StarterSourceImageRepositories = extractStringMap(outputs, "starter_source_image_repositories")
	contract.StarterClusterQueueURL = valueAsString(outputs, "starter_cluster_queue_url")
	contract.ProviderCredentialSecrets = extractStringMap(outputs, "provider_credential_secret_names")
	contract.SecretRefs = extractStringMap(outputs, "secret_refs")
	return outputs, contract, nil
}

func WriteContract(path string, contract *OutputContract) error {
	if contract == nil {
		return cp9errors.New(cp9errors.CategoryConfig, "terraform output contract is required")
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "create contract dir: %v", err)
	}
	data, err := json.MarshalIndent(contract, "", "  ")
	if err != nil {
		return cp9errors.Newf(cp9errors.CategoryConfig, "marshal terraform output contract: %v", err)
	}
	if err := os.WriteFile(path, data, 0o600); err != nil {
		return cp9errors.Newf(cp9errors.CategoryResource, "write terraform output contract: %v", err)
	}
	return nil
}

func extractIAM(outputs map[string]OutputValue) *IAMOutput {
	iam := &IAMOutput{}
	if raw, ok := outputs["iam"]; ok {
		if obj, ok := raw.Value.(map[string]any); ok {
			iam.RoleARN = stringField(obj, "role_arn")
			iam.RoleName = stringField(obj, "role_name")
			iam.DataplaneRoleARN = stringField(obj, "dataplane_role_arn")
			iam.DataplaneRoleName = stringField(obj, "dataplane_role_name")
		}
	}
	if iam.RoleARN == "" {
		iam.RoleARN = valueAsString(outputs, "iam_role_arn")
	}
	if iam.RoleName == "" {
		iam.RoleName = valueAsString(outputs, "iam_role_name")
	}
	if iam.DataplaneRoleARN == "" {
		iam.DataplaneRoleARN = valueAsString(outputs, "dataplane_role_arn")
	}
	if iam.DataplaneRoleName == "" {
		iam.DataplaneRoleName = valueAsString(outputs, "dataplane_role_name")
	}
	if iam.RoleARN == "" && iam.RoleName == "" && iam.DataplaneRoleARN == "" && iam.DataplaneRoleName == "" {
		return nil
	}
	return iam
}

func extractMetering(outputs map[string]OutputValue) *MeteringOutput {
	raw, ok := outputs["metering"]
	if !ok {
		return nil
	}
	obj, ok := raw.Value.(map[string]any)
	if !ok {
		return nil
	}
	metering := &MeteringOutput{
		Bucket:           stringField(obj, "bucket"),
		ReadWriteRoleARN: stringField(obj, "read_write_role_arn"),
		WriterRoleARN:    stringField(obj, "writer_role_arn"),
	}
	if metering.Bucket == "" && metering.ReadWriteRoleARN == "" && metering.WriterRoleARN == "" {
		return nil
	}
	return metering
}

func extractRemoteEC2Runtime(outputs map[string]OutputValue) *RemoteEC2RuntimeOutput {
	raw, ok := outputs["remote_ec2_runtime"]
	if !ok {
		return nil
	}
	obj, ok := raw.Value.(map[string]any)
	if !ok {
		return nil
	}
	runtime := &RemoteEC2RuntimeOutput{
		Enabled:             boolField(obj, "enabled"),
		RoleARN:             stringField(obj, "role_arn"),
		RoleName:            stringField(obj, "role_name"),
		InstanceProfileARN:  stringField(obj, "instance_profile_arn"),
		InstanceProfileName: stringField(obj, "instance_profile_name"),
		PowerPolicyARN:      stringField(obj, "power_policy_arn"),
		BoundaryPolicyARN:   stringField(obj, "boundary_policy_arn"),
	}
	if !runtime.Enabled && runtime.RoleARN == "" && runtime.InstanceProfileName == "" {
		return nil
	}
	return runtime
}

func extractMetaDB(outputs map[string]OutputValue) *MetaDBOutput {
	metadb := &MetaDBOutput{}
	if raw, ok := outputs["metadb"]; ok {
		if obj, ok := raw.Value.(map[string]any); ok {
			metadb.Endpoint = stringField(obj, "endpoint")
			metadb.Port = intField(obj, "port")
			metadb.Database = stringField(obj, "database")
			metadb.User = stringField(obj, "user")
			metadb.SecretRef = stringField(obj, "secret_ref")
		}
	}
	if metadb.Endpoint == "" {
		metadb.Endpoint = valueAsString(outputs, "metadb_endpoint")
	}
	if metadb.Port == 0 {
		metadb.Port = valueAsInt(outputs, "metadb_port")
	}
	if metadb.Database == "" {
		metadb.Database = valueAsString(outputs, "metadb_database")
	}
	if metadb.User == "" {
		metadb.User = valueAsString(outputs, "metadb_user")
	}
	if metadb.SecretRef == "" {
		metadb.SecretRef = valueAsString(outputs, "metadb_secret_ref")
	}
	if metadb.Endpoint == "" && metadb.Port == 0 && metadb.Database == "" && metadb.User == "" && metadb.SecretRef == "" {
		return nil
	}
	return metadb
}

func extractRemoteEKS(outputs map[string]OutputValue) *RemoteEKSOutput {
	raw, ok := outputs["remote_eks"]
	if !ok {
		return nil
	}
	obj, ok := raw.Value.(map[string]any)
	if !ok {
		return nil
	}
	remote := &RemoteEKSOutput{
		Enabled:                  boolField(obj, "enabled"),
		ClusterName:              stringField(obj, "cluster_name"),
		ClusterARN:               stringField(obj, "cluster_arn"),
		Endpoint:                 stringField(obj, "endpoint"),
		CertificateAuthorityData: stringField(obj, "certificate_authority_data"),
		Region:                   stringField(obj, "region"),
		AccountID:                stringField(obj, "account_id"),
		KubeconfigContext:        stringField(obj, "kubeconfig_context"),
		RuntimeRoleARN:           stringField(obj, "runtime_role_arn"),
		NodeGroups:               remoteEKSNodeGroupsField(obj, "node_groups"),
		PrivateLinkDiscovery:     remoteEKSPrivateLinkDiscoveryField(obj, "private_link_discovery"),
	}
	if !remote.Enabled && remote.ClusterName == "" && remote.ClusterARN == "" && remote.Endpoint == "" {
		return nil
	}
	return remote
}

func remoteEKSPrivateLinkDiscoveryField(obj map[string]any, key string) *RemoteEKSPrivateLinkDiscoveryOutput {
	raw, ok := obj[key]
	if !ok || raw == nil {
		return nil
	}
	data, err := json.Marshal(raw)
	if err != nil {
		return nil
	}
	var discovery RemoteEKSPrivateLinkDiscoveryOutput
	if err := json.Unmarshal(data, &discovery); err != nil {
		return nil
	}
	if discovery.SubnetTagKey == "" && discovery.SubnetTagValue == "" && discovery.EndpointSecurityGroupName == "" {
		return nil
	}
	return &discovery
}

func remoteEKSNodeGroupsField(obj map[string]any, key string) []RemoteEKSNodeGroupInput {
	raw, ok := obj[key]
	if !ok || raw == nil {
		return nil
	}
	data, err := json.Marshal(raw)
	if err != nil {
		return nil
	}
	var groups []RemoteEKSNodeGroupInput
	if err := json.Unmarshal(data, &groups); err != nil {
		return nil
	}
	for index := range groups {
		if len(groups[index].Labels) == 0 {
			groups[index].Labels = nil
		}
		if len(groups[index].Taints) == 0 {
			groups[index].Taints = nil
		}
	}
	return groups
}

func extractSessionProductPCA(outputs map[string]OutputValue) *SessionProductPCA {
	raw, ok := outputs["session_product_pca"]
	if !ok {
		return nil
	}
	obj, ok := raw.Value.(map[string]any)
	if !ok {
		return nil
	}
	pca := &SessionProductPCA{
		ARN:    stringField(obj, "arn"),
		Region: stringField(obj, "region"),
	}
	if pca.ARN == "" && pca.Region == "" {
		return nil
	}
	return pca
}

func extractSessionCMK(outputs map[string]OutputValue) *SessionCMK {
	raw, ok := outputs["session_cmk"]
	if !ok {
		return nil
	}
	obj, ok := raw.Value.(map[string]any)
	if !ok {
		return nil
	}
	kms := &SessionCMK{
		ARN:    stringField(obj, "arn"),
		Region: stringField(obj, "region"),
	}
	if kms.ARN == "" && kms.Region == "" {
		return nil
	}
	return kms
}

func extractDedicatedPublicDNS(outputs map[string]OutputValue) *DedicatedPublicDNS {
	raw, ok := outputs["dedicated_public_dns"]
	if !ok {
		return nil
	}
	obj, ok := raw.Value.(map[string]any)
	if !ok {
		return nil
	}
	dns := &DedicatedPublicDNS{
		ZoneID: stringField(obj, "zone_id"),
		Domain: stringField(obj, "domain"),
	}
	if dns.ZoneID == "" && dns.Domain == "" {
		return nil
	}
	return dns
}

func extractO11yStub(outputs map[string]OutputValue) *O11yStubOutput {
	raw, ok := outputs["o11y_stub"]
	if !ok {
		return nil
	}
	obj, ok := raw.Value.(map[string]any)
	if !ok {
		return nil
	}
	stub := &O11yStubOutput{
		Enabled:       boolField(obj, "enabled"),
		EndpointURL:   stringField(obj, "endpoint_url"),
		Token:         stringField(obj, "token"),
		EvidenceTable: stringField(obj, "evidence_table"),
		ScriptHash:    stringField(obj, "script_hash"),
	}
	if !stub.Enabled && stub.EndpointURL == "" && stub.EvidenceTable == "" {
		return nil
	}
	return stub
}

func extractStringMap(outputs map[string]OutputValue, key string) map[string]string {
	raw, ok := outputs[key]
	if !ok {
		return nil
	}
	obj, ok := raw.Value.(map[string]any)
	if !ok {
		return nil
	}
	out := make(map[string]string, len(obj))
	for name, value := range obj {
		str, ok := value.(string)
		if ok && strings.TrimSpace(str) != "" {
			out[name] = str
		}
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

func valueAsString(outputs map[string]OutputValue, key string) string {
	raw, ok := outputs[key]
	if !ok {
		return ""
	}
	if str, ok := raw.Value.(string); ok {
		return strings.TrimSpace(str)
	}
	return ""
}

func valueAsInt(outputs map[string]OutputValue, key string) int {
	raw, ok := outputs[key]
	if !ok {
		return 0
	}
	switch v := raw.Value.(type) {
	case float64:
		return int(v)
	case int:
		return v
	default:
		return 0
	}
}

func stringField(obj map[string]any, key string) string {
	raw, ok := obj[key]
	if !ok {
		return ""
	}
	str, ok := raw.(string)
	if !ok {
		return ""
	}
	return strings.TrimSpace(str)
}

func intField(obj map[string]any, key string) int {
	raw, ok := obj[key]
	if !ok {
		return 0
	}
	switch v := raw.(type) {
	case float64:
		return int(v)
	case int:
		return v
	default:
		return 0
	}
}

func boolField(obj map[string]any, key string) bool {
	raw, ok := obj[key]
	if !ok {
		return false
	}
	value, ok := raw.(bool)
	return ok && value
}
