package daemon

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path"
	"path/filepath"
	"strconv"
	"strings"

	"banger/internal/config"
	"banger/internal/guest"
	"banger/internal/model"
	"banger/internal/system"
)

const (
	workDiskGitConfigRelativePath = ".gitconfig"
	hostGlobalGitIdentitySource   = "git config --global"
)

type gitIdentity struct {
	Name  string
	Email string
}

func (s *WorkspaceService) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VMRecord, image model.Image, prep workDiskPreparation) error {
	fingerprint, err := guest.AuthorizedPublicKeyFingerprint(s.config.SSHKeyPath)
	if err != nil {
		return fmt.Errorf("derive authorized ssh key fingerprint: %w", err)
	}
	if prep.ClonedFromSeed && image.SeededSSHPublicKeyFingerprint != "" && image.SeededSSHPublicKeyFingerprint == fingerprint {
		vmCreateStage(ctx, "prepare_work_disk", "using seeded SSH access")
		return nil
	}
	publicKey, err := guest.AuthorizedPublicKey(s.config.SSHKeyPath)
	if err != nil {
		return fmt.Errorf("derive authorized ssh key: %w", err)
	}
	vmCreateStage(ctx, "prepare_work_disk", "provisioning SSH access on work disk")

	workDisk := vm.Runtime.WorkDiskPath
	if err := provisionAuthorizedKey(ctx, s.runner, workDisk, publicKey); err != nil {
		return err
	}

	if prep.ClonedFromSeed && image.Managed {
		vmCreateStage(ctx, "prepare_work_disk", "refreshing managed work seed")
		if err := s.imageWorkSeed(ctx, image, fingerprint); err != nil {
			return err
		}
	}
	return nil
}

// provisionAuthorizedKey writes the managed SSH key into
// /.ssh/authorized_keys on an ext4 image via the sudoless toolkit.
// Shared between work-disk and image-seed paths — both need the same
// sequence: normalise fs-root perms, create /.ssh, merge against any
// existing authorized_keys, rewrite with root:root:0600.
//
// The fs root doubles as /root inside the guest, which sshd walks
// under StrictModes; forcing 0755 root:root here keeps a drifted
// seed image from silently rejecting the key at login time.
func provisionAuthorizedKey(ctx context.Context, runner system.CommandRunner, imagePath string, publicKey []byte) error {
	if err := system.EnsureExt4RootPerms(ctx, runner, imagePath, 0o755, 0, 0); err != nil {
		return err
	}
	if err := system.MkdirExt4(ctx, runner, imagePath, "/.ssh", 0o700, 0, 0); err != nil {
		return err
	}
	var existing []byte
	exists, err := system.Ext4PathExists(ctx, runner, imagePath, "/.ssh/authorized_keys")
	if err != nil {
		return err
	}
	if exists {
		existing, err = system.ReadExt4File(ctx, runner, imagePath, "/.ssh/authorized_keys")
		if err != nil {
			return err
		}
	}
	merged := mergeAuthorizedKey(existing, publicKey)
	return system.WriteExt4FileOwned(ctx, runner, imagePath, "/.ssh/authorized_keys", 0o600, 0, 0, merged)
}

// ensureHushLoginOnWorkDisk lands /root/.hushlogin in the guest by
// writing /.hushlogin at the root of the work disk (which mounts at
// /root inside the guest). pam_motd checks $HOME/.hushlogin and stays
// silent when it exists — combined with sshd's PrintMotd no / PrintLastLog no
// that suppresses the Debian-style banner on `banger vm run`.
func (s *WorkspaceService) ensureHushLoginOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
	return system.WriteExt4FileOwned(ctx, s.runner, vm.Runtime.WorkDiskPath, "/.hushlogin", 0o644, 0, 0, nil)
}

func (s *WorkspaceService) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
	runner := s.runner
	if runner == nil {
		runner = system.NewRunner()
	}

	identity, err := resolveHostGlobalGitIdentity(ctx, runner)
	if err != nil {
		s.warnGitIdentitySyncSkipped(*vm, hostGlobalGitIdentitySource, err)
		return nil
	}

	vmCreateStage(ctx, "prepare_work_disk", "syncing git identity")
	return writeGitIdentity(ctx, runner, vm.Runtime.WorkDiskPath, "/"+workDiskGitConfigRelativePath, identity)
}

