package config

import (
	"crypto/ed25519"
	"crypto/rand"
	"crypto/x509"
	"encoding/pem"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	toml "github.com/pelletier/go-toml"
	"golang.org/x/crypto/ssh"

	"banger/internal/model"
	"banger/internal/paths"
	"banger/internal/system"
)

type fileConfig struct {
	LogLevel           string              `toml:"log_level"`
	FirecrackerBin     string              `toml:"firecracker_bin"`
	JailerBin          string              `toml:"jailer_bin"`
	JailerEnabled      *bool               `toml:"jailer_enabled"`
	JailerChrootBase   string              `toml:"jailer_chroot_base"`
	SSHKeyPath         string              `toml:"ssh_key_path"`
	DefaultImageName   string              `toml:"default_image_name"`
	AutoStopStaleAfter string              `toml:"auto_stop_stale_after"`
	StatsPollInterval  string              `toml:"stats_poll_interval"`
	BridgeName         string              `toml:"bridge_name"`
	BridgeIP           string              `toml:"bridge_ip"`
	CIDR               string              `toml:"cidr"`
	TapPoolSize        int                 `toml:"tap_pool_size"`
	DefaultDNS         string              `toml:"default_dns"`
	FileSync           []fileSyncEntryFile `toml:"file_sync"`
	VMDefaults         *vmDefaultsFile     `toml:"vm_defaults"`
}

type fileSyncEntryFile struct {
	Host  string `toml:"host"`
	Guest string `toml:"guest"`
	Mode  string `toml:"mode"`
}

// vmDefaultsFile mirrors the optional `[vm_defaults]` block. All
// fields are zero-valued when omitted; the resolver treats zero as
// "not set, compute from host or fall back to builtin constants."
type vmDefaultsFile struct {
	VCPUCount         int    `toml:"vcpu"`
	MemoryMiB         int    `toml:"memory_mib"`
	DiskSize          string `toml:"disk_size"`
	SystemOverlaySize string `toml:"system_overlay_size"`
}

func Load(layout paths.Layout) (model.DaemonConfig, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return model.DaemonConfig{}, err
	}
	return load(layout, home, true)
}

func LoadDaemon(layout paths.Layout, ownerHome string) (model.DaemonConfig, error) {
	return load(layout, ownerHome, false)
}

func load(layout paths.Layout, home string, ensureDefaultSSHKey bool) (model.DaemonConfig, error) {
	cfg := model.DaemonConfig{
		LogLevel:           "info",
		AutoStopStaleAfter: 0,
		StatsPollInterval:  model.DefaultStatsPollInterval,
		BridgeName:         model.DefaultBridgeName,
		BridgeIP:           model.DefaultBridgeIP,
		CIDR:               model.DefaultCIDR,
		TapPoolSize:        4,
		DefaultDNS:         model.DefaultDNS,
		DefaultImageName:   "debian-bookworm",
		HostHomeDir:        home,
		JailerBin:          model.DefaultJailerBinary,
		JailerEnabled:      true,
		// Chroot lives under StateDir (ext4) — not RuntimeDir (tmpfs).
		// Hard-linking the kernel and any file-backed drives into the
		// chroot requires same-filesystem; images already live under
		// StateDir, so colocating the chroot avoids EXDEV.
		JailerChrootBase: filepath.Join(layout.StateDir, "jail"),
	}

	var file fileConfig
	configPath := filepath.Join(layout.ConfigDir, "config.toml")
	if info, err := os.Stat(configPath); err == nil && !info.IsDir() {
		data, err := os.ReadFile(configPath)
		if err != nil {
			return cfg, err
		}
		if err := toml.Unmarshal(data, &file); err != nil {
			return cfg, err
		}
	} else if err != nil && !os.IsNotExist(err) {
		return cfg, err
	}

	if value := strings.TrimSpace(file.LogLevel); value != "" {
		cfg.LogLevel = value
	}
	if value := strings.TrimSpace(file.FirecrackerBin); value != "" {
		cfg.FirecrackerBin = value
	} else if path, err := system.LookupExecutable("firecracker"); err == nil {
		cfg.FirecrackerBin = path
	}
	if value := strings.TrimSpace(file.JailerBin); value != "" {
		cfg.JailerBin = value
	}
	if file.JailerEnabled != nil {
		cfg.JailerEnabled = *file.JailerEnabled
	}
	if value := strings.TrimSpace(file.JailerChrootBase); value != "" {
		cfg.JailerChrootBase = value
	}
	if value := strings.TrimSpace(file.DefaultImageName); value != "" {
		cfg.DefaultImageName = value
	}
	if value := strings.TrimSpace(file.BridgeName); value != "" {
		cfg.BridgeName = value
	}
	if value := strings.TrimSpace(file.BridgeIP); value != "" {
		cfg.BridgeIP = value
	}
	if value := strings.TrimSpace(file.CIDR); value != "" {
		cfg.CIDR = value
	}
	if file.TapPoolSize > 0 {
		cfg.TapPoolSize = file.TapPoolSize
	}
	if value := strings.TrimSpace(file.DefaultDNS); value != "" {
		cfg.DefaultDNS = value
	}
	if value := strings.TrimSpace(file.AutoStopStaleAfter); value != "" {
		duration, err := time.ParseDuration(value)
		if err != nil {
			return cfg, err
		}
		cfg.AutoStopStaleAfter = duration
	}
	if value := strings.TrimSpace(file.StatsPollInterval); value != "" {
		duration, err := time.ParseDuration(value)
		if err != nil {
			return cfg, err
		}
		cfg.StatsPollInterval = duration
	}
	if value := strings.TrimSpace(os.Getenv("BANGER_LOG_LEVEL")); value != "" {
		cfg.LogLevel = value
	}

	sshKeyPath, err := resolveSSHKeyPath(layout, file.SSHKeyPath, home, ensureDefaultSSHKey)
	if err != nil {
		return cfg, err
	}
	cfg.SSHKeyPath = sshKeyPath

	for i, entry := range file.FileSync {
		validated, err := validateFileSyncEntry(entry, home)
		if err != nil {
			return cfg, fmt.Errorf("file_sync[%d]: %w", i, err)
		}
		cfg.FileSync = append(cfg.FileSync, validated)
	}

	if file.VMDefaults != nil {
		override, err := parseVMDefaults(*file.VMDefaults)
		if err != nil {
			return cfg, fmt.Errorf("vm_defaults: %w", err)
		}
		cfg.VMDefaults = override
	}
	return cfg, nil
}

