package service

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"testing"
	"time"

	"metis/pkg/facts"
	"metis/pkg/inventory"
	"metis/pkg/sentinel"
)

func TestSettingsHelpersAndSmallUtilities(t *testing.T) {
	dataDir := filepath.Join(t.TempDir(), "data")
	t.Setenv("METIS_DATA_DIR", dataDir)
	t.Setenv("METIS_FLASH_HOSTS", "a, b,, c")
	t.Setenv("METIS_MAX_DEVICE_BYTES", "12345")
	t.Setenv("METIS_DEFAULT_FLASH_HOST", "flash-1")
	t.Setenv("METIS_LOCAL_HOST", "local-1")
	t.Setenv("METIS_REMOTE_POD_TIMEOUT_SEC", "1800")

	settings := FromEnv()
	if got, want := settings.CacheDir, filepath.Join(dataDir, "cache"); got != want {
		t.Fatalf("CacheDir = %q, want %q", got, want)
	}
	if settings.DefaultFlashHost != "flash-1" || settings.LocalHost != "local-1" {
		t.Fatalf("unexpected env settings: %+v", settings)
	}
	if settings.MaxDeviceBytes != 12345 {
		t.Fatalf("expected MaxDeviceBytes=12345, got %d", settings.MaxDeviceBytes)
	}
	if settings.RemotePodTimeout != 1800 {
		t.Fatalf("expected RemotePodTimeout=1800, got %d", settings.RemotePodTimeout)
	}
	if !reflect.DeepEqual(splitList("a, b,, c"), []string{"a", "b", "c"}) {
		t.Fatalf("splitList mismatch")
	}
	if got := getenvInt64("METIS_MAX_DEVICE_BYTES", 1); got != 12345 {
		t.Fatalf("getenvInt64 = %d", got)
	}
	if got := hostnameOr("fallback"); got == "" {
		t.Fatal("hostnameOr returned empty string")
	}
	if got := humanBytes(1536); got != "1.5 KiB" {
		t.Fatalf("humanBytes = %q", got)
	}
	if got := firstLine("alpha\nbeta"); got != "alpha" {
		t.Fatalf("firstLine = %q", got)
	}
	if got := moveToFront([]string{"b", "a", "c"}, "a"); !reflect.DeepEqual(got, []string{"a", "b", "c"}) {
		t.Fatalf("moveToFront = %#v", got)
	}
	if got := errorString(nil); got != "" {
		t.Fatalf("errorString(nil) = %q", got)
	}
	if got := preferredDevice([]Device{{Path: "/dev/sda"}}); got != "/dev/sda" {
		t.Fatalf("preferredDevice = %q", got)
	}
	if got := deviceScore(Device{Name: "mmcblk0", Model: "Micro SD card", Removable: true, Hotplug: true, Transport: "usb"}); got <= 0 {
		t.Fatalf("expected positive device score, got %d", got)
	}
	if got := inventoryNodeArch(nil, nil); got != "arm64" {
		t.Fatalf("inventoryNodeArch fallback = %q", got)
	}
	if got := (&App{settings: Settings{HarborRegistry: "reg/", HarborProject: "/proj/"}}).artifactRepo("node"); got != "reg/proj/node" {
		t.Fatalf("artifactRepo = %q", got)
	}
}

