package main

import (
	"go/ast"
	"go/parser"
	"go/token"
	"testing"
)

// R-166 §10 seam discipline — the recovery and the backfill are seams, and a seam that is never
// called is the defect class this project has shipped four times: a correct component, green unit
// tests that inject it directly, and no production caller.
//
// These walk main.go's AST. NOT strings.Contains — the sibling bootrecon test records the reason at
// first hand: a commented-out call still satisfies a substring match, so the text version passed the
// very red-proof it existed to fail. Comments are not code.

// mainBody returns func main()'s body from main.go, parsed.
func mainBody(t *testing.T) *ast.BlockStmt {
	t.Helper()
	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, "main.go", nil, 0)
	if err != nil {
		t.Fatalf("parse main.go: %v", err)
	}
	for _, decl := range f.Decls {
		if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "main" && fn.Body != nil {
			return fn.Body
		}
	}
	t.Fatal("func main() not found in main.go")
	return nil
}

// callsInMain returns, in source order, the names of every call in func main() whose function
// expression is `x.Sel(...)` or `Sel(...)` — enough to identify the wiring calls by name.
func callsInMain(t *testing.T, body *ast.BlockStmt) []string {
	t.Helper()
	var names []string
	ast.Inspect(body, func(n ast.Node) bool {
		call, ok := n.(*ast.CallExpr)
		if !ok {
			return true
		}
		switch fun := call.Fun.(type) {
		case *ast.SelectorExpr:
			names = append(names, fun.Sel.Name)
		case *ast.Ident:
			names = append(names, fun.Name)
		}
		return true
	})
	return names
}

func indexOfCall(names []string, want string) int {
	for i, n := range names {
		if n == want {
			return i
		}
	}
	return -1
}

// TestMainWiresAppStopRecovery is the Group-I seam test. Comment out the `appStopGuard.Recover()`
// line in main.go and this fails, where every behavioural test in internal/backup still passes.
func TestMainWiresAppStopRecovery(t *testing.T) {
	names := callsInMain(t, mainBody(t))

	if indexOfCall(names, "NewAppStopGuard") < 0 {
		t.Fatal("func main() no longer builds the R-166 app-stop guard — nothing writes or reads the marker")
	}
	if indexOfCall(names, "SetStarter") < 0 {
		t.Fatal("func main() no longer calls SetStarter on the app-stop guard — Recover would find the " +
			"marker and be unable to start anything, leaving every interrupted app down")
	}
	if indexOfCall(names, "Recover") < 0 {
		t.Fatal("func main() no longer calls Recover() on the app-stop guard — apps left stopped by an " +
			"interrupted backup stay down forever (the R-166 defect, un-fixed)")
	}
	if indexOfCall(names, "SetAppStopGuard") < 0 {
		t.Fatal("func main() no longer hands the recovered guard to the backup manager — the manager " +
			"would build a SECOND guard over the same file, i.e. one file with two owners")
	}
	if indexOfCall(names, "SetStopGuard") < 0 {
		t.Fatal("func main() no longer wires the exporter's stop guard — the .fab export path would be " +
			"the one uncovered stop-and-restart site, which is how a reader concludes the class is handled")
	}
}

// TestMainWiresDesiredStateBackfill pins the Part-1.5 call.
func TestMainWiresDesiredStateBackfill(t *testing.T) {
	if indexOfCall(callsInMain(t, mainBody(t)), "BackfillDesiredState") < 0 {
		t.Fatal("func main() no longer calls BackfillDesiredState — every existing app would stay on " +
			"legacy inference until someone pressed a button on it")
	}
}

// TestAppStopRecoveryPrecedesTheBootReconciler is §8.4's ORDERING requirement, and it is the reason
// the recovery returns its result instead of pushing it through a notifier seam.
//
// The recovery must COMPLETE — not merely be reached — before `go runBootReconcile(...)` is
// launched. If the boot reconciler ran first it would see an app the marker already explains, list
// it as an unexplained boot orphan, and one fault would be reported as two.
func TestAppStopRecoveryPrecedesTheBootReconciler(t *testing.T) {
	names := callsInMain(t, mainBody(t))

	recover := indexOfCall(names, "Recover")
	bootrecon := indexOfCall(names, "runBootReconcile")
	backfill := indexOfCall(names, "BackfillDesiredState")

	if recover < 0 || bootrecon < 0 || backfill < 0 {
		t.Fatalf("missing a call: Recover=%d runBootReconcile=%d BackfillDesiredState=%d", recover, bootrecon, backfill)
	}
	if recover >= bootrecon {
		t.Fatal("the app-stop Recover no longer runs BEFORE the boot reconciler is launched — an app " +
			"the marker explains would also be reported as an unexplained boot orphan (§8.4)")
	}
	if backfill >= bootrecon {
		t.Fatal("the desired-state backfill no longer runs BEFORE the boot reconciler — the reconciler " +
			"would decide from intent the backfill had not yet written")
	}
	if recover >= backfill {
		t.Fatal("the backfill no longer runs AFTER the app-stop recovery — an app the recovery just " +
			"restarted would still read as down and be left unrecorded")
	}
}