// parseVMDefaults validates and translates the TOML block into the
// model-level override struct. Negative values are rejected outright;
// zero means "not set."
func parseVMDefaults(file vmDefaultsFile) (model.VMDefaultsOverride, error) {
	override := model.VMDefaultsOverride{
		VCPUCount: file.VCPUCount,
		MemoryMiB: file.MemoryMiB,
	}
	if override.VCPUCount < 0 {
		return model.VMDefaultsOverride{}, fmt.Errorf("vcpu must be >= 0 (got %d)", override.VCPUCount)
	}
	if override.MemoryMiB < 0 {
		return model.VMDefaultsOverride{}, fmt.Errorf("memory_mib must be >= 0 (got %d)", override.MemoryMiB)
	}
	if value := strings.TrimSpace(file.DiskSize); value != "" {
		bytes, err := model.ParseSize(value)
		if err != nil {
			return model.VMDefaultsOverride{}, fmt.Errorf("disk_size: %w", err)
		}
		override.WorkDiskSizeBytes = bytes
	}
	if value := strings.TrimSpace(file.SystemOverlaySize); value != "" {
		bytes, err := model.ParseSize(value)
		if err != nil {
			return model.VMDefaultsOverride{}, fmt.Errorf("system_overlay_size: %w", err)
		}
		override.SystemOverlaySizeByte = bytes
	}
	return override, nil
}

// validateFileSyncEntry normalises a single `[[file_sync]]` entry
// and rejects anything the operator would regret later: empty
// paths, unsupported leading characters, path traversal, host paths
// outside the owner home, or non-absolute guest targets.
func validateFileSyncEntry(entry fileSyncEntryFile, home string) (model.FileSyncEntry, error) {
	host := strings.TrimSpace(entry.Host)
	guest := strings.TrimSpace(entry.Guest)
	if host == "" {
		return model.FileSyncEntry{}, fmt.Errorf("host path is required")
	}
	if guest == "" {
		return model.FileSyncEntry{}, fmt.Errorf("guest path is required")
	}
	if _, err := ResolveFileSyncHostPath(host, home); err != nil {
		return model.FileSyncEntry{}, err
	}
	if err := validateFileSyncPath("guest", guest, true); err != nil {
		return model.FileSyncEntry{}, err
	}
	// Guest paths must resolve under /root — that's where banger mounts
	// the work disk. Syncing to /etc, /var, etc. would require writing
	// to the rootfs snapshot, which file_sync deliberately doesn't do.
	if !strings.HasPrefix(guest, "~/") && !strings.HasPrefix(guest, "/root/") && guest != "~" && guest != "/root" {
		return model.FileSyncEntry{}, fmt.Errorf("guest path %q: must be under /root or ~/ (the work disk is mounted at /root)", guest)
	}
	mode := strings.TrimSpace(entry.Mode)
	if mode != "" {
		if err := validateFileSyncMode(mode); err != nil {
			return model.FileSyncEntry{}, err
		}
	}
	return model.FileSyncEntry{Host: host, Guest: guest, Mode: mode}, nil
}