func TestAppJobDeviceAndStateHelpers(t *testing.T) {
	app := newTestApp(t)
	app.settings.HistoryPath = filepath.Join(t.TempDir(), "history.jsonl")
	app.settings.ArtifactStatePath = filepath.Join(t.TempDir(), "artifacts.json")

	job := app.newJob("build", "titan-15", "titan-22", "/dev/sdz")
	if job.Status != JobQueued {
		t.Fatalf("new job status = %s", job.Status)
	}
	app.setJob(job.ID, func(j *Job) {
		j.Status = JobRunning
		j.Stage = "build"
	})
	if got := app.job(job.ID); got == nil || got.Status != JobRunning {
		t.Fatalf("setJob did not update job: %#v", got)
	}
	app.completeJob(job.ID, func(j *Job) {
		j.Message = "done"
	})
	if got := app.job(job.ID); got == nil || got.Status != JobDone || got.FinishedAt.IsZero() {
		t.Fatalf("completeJob did not finish job: %#v", got)
	}
	failed := app.newJob("replace", "titan-15", "titan-22", "/dev/sdz")
	app.failJob(failed.ID, os.ErrNotExist)
	if got := app.job(failed.ID); got == nil || got.Status != JobError || got.Error == "" {
		t.Fatalf("failJob did not mark error: %#v", got)
	}

	app.appendEvent(Event{Kind: "one", Summary: "first"})
	app.appendEvent(Event{Kind: "two", Summary: "second"})
	events := app.recentEvents(1)
	if len(events) != 1 || events[0].Kind != "two" {
		t.Fatalf("recentEvents returned %#v", events)
	}

	app.recordDevices("titan-22", []Device{{Name: "sda", Path: "/dev/sda"}}, nil)
	devices, err := app.cachedDevices("titan-22")
	if err != nil || len(devices) != 1 || devices[0].Path != "/dev/sda" {
		t.Fatalf("cachedDevices = %#v err=%v", devices, err)
	}
	devices[0].Path = "/dev/mutated"
	again, _ := app.cachedDevices("titan-22")
	if again[0].Path != "/dev/sda" {
		t.Fatalf("cachedDevices should return a copy, got %#v", again)
	}

	app.recordDevices("titan-22", nil, os.ErrPermission)
	if _, err := app.cachedDevices("titan-22"); err == nil {
		t.Fatal("expected cached device error")
	}

	app.recordDevices("titan-22", []Device{{Path: "/dev/sda"}}, nil)
	state := app.State("titan-22")
	if state.SelectedHost != "titan-22" || state.PreferredDevice == "" {
		t.Fatalf("unexpected state: %+v", state)
	}
}

func TestBuildAndReplaceRejectDuplicateActiveNodeJobs(t *testing.T) {
	app := newTestApp(t)
	active := app.newJob("replace", "titan-15", "titan-22", "/dev/sdz")
	if _, err := app.Build("titan-15"); err == nil || !strings.Contains(err.Error(), active.ID) {
		t.Fatalf("expected build conflict mentioning %s, got %v", active.ID, err)
	}
	if _, err := app.Replace("titan-15", "titan-22", "/dev/sdz"); err == nil || !strings.Contains(err.Error(), "active replace job") {
		t.Fatalf("expected replace conflict, got %v", err)
	}
}

