package stacks

import (
	"io"
	"log"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)

// R-166 / decision D-b — desired state is owned by the customer's action, persisted in app.yaml, and
// backfilled only from an UNAMBIGUOUS observation.
//
// Every assertion here is on the FILE ON DISK (or on the started/skipped effect), never on "no error
// returned": the whole feature is a durable record, so a test that does not read the record back has
// proven nothing.

// newDSManager builds a Manager over a temp stacks dir, with `names` registered as stacks. Real FS
// (t.TempDir) because the thing under test is a file write.
func newDSManager(t *testing.T, names ...string) (*Manager, string) {
	t.Helper()
	root := t.TempDir()
	cfg := &config.Config{}
	m := &Manager{cfg: cfg, logger: log.New(io.Discard, "", 0), stacks: map[string]*Stack{}}
	for _, n := range names {
		dir := filepath.Join(root, n)
		if err := os.MkdirAll(dir, 0o755); err != nil {
			t.Fatal(err)
		}
		compose := filepath.Join(dir, "docker-compose.yml")
		if err := os.WriteFile(compose, []byte("services: {}\n"), 0o644); err != nil {
			t.Fatal(err)
		}
		m.stacks[n] = &Stack{Name: n, ComposePath: compose}
	}
	return m, root
}

func stackDirOf(root, name string) string { return filepath.Join(root, name) }

// writeAppYAML puts an app.yaml on disk verbatim — so a LEGACY file (no desired_state key) can be
// modelled exactly, rather than approximated through the struct that added the key.
func writeAppYAML(t *testing.T, dir, body string) {
	t.Helper()
	if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(body), 0o600); err != nil {
		t.Fatal(err)
	}
}

func readAppYAML(t *testing.T, dir string) string {
	t.Helper()
	b, err := os.ReadFile(filepath.Join(dir, "app.yaml"))
	if err != nil {
		t.Fatal(err)
	}
	return string(b)
}

// --- Group A/B — the intent is persisted, and only the two legal values are accepted --------------

func TestSetDesiredState_PersistsStoppedToDisk(t *testing.T) {
	m, root := newDSManager(t, "immich")
	dir := stackDirOf(root, "immich")
	writeAppYAML(t, dir, "deployed: true\ndeployed_at: \"2026-08-01T10:00:00Z\"\nenv:\n  HDD_PATH: /mnt/hdd_1\n")

	if err := m.SetDesiredState("immich", DesiredStateStopped); err != nil {
		t.Fatalf("SetDesiredState: %v", err)
	}

	got := LoadAppConfig(dir)
	if got == nil {
		t.Fatal("app.yaml disappeared")
	}
	if got.DesiredState != DesiredStateStopped {
		t.Fatalf("desired_state on disk = %q, want %q", got.DesiredState, DesiredStateStopped)
	}
	// The rest of the file must be intact — this write must not cost the app its deploy record.
	if !got.Deployed || got.Env["HDD_PATH"] != "/mnt/hdd_1" || got.DeployedAt == "" {
		t.Fatalf("recording intent damaged the config: %+v", got)
	}
	if raw := readAppYAML(t, dir); !strings.Contains(raw, "desired_state: stopped") {
		t.Fatalf("the YAML key is not on disk:\n%s", raw)
	}
}

func TestSetDesiredState_RunningAndStoppedRoundTrip(t *testing.T) {
	m, root := newDSManager(t, "app")
	dir := stackDirOf(root, "app")
	writeAppYAML(t, dir, "deployed: true\nenv: {}\n")

	for _, want := range []string{DesiredStateRunning, DesiredStateStopped, DesiredStateRunning} {
		if err := m.SetDesiredState("app", want); err != nil {
			t.Fatalf("SetDesiredState(%q): %v", want, err)
		}
		if got := LoadAppConfig(dir).DesiredState; got != want {
			t.Fatalf("after SetDesiredState(%q), disk says %q", want, got)
		}
	}
}

