package config

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"banger/internal/paths"
)

func TestLoadDefaultsResolveFirecrackerAndGenerateSSHKey(t *testing.T) {
	configDir := t.TempDir()
	sshDir := t.TempDir()
	binDir := t.TempDir()
	firecrackerPath := filepath.Join(binDir, "firecracker")
	if err := os.WriteFile(firecrackerPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
		t.Fatalf("write firecracker: %v", err)
	}
	t.Setenv("PATH", binDir)

	cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: sshDir})
	if err != nil {
		t.Fatalf("Load: %v", err)
	}

	if cfg.FirecrackerBin != firecrackerPath {
		t.Fatalf("FirecrackerBin = %q, want %q", cfg.FirecrackerBin, firecrackerPath)
	}
	// Default key lives under SSHDir (state dir), NOT ConfigDir/ssh.
	// ConfigDir/ssh gets scrubbed by ensureVMSSHClientConfig on every
	// daemon Open, so regression-guard that the generator never picks
	// that path again.
	wantKey := filepath.Join(sshDir, "id_ed25519")
	if cfg.SSHKeyPath != wantKey {
		t.Fatalf("SSHKeyPath = %q, want %q", cfg.SSHKeyPath, wantKey)
	}
	for _, path := range []string{wantKey, wantKey + ".pub"} {
		if _, err := os.Stat(path); err != nil {
			t.Fatalf("stat %s: %v", path, err)
		}
	}
	forbiddenKey := filepath.Join(configDir, "ssh", "id_ed25519")
	if _, err := os.Stat(forbiddenKey); err == nil {
		t.Fatalf("key was also generated at %s; config.Load must not write under ConfigDir/ssh", forbiddenKey)
	}
	if cfg.DefaultImageName != "debian-bookworm" {
		t.Fatalf("DefaultImageName = %q, want debian-bookworm", cfg.DefaultImageName)
	}
}

func TestLoadSSHKeyPathExpandsHomeAnchored(t *testing.T) {
	homeDir := t.TempDir()
	t.Setenv("HOME", homeDir)

	configDir := t.TempDir()
	data := []byte("ssh_key_path = \"~/mykeys/id_ed25519\"\n")
	if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
		t.Fatalf("write config.toml: %v", err)
	}

	cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	want := filepath.Join(homeDir, "mykeys", "id_ed25519")
	if cfg.SSHKeyPath != want {
		t.Fatalf("SSHKeyPath = %q, want %q", cfg.SSHKeyPath, want)
	}
}

func TestLoadDaemonDoesNotGenerateDefaultSSHKey(t *testing.T) {
	ownerHome := t.TempDir()
	sshDir := filepath.Join(t.TempDir(), "daemon-ssh")
	cfg, err := LoadDaemon(paths.Layout{ConfigDir: t.TempDir(), SSHDir: sshDir}, ownerHome)
	if err != nil {
		t.Fatalf("LoadDaemon: %v", err)
	}
	wantKey := filepath.Join(sshDir, "id_ed25519")
	if cfg.SSHKeyPath != wantKey {
		t.Fatalf("SSHKeyPath = %q, want %q", cfg.SSHKeyPath, wantKey)
	}
	if cfg.HostHomeDir != ownerHome {
		t.Fatalf("HostHomeDir = %q, want %q", cfg.HostHomeDir, ownerHome)
	}
	if _, err := os.Stat(wantKey); !os.IsNotExist(err) {
		t.Fatalf("LoadDaemon created %s, want no key material on daemon config load", wantKey)
	}
}

// TestLoadNormalizesAbsoluteSSHKeyPath pins filepath.Clean behaviour
// for configured paths: trailing slashes and duplicate slashes are
// flattened so downstream path comparisons don't see two spellings
// for the same path.
func TestLoadNormalizesAbsoluteSSHKeyPath(t *testing.T) {
	cases := []struct {
		name string
		raw  string
		want string
	}{
		{"trailing slash collapsed", "/tmp/keys/id_ed25519/", "/tmp/keys/id_ed25519"},
		{"duplicate slashes collapsed", "/tmp//keys///id_ed25519", "/tmp/keys/id_ed25519"},
		{"dot segments resolved", "/tmp/keys/./id_ed25519", "/tmp/keys/id_ed25519"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			configDir := t.TempDir()
			data := []byte("ssh_key_path = \"" + tc.raw + "\"\n")
			if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
				t.Fatalf("write config.toml: %v", err)
			}
			cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
			if err != nil {
				t.Fatalf("Load %q: %v", tc.raw, err)
			}
			if cfg.SSHKeyPath != tc.want {
				t.Fatalf("SSHKeyPath = %q, want %q", cfg.SSHKeyPath, tc.want)
			}
		})
	}
}