// TestMainReportsTheInterruptedOperation pins §2.4: the recovery's outcome reaches the operator.
//
// The reporting call is deliberately far from the recovery (the notifier does not exist yet at
// recovery time), which is exactly the distance across which a wiring gets dropped.
func TestMainReportsTheInterruptedOperation(t *testing.T) {
	body := mainBody(t)
	names := callsInMain(t, body)

	if indexOfCall(names, "NotifyBackupFailed") < 0 {
		t.Fatal("func main() no longer reports an interrupted app-data operation to the operator — the " +
			"controller died mid-backup and nobody is told (§2.4)")
	}
	// It must be guarded, not unconditional: a box with nothing to recover must not email an operator
	// on every single boot.
	guarded := false
	ast.Inspect(body, func(n ast.Node) bool {
		ifst, ok := n.(*ast.IfStmt)
		if !ok || ifst.Cond == nil {
			return true
		}
		bin, ok := ifst.Cond.(*ast.BinaryExpr)
		if !ok {
			return true
		}
		x, ok := bin.X.(*ast.Ident)
		if !ok || x.Name != "appStopRecovery" {
			return true
		}
		for _, name := range callsInMain(t, ifst.Body) {
			if name == "NotifyBackupFailed" {
				guarded = true
			}
		}
		return true
	})
	if !guarded {
		t.Fatal("the interrupted-operation alert is not guarded by `if appStopRecovery != nil` — every " +
			"healthy boot would page the operator about a backup that was never interrupted")
	}
}

// --- R-171 seam: the boot drive gate must be WIRED in production -------------------------------

// TestMainWiresBootDriveGate is the Group-H seam test. An unwired drive gate is not a crash — it is
// SILENTLY the pre-v0.190.0 behaviour, which started apps onto absent drives (observed live,
// audits/DIAG-bootrecon-drive-absent-2026-08-02.md). Every behavioural test in internal/bootrecon
// still passes with the wiring gone, which is exactly the hole this walks the AST to close.
//
// AST, not strings.Contains: a commented-out call still contains the string — the distinction that
// made a previous version of this project's own seam test pass its red-proof (2026-07-21).
func TestMainWiresBootDriveGate(t *testing.T) {
	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, "main.go", nil, 0)
	if err != nil {
		t.Fatalf("parse main.go: %v", err)
	}

	// (a) the settings handle the gate reads is assigned somewhere in main().
	assigned := false
	for _, name := range assignedIdentsIn(mainBody(t)) {
		if name == "bootDriveSettings" {
			assigned = true
		}
	}
	if !assigned {
		t.Fatal("func main() no longer assigns bootDriveSettings — the boot drive gate would read a " +
			"nil settings handle and could not see a disconnected drive")
	}

	// (b) SetDriveGate is actually called where the reconciler is constructed.
	called := false
	ast.Inspect(f, func(n ast.Node) bool {
		call, ok := n.(*ast.CallExpr)
		if !ok {
			return true
		}
		if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "SetDriveGate" {
			called = true
		}
		return true
	})
	if !called {
		t.Fatal("main.go no longer calls SetDriveGate on the boot reconciler — the sweep would start " +
			"apps whose data drive is absent (R-171, a regression observed live on 2026-08-02)")
	}
}

// assignedIdentsIn returns the names assigned to in a block (plain `=` and `:=`).
func assignedIdentsIn(body *ast.BlockStmt) []string {
	var names []string
	ast.Inspect(body, func(n ast.Node) bool {
		as, ok := n.(*ast.AssignStmt)
		if !ok {
			return true
		}
		for _, lhs := range as.Lhs {
			if id, ok := lhs.(*ast.Ident); ok {
				names = append(names, id.Name)
			}
		}
		return true
	})
	return names
}