func TestSetDesiredState_RefusesUnknownAndArbitraryValues(t *testing.T) {
	m, root := newDSManager(t, "app")
	dir := stackDirOf(root, "app")
	writeAppYAML(t, dir, "deployed: true\ndesired_state: running\nenv: {}\n")

	for _, bad := range []string{DesiredStateUnknown, "paused", "RUNNING", "true"} {
		if err := m.SetDesiredState("app", bad); err == nil {
			t.Fatalf("SetDesiredState(%q) was accepted — only running/stopped are writable, and "+
				"'unknown' in particular must be the ABSENCE of a record, never a written value", bad)
		}
	}
	// A refused write must not have touched the file.
	if got := LoadAppConfig(dir).DesiredState; got != DesiredStateRunning {
		t.Fatalf("a refused write changed the record to %q", got)
	}
}

func TestSetDesiredState_NoAppYAMLIsANoOpNotAnError(t *testing.T) {
	// No app.yaml = nothing deployed in that dir. Every consumer gates on Deployed first, so there
	// is no intent to record — and returning an error here would refuse a customer's Start on a
	// stack that simply is not installed.
	m, root := newDSManager(t, "app")
	if err := m.SetDesiredState("app", DesiredStateRunning); err != nil {
		t.Fatalf("want a silent no-op, got %v", err)
	}
	if _, err := os.Stat(filepath.Join(stackDirOf(root, "app"), "app.yaml")); !os.IsNotExist(err) {
		t.Fatal("an app.yaml was created for a stack that has none")
	}
}

func TestSetDesiredState_UnknownStackIsAnError(t *testing.T) {
	m, _ := newDSManager(t)
	if err := m.SetDesiredState("ghost", DesiredStateStopped); err == nil {
		t.Fatal("SetDesiredState on an unknown stack silently succeeded")
	}
}

// --- Group H (§1.2) — the save path preserves what it is given ----------------------------------

func TestSaveAppConfig_PreservesEveryKnownFieldAcrossLoadSave(t *testing.T) {
	// THE R-100 SHAPE. SaveAppConfig used to rebuild AppConfig from a five-field struct literal, so
	// any field added later was dropped on every save — and nine call sites share this path, so the
	// loss would surface far from its cause. DesiredState is exactly such a later field: without the
	// copy-and-overlay, a customer's Stop would be erased by the next unrelated app.yaml write (an
	// email-toggle change, an optional-config edit, the encryption migration).
	//
	// RED-PROOF: replace `saveCfg := *cfg` with the old literal
	//     saveCfg := AppConfig{Deployed: cfg.Deployed, DeployedAt: cfg.DeployedAt,
	//                          Env: ..., LockedFields: cfg.LockedFields, EmailEnabled: cfg.EmailEnabled}
	// and this test fails on desired_state. Demonstrated in REPORT.md §5.
	dir := t.TempDir()
	orig := &AppConfig{
		Deployed:     true,
		DeployedAt:   "2026-08-02T09:00:00Z",
		Env:          map[string]string{"HDD_PATH": "/mnt/hdd_1", "SUBDOMAIN": "fotok"},
		LockedFields: []string{"HDD_PATH"},
		EmailEnabled: true,
		DesiredState: DesiredStateStopped,
	}
	if err := SaveAppConfig(dir, orig, nil, nil); err != nil {
		t.Fatalf("first save: %v", err)
	}

	// Load and save again WITHOUT touching anything — the round-trip an unrelated writer performs.
	reloaded := LoadAppConfig(dir)
	if reloaded == nil {
		t.Fatal("load returned nil")
	}
	if err := SaveAppConfig(dir, reloaded, nil, nil); err != nil {
		t.Fatalf("second save: %v", err)
	}

	got := LoadAppConfig(dir)
	if got.DesiredState != DesiredStateStopped {
		t.Fatalf("desired_state was LOST across load→save (got %q) — a customer's Stop would be "+
			"erased by any unrelated app.yaml write", got.DesiredState)
	}
	if !got.Deployed || got.DeployedAt != orig.DeployedAt || !got.EmailEnabled {
		t.Fatalf("a known field was lost across load→save: %+v", got)
	}
	if len(got.LockedFields) != 1 || got.LockedFields[0] != "HDD_PATH" {
		t.Fatalf("locked_fields lost: %v", got.LockedFields)
	}
	if got.Env["HDD_PATH"] != "/mnt/hdd_1" || got.Env["SUBDOMAIN"] != "fotok" {
		t.Fatalf("env lost: %v", got.Env)
	}
}