func TestAppPersistenceAndTargets(t *testing.T) {
	dir := t.TempDir()
	invPath := filepath.Join(dir, "inventory.yaml")
	if err := os.WriteFile(invPath, []byte(`
classes:
  - name: rpi4
    arch: arm64
    os: armbian
    image: file:///tmp/base.img
nodes:
  - name: titan-15
    class: rpi4
    hostname: titan-15
    ip: 192.168.22.43
    k3s_role: agent
`), 0o644); err != nil {
		t.Fatal(err)
	}
	snapshotsPath := filepath.Join(dir, "snapshots.json")
	targetsPath := filepath.Join(dir, "targets.json")
	artifactStatePath := filepath.Join(dir, "artifacts.json")
	desiredMetadataPath := filepath.Join(dir, "desired-node-metadata.json")

	seedSnapshots := map[string]SnapshotRecord{
		"titan-15": {
			Node:        "titan-15",
			CollectedAt: testTime(t),
			Snapshot:    sentinel.Snapshot{Hostname: "titan-15", Kernel: "6.6.63", K3sVersion: "v1.31.5+k3s1"},
		},
	}
	data, _ := json.MarshalIndent(seedSnapshots, "", "  ")
	if err := os.WriteFile(snapshotsPath, data, 0o644); err != nil {
		t.Fatal(err)
	}

	seedTargets := map[string]facts.Targets{
		"rpi4": {Kernel: "6.6.63"},
	}
	data, _ = json.MarshalIndent(seedTargets, "", "  ")
	if err := os.WriteFile(targetsPath, data, 0o644); err != nil {
		t.Fatal(err)
	}

	seedArtifacts := map[string]ArtifactSummary{
		"titan-15": {Node: "titan-15", Ref: "reg/proj/titan-15:latest"},
	}
	data, _ = json.MarshalIndent(seedArtifacts, "", "  ")
	if err := os.WriteFile(artifactStatePath, data, 0o644); err != nil {
		t.Fatal(err)
	}
	seedDesiredMetadata := map[string]DesiredNodeMetadata{
		"titan-15": {
			Node:       "titan-15",
			Hostname:   "titan-15",
			CapturedAt: testTime(t),
			Labels:     map[string]string{"hardware": "rpi5"},
			Taints:     []string{"dedicated=recovery:NoSchedule"},
		},
	}
	data, _ = json.MarshalIndent(seedDesiredMetadata, "", "  ")
	if err := os.WriteFile(desiredMetadataPath, data, 0o644); err != nil {
		t.Fatal(err)
	}

	app, err := NewApp(Settings{
		InventoryPath:       invPath,
		CacheDir:            filepath.Join(dir, "cache"),
		ArtifactDir:         filepath.Join(dir, "artifacts"),
		ArtifactStatePath:   artifactStatePath,
		HistoryPath:         filepath.Join(dir, "history.jsonl"),
		SnapshotsPath:       snapshotsPath,
		TargetsPath:         targetsPath,
		DesiredMetadataPath: desiredMetadataPath,
		DefaultFlashHost:    "titan-22",
		FlashHosts:          []string{"titan-22"},
		LocalHost:           "titan-22",
		AllowedGroups:       []string{"admin"},
	})
	if err != nil {
		t.Fatalf("NewApp: %v", err)
	}

	if got := app.artifacts()["titan-15"].Ref; got != "reg/proj/titan-15:latest" {
		t.Fatalf("artifacts() = %q", got)
	}
	if desired, ok := app.desiredMetadataForNode("titan-15"); !ok || desired.Labels["hardware"] != "rpi5" {
		t.Fatalf("desiredMetadataForNode() = %#v ok=%v", desired, ok)
	}
	if err := app.recordArtifact(ArtifactSummary{Node: "titan-15", Ref: "reg/proj/titan-15:v2"}); err != nil {
		t.Fatalf("recordArtifact: %v", err)
	}
	if err := app.loadArtifacts(); err != nil {
		t.Fatalf("loadArtifacts: %v", err)
	}
	if got := app.artifacts()["titan-15"].Ref; got != "reg/proj/titan-15:v2" {
		t.Fatalf("recordArtifact/persist mismatch: %q", got)
	}

	if err := app.StoreSnapshot(SnapshotRecord{Node: "titan-15", Snapshot: sentinel.Snapshot{Hostname: "titan-15"}}); err != nil {
		t.Fatalf("StoreSnapshot: %v", err)
	}
	if event, err := app.WatchSentinel(); err != nil || event == nil || event.Kind != "sentinel.watch" {
		t.Fatalf("WatchSentinel: event=%#v err=%v", event, err)
	}
}