// ResolveFileSyncHostPath expands a configured [[file_sync]].host path
// against the owner home and rejects anything that lands outside that
// home. Both config.Load and the root daemon use this so policy cannot
// drift between startup-time validation and runtime file reads.
func ResolveFileSyncHostPath(raw, home string) (string, error) {
	raw = strings.TrimSpace(raw)
	if err := validateFileSyncPath("host", raw, true); err != nil {
		return "", err
	}
	home = strings.TrimSpace(home)
	if home == "" {
		return "", fmt.Errorf("host path %q: owner home is required", raw)
	}
	if !filepath.IsAbs(home) {
		return "", fmt.Errorf("host path %q: owner home %q must be absolute", raw, home)
	}
	candidate := raw
	if strings.HasPrefix(raw, "~/") {
		candidate = filepath.Join(home, strings.TrimPrefix(raw, "~/"))
	}
	candidate = filepath.Clean(candidate)
	if !filepath.IsAbs(candidate) {
		return "", fmt.Errorf("host path %q: resolved path %q must be absolute", raw, candidate)
	}
	if err := ensurePathWithinRoot(candidate, home); err != nil {
		return "", fmt.Errorf("host path %q: %w", raw, err)
	}
	return candidate, nil
}

// ResolveExistingFileSyncHostPath resolves a configured
// [[file_sync]].host path to its real on-disk target. This is the
// runtime companion to ResolveFileSyncHostPath: once os.Stat succeeds,
// the daemon uses this to ensure a top-level symlink still points
// inside the owner home before it reads from the path as root.
func ResolveExistingFileSyncHostPath(raw, home string) (string, error) {
	candidate, err := ResolveFileSyncHostPath(raw, home)
	if err != nil {
		return "", err
	}
	resolved, err := filepath.EvalSymlinks(candidate)
	if err != nil {
		return "", fmt.Errorf("host path %q: resolve symlinks: %w", raw, err)
	}
	resolved = filepath.Clean(resolved)
	if err := ensurePathWithinRoot(resolved, home); err != nil {
		return "", fmt.Errorf("host path %q: resolved symlink target %q: %w", raw, resolved, err)
	}
	return resolved, nil
}

// validateFileSyncPath rejects relative paths (other than a leading
// "~/"), "..", empty segments, and "~user/..." forms banger doesn't
// expand. Absolute paths and home-anchored paths pass through — the
// actual expansion happens at sync time.
func validateFileSyncPath(label, raw string, allowHome bool) error {
	if raw == "~" {
		return fmt.Errorf("%s path %q: bare '~' is not supported, point at a file or directory under it", label, raw)
	}
	// "~user/..." must be rejected specifically — catch it before the
	// generic "must be absolute" message so the error names the real
	// problem.
	if strings.HasPrefix(raw, "~") && !strings.HasPrefix(raw, "~/") {
		return fmt.Errorf("%s path %q: only '~/' is expanded, not '~user/'", label, raw)
	}
	if strings.HasPrefix(raw, "~/") {
		if !allowHome {
			return fmt.Errorf("%s path %q: home-relative paths are not supported here", label, raw)
		}
	} else if !strings.HasPrefix(raw, "/") {
		return fmt.Errorf("%s path %q: must be absolute (start with '/') or home-anchored (start with '~/')", label, raw)
	}
	for _, segment := range strings.Split(raw, "/") {
		if segment == ".." {
			return fmt.Errorf("%s path %q: '..' segments are not allowed", label, raw)
		}
	}
	return nil
}

func ensurePathWithinRoot(candidate, root string) error {
	root = filepath.Clean(strings.TrimSpace(root))
	candidate = filepath.Clean(strings.TrimSpace(candidate))
	rel, err := filepath.Rel(root, candidate)
	if err != nil {
		return fmt.Errorf("compare against owner home %q: %w", root, err)
	}
	if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
		return fmt.Errorf("must stay under owner home %q", root)
	}
	return nil
}