func TestSaveAppConfig_UnknownYAMLKeysAreDropped(t *testing.T) {
	// MEASURED, NOT ASSUMED (§1.2 / §15.12). The answer is NO: app.yaml does not round-trip keys the
	// struct does not model, because the trip goes through the struct and yaml.Unmarshal discards
	// them before SaveAppConfig is ever reached.
	//
	// This test exists to make that limitation VISIBLE rather than discovered later. It is not a
	// defect introduced here and R-166 does not widen it — but it is the reason every writer must
	// load-then-save, and the reason a hand-edited app.yaml annotation will not survive.
	dir := t.TempDir()
	writeAppYAML(t, dir, "deployed: true\ndesired_state: running\nenv:\n  A: b\nfuture_field: keep-me\n")

	cfg := LoadAppConfig(dir)
	if cfg == nil {
		t.Fatal("load returned nil")
	}
	if err := SaveAppConfig(dir, cfg, nil, nil); err != nil {
		t.Fatalf("save: %v", err)
	}

	raw := readAppYAML(t, dir)
	if strings.Contains(raw, "future_field") {
		t.Fatal("an unknown key SURVIVED — the documented limitation no longer holds; update the " +
			"comment on SaveAppConfig and REPORT.md §12, which both state that it does not")
	}
	// The modelled fields must of course survive.
	if got := LoadAppConfig(dir); got.DesiredState != DesiredStateRunning || got.Env["A"] != "b" {
		t.Fatalf("a MODELLED field was lost: %+v", got)
	}
}

// --- Scenario D — backfill is running-only, and never invents "stopped" --------------------------

func TestBackfillDesiredState_RunningIsRecorded_AmbiguousIsLeftAlone(t *testing.T) {
	// Two legacy apps, no desired_state on either. One is observed RUNNING — unambiguous, so its
	// intent converges without waiting for a button press. One has ZERO CONTAINERS — the ambiguous
	// case that could be a deliberate stop, a power cut or an interrupted deploy, which is precisely
	// the inference R-166 exists to remove. It must be left with NO record.
	//
	// RED-PROOF: delete the `if !isObservedUp(s) { ... continue }` guard in BackfillDesiredState and
	// this test fails — the stopped app gets `running` written and would be started at the next boot.
	// Demonstrated in REPORT.md §5.
	m, root := newDSManager(t, "running-app", "stopped-app")
	writeAppYAML(t, stackDirOf(root, "running-app"), "deployed: true\nenv: {}\n")
	writeAppYAML(t, stackDirOf(root, "stopped-app"), "deployed: true\nenv: {}\n")

	m.stacks["running-app"].Deployed = true
	m.stacks["running-app"].State = StateRunning
	m.stacks["running-app"].AppConfig = LoadAppConfig(stackDirOf(root, "running-app"))
	m.stacks["stopped-app"].Deployed = true
	m.stacks["stopped-app"].State = StateStopped
	m.stacks["stopped-app"].AppConfig = LoadAppConfig(stackDirOf(root, "stopped-app"))

	if n := m.BackfillDesiredState(); n != 1 {
		t.Fatalf("backfilled %d, want exactly 1", n)
	}

	if got := LoadAppConfig(stackDirOf(root, "running-app")).DesiredState; got != DesiredStateRunning {
		t.Fatalf("a deployed, RUNNING app was not backfilled: desired_state=%q", got)
	}
	if got := LoadAppConfig(stackDirOf(root, "stopped-app")).DesiredState; got != DesiredStateUnknown {
		t.Fatalf("an AMBIGUOUS app (zero containers) was given desired_state=%q — inferring intent "+
			"from a container count is the exact defect R-166 removes", got)
	}
}

