package bootrecon

import (
	"context"
	"testing"

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

// R-171 — the sweep must not start an app whose data drive is absent.
//
// This is a REGRESSION TEST for a defect this package caused: v0.189.0 replaced the container-count
// term with recorded intent, and a drive-gate-stopped app reads as `running` + zero containers, i.e.
// a boot orphan. Confirmed live on 2026-08-02 (the sweep started it, burned both attempts, and
// handed it to the dead-app alarm).

// fakeDriveGate answers a scripted liveness verdict per app.
type fakeDriveGate struct {
	dead   map[string]string // app → reason it is not live
	asked  []string
	unsure map[string]bool // app → the gate cannot determine (must be treated as NOT live)
}

func (g *fakeDriveGate) MayStart(name string) (bool, string) {
	g.asked = append(g.asked, name)
	if r, ok := g.dead[name]; ok {
		return false, r
	}
	if g.unsure[name] {
		return false, "cannot determine"
	}
	return true, ""
}

// --- Group G — Scenario A / Part 0's finding, as a test ------------------------------------------

func TestReconcile_DriveAbsentApp_IsNeverStarted(t *testing.T) {
	// The exact live shape: the drive gate stopped it (zero containers), it is still recorded
	// `running` because the gate is not the customer, and its drive is gone.
	//
	// RED-PROOF: delete the `if live, reason := r.driveLive(...)` block from Run and this test fails
	// with a start count of 1 — which is precisely what was observed on the box before the fix.
	// Demonstrated in REPORT.md §4.
	app := withDesired(vanished("calibre-web"), stacks.DesiredStateRunning)
	f := &fakeStacks{list: []stacks.Stack{app}, onStart: comesUp}
	r, _ := newTestReconciler(f)
	gate := &fakeDriveGate{dead: map[string]string{"calibre-web": "drive /mnt/felhom-drives/hdd_1 is not a live mountpoint"}}
	r.SetDriveGate(gate)

	res := r.Run(context.Background())

	if n := f.starts["calibre-web"]; n != 0 {
		t.Fatalf("an app whose data drive is ABSENT was started %d time(s) — compose would create its "+
			"bind sources on the guest rootfs, which is the hazard the drive gate exists to prevent", n)
	}
	if len(res.Candidates) != 0 {
		t.Fatalf("a drive-held app was listed as a start candidate: %v", res.Candidates)
	}
	if len(res.HeldByDrive) != 1 || res.HeldByDrive[0] != "calibre-web" {
		t.Fatalf("HeldByDrive = %v, want [calibre-web] — a held app must be reported, not silently dropped", res.HeldByDrive)
	}
	if len(res.StillDown) != 0 {
		t.Fatalf("a deliberately-held app was reported as StillDown %v — that is the dead-app alarm's "+
			"bucket, and putting it there is the false alarm this fix removes", res.StillDown)
	}
	if res.Attempts != 0 {
		t.Fatalf("attempts=%d, want 0 — nothing should have been attempted", res.Attempts)
	}
}

func TestReconcile_UndeterminableDrive_IsNotStarted(t *testing.T) {
	// §8.4's fail-safe direction. "Cannot determine" must behave exactly like "absent": not starting
	// is recoverable (the drive gate's Return branch owns it); starting on an absent drive is not.
	app := withDesired(vanished("immich"), stacks.DesiredStateRunning)
	f := &fakeStacks{list: []stacks.Stack{app}, onStart: comesUp}
	r, _ := newTestReconciler(f)
	r.SetDriveGate(&fakeDriveGate{unsure: map[string]bool{"immich": true}})

	res := r.Run(context.Background())

	if len(f.starts) != 0 {
		t.Fatalf("an app whose drive liveness could NOT be determined was started: %v — the fail-safe "+
			"direction is to refuse", f.starts)
	}
	if len(res.HeldByDrive) != 1 {
		t.Fatalf("HeldByDrive = %v, want the undeterminable app held", res.HeldByDrive)
	}
}

func TestReconcile_LiveDriveApp_IsStillRecovered(t *testing.T) {
	// The gate must not become a blanket refusal — an app on a LIVE drive is still the R-157 case
	// and must still be recovered. Without this, a "fix" that returns false always would pass the
	// test above and silently disable the whole feature.
	app := withDesired(vanished("bookstack"), stacks.DesiredStateRunning)
	f := &fakeStacks{list: []stacks.Stack{app}, onStart: comesUp}
	r, _ := newTestReconciler(f)
	gate := &fakeDriveGate{}
	r.SetDriveGate(gate)

	res := r.Run(context.Background())

	if f.starts["bookstack"] == 0 {
		t.Fatal("an app on a LIVE drive was not recovered — the drive gate must refuse absent drives, not all of them")
	}
	if len(res.HeldByDrive) != 0 {
		t.Fatalf("an app on a live drive was reported held: %v", res.HeldByDrive)
	}
	if len(gate.asked) != 1 || gate.asked[0] != "bookstack" {
		t.Fatalf("the gate was asked %v, want exactly [bookstack] — one question per candidate", gate.asked)
	}
}

func TestReconcile_DriveGateIsOnlyAskedAboutOrphans(t *testing.T) {
	// A running app and a customer-stopped app are not candidates, so the gate must never be asked
	// about them. Asking is not merely wasteful: the production gate reads app.yaml off disk per
	// call, and a stopped app's drive being absent is not a fault anyone should hear about.
	running := withDesired(stacks.Stack{
		Name: "docmost", Deployed: true, State: stacks.StateRunning,
		Containers: []stacks.ContainerInfo{{Name: "docmost", State: stacks.StateRunning}},
	}, stacks.DesiredStateRunning)
	stopped := withDesired(vanished("nextcloud"), stacks.DesiredStateStopped)
	orphan := withDesired(vanished("immich"), stacks.DesiredStateRunning)

	f := &fakeStacks{list: []stacks.Stack{running, stopped, orphan}, onStart: comesUp}
	r, _ := newTestReconciler(f)
	gate := &fakeDriveGate{}
	r.SetDriveGate(gate)
	r.Run(context.Background())

	if len(gate.asked) != 1 || gate.asked[0] != "immich" {
		t.Fatalf("the drive gate was asked about %v, want exactly [immich] — only boot orphans", gate.asked)
	}
}

func TestReconcile_NoDriveGateWired_IsPermissive(t *testing.T) {
	// nil gate = "this caller has no drive concept" (the fixtures' case), NOT "cannot determine".
	// Production wiring is pinned separately by TestMainWiresBootDriveGate — an unwired gate here
	// would silently be the pre-v0.190.0 behaviour, which is why that AST test exists.
	app := withDesired(vanished("immich"), stacks.DesiredStateRunning)
	f := &fakeStacks{list: []stacks.Stack{app}, onStart: comesUp}
	r, _ := newTestReconciler(f)

	r.Run(context.Background())

	if f.starts["immich"] == 0 {
		t.Fatal("with no drive gate wired the sweep must behave as before — the nil case is permissive")
	}
}

// --- Scenario F — the two boot gates agree -------------------------------------------------------

func TestBothBootGatesAgreeOnIntent(t *testing.T) {
	// R-170 + R-166: isBootOrphan and shouldRecreateOnBoot answer the SAME question — did the
	// customer want this running? — and until v0.190.0 they answered it with different signals.
	//
	// shouldRecreateOnBoot lives in internal/web and cannot be called from here without an import
	// cycle, so this test pins THIS side of the agreement and its sibling
	// TestShouldRecreateOnBoot_AgreesWithBootrecon (internal/web) pins the other, against the same
	// fixture table. Both must be updated together if the table changes.
	cases := []struct {
		desired    string
		containers int
		wantWanted bool // "the customer wanted this running"
	}{
		{stacks.DesiredStateStopped, 0, false},
		{stacks.DesiredStateStopped, 2, false},
		{stacks.DesiredStateRunning, 0, true},
		{stacks.DesiredStateRunning, 2, true},
		{stacks.DesiredStateUnknown, 0, false}, // legacy: zero containers ⇒ treated as stopped
		{stacks.DesiredStateUnknown, 2, true},  // legacy: containers present ⇒ treated as wanted
	}
	for _, c := range cases {
		s := stacks.Stack{
			Name: "app", Deployed: true, State: stacks.StateExited,
			Containers: make([]stacks.ContainerInfo, c.containers),
			AppConfig:  &stacks.AppConfig{Deployed: true, DesiredState: c.desired},
		}
		if got := isBootOrphan(s); got != c.wantWanted {
			t.Fatalf("isBootOrphan(desired=%q containers=%d) = %v, want %v — the two boot gates must "+
				"answer the intent question identically", c.desired, c.containers, got, c.wantWanted)
		}
	}
}

// --- Group F / §8.2 — every holder the widened window can now overlap ----------------------------

func TestReconcile_HeldByAnyHolder_IsNeverStarted(t *testing.T) {
	// §8.2's table, one case per row that the gate is responsible for. The reasons differ; the
	// required behaviour is identical, which is why they share one seam.
	//
	// The first two rows only became reachable when R-157 mechanism A widened the boot window — the
	// old T+5 s single sweep never overlapped a quiesce or a running app-data operation. Widening the
	// window without these would have traded a fixed bug for two new ones.
	for _, reason := range []string{
		"a whole-guest backup (quiesce) is holding it — the quiesce loop restarts its own stacks",
		"an app-data operation is holding it — the app-stop guard restarts it when the operation ends",
		"drive /mnt/felhom-drives/hdd_1 is not a live mountpoint",
	} {
		app := withDesired(vanished("immich"), stacks.DesiredStateRunning)
		f := &fakeStacks{list: []stacks.Stack{app}, onStart: comesUp}
		r, _ := newTestReconciler(f)
		r.SetDriveGate(&fakeDriveGate{dead: map[string]string{"immich": reason}})

		res := r.Run(context.Background())

		if len(f.starts) != 0 {
			t.Fatalf("held by %q but started anyway: %v", reason, f.starts)
		}
		if len(res.HeldByDrive) != 1 {
			t.Fatalf("held by %q but not reported as held: %+v", reason, res)
		}
		if len(res.StillDown) != 0 {
			t.Fatalf("held by %q and reported as StillDown %v — that is the dead-app alarm's bucket",
				reason, res.StillDown)
		}
	}
}