// TestEnsureDefaultSSHKeyRejectsCorruptExistingFile pins the
// "don't silently overwrite" contract: if someone wrote garbage to
// the default key path (or the key was truncated mid-write by a
// previous crash), config.Load must surface the parse error instead
// of pretending the file is usable. The regression we care about is
// a future refactor that adds "regenerate if invalid" silently —
// that would nuke a real user key on every daemon Open.
func TestEnsureDefaultSSHKeyRejectsCorruptExistingFile(t *testing.T) {
	sshDir := t.TempDir()
	corruptKey := filepath.Join(sshDir, "id_ed25519")
	if err := os.WriteFile(corruptKey, []byte("not a pem private key"), 0o600); err != nil {
		t.Fatalf("write corrupt key: %v", err)
	}

	_, err := Load(paths.Layout{ConfigDir: t.TempDir(), SSHDir: sshDir})
	if err == nil {
		t.Fatal("Load: want error when existing key file is not a valid private key")
	}
	// The error should mention the parse failure, not "regenerated".
	if strings.Contains(err.Error(), "regenerat") {
		t.Fatalf("Load silently regenerated: %v", err)
	}
	// Original garbage must still be there — the invariant is "don't
	// touch files you can't parse".
	data, readErr := os.ReadFile(corruptKey)
	if readErr != nil {
		t.Fatalf("ReadFile: %v", readErr)
	}
	if string(data) != "not a pem private key" {
		t.Fatalf("key content = %q, want the original garbage", string(data))
	}
}

// TestResolveSSHKeyPathRejectsEmptySSHDirAndStateDir pins the
// guard in resolveSSHKeyPath: if a caller builds a layout without
// SSHDir and StateDir, they shouldn't get a key generated in cwd.
// The guard existed before (added after a test scribbled into
// internal/config/ssh/); this test prevents it from going away.
func TestResolveSSHKeyPathRejectsEmptySSHDirAndStateDir(t *testing.T) {
	_, err := Load(paths.Layout{ConfigDir: t.TempDir()})
	if err == nil {
		t.Fatal("Load: want error when neither SSHDir nor StateDir is set")
	}
	if !strings.Contains(err.Error(), "must be absolute") {
		t.Fatalf("Load error = %v, want 'must be absolute' diagnostic", err)
	}
}

func TestLoadRejectsInvalidSSHKeyPath(t *testing.T) {
	cases := []struct {
		name string
		raw  string
		want string
	}{
		{"relative bare", "id_ed25519", "must be absolute"},
		{"relative with dot", "./keys/id_ed25519", "must be absolute"},
		{"bare tilde", "~", "bare '~' is not supported"},
		{"user-tilde", "~other/id_ed25519", "only '~/' is expanded"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			configDir := t.TempDir()
			data := []byte("ssh_key_path = \"" + tc.raw + "\"\n")
			if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
				t.Fatalf("write config.toml: %v", err)
			}
			_, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
			if err == nil {
				t.Fatalf("Load %q: want error containing %q", tc.raw, tc.want)
			}
			if !strings.Contains(err.Error(), tc.want) {
				t.Fatalf("Load %q: error = %v, want contains %q", tc.raw, err, tc.want)
			}
		})
	}
}

func TestLoadAppliesConfigOverrides(t *testing.T) {
	configDir := t.TempDir()
	data := []byte(`
log_level = "debug"
firecracker_bin = "/opt/firecracker"
ssh_key_path = "/tmp/custom-key"
default_image_name = "void"
auto_stop_stale_after = "1h"
stats_poll_interval = "15s"
bridge_name = "br-test"
bridge_ip = "10.0.0.1"
cidr = "25"
tap_pool_size = 8
default_dns = "9.9.9.9"
`)
	if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
		t.Fatalf("write config.toml: %v", err)
	}

	cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
	if err != nil {
		t.Fatalf("Load: %v", err)
	}

	if cfg.LogLevel != "debug" {
		t.Fatalf("LogLevel = %q", cfg.LogLevel)
	}
	if cfg.FirecrackerBin != "/opt/firecracker" {
		t.Fatalf("FirecrackerBin = %q", cfg.FirecrackerBin)
	}
	if cfg.SSHKeyPath != "/tmp/custom-key" {
		t.Fatalf("SSHKeyPath = %q", cfg.SSHKeyPath)
	}
	if cfg.DefaultImageName != "void" {
		t.Fatalf("DefaultImageName = %q", cfg.DefaultImageName)
	}
	if cfg.AutoStopStaleAfter != time.Hour {
		t.Fatalf("AutoStopStaleAfter = %s", cfg.AutoStopStaleAfter)
	}
	if cfg.StatsPollInterval != 15*time.Second {
		t.Fatalf("StatsPollInterval = %s", cfg.StatsPollInterval)
	}
	if cfg.BridgeName != "br-test" || cfg.BridgeIP != "10.0.0.1" || cfg.CIDR != "25" {
		t.Fatalf("bridge config = %+v", cfg)
	}
	if cfg.TapPoolSize != 8 {
		t.Fatalf("TapPoolSize = %d", cfg.TapPoolSize)
	}
	if cfg.DefaultDNS != "9.9.9.9" {
		t.Fatalf("DefaultDNS = %q", cfg.DefaultDNS)
	}
}

