package aws

import (
	"bytes"
	_ "embed"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"syscall"
	"time"
)

const (
	localProfileLockSuffix  = ".cp9-profile.lock"
	credentialHelperRelPath = "cp9/credential-process/cp9-credential-process.sh"
)

//go:embed cp9-credential-process.sh
var credentialProcessHelper []byte

// LocalProfileBinding is the complete, validated runtime profile derived from
// one Session. The owner is the AWS-safe cp9-owner session-tag value.
type LocalProfileBinding struct {
	Name          string
	SourceProfile string
	RoleARN       string
	SessionName   string
	Owner         string
	Region        string
}

// LocalProfileWrite records the local-only mutation performed by setup or
// iam_ready. No cloud resource is created by writing this profile.
type LocalProfileWrite struct {
	Name       string `json:"name"`
	ConfigPath string `json:"config_path"`
	Updated    bool   `json:"updated"`
}

// WriteLocalProfile atomically replaces the configured runtime profile while
// preserving every unrelated AWS config section. Setup and iam_ready call the
// same writer so a stale profile can never survive until Terraform apply.
func WriteLocalProfile(binding *LocalProfileBinding) (*LocalProfileWrite, error) {
	normalizeLocalProfileBinding(binding)
	if err := validateLocalProfileBinding(binding); err != nil {
		return nil, err
	}

	configPath, err := LocalAWSConfigPath()
	if err != nil {
		return nil, err
	}
	if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
		return nil, fmt.Errorf("create AWS config directory: %w", err)
	}

	releaseLock, acquired, err := LockLocalAWSConfig(configPath, false)
	if err != nil {
		return nil, fmt.Errorf("open AWS profile update lock: %w", err)
	}
	if !acquired {
		return nil, fmt.Errorf("lock AWS profile update: lock was not acquired")
	}
	defer releaseLock()

	helperPath := filepath.Join(filepath.Dir(configPath), credentialHelperRelPath)
	helperUpdated, err := writeFileIfChangedAtomic(helperPath, credentialProcessHelper, 0o755)
	if err != nil {
		return nil, fmt.Errorf("install AWS credential-process helper: %w", err)
	}

	writePath, err := resolveLocalWritePath(configPath)
	if err != nil {
		return nil, err
	}
	oldConfig, err := os.ReadFile(writePath)
	if err != nil && !os.IsNotExist(err) {
		return nil, fmt.Errorf("read AWS config %s: %w", writePath, err)
	}
	newConfig, err := replaceLocalProfileSection(string(oldConfig), binding, helperPath)
	if err != nil {
		return nil, err
	}
	configUpdated := newConfig != string(oldConfig)
	if configUpdated {
		if len(oldConfig) > 0 {
			if err := backupLocalAWSConfig(writePath, oldConfig); err != nil {
				return nil, err
			}
		}
		if err := WriteLocalAWSConfigAtomic(configPath, []byte(newConfig)); err != nil {
			return nil, fmt.Errorf("write AWS config %s: %w", writePath, err)
		}
	}

	return &LocalProfileWrite{
		Name:       binding.Name,
		ConfigPath: configPath,
		Updated:    configUpdated || helperUpdated,
	}, nil
}

// ReconcileLocalProfileFromSessionState restores the runtime profile for a
// lifecycle whose IAM validation already completed. It fails closed if the
// persisted AWS identity no longer matches the current Session config.
func ReconcileLocalProfileFromSessionState(
	stateDir string,
	expected *LocalProfileBinding,
) (*LocalProfileWrite, error) {
	state, err := LoadSessionState(stateDir)
	if err != nil {
		return nil, fmt.Errorf("load validated AWS session state: %w", err)
	}
	if expected == nil {
		return nil, fmt.Errorf("reconcile local AWS profile: binding is required")
	}
	binding := *expected
	binding.Owner = state.SessionTags[TagOwner]
	normalizeLocalProfileBinding(&binding)
	if err := validateLocalProfileBinding(&binding); err != nil {
		return nil, err
	}

	persisted := []struct {
		field   string
		current string
		saved   string
	}{
		{field: "session name", current: binding.SessionName, saved: state.SessionID},
		{field: "session tag", current: binding.SessionName, saved: state.SessionTags[TagSession]},
		{field: "profile", current: binding.Name, saved: state.Profile},
		{
			field:   "source profile",
			current: binding.SourceProfile,
			saved:   effectiveSourceProfile(state.Profile, state.SourceProfile),
		},
		{field: "role ARN", current: binding.RoleARN, saved: state.RoleARN},
		{field: "region", current: binding.Region, saved: state.Region},
	}
	for _, value := range persisted {
		if value.current != strings.TrimSpace(value.saved) {
			return nil, fmt.Errorf(
				"reconcile local AWS profile: current %s %q does not match validated session state %q",
				value.field,
				value.current,
				strings.TrimSpace(value.saved),
			)
		}
	}
	return WriteLocalProfile(&binding)
}