func TestHelperBranchesAndPersistenceFailures(t *testing.T) {
	app := newTestApp(t)

	if got := cachedImageName("/tmp/archive/base.img.xz"); got != "base.img" {
		t.Fatalf("cachedImageName = %q", got)
	}
	if got := humanBytes(1); got != "1 B" {
		t.Fatalf("humanBytes(1) = %q", got)
	}
	if got := humanBytes(1024 * 1024); got != "1.0 MiB" {
		t.Fatalf("humanBytes(1MiB) = %q", got)
	}
	if got := errorString(fmt.Errorf("boom")); got != "boom" {
		t.Fatalf("errorString = %q", got)
	}
	if got := moveToFront([]string{"a", "b", "c"}, "missing"); !reflect.DeepEqual(got, []string{"a", "b", "c"}) {
		t.Fatalf("moveToFront missing = %#v", got)
	}
	if targetsEqual(facts.Targets{Kernel: "a"}, facts.Targets{Kernel: "b"}) {
		t.Fatal("targetsEqual should reject differing kernels")
	}
	if got := deviceScore(Device{Name: "reader", Model: "Card reader", Transport: "usb", Removable: true, Hotplug: true}); got < 75 {
		t.Fatalf("unexpected deviceScore: %d", got)
	}

	if got := cachedImageName("foo.xz"); got != "foo" {
		t.Fatalf("cachedImageName alias = %q", got)
	}
	if got := app.flashHosts(); len(got) == 0 {
		t.Fatal("flashHosts returned empty list")
	}

	app.settings.SnapshotsPath = filepath.Join(t.TempDir(), "missing", "snapshots.json")
	if err := app.loadSnapshots(); err == nil {
		t.Fatal("expected loadSnapshots error for missing file")
	}
	app.settings.TargetsPath = filepath.Join(t.TempDir(), "missing", "targets.json")
	if err := app.loadTargets(); err == nil {
		t.Fatal("expected loadTargets error for missing file")
	}
	app.settings.ArtifactStatePath = filepath.Join(t.TempDir(), "missing", "artifacts.json")
	if err := app.loadArtifacts(); err == nil {
		t.Fatal("expected loadArtifacts error for missing file")
	}

	tmpDir := t.TempDir()
	app.settings.SnapshotsPath = tmpDir
	if err := app.persistSnapshots(); err == nil {
		t.Fatal("expected persistSnapshots error when path is a directory")
	}
	app.settings.TargetsPath = tmpDir
	if err := app.persistTargets(); err == nil {
		t.Fatal("expected persistTargets error when path is a directory")
	}
	app.settings.ArtifactStatePath = tmpDir
	if err := app.persistArtifacts(); err == nil {
		t.Fatal("expected persistArtifacts error when path is a directory")
	}

	if err := app.StoreSnapshot(SnapshotRecord{}); err == nil {
		t.Fatal("expected snapshot validation error")
	}
	blockedSnapshotParent := filepath.Join(t.TempDir(), "blocked-snapshot-parent")
	if err := os.WriteFile(blockedSnapshotParent, []byte("block"), 0o644); err != nil {
		t.Fatal(err)
	}
	app.settings.SnapshotsPath = filepath.Join(blockedSnapshotParent, "valid-snapshot.json")
	if err := app.StoreSnapshot(SnapshotRecord{Node: "titan-15"}); err == nil {
		t.Fatal("expected snapshot persistence error")
	}
	if _, err := app.Build("missing"); err == nil {
		t.Fatal("expected Build to reject unknown node")
	}
	if _, err := app.Replace("missing", "", ""); err == nil {
		t.Fatal("expected Replace to reject unknown node")
	}
}

func TestNewAppReportsInventoryErrors(t *testing.T) {
	settings := Settings{
		InventoryPath:     filepath.Join(t.TempDir(), "missing.yaml"),
		CacheDir:          t.TempDir(),
		ArtifactDir:       t.TempDir(),
		ArtifactStatePath: filepath.Join(t.TempDir(), "artifacts.json"),
		HistoryPath:       filepath.Join(t.TempDir(), "history.jsonl"),
		SnapshotsPath:     filepath.Join(t.TempDir(), "snapshots.json"),
		TargetsPath:       filepath.Join(t.TempDir(), "targets.json"),
	}
	if _, err := NewApp(settings); err == nil {
		t.Fatal("expected NewApp inventory error")
	}
}