// runFileSync applies every [[file_sync]] entry from the daemon config
// to the VM's work disk. Missing host paths are skipped with a warn.
// Other errors abort the VM create (since the user explicitly asked
// for the sync).
//
// Operates directly on the ext4 image via the sudoless toolkit — no
// mount, no privileged install(1). Every write lands as root:root;
// file modes come from the [[file_sync]] entry (default 0600),
// directory modes from the source on the host.
func (s *WorkspaceService) runFileSync(ctx context.Context, vm *model.VMRecord) error {
	if len(s.config.FileSync) == 0 {
		return nil
	}

	runner := s.runner
	if runner == nil {
		runner = system.NewRunner()
	}

	hostHome := strings.TrimSpace(s.config.HostHomeDir)
	if hostHome == "" {
		var err error
		hostHome, err = os.UserHomeDir()
		if err != nil {
			return fmt.Errorf("resolve host user home: %w", err)
		}
	}

	workDisk := vm.Runtime.WorkDiskPath

	for _, entry := range s.config.FileSync {
		hostPath, err := config.ResolveFileSyncHostPath(entry.Host, hostHome)
		if err != nil {
			return fmt.Errorf("file_sync: %w", err)
		}
		guestRel := guestPathRelativeToRoot(entry.Guest)
		guestImagePath := "/" + guestRel

		info, err := os.Stat(hostPath)
		if err != nil {
			if os.IsNotExist(err) {
				s.warnFileSyncSkipped(*vm, hostPath, err)
				continue
			}
			return fmt.Errorf("file_sync: stat %s: %w", hostPath, err)
		}
		hostPath, err = config.ResolveExistingFileSyncHostPath(entry.Host, hostHome)
		if err != nil {
			return fmt.Errorf("file_sync: %w", err)
		}

		vmCreateStage(ctx, "prepare_work_disk", "file sync: "+entry.Host+" → "+entry.Guest)

		parent := path.Dir(guestImagePath)
		if parent != "/" && parent != "." {
			if err := system.MkdirAllExt4(ctx, runner, workDisk, parent, 0o755, 0, 0); err != nil {
				return fmt.Errorf("file_sync: mkdir %s: %w", parent, err)
			}
		}

		if info.IsDir() {
			if err := s.copyHostDir(ctx, *vm, runner, workDisk, hostPath, guestImagePath); err != nil {
				return fmt.Errorf("file_sync: copy directory %s → %s: %w", hostPath, guestImagePath, err)
			}
			continue
		}

		mode, err := parseFileSyncMode(entry.Mode)
		if err != nil {
			return fmt.Errorf("file_sync: %s: %w", entry.Host, err)
		}
		data, err := os.ReadFile(hostPath)
		if err != nil {
			return fmt.Errorf("file_sync: read %s: %w", hostPath, err)
		}
		if err := system.WriteExt4FileOwned(ctx, runner, workDisk, guestImagePath, mode, 0, 0, data); err != nil {
			return fmt.Errorf("file_sync: write %s → %s: %w", hostPath, guestImagePath, err)
		}
	}
	return nil
}

// copyHostDir recursively copies hostDir into guestTarget on the
// ext4 image via the sudoless toolkit. Each file's source permissions
// are preserved; directories get 0755; ownership is forced to
// root:root. Symlinks are SKIPPED with a warning — os.Lstat identifies
// the entry itself as a link without resolving it, so a symlink
// inside ~/.aws that points at ~/secrets can't leak out of the tree
// the user named. Other special types (devices, FIFOs) are skipped
// silently. Top-level host paths go through os.Stat back in
// runFileSync and may still follow, but only when the resolved target
// stays under the configured owner home.
func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, runner system.CommandRunner, imagePath, hostDir, guestTarget string) error {
	if err := system.MkdirExt4(ctx, runner, imagePath, guestTarget, 0o755, 0, 0); err != nil {
		return err
	}
	entries, err := os.ReadDir(hostDir)
	if err != nil {
		return err
	}
	for _, entry := range entries {
		hostChild := filepath.Join(hostDir, entry.Name())
		guestChild := path.Join(guestTarget, entry.Name())

		info, err := os.Lstat(hostChild)
		if err != nil {
			return err
		}
		switch {
		case info.Mode()&os.ModeSymlink != 0:
			s.warnFileSyncSymlinkSkipped(vm, hostChild)
		case info.IsDir():
			if err := s.copyHostDir(ctx, vm, runner, imagePath, hostChild, guestChild); err != nil {
				return err
			}
		case info.Mode().IsRegular():
			data, err := os.ReadFile(hostChild)
			if err != nil {
				return err
			}
			if err := system.WriteExt4FileOwned(ctx, runner, imagePath, guestChild, info.Mode().Perm(), 0, 0, data); err != nil {
				return err
			}
		}
	}
	return nil
}

// parseFileSyncMode parses the [[file_sync]] mode field (octal string,
// default "0600"). Returns the parsed FileMode with only the permission
// bits set; callers OR in S_IFREG via WriteExt4FileOwned.
func parseFileSyncMode(raw string) (os.FileMode, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		raw = "0600"
	}
	v, err := strconv.ParseUint(raw, 8, 32)
	if err != nil {
		return 0, fmt.Errorf("parse mode %q: %w", raw, err)
	}
	return os.FileMode(v) & os.ModePerm, nil
}