func TestBackfillDesiredState_NeverWritesStopped_AndNeverOverwrites(t *testing.T) {
	// Two invariants that must hold no matter what is observed:
	//   1. "stopped" is never written by the backfill, from any signal, ever.
	//   2. an EXISTING record is never overwritten — the customer's own decision outranks any
	//      observation, so a stopped-but-somehow-running app keeps its recorded stop.
	m, root := newDSManager(t, "exited", "degraded", "restarting", "already-stopped")
	for _, n := range []string{"exited", "degraded", "restarting"} {
		writeAppYAML(t, stackDirOf(root, n), "deployed: true\nenv: {}\n")
	}
	writeAppYAML(t, stackDirOf(root, "already-stopped"), "deployed: true\ndesired_state: stopped\nenv: {}\n")

	states := map[string]ContainerState{
		"exited": StateExited, "degraded": StateDegraded,
		"restarting": StateRestarting, "already-stopped": StateRunning,
	}
	for n, st := range states {
		m.stacks[n].Deployed = true
		m.stacks[n].State = st
		m.stacks[n].AppConfig = LoadAppConfig(stackDirOf(root, n))
	}

	m.BackfillDesiredState()

	for _, n := range []string{"exited", "degraded", "restarting"} {
		if got := LoadAppConfig(stackDirOf(root, n)).DesiredState; got != DesiredStateUnknown {
			t.Fatalf("%s (state=%s) was backfilled to %q — only a POSITIVE up-reading may seed a record",
				n, states[n], got)
		}
	}
	if got := LoadAppConfig(stackDirOf(root, "already-stopped")).DesiredState; got != DesiredStateStopped {
		t.Fatalf("the backfill OVERWROTE a customer's recorded stop with %q", got)
	}
}

func TestBackfillDesiredState_SkipsProtectedAndUndeployed(t *testing.T) {
	m, root := newDSManager(t, "traefik", "not-deployed")
	writeAppYAML(t, stackDirOf(root, "traefik"), "deployed: true\nenv: {}\n")
	writeAppYAML(t, stackDirOf(root, "not-deployed"), "deployed: false\nenv: {}\n")

	m.stacks["traefik"].Deployed = true
	m.stacks["traefik"].Protected = true
	m.stacks["traefik"].State = StateRunning
	m.stacks["traefik"].AppConfig = LoadAppConfig(stackDirOf(root, "traefik"))
	m.stacks["not-deployed"].Deployed = false
	m.stacks["not-deployed"].State = StateRunning
	m.stacks["not-deployed"].AppConfig = LoadAppConfig(stackDirOf(root, "not-deployed"))

	if n := m.BackfillDesiredState(); n != 0 {
		t.Fatalf("backfilled %d, want 0 — protected stacks have their own supervision and an "+
			"undeployed stack has no intent to record", n)
	}
	if got := LoadAppConfig(stackDirOf(root, "traefik")).DesiredState; got != DesiredStateUnknown {
		t.Fatalf("a PROTECTED stack was backfilled to %q", got)
	}
}

// --- The in-memory view keeps step with the disk -------------------------------------------------

func TestSetDesiredState_UpdatesTheInMemoryStackToo(t *testing.T) {
	// Otherwise a reader between this write and the next ScanStacks sees a stale intent — and the
	// dashboard reads GetStacks on every render.
	m, root := newDSManager(t, "app")
	dir := stackDirOf(root, "app")
	writeAppYAML(t, dir, "deployed: true\nenv: {}\n")
	m.stacks["app"].Deployed = true
	m.stacks["app"].AppConfig = LoadAppConfig(dir)

	if err := m.SetDesiredState("app", DesiredStateStopped); err != nil {
		t.Fatal(err)
	}
	for _, s := range m.GetStacks() {
		if s.Name == "app" && DesiredStateOf(s) != DesiredStateStopped {
			t.Fatalf("in-memory desired state = %q, disk says stopped", DesiredStateOf(s))
		}
	}
}

func TestDesiredStateOf_NilAppConfigIsUnknown(t *testing.T) {
	if got := DesiredStateOf(Stack{Name: "x"}); got != DesiredStateUnknown {
		t.Fatalf("a stack with no AppConfig reported desired state %q — absent must read as unknown", got)
	}
}