func TestNewAppReportsDirectoryCreationErrors(t *testing.T) {
	dir := t.TempDir()
	inventoryPath := filepath.Join(dir, "inventory.yaml")
	if err := os.WriteFile(inventoryPath, []byte("classes: []\nnodes: []\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	blocked := filepath.Join(dir, "blocked")
	if err := os.WriteFile(blocked, []byte("not-a-dir"), 0o644); err != nil {
		t.Fatal(err)
	}
	base := Settings{
		InventoryPath:     inventoryPath,
		CacheDir:          filepath.Join(dir, "cache"),
		ArtifactDir:       filepath.Join(dir, "artifacts"),
		ArtifactStatePath: filepath.Join(dir, "artifacts.json"),
		HistoryPath:       filepath.Join(dir, "history.jsonl"),
		SnapshotsPath:     filepath.Join(dir, "snapshots.json"),
		TargetsPath:       filepath.Join(dir, "targets.json"),
	}
	for name, mutate := range map[string]func(*Settings){
		"cache": func(s *Settings) { s.CacheDir = filepath.Join(blocked, "cache") },
		"artifact": func(s *Settings) {
			s.ArtifactDir = filepath.Join(blocked, "artifacts")
		},
		"history": func(s *Settings) {
			s.HistoryPath = filepath.Join(blocked, "history.jsonl")
		},
	} {
		settings := base
		mutate(&settings)
		if _, err := NewApp(settings); err == nil {
			t.Fatalf("expected NewApp %s directory error", name)
		}
	}
}

func TestAppHelperNoopAndInvalidStateBranches(t *testing.T) {
	app := newTestApp(t)
	app.setJob("missing", func(*Job) { t.Fatal("setJob should not run for missing job") })
	app.completeJob("missing", func(*Job) { t.Fatal("completeJob should not run for missing job") })
	app.failJob("missing", os.ErrNotExist)
	if got := (*activeNodeJobError)(nil).Error(); !strings.Contains(got, "active metis job") {
		t.Fatalf("nil activeNodeJobError = %q", got)
	}
	if got := app.activeJobForNode(""); got != nil {
		t.Fatalf("activeJobForNode empty = %#v", got)
	}
	now := time.Now()
	app.jobs["ignored"] = nil
	app.jobs["done"] = &Job{ID: "done", Kind: "build", Node: "titan-15", Status: JobDone, StartedAt: now}
	app.jobs["other-kind"] = &Job{ID: "other-kind", Kind: "watch", Node: "titan-15", Status: JobRunning, StartedAt: now}
	app.jobs["newer"] = &Job{ID: "newer", Kind: "replace", Node: "titan-15", Status: JobRunning, StartedAt: now}
	app.jobs["older"] = &Job{ID: "older", Kind: "build", Node: "titan-15", Status: JobQueued, StartedAt: now.Add(-time.Minute)}
	if got := app.activeJobForNode("titan-15"); got == nil || got.ID != "older" {
		t.Fatalf("activeJobForNode earliest active = %#v", got)
	}
	if _, err := app.reserveJob("build", "titan-15", "", ""); err == nil {
		t.Fatal("expected reserveJob to reject active node")
	}
	app.appendEvent(Event{Kind: "bad", Details: map[string]any{"bad": make(chan int)}})

	if replacementReady(nil, nil) {
		t.Fatal("replacementReady nil should be false")
	}
	if replacementReady(&inventory.NodeSpec{}, &inventory.NodeClass{}) {
		t.Fatal("replacementReady empty should be false")
	}
	app.inventory = &inventory.Inventory{}
	if got := app.replacementNodes(); len(got) != 0 {
		t.Fatalf("replacementNodes empty inventory = %#v", got)
	}
	app.inventory = &inventory.Inventory{
		Classes: []inventory.NodeClass{{Name: "ready", Image: "img", Checksum: "sum"}},
		Nodes: []inventory.NodeSpec{
			{Name: "z-node", Class: "ready", Hostname: "z-node", IP: "192.168.22.11", K3sRole: "server", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh-ed25519 AAA"}},
			{Name: "a-node", Class: "ready", Hostname: "a-node", IP: "192.168.22.10", K3sRole: "server", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh-ed25519 AAA"}},
			{Name: "missing-class", Class: "missing", Hostname: "missing-class", IP: "192.168.22.12", K3sRole: "server", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh-ed25519 AAA"}},
		},
	}
	if got := app.replacementNodes(); len(got) != 2 || got[0].Name != "a-node" || got[1].Name != "z-node" {
		t.Fatalf("replacementNodes sorted/filtered = %#v", got)
	}
	app.settings.FlashHosts = []string{"titan-22"}
	app.settings.DefaultFlashHost = ""
	if got := app.flashHosts(); len(got) == 0 {
		t.Fatal("flashHosts should still include cluster nodes")
	}
	if !replacementReady(&inventory.NodeSpec{
		Name:          "ready",
		Hostname:      "ready",
		IP:            "192.168.22.10",
		K3sRole:       "agent",
		K3sURL:        "https://192.168.22.1:6443",
		K3sToken:      "token",
		SSHUser:       "atlas",
		SSHAuthorized: []string{"ssh-ed25519 AAA"},
	}, &inventory.NodeClass{Image: "img", Checksum: "sum"}) {
		t.Fatal("replacementReady valid node should be true")
	}
	for name, node := range map[string]inventory.NodeSpec{
		"missing-role":  {Name: "n", Hostname: "n", IP: "1.1.1.1", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh"}},
		"missing-url":   {Name: "n", Hostname: "n", IP: "1.1.1.1", K3sRole: "agent", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh"}},
		"missing-token": {Name: "n", Hostname: "n", IP: "1.1.1.1", K3sRole: "server", SSHUser: "atlas", SSHAuthorized: []string{"ssh"}},
		"missing-ssh":   {Name: "n", Hostname: "n", IP: "1.1.1.1", K3sRole: "server", K3sToken: "token"},
	} {
		if replacementReady(&node, &inventory.NodeClass{Image: "img", Checksum: "sum"}) {
			t.Fatalf("replacementReady should reject %s", name)
		}
	}

	fileParent := filepath.Join(t.TempDir(), "blocked")
	if err := os.WriteFile(fileParent, []byte("block"), 0o644); err != nil {
		t.Fatal(err)
	}
	app.settings.HistoryPath = filepath.Join(fileParent, "history.jsonl")
	app.appendEvent(Event{Kind: "noop"})
	if got := app.recentEvents(1); got != nil {
		t.Fatalf("recentEvents missing file = %#v", got)
	}
	historyPath := filepath.Join(t.TempDir(), "history.jsonl")
	if err := os.WriteFile(historyPath, []byte("{bad-json}\n{\"kind\":\"ok\"}\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	app.settings.HistoryPath = historyPath
	if got := app.recentEvents(10); len(got) != 1 || got[0].Kind != "ok" {
		t.Fatalf("recentEvents should skip invalid rows: %#v", got)
	}
	app.settings.SnapshotsPath = historyPath
	if err := app.loadSnapshots(); err == nil {
		t.Fatal("expected loadSnapshots invalid JSON error")
	}
	app.settings.TargetsPath = historyPath
	if err := app.loadTargets(); err == nil {
		t.Fatal("expected loadTargets invalid JSON error")
	}
	if targetsEqual(facts.Targets{Packages: map[string]string{"a": "1"}}, facts.Targets{Packages: map[string]string{"a": "1", "b": "2"}}) {
		t.Fatal("targetsEqual should reject package length mismatch")
	}
	if targetsEqual(facts.Targets{Packages: map[string]string{"a": "1"}}, facts.Targets{Packages: map[string]string{"a": "2"}}) {
		t.Fatal("targetsEqual should reject package value mismatch")
	}
	if got := firstLine(" one\ntwo "); got != "one" {
		t.Fatalf("firstLine = %q", got)
	}

	kube := fakeKubeServer(t)
	installKubeFactory(t, kube)
	if err := deleteNodeObjectInCluster("titan-15"); err != nil {
		t.Fatalf("deleteNodeObjectInCluster success: %v", err)
	}
}

func testTime(t *testing.T) time.Time {
	t.Helper()
	return time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)
}