func TestLoadAppliesLogLevelEnvOverride(t *testing.T) {
	t.Setenv("BANGER_LOG_LEVEL", "warn")

	cfg, err := Load(paths.Layout{ConfigDir: t.TempDir(), SSHDir: t.TempDir()})
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if cfg.LogLevel != "warn" {
		t.Fatalf("LogLevel = %q, want warn", cfg.LogLevel)
	}
}

func TestLoadAcceptsFileSyncEntries(t *testing.T) {
	homeDir := t.TempDir()
	t.Setenv("HOME", homeDir)

	configDir := t.TempDir()
	hostsFile := filepath.Join(homeDir, ".config", "gh", "hosts.yml")
	data := []byte(`
[[file_sync]]
host = "~/.aws"
guest = "~/.aws"

[[file_sync]]
host = "` + hostsFile + `"
guest = "/root/.config/gh/hosts.yml"
mode = "0644"
`)
	if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
		t.Fatal(err)
	}
	cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if len(cfg.FileSync) != 2 {
		t.Fatalf("FileSync = %+v", cfg.FileSync)
	}
	if cfg.FileSync[0].Host != "~/.aws" || cfg.FileSync[0].Guest != "~/.aws" {
		t.Fatalf("entry[0] = %+v", cfg.FileSync[0])
	}
	if cfg.FileSync[1].Host != hostsFile || cfg.FileSync[1].Guest != "/root/.config/gh/hosts.yml" {
		t.Fatalf("entry[1] = %+v", cfg.FileSync[1])
	}
	if cfg.FileSync[1].Mode != "0644" {
		t.Fatalf("entry[1] mode = %q", cfg.FileSync[1].Mode)
	}
}

func TestLoadDaemonAcceptsFileSyncPathUnderOwnerHome(t *testing.T) {
	ownerHome := t.TempDir()
	t.Setenv("HOME", t.TempDir())

	configDir := t.TempDir()
	allowed := filepath.Join(ownerHome, ".config", "gh", "hosts.yml")
	data := []byte(`
[[file_sync]]
host = "` + allowed + `"
guest = "~/.config/gh/hosts.yml"
`)
	if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
		t.Fatal(err)
	}

	cfg, err := LoadDaemon(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()}, ownerHome)
	if err != nil {
		t.Fatalf("LoadDaemon: %v", err)
	}
	got, err := ResolveFileSyncHostPath(cfg.FileSync[0].Host, cfg.HostHomeDir)
	if err != nil {
		t.Fatalf("ResolveFileSyncHostPath: %v", err)
	}
	if got != allowed {
		t.Fatalf("resolved host path = %q, want %q", got, allowed)
	}
}

func TestLoadRejectsInvalidFileSyncEntries(t *testing.T) {
	cases := []struct {
		name string
		toml string
		want string
	}{
		{
			"empty host",
			`[[file_sync]]` + "\n" + `host = ""` + "\n" + `guest = "~/foo"`,
			"host path is required",
		},
		{
			"empty guest",
			`[[file_sync]]` + "\n" + `host = "~/foo"` + "\n" + `guest = ""`,
			"guest path is required",
		},
		{
			"relative host",
			`[[file_sync]]` + "\n" + `host = "foo/bar"` + "\n" + `guest = "~/foo"`,
			"must be absolute",
		},
		{
			"guest outside /root",
			`[[file_sync]]` + "\n" + `host = "~/x"` + "\n" + `guest = "/etc/resolv.conf"`,
			"must be under /root or ~/",
		},
		{
			"path traversal",
			`[[file_sync]]` + "\n" + `host = "~/../secrets"` + "\n" + `guest = "~/secrets"`,
			"'..' segments",
		},
		{
			"tilde user",
			`[[file_sync]]` + "\n" + `host = "~other/foo"` + "\n" + `guest = "~/foo"`,
			"only '~/' is expanded",
		},
		{
			"invalid mode",
			`[[file_sync]]` + "\n" + `host = "~/x"` + "\n" + `guest = "~/x"` + "\n" + `mode = "rwx"`,
			"must be octal",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			configDir := t.TempDir()
			if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(tc.toml+"\n"), 0o644); err != nil {
				t.Fatal(err)
			}
			_, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
			if err == nil {
				t.Fatalf("Load: want error containing %q", tc.want)
			}
			if !strings.Contains(err.Error(), tc.want) {
				t.Fatalf("Load error = %v, want contains %q", err, tc.want)
			}
		})
	}
}