// validateFileSyncMode accepts three- or four-digit octal strings.
// Three-digit modes like "600" are auto-prefixed with a leading 0
// when parsed by the consumer.
func validateFileSyncMode(mode string) error {
	if len(mode) < 3 || len(mode) > 4 {
		return fmt.Errorf("mode %q: must be a 3- or 4-digit octal string", mode)
	}
	for _, r := range mode {
		if r < '0' || r > '7' {
			return fmt.Errorf("mode %q: must be octal (digits 0-7)", mode)
		}
	}
	return nil
}

func resolveSSHKeyPath(layout paths.Layout, configured, home string, ensureDefault bool) (string, error) {
	configured = strings.TrimSpace(configured)
	if configured != "" {
		return normalizeSSHKeyPath(configured, home)
	}
	// Key lives under the state dir, not the config dir. The daemon's
	// ensureVMSSHClientConfig scrubs ConfigDir/ssh on every Open as
	// part of migrating off the pre-state-dir layout — putting the
	// default key there would race with that cleanup (create → delete
	// → next VM create fails to read the key).
	sshDir := strings.TrimSpace(layout.SSHDir)
	if sshDir == "" {
		sshDir = filepath.Join(layout.StateDir, "ssh")
	}
	if !filepath.IsAbs(sshDir) {
		return "", fmt.Errorf("ssh key dir must be absolute; got %q (check paths.Resolve populated SSHDir / StateDir)", sshDir)
	}
	defaultPath := filepath.Join(sshDir, "id_ed25519")
	if ensureDefault {
		return ensureDefaultSSHKey(defaultPath)
	}
	return defaultPath, nil
}

// normalizeSSHKeyPath validates and canonicalises a user-configured
// ssh_key_path. Accepts:
//
//   - absolute paths ("/home/me/keys/id_ed25519")
//   - home-anchored paths ("~/keys/id_ed25519") — expanded against $HOME
//
// Rejects:
//
//   - bare "~" (ambiguous — expand to what?)
//   - "~other/foo" (we only expand the current user's home)
//   - relative paths ("id_ed25519", "./keys/id_ed25519") — these are
//     ambiguous because the daemon's cwd isn't the user's shell cwd,
//     and readers in internal/guest + internal/cli do raw os.ReadFile
//     on the path without re-resolving against a known anchor
func normalizeSSHKeyPath(raw, home string) (string, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return "", nil
	}
	if raw == "~" {
		return "", fmt.Errorf("ssh_key_path %q: bare '~' is not supported, point at a specific key file", raw)
	}
	if strings.HasPrefix(raw, "~") && !strings.HasPrefix(raw, "~/") {
		return "", fmt.Errorf("ssh_key_path %q: only '~/' is expanded, not '~user/'", raw)
	}
	if strings.HasPrefix(raw, "~/") {
		home = strings.TrimSpace(home)
		if home == "" {
			return "", fmt.Errorf("ssh_key_path %q: no home directory available for ~ expansion", raw)
		}
		raw = filepath.Join(home, strings.TrimPrefix(raw, "~/"))
	}
	if !filepath.IsAbs(raw) {
		return "", fmt.Errorf("ssh_key_path %q: must be absolute (start with '/') or home-anchored (start with '~/')", raw)
	}
	return filepath.Clean(raw), nil
}

func ensureDefaultSSHKey(path string) (string, error) {
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return "", err
	}
	if _, err := os.Stat(path); err == nil {
		if err := ensurePublicKeyFile(path); err != nil {
			return "", err
		}
		return path, nil
	} else if !os.IsNotExist(err) {
		return "", err
	}

	_, privateKey, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		return "", err
	}
	pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey)
	if err != nil {
		return "", err
	}
	privatePEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
	if err := os.WriteFile(path, privatePEM, 0o600); err != nil {
		return "", err
	}
	if err := ensurePublicKeyFile(path); err != nil {
		return "", err
	}
	return path, nil
}

func ensurePublicKeyFile(privateKeyPath string) error {
	data, err := os.ReadFile(privateKeyPath)
	if err != nil {
		return err
	}
	signer, err := ssh.ParsePrivateKey(data)
	if err != nil {
		return err
	}
	publicKey := ssh.MarshalAuthorizedKey(signer.PublicKey())
	return os.WriteFile(privateKeyPath+".pub", publicKey, 0o644)
}