func normalizeLocalProfileBinding(binding *LocalProfileBinding) {
	if binding == nil {
		return
	}
	binding.Name = strings.TrimSpace(binding.Name)
	binding.SourceProfile = strings.TrimSpace(binding.SourceProfile)
	binding.RoleARN = strings.TrimSpace(binding.RoleARN)
	binding.SessionName = strings.TrimSpace(binding.SessionName)
	binding.Owner = strings.TrimSpace(binding.Owner)
	binding.Region = strings.TrimSpace(binding.Region)
}

func validateLocalProfileBinding(binding *LocalProfileBinding) error {
	if binding == nil {
		return fmt.Errorf("write local AWS profile: binding is required")
	}
	fields := map[string]string{
		"name":           binding.Name,
		"source profile": binding.SourceProfile,
		"role ARN":       binding.RoleARN,
		"session name":   binding.SessionName,
		"owner":          binding.Owner,
		"region":         binding.Region,
	}
	for field, value := range fields {
		if value == "" {
			return fmt.Errorf("write local AWS profile: %s is required", field)
		}
		if strings.ContainsAny(value, "\r\n") {
			return fmt.Errorf("write local AWS profile: %s contains a newline", field)
		}
	}
	if strings.ContainsAny(binding.Name, "[]") {
		return fmt.Errorf("write local AWS profile: profile name %q is invalid", binding.Name)
	}
	if binding.Name == binding.SourceProfile {
		return fmt.Errorf(
			"write local AWS profile: runtime profile %q must differ from source profile to avoid a credential_process cycle",
			binding.Name,
		)
	}
	return nil
}

// LocalAWSConfigPath resolves the AWS shared config file used by both profile
// reconciliation and destroy-time ownership cleanup.
func LocalAWSConfigPath() (string, error) {
	if configured := strings.TrimSpace(os.Getenv("AWS_CONFIG_FILE")); configured != "" {
		path, err := filepath.Abs(configured)
		if err != nil {
			return "", fmt.Errorf("resolve AWS_CONFIG_FILE: %w", err)
		}
		return path, nil
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return "", fmt.Errorf("resolve home directory for AWS profile update: %w", err)
	}
	return filepath.Join(home, ".aws", "config"), nil
}

func replaceLocalProfileSection(
	config string,
	binding *LocalProfileBinding,
	helperPath string,
) (string, error) {
	lines := []string{}
	if config != "" {
		lines = strings.Split(strings.TrimRight(config, "\n"), "\n")
	}
	header := "[profile " + binding.Name + "]"
	start := -1
	end := len(lines)
	count := 0
	for index, line := range lines {
		trimmed := strings.TrimSpace(line)
		if trimmed == header {
			count++
			if start < 0 {
				start = index
			}
			continue
		}
		if start >= 0 && end == len(lines) && isAWSConfigSectionHeader(trimmed) {
			end = index
		}
	}
	if count > 1 {
		return "", fmt.Errorf(
			"write local AWS profile: AWS config contains %d %q sections",
			count,
			header,
		)
	}
	if start >= 0 {
		sessionMarker := "# cp9-session = " + binding.SessionName
		if !slices.ContainsFunc(lines[start+1:end], func(line string) bool {
			return strings.TrimSpace(line) == sessionMarker
		}) {
			return "", fmt.Errorf(
				"write local AWS profile: refusing to replace %q because it is not owned by session %q",
				binding.Name,
				binding.SessionName,
			)
		}
	}

	newSection := []string{
		header,
		"# cp9-session = " + binding.SessionName,
		"# cp9-owner = " + binding.Owner,
		"region = " + binding.Region,
		"output = yaml",
		"credential_process = /bin/bash " +
			shellQuote(helperPath) + " " +
			shellQuote(binding.SourceProfile) + " " +
			shellQuote(binding.RoleARN) + " " +
			shellQuote(binding.SessionName) + " 3600 " +
			shellQuote(binding.Owner),
	}

	var out []string
	if start < 0 {
		out = append(out, lines...)
		if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
			out = append(out, "")
		}
		out = append(out, newSection...)
	} else {
		out = append(out, lines[:start]...)
		out = append(out, newSection...)
		if end < len(lines) {
			if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
				out = append(out, "")
			}
			out = append(out, lines[end:]...)
		}
	}
	return strings.Join(out, "\n") + "\n", nil
}