func TestLoadRejectsFileSyncHostOutsideHome(t *testing.T) {
	homeDir := t.TempDir()
	t.Setenv("HOME", homeDir)

	configDir := t.TempDir()
	data := []byte(`
[[file_sync]]
host = "/etc/resolv.conf"
guest = "~/resolv.conf"
`)
	if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
		t.Fatal(err)
	}
	_, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
	if err == nil {
		t.Fatal("Load: want error for host path outside home")
	}
	if !strings.Contains(err.Error(), "owner home") {
		t.Fatalf("Load error = %v, want owner-home diagnostic", err)
	}
}

func TestLoadDaemonRejectsFileSyncHostOutsideOwnerHome(t *testing.T) {
	ownerHome := t.TempDir()
	t.Setenv("HOME", t.TempDir())

	configDir := t.TempDir()
	outside := filepath.Join(t.TempDir(), "secret.txt")
	data := []byte(`
[[file_sync]]
host = "` + outside + `"
guest = "~/secret.txt"
`)
	if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
		t.Fatal(err)
	}
	_, err := LoadDaemon(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()}, ownerHome)
	if err == nil {
		t.Fatal("LoadDaemon: want error for host path outside owner home")
	}
	if !strings.Contains(err.Error(), "owner home") {
		t.Fatalf("LoadDaemon error = %v, want owner-home diagnostic", err)
	}
}

func TestLoadAcceptsVMDefaults(t *testing.T) {
	configDir := t.TempDir()
	data := []byte(`
[vm_defaults]
vcpu = 4
memory_mib = 4096
disk_size = "16G"
system_overlay_size = "12G"
`)
	if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil {
		t.Fatal(err)
	}
	cfg, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()})
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if cfg.VMDefaults.VCPUCount != 4 {
		t.Errorf("VCPUCount = %d, want 4", cfg.VMDefaults.VCPUCount)
	}
	if cfg.VMDefaults.MemoryMiB != 4096 {
		t.Errorf("MemoryMiB = %d, want 4096", cfg.VMDefaults.MemoryMiB)
	}
	if cfg.VMDefaults.WorkDiskSizeBytes != 16*1024*1024*1024 {
		t.Errorf("WorkDiskSizeBytes = %d, want 16 GiB", cfg.VMDefaults.WorkDiskSizeBytes)
	}
	if cfg.VMDefaults.SystemOverlaySizeByte != 12*1024*1024*1024 {
		t.Errorf("SystemOverlaySizeByte = %d, want 12 GiB", cfg.VMDefaults.SystemOverlaySizeByte)
	}
}

func TestLoadEmptyVMDefaultsLeavesZeros(t *testing.T) {
	// No [vm_defaults] block → cfg.VMDefaults is the zero value,
	// which the resolver will map to auto or builtin.
	cfg, err := Load(paths.Layout{ConfigDir: t.TempDir(), SSHDir: t.TempDir()})
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if cfg.VMDefaults.VCPUCount != 0 || cfg.VMDefaults.MemoryMiB != 0 {
		t.Errorf("VMDefaults = %+v, want zeroed", cfg.VMDefaults)
	}
}

func TestLoadRejectsNegativeVMDefaults(t *testing.T) {
	cases := map[string]string{
		"vcpu":      `[vm_defaults]` + "\n" + `vcpu = -1`,
		"memory":    `[vm_defaults]` + "\n" + `memory_mib = -1`,
		"disk_size": `[vm_defaults]` + "\n" + `disk_size = "banana"`,
		"overlay":   `[vm_defaults]` + "\n" + `system_overlay_size = "banana"`,
	}
	for name, body := range cases {
		t.Run(name, func(t *testing.T) {
			configDir := t.TempDir()
			if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(body+"\n"), 0o644); err != nil {
				t.Fatal(err)
			}
			if _, err := Load(paths.Layout{ConfigDir: configDir, SSHDir: t.TempDir()}); err == nil {
				t.Fatal("expected error")
			}
		})
	}
}