// expandHostPath expands a leading "~/" against the host user's
// guestPathRelativeToRoot returns the guest path as a relative path
// under /root (banger's work disk is mounted at /root in the guest,
// so everything syncable lives there). "~/foo" and "/root/foo" both
// return "foo"; config validation rejects anything outside that
// scope, so the string prefixes are the only forms we see here.
func guestPathRelativeToRoot(raw string) string {
	raw = strings.TrimSpace(raw)
	switch {
	case raw == "~" || raw == "/root":
		return ""
	case strings.HasPrefix(raw, "~/"):
		return strings.TrimPrefix(raw, "~/")
	case strings.HasPrefix(raw, "/root/"):
		return strings.TrimPrefix(raw, "/root/")
	}
	return raw
}

func resolveHostGlobalGitIdentity(ctx context.Context, runner system.CommandRunner) (gitIdentity, error) {
	name, err := gitConfigValue(ctx, runner, nil, "user.name")
	if err != nil {
		return gitIdentity{}, err
	}
	if name == "" {
		return gitIdentity{}, errors.New("host git user.name is empty")
	}

	email, err := gitConfigValue(ctx, runner, nil, "user.email")
	if err != nil {
		return gitIdentity{}, err
	}
	if email == "" {
		return gitIdentity{}, errors.New("host git user.email is empty")
	}

	return gitIdentity{Name: name, Email: email}, nil
}

func gitConfigValue(ctx context.Context, runner system.CommandRunner, extraArgs []string, key string) (string, error) {
	args := []string{"config"}
	args = append(args, extraArgs...)
	args = append(args, "--default", "", "--get", key)
	out, err := runner.Run(ctx, "git", args...)
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(string(out)), nil
}

// writeGitIdentity merges user.name + user.email into the on-image
// gitconfig at guestPath. Reads the existing bytes via the ext4
// toolkit (no-op to empty if absent), edits via `git config --file`
// on a host tempfile so any pre-existing unrelated sections are
// preserved verbatim, then writes back through WriteExt4FileOwned
// at 0644 root:root.
func writeGitIdentity(ctx context.Context, runner system.CommandRunner, imagePath, guestPath string, identity gitIdentity) error {
	var existing []byte
	exists, err := system.Ext4PathExists(ctx, runner, imagePath, guestPath)
	if err != nil {
		return err
	}
	if exists {
		existing, err = system.ReadExt4File(ctx, runner, imagePath, guestPath)
		if err != nil {
			return err
		}
	}

	tmpFile, err := os.CreateTemp("", "banger-gitconfig-*")
	if err != nil {
		return err
	}
	tmpPath := tmpFile.Name()
	if _, err := tmpFile.Write(existing); err != nil {
		_ = tmpFile.Close()
		_ = os.Remove(tmpPath)
		return err
	}
	if err := tmpFile.Close(); err != nil {
		_ = os.Remove(tmpPath)
		return err
	}
	defer os.Remove(tmpPath)

	if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.name", identity.Name); err != nil {
		return err
	}
	if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.email", identity.Email); err != nil {
		return err
	}
	merged, err := os.ReadFile(tmpPath)
	if err != nil {
		return err
	}
	return system.WriteExt4FileOwned(ctx, runner, imagePath, guestPath, 0o644, 0, 0, merged)
}

func (s *WorkspaceService) warnFileSyncSkipped(vm model.VMRecord, hostPath string, err error) {
	if s.logger == nil || err == nil {
		return
	}
	s.logger.Warn("file_sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
}

// warnFileSyncSymlinkSkipped surfaces a skipped nested symlink to the
// user through the daemon log. Skipping is deliberate — see
// copyHostDir's docstring — but invisible skips would hide a
// "why did my file not show up in the guest?" debugging trail.
func (s *WorkspaceService) warnFileSyncSymlinkSkipped(vm model.VMRecord, hostPath string) {
	if s.logger == nil {
		return
	}
	s.logger.Warn("file_sync skipped symlink (would escape the requested tree)", append(vmLogAttrs(vm), "host_path", hostPath)...)
}

func (s *WorkspaceService) warnGitIdentitySyncSkipped(vm model.VMRecord, source string, err error) {
	if s.logger == nil || err == nil {
		return
	}
	s.logger.Warn("guest git identity sync skipped", append(vmLogAttrs(vm), "source", source, "error", err.Error())...)
}

func mergeAuthorizedKey(existing, managed []byte) []byte {
	managedLine := strings.TrimSpace(string(managed))
	if managedLine == "" {
		return append([]byte(nil), existing...)
	}

	lines := strings.Split(strings.ReplaceAll(string(existing), "\r\n", "\n"), "\n")
	out := make([]string, 0, len(lines)+1)
	found := false
	for _, line := range lines {
		line = strings.TrimRight(line, "\r")
		trimmed := strings.TrimSpace(line)
		if trimmed == "" {
			continue
		}
		if trimmed == managedLine {
			found = true
		}
		out = append(out, line)
	}
	if !found {
		out = append(out, managedLine)
	}
	return []byte(strings.Join(out, "\n") + "\n")
}