func isAWSConfigSectionHeader(line string) bool {
	return strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]")
}

func shellQuote(value string) string {
	return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}

func backupLocalAWSConfig(path string, data []byte) error {
	info, err := os.Stat(path)
	if err != nil {
		return fmt.Errorf("stat AWS config %s: %w", path, err)
	}
	backupPath := fmt.Sprintf(
		"%s.cp9-backup.%s",
		path,
		time.Now().UTC().Format("20060102T150405.000000000"),
	)
	// #nosec G703 -- backupPath is derived from the resolved local AWS config path, not caller input.
	if err := os.WriteFile(backupPath, data, info.Mode().Perm()); err != nil {
		return fmt.Errorf("backup AWS config %s: %w", path, err)
	}
	return nil
}

// LockLocalAWSConfig acquires the host-global profile update lock shared by
// setup and destroy. A non-blocking caller receives acquired=false when another
// process owns the lock.
func LockLocalAWSConfig(configPath string, nonBlocking bool) (release func(), acquired bool, err error) {
	lock, err := os.OpenFile(configPath+localProfileLockSuffix, os.O_CREATE|os.O_RDWR, 0o600)
	if err != nil {
		return nil, false, err
	}
	flags := syscall.LOCK_EX
	if nonBlocking {
		flags |= syscall.LOCK_NB
	}
	if err := syscall.Flock(int(lock.Fd()), flags); err != nil {
		_ = lock.Close()
		if nonBlocking && (errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN)) {
			return nil, false, nil
		}
		return nil, false, err
	}
	return func() {
		_ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
		_ = lock.Close()
	}, true, nil
}

// WriteLocalAWSConfigAtomic replaces the shared AWS config without following a
// symlink at rename time and preserves existing file permissions.
func WriteLocalAWSConfigAtomic(path string, data []byte) error {
	writePath, err := resolveLocalWritePath(path)
	if err != nil {
		return err
	}
	return writeFileAtomic(writePath, data, 0o600)
}

func writeFileIfChangedAtomic(path string, data []byte, mode os.FileMode) (bool, error) {
	writePath, err := resolveLocalWritePath(path)
	if err != nil {
		return false, err
	}
	existing, err := os.ReadFile(writePath)
	if err == nil && bytes.Equal(existing, data) {
		info, statErr := os.Stat(writePath)
		if statErr != nil {
			return false, statErr
		}
		modeChanged := info.Mode().Perm() != mode.Perm()
		if err := os.Chmod(writePath, mode); err != nil {
			return false, err
		}
		return modeChanged, nil
	}
	if err != nil && !os.IsNotExist(err) {
		return false, err
	}
	if err := writeFileAtomic(writePath, data, mode); err != nil {
		return false, err
	}
	if err := os.Chmod(writePath, mode); err != nil {
		return false, err
	}
	return true, nil
}

func resolveLocalWritePath(path string) (string, error) {
	resolved, err := filepath.EvalSymlinks(path)
	if err == nil {
		return resolved, nil
	}
	if os.IsNotExist(err) {
		return path, nil
	}
	return "", fmt.Errorf("resolve local AWS path %s: %w", path, err)
}

func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return err
	}
	if info, err := os.Stat(path); err == nil {
		mode = info.Mode().Perm()
	} else if !os.IsNotExist(err) {
		return err
	}
	temporary, err := os.CreateTemp(filepath.Dir(path), ".cp9-local-write-*")
	if err != nil {
		return err
	}
	temporaryPath := temporary.Name()
	defer os.Remove(temporaryPath)
	if err := temporary.Chmod(mode); err != nil {
		_ = temporary.Close()
		return err
	}
	if _, err := temporary.Write(data); err != nil {
		_ = temporary.Close()
		return err
	}
	if err := temporary.Sync(); err != nil {
		_ = temporary.Close()
		return err
	}
	if err := temporary.Close(); err != nil {
		return err
	}
	if err := os.Rename(temporaryPath, path); err != nil {
		return err
	}
	directory, err := os.Open(filepath.Dir(path))
	if err != nil {
		return err
	}
	defer directory.Close()
	return directory.Sync()
}
