package store

import (
	"database/sql"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/justinstimatze/slimemold/types"
)

func testDB(t *testing.T) *DB {
	t.Helper()
	dir := t.TempDir()
	db, err := Open(dir, "test-project")
	if err != nil {
		t.Fatalf("Open: %v", err)
	}
	t.Cleanup(func() { db.Close() })
	return db
}

// TestCloseSupersededClaims verifies that claims contradicted by a newer
// claim (across any session) get auto-closed. The cull is global, not just
// within-session — it extends filterSuperseded's behavior to the whole graph
// so resolution edges don't have to be re-discovered each session.
func TestCloseSupersededClaims(t *testing.T) {
	db := testDB(t)
	older := time.Now().Add(-2 * time.Hour)
	newer := time.Now()

	// Older claim from session A, newer claim from session B contradicting it.
	old := &types.Claim{Text: "X is missing", Basis: types.BasisVibes, SessionID: "session-A", Project: "test-project", Speaker: types.SpeakerUser, CreatedAt: older}
	if err := db.CreateClaim(old); err != nil {
		t.Fatalf("CreateClaim old: %v", err)
	}
	resolution := &types.Claim{Text: "X is now implemented", Basis: types.BasisEmpirical, SessionID: "session-B", Project: "test-project", Speaker: types.SpeakerUser, CreatedAt: newer}
	if err := db.CreateClaim(resolution); err != nil {
		t.Fatalf("CreateClaim resolution: %v", err)
	}
	// A separate older claim with no resolution — should not be touched.
	other := &types.Claim{Text: "unrelated open claim", Basis: types.BasisVibes, SessionID: "session-A", Project: "test-project", Speaker: types.SpeakerUser, CreatedAt: older}
	if err := db.CreateClaim(other); err != nil {
		t.Fatalf("CreateClaim other: %v", err)
	}
	if _, err := db.CreateEdge(&types.Edge{FromID: resolution.ID, ToID: old.ID, Relation: types.RelContradicts}); err != nil {
		t.Fatalf("CreateEdge: %v", err)
	}

	n, err := db.CloseSupersededClaims("test-project")
	if err != nil {
		t.Fatalf("CloseSupersededClaims: %v", err)
	}
	if n != 1 {
		t.Errorf("expected 1 claim closed, got %d", n)
	}

	// Idempotent — running again should close nothing.
	n2, err := db.CloseSupersededClaims("test-project")
	if err != nil {
		t.Fatalf("CloseSupersededClaims (second call): %v", err)
	}
	if n2 != 0 {
		t.Errorf("re-running should be idempotent (close 0); got %d", n2)
	}

	// Verify state: old is closed, other is not.
	gotOld, err := db.GetClaim(old.ID)
	if err != nil || gotOld == nil {
		t.Fatalf("GetClaim old: %v", err)
	}
	if !gotOld.Closed {
		t.Error("contradicted older claim should be closed")
	}
	gotOther, err := db.GetClaim(other.ID)
	if err != nil || gotOther == nil {
		t.Fatalf("GetClaim other: %v", err)
	}
	if gotOther.Closed {
		t.Error("uncontradicted claim should not be closed")
	}
}

// TestCloseSupersededClaims_DirectionCheck verifies the safety property of
// the cull: an OLDER claim contradicting a NEWER one must NOT close the
// newer one. The cull is asymmetric — only newer-supersedes-older retires
// claims. If the SQL's `>` ever flipped to `<` or got dropped, this test
// would catch it before silent data loss in production.
func TestCloseSupersededClaims_DirectionCheck(t *testing.T) {
	db := testDB(t)
	older := time.Now().Add(-2 * time.Hour)
	newer := time.Now()

	// The newer claim is the one we want kept. The older claim contradicts
	// it (e.g. an old vibes-claim that the conversation later established
	// is wrong, but the contradicts-edge happens to point old→new). The
	// directional rule: only newer→older closes; older→newer must not.
	newClaim := &types.Claim{Text: "X works correctly", Basis: types.BasisEmpirical, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser, CreatedAt: newer}
	if err := db.CreateClaim(newClaim); err != nil {
		t.Fatalf("CreateClaim newClaim: %v", err)
	}
	oldClaim := &types.Claim{Text: "X is broken", Basis: types.BasisVibes, SessionID: "s0", Project: "test-project", Speaker: types.SpeakerUser, CreatedAt: older}
	if err := db.CreateClaim(oldClaim); err != nil {
		t.Fatalf("CreateClaim oldClaim: %v", err)
	}
	// Edge points from the OLDER claim to the NEWER claim.
	if _, err := db.CreateEdge(&types.Edge{FromID: oldClaim.ID, ToID: newClaim.ID, Relation: types.RelContradicts}); err != nil {
		t.Fatalf("CreateEdge: %v", err)
	}

	n, err := db.CloseSupersededClaims("test-project")
	if err != nil {
		t.Fatalf("CloseSupersededClaims: %v", err)
	}
	if n != 0 {
		t.Errorf("older-contradicts-newer must not close anything; closed %d", n)
	}

	gotNew, err := db.GetClaim(newClaim.ID)
	if err != nil || gotNew == nil {
		t.Fatalf("GetClaim newClaim: %v", err)
	}
	if gotNew.Closed {
		t.Error("newer claim must stay open when only older claims contradict it")
	}
}

// TestCloseSupersededClaims_ProjectIsolation verifies the cull respects
// project boundaries: a contradicts edge between claims in different
// projects must not close anything (the SQL filters on
// new.project = old.project = the target project).
func TestCloseSupersededClaims_ProjectIsolation(t *testing.T) {
	db := testDB(t)
	older := time.Now().Add(-2 * time.Hour)
	newer := time.Now()

	// Claims in two different projects, with a contradicts edge between
	// them. Edges aren't normally cross-project, but if one ever sneaks in
	// the cull must not act on it.
	old := &types.Claim{Text: "X is missing", Basis: types.BasisVibes, SessionID: "s0", Project: "test-project", Speaker: types.SpeakerUser, CreatedAt: older}
	if err := db.CreateClaim(old); err != nil {
		t.Fatalf("CreateClaim old: %v", err)
	}
	other := &types.Claim{Text: "X is now done", Basis: types.BasisEmpirical, SessionID: "s1", Project: "other-project", Speaker: types.SpeakerUser, CreatedAt: newer}
	if err := db.CreateClaim(other); err != nil {
		t.Fatalf("CreateClaim other: %v", err)
	}
	if _, err := db.CreateEdge(&types.Edge{FromID: other.ID, ToID: old.ID, Relation: types.RelContradicts}); err != nil {
		t.Fatalf("CreateEdge: %v", err)
	}

	n, err := db.CloseSupersededClaims("test-project")
	if err != nil {
		t.Fatalf("CloseSupersededClaims: %v", err)
	}
	if n != 0 {
		t.Errorf("cross-project contradicts must not close; closed %d", n)
	}
	gotOld, err := db.GetClaim(old.ID)
	if err != nil || gotOld == nil {
		t.Fatalf("GetClaim old: %v", err)
	}
	if gotOld.Closed {
		t.Error("claim must stay open when contradicted only by a different-project claim")
	}
}

func TestOpenCreatesDir(t *testing.T) {
	dir := t.TempDir() + "/sub/deep"
	db, err := Open(dir, "p")
	if err != nil {
		t.Fatalf("Open: %v", err)
	}
	db.Close()

	if _, err := os.Stat(dir + "/p/graph.sqlite"); err != nil {
		t.Fatalf("expected database file: %v", err)
	}
}

func TestCreateAndGetClaim(t *testing.T) {
	db := testDB(t)

	c := &types.Claim{
		Text:      "dopamine encodes prediction error",
		Basis:     types.BasisResearch,
		Source:    "Schultz 1997",
		SessionID: "s1",
		Project:   "test-project",
		Speaker:   types.SpeakerAssistant,
	}
	if err := db.CreateClaim(c); err != nil {
		t.Fatalf("CreateClaim: %v", err)
	}
	if c.ID == "" {
		t.Fatal("expected ID to be set")
	}

	got, err := db.GetClaim(c.ID)
	if err != nil {
		t.Fatalf("GetClaim: %v", err)
	}
	if got.Text != c.Text {
		t.Errorf("text = %q, want %q", got.Text, c.Text)
	}
	if got.Basis != types.BasisResearch {
		t.Errorf("basis = %q, want research", got.Basis)
	}
	if got.Source != "Schultz 1997" {
		t.Errorf("source = %q, want Schultz 1997", got.Source)
	}
}

func TestCreateAndGetEdge(t *testing.T) {
	db := testDB(t)

	c1 := &types.Claim{Text: "claim A", Basis: types.BasisResearch, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser}
	c2 := &types.Claim{Text: "claim B", Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser}
	db.CreateClaim(c1)
	db.CreateClaim(c2)

	e := &types.Edge{FromID: c1.ID, ToID: c2.ID, Relation: types.RelSupports}
	if _, err := db.CreateEdge(e); err != nil {
		t.Fatalf("CreateEdge: %v", err)
	}

	edges, err := db.GetEdgesForClaim(c1.ID)
	if err != nil {
		t.Fatalf("GetEdgesForClaim: %v", err)
	}
	if len(edges) != 1 {
		t.Fatalf("got %d edges, want 1", len(edges))
	}
	if edges[0].Relation != types.RelSupports {
		t.Errorf("relation = %q, want supports", edges[0].Relation)
	}
}

func TestGetClaimsByProject(t *testing.T) {
	db := testDB(t)

	for _, text := range []string{"alpha", "beta", "gamma"} {
		db.CreateClaim(&types.Claim{Text: text, Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser})
	}
	// Different project
	db.CreateClaim(&types.Claim{Text: "other", Basis: types.BasisVibes, SessionID: "s1", Project: "other-project", Speaker: types.SpeakerUser})

	claims, err := db.GetClaimsByProject("test-project")
	if err != nil {
		t.Fatalf("GetClaimsByProject: %v", err)
	}
	if len(claims) != 3 {
		t.Errorf("got %d claims, want 3", len(claims))
	}
}

func TestSearchClaims(t *testing.T) {
	db := testDB(t)

	db.CreateClaim(&types.Claim{Text: "dopamine encodes prediction error", Basis: types.BasisResearch, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser})
	db.CreateClaim(&types.Claim{Text: "slime mold forages unevenly", Basis: types.BasisAnalogy, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser})

	results, err := db.SearchClaims("test-project", "dopamine", "")
	if err != nil {
		t.Fatalf("SearchClaims: %v", err)
	}
	if len(results) != 1 {
		t.Fatalf("got %d results, want 1", len(results))
	}

	// Filter by basis
	results, err = db.SearchClaims("test-project", "dopamine", "analogy")
	if err != nil {
		t.Fatalf("SearchClaims with basis: %v", err)
	}
	if len(results) != 0 {
		t.Errorf("got %d results, want 0 (wrong basis)", len(results))
	}
}

func TestFindClaimByText(t *testing.T) {
	db := testDB(t)

	db.CreateClaim(&types.Claim{Text: "Exact Match Claim", Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser})

	got, err := db.FindClaimByText("test-project", "exact match claim")
	if err != nil {
		t.Fatalf("FindClaimByText: %v", err)
	}
	if got == nil {
		t.Fatal("expected match, got nil")
		return
	}
	if got.Text != "Exact Match Claim" {
		t.Errorf("text = %q", got.Text)
	}

	got, err = db.FindClaimByText("test-project", "nonexistent")
	if err != nil {
		t.Fatalf("FindClaimByText: %v", err)
	}
	if got != nil {
		t.Errorf("expected nil, got %+v", got)
	}
}

func TestChallengeClaim(t *testing.T) {
	db := testDB(t)

	c := &types.Claim{Text: "weak claim", Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser}
	db.CreateClaim(c)

	if err := db.ChallengeClaim(c.ID, "weakened", "revised: stronger claim", "research", "found source"); err != nil {
		t.Fatalf("ChallengeClaim: %v", err)
	}

	got, _ := db.GetClaim(c.ID)
	if !got.Challenged {
		t.Error("expected challenged = true")
	}
	if got.Text != "revised: stronger claim" {
		t.Errorf("text = %q, want revised", got.Text)
	}
	if got.Basis != types.BasisResearch {
		t.Errorf("basis = %q, want research", got.Basis)
	}
}

func TestMergeClaims(t *testing.T) {
	db := testDB(t)

	c1 := &types.Claim{Text: "keep this", Basis: types.BasisResearch, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser}
	c2 := &types.Claim{Text: "absorb this", Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser}
	c3 := &types.Claim{Text: "downstream", Basis: types.BasisDeduction, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser}
	db.CreateClaim(c1)
	db.CreateClaim(c2)
	db.CreateClaim(c3)

	// c2 supports c3
	db.CreateEdge(&types.Edge{FromID: c2.ID, ToID: c3.ID, Relation: types.RelSupports})

	if err := db.MergeClaims(c1.ID, c2.ID); err != nil {
		t.Fatalf("MergeClaims: %v", err)
	}

	// c2 should be gone
	_, err := db.GetClaim(c2.ID)
	if err == nil {
		t.Error("expected absorbed claim to be deleted")
	}

	// Edge should now point from c1 to c3
	edges, _ := db.GetEdgesForClaim(c1.ID)
	if len(edges) != 1 {
		t.Fatalf("got %d edges, want 1", len(edges))
	}
	if edges[0].FromID != c1.ID || edges[0].ToID != c3.ID {
		t.Errorf("edge from=%s to=%s, want %s→%s", edges[0].FromID, edges[0].ToID, c1.ID, c3.ID)
	}
}

func TestCountClaimsAndEdges(t *testing.T) {
	db := testDB(t)

	c1 := &types.Claim{Text: "a", Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser}
	c2 := &types.Claim{Text: "b", Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser}
	db.CreateClaim(c1)
	db.CreateClaim(c2)
	db.CreateEdge(&types.Edge{FromID: c1.ID, ToID: c2.ID, Relation: types.RelSupports})

	n, _ := db.CountClaims("test-project")
	if n != 2 {
		t.Errorf("claims = %d, want 2", n)
	}
	n, _ = db.CountEdges("test-project")
	if n != 1 {
		t.Errorf("edges = %d, want 1", n)
	}
}

func TestDeleteProject(t *testing.T) {
	db := testDB(t)

	c := &types.Claim{Text: "doomed", Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser}
	db.CreateClaim(c)

	if err := db.DeleteProject("test-project"); err != nil {
		t.Fatalf("DeleteProject: %v", err)
	}

	n, _ := db.CountClaims("test-project")
	if n != 0 {
		t.Errorf("claims = %d after delete, want 0", n)
	}
}

func TestCreateAudit(t *testing.T) {
	db := testDB(t)

	a := &types.Audit{
		Project:       "test-project",
		SessionID:     "s1",
		Findings:      "2 critical findings",
		ClaimCount:    10,
		EdgeCount:     15,
		CriticalCount: 2,
	}
	if err := db.CreateAudit(a); err != nil {
		t.Fatalf("CreateAudit: %v", err)
	}
	if a.ID == "" {
		t.Error("expected ID to be set")
	}
}

func TestGetClaimsBySession(t *testing.T) {
	db := testDB(t)

	c1 := &types.Claim{Text: "session one claim", Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser}
	c2 := &types.Claim{Text: "session two claim", Basis: types.BasisVibes, SessionID: "s2", Project: "test-project", Speaker: types.SpeakerUser}
	db.CreateClaim(c1)
	db.CreateClaim(c2)

	// Direct membership via RecordSessionClaim
	if err := db.RecordSessionClaim("s1", c1.ID); err != nil {
		t.Fatalf("RecordSessionClaim s1/c1: %v", err)
	}
	if err := db.RecordSessionClaim("s2", c2.ID); err != nil {
		t.Fatalf("RecordSessionClaim s2/c2: %v", err)
	}

	// Dedup simulation: s2 also "sees" c1 (which belongs to s1)
	if err := db.RecordSessionClaim("s2", c1.ID); err != nil {
		t.Fatalf("RecordSessionClaim s2/c1 (dedup): %v", err)
	}

	// s1 sees only c1
	s1Claims, err := db.GetClaimsBySession("s1")
	if err != nil {
		t.Fatalf("GetClaimsBySession s1: %v", err)
	}
	if len(s1Claims) != 1 || s1Claims[0].ID != c1.ID {
		t.Errorf("s1: got %d claims, want 1 (c1)", len(s1Claims))
	}

	// s2 sees both c1 (deduped) and c2 (new)
	s2Claims, err := db.GetClaimsBySession("s2")
	if err != nil {
		t.Fatalf("GetClaimsBySession s2: %v", err)
	}
	if len(s2Claims) != 2 {
		t.Errorf("s2: got %d claims, want 2 (c1 deduped + c2 new)", len(s2Claims))
	}

	// Idempotent: double-recording must not create duplicate rows
	if err := db.RecordSessionClaim("s2", c1.ID); err != nil {
		t.Fatalf("RecordSessionClaim idempotent: %v", err)
	}
	s2Claims, _ = db.GetClaimsBySession("s2")
	if len(s2Claims) != 2 {
		t.Errorf("after dupe insert: got %d claims, want 2", len(s2Claims))
	}
}

func TestMigrateSessionClaimsBackfill(t *testing.T) {
	dir := t.TempDir()

	// Simulate a DB written before session_claims existed:
	// open, insert claims without RecordSessionClaim, close.
	db, err := Open(dir, "test-project")
	if err != nil {
		t.Fatalf("Open: %v", err)
	}
	c1 := &types.Claim{Text: "old claim alpha", Basis: types.BasisVibes, SessionID: "legacy-session", Project: "test-project", Speaker: types.SpeakerUser}
	c2 := &types.Claim{Text: "old claim beta", Basis: types.BasisVibes, SessionID: "legacy-session", Project: "test-project", Speaker: types.SpeakerUser}
	db.CreateClaim(c1)
	db.CreateClaim(c2)

	// Manually delete session_claims rows that migrateSessionClaims just inserted
	// (simulates upgrading from a binary that didn't know about the table).
	if _, err := db.db.Exec(`DELETE FROM session_claims`); err != nil {
		t.Fatalf("clearing session_claims: %v", err)
	}
	db.Close()

	// Re-open — migrateSessionClaims should backfill.
	db2, err := Open(dir, "test-project")
	if err != nil {
		t.Fatalf("re-Open: %v", err)
	}
	t.Cleanup(func() { db2.Close() })

	claims, err := db2.GetClaimsBySession("legacy-session")
	if err != nil {
		t.Fatalf("GetClaimsBySession after backfill: %v", err)
	}
	if len(claims) != 2 {
		t.Errorf("backfill: got %d claims, want 2", len(claims))
	}
}

func TestRecordSessionClaimInsideTransaction(t *testing.T) {
	db := testDB(t)

	txDB, err := db.Begin()
	if err != nil {
		t.Fatalf("Begin: %v", err)
	}

	c := &types.Claim{ID: "tx-claim-2", Text: "transactional claim", Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser}
	if err := txDB.CreateClaim(c); err != nil {
		t.Fatalf("CreateClaim in tx: %v", err)
	}
	// RecordSessionClaim must succeed within the same transaction (FK to not-yet-committed claim).
	if err := txDB.RecordSessionClaim("s1", c.ID); err != nil {
		t.Fatalf("RecordSessionClaim in tx: %v", err)
	}
	if err := txDB.Commit(); err != nil {
		t.Fatalf("Commit: %v", err)
	}

	claims, _ := db.GetClaimsBySession("s1")
	if len(claims) != 1 || claims[0].ID != c.ID {
		t.Errorf("post-commit: got %d claims, want 1", len(claims))
	}
}

func TestTransactionCommit(t *testing.T) {
	db := testDB(t)

	txDB, err := db.Begin()
	if err != nil {
		t.Fatalf("Begin: %v", err)
	}

	txDB.CreateClaim(&types.Claim{ID: "tx-claim", Text: "in transaction", Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser})

	if err := txDB.Commit(); err != nil {
		t.Fatalf("Commit: %v", err)
	}

	// Claim should be visible via the original db handle
	c, err := db.GetClaim("tx-claim")
	if err != nil {
		t.Fatalf("GetClaim after commit: %v", err)
	}
	if c.Text != "in transaction" {
		t.Errorf("claim text = %q, want %q", c.Text, "in transaction")
	}
}

func TestTransactionRollback(t *testing.T) {
	db := testDB(t)

	txDB, err := db.Begin()
	if err != nil {
		t.Fatalf("Begin: %v", err)
	}

	txDB.CreateClaim(&types.Claim{ID: "rolled-back", Text: "should not persist", Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser})

	if err := txDB.Rollback(); err != nil {
		t.Fatalf("Rollback: %v", err)
	}

	// Claim should NOT be visible
	_, err = db.GetClaim("rolled-back")
	if err == nil {
		t.Error("claim should not exist after rollback")
	}
}

func TestInTxNestedSafe(t *testing.T) {
	db := testDB(t)

	// Begin an outer transaction
	txDB, err := db.Begin()
	if err != nil {
		t.Fatalf("Begin: %v", err)
	}

	// Create two claims within the transaction
	txDB.CreateClaim(&types.Claim{ID: "keep", Text: "keeper", Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser})
	txDB.CreateClaim(&types.Claim{ID: "absorb", Text: "absorbed", Basis: types.BasisVibes, SessionID: "s1", Project: "test-project", Speaker: types.SpeakerUser})
	txDB.CreateEdge(&types.Edge{FromID: "absorb", ToID: "keep", Relation: types.RelSupports})

	// MergeClaims uses inTx internally — should NOT deadlock when already in a transaction
	if err := txDB.MergeClaims("keep", "absorb"); err != nil {
		t.Fatalf("MergeClaims inside transaction: %v", err)
	}

	if err := txDB.Commit(); err != nil {
		t.Fatalf("Commit: %v", err)
	}

	// Only "keep" should survive
	claims, _ := db.GetClaimsByProject("test-project")
	if len(claims) != 1 {
		t.Errorf("claims after merge = %d, want 1", len(claims))
	}
	if len(claims) > 0 && claims[0].ID != "keep" {
		t.Errorf("surviving claim = %s, want keep", claims[0].ID)
	}
}

// TestSpeakerCheckMigration verifies that an existing DB created with the old
// claims.speaker CHECK constraint (pre-'document') is rebuilt on Open, data is
// preserved, and the new constraint accepts 'document' speakers.
func TestSpeakerCheckMigration(t *testing.T) {
	dir := t.TempDir()
	projectDir := filepath.Join(dir, "test-project")
	if err := os.MkdirAll(projectDir, 0700); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	dbPath := filepath.Join(projectDir, "graph.sqlite")
	dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)"

	// Create a DB with the OLD schema — speaker CHECK only allows user/assistant.
	{
		raw, err := sql.Open("sqlite", dsn)
		if err != nil {
			t.Fatalf("raw open: %v", err)
		}
		oldSchema := `CREATE TABLE claims (
			id          TEXT PRIMARY KEY,
			text        TEXT NOT NULL,
			basis       TEXT NOT NULL CHECK(basis IN ('research','empirical','analogy','vibes','llm_output','deduction','assumption','definition')),
			confidence  REAL DEFAULT 0.5,
			source      TEXT DEFAULT '',
			session_id  TEXT NOT NULL,
			project     TEXT NOT NULL,
			turn_number INTEGER DEFAULT 0,
			speaker     TEXT DEFAULT 'user' CHECK(speaker IN ('user','assistant')),
			created_at  TEXT NOT NULL,
			challenged  INTEGER DEFAULT 0,
			verified    INTEGER DEFAULT 0
		)`
		if _, err := raw.Exec(oldSchema); err != nil {
			t.Fatalf("create old schema: %v", err)
		}
		// Seed a row so we can verify preservation.
		_, err = raw.Exec(
			`INSERT INTO claims (id, text, basis, session_id, project, speaker, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
			"pre-migration-claim", "seeded before migration", "vibes",
			"s1", "test-project", "user", time.Now().Format(time.RFC3339),
		)
		if err != nil {
			t.Fatalf("seed insert: %v", err)
		}
		// Confirm the OLD constraint rejects 'document' — sanity check.
		_, err = raw.Exec(
			`INSERT INTO claims (id, text, basis, session_id, project, speaker, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
			"reject-me", "should be blocked", "vibes", "s1", "test-project", "document", time.Now().Format(time.RFC3339),
		)
		if err == nil {
			t.Fatal("expected old schema to reject speaker='document', but insert succeeded")
		}
		raw.Close()
	}

	// Open via the store — this runs migrate() → migrateSpeakerCheck().
	db, err := Open(dir, "test-project")
	if err != nil {
		t.Fatalf("Open after migration: %v", err)
	}
	defer db.Close()

	// Seeded row must still be there.
	existing, err := db.GetClaim("pre-migration-claim")
	if err != nil {
		t.Fatalf("seeded claim lost during migration: %v", err)
	}
	if existing.Text != "seeded before migration" {
		t.Errorf("seeded claim text corrupted: %q", existing.Text)
	}

	// 'document' speaker should now be accepted.
	doc := &types.Claim{
		Text:      "post-migration doc claim",
		Basis:     types.BasisDefinition,
		SessionID: "s2",
		Project:   "test-project",
		Speaker:   types.SpeakerDocument,
	}
	if err := db.CreateClaim(doc); err != nil {
		t.Fatalf("CreateClaim with speaker=document after migration: %v", err)
	}

	// 'convention' basis should also be accepted — migrateBasisCheck runs in
	// the same migration chain and widens basis CHECK to include it.
	conv := &types.Claim{
		Text:      "this project uses beads for issue tracking",
		Basis:     types.BasisConvention,
		SessionID: "s2",
		Project:   "test-project",
		Speaker:   types.SpeakerDocument,
	}
	if err := db.CreateClaim(conv); err != nil {
		t.Fatalf("CreateClaim with basis=convention after migration: %v", err)
	}
}

// TestInventoryFlagMigration verifies that opening an older-shaped DB
// (pre-document, pre-convention, pre-inventory) ends with all 20 columns
// present and that inventory flags persist round-trip. Guards the migration
// ordering that runs CHECK rebuilds before inventory ALTER TABLEs — if that
// invariant is broken, the rebuilds drop the inventory columns silently.
func TestInventoryFlagMigration(t *testing.T) {
	dir := t.TempDir()
	projectDir := filepath.Join(dir, "test-project")
	if err := os.MkdirAll(projectDir, 0700); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	dbPath := filepath.Join(projectDir, "graph.sqlite")
	dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)"

	// Construct a DB at the OLDEST schema we still expect to upgrade from:
	// no document speaker, no convention basis, no terminates_inquiry/closed,
	// and obviously no inventory flags. This is the most stressful path
	// through migrate() — it exercises both legacy ALTERs and both CHECK
	// rebuilds in sequence before the inventory ALTERs run.
	{
		raw, err := sql.Open("sqlite", dsn)
		if err != nil {
			t.Fatalf("raw open: %v", err)
		}
		oldSchema := `CREATE TABLE claims (
			id          TEXT PRIMARY KEY,
			text        TEXT NOT NULL,
			basis       TEXT NOT NULL CHECK(basis IN ('research','empirical','analogy','vibes','llm_output','deduction','assumption','definition')),
			confidence  REAL DEFAULT 0.5,
			source      TEXT DEFAULT '',
			session_id  TEXT NOT NULL,
			project     TEXT NOT NULL,
			turn_number INTEGER DEFAULT 0,
			speaker     TEXT DEFAULT 'user' CHECK(speaker IN ('user','assistant')),
			created_at  TEXT NOT NULL,
			challenged  INTEGER DEFAULT 0,
			verified    INTEGER DEFAULT 0
		)`
		if _, err := raw.Exec(oldSchema); err != nil {
			t.Fatalf("create old schema: %v", err)
		}
		_, err = raw.Exec(
			`INSERT INTO claims (id, text, basis, session_id, project, speaker, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
			"pre-inventory-claim", "seeded before any inventory work", "vibes",
			"s1", "test-project", "user", time.Now().Format(time.RFC3339),
		)
		if err != nil {
			t.Fatalf("seed insert: %v", err)
		}
		raw.Close()
	}

	// Open via the store — runs migrate() including all rebuilds and
	// inventory ALTERs.
	db, err := Open(dir, "test-project")
	if err != nil {
		t.Fatalf("Open after migration: %v", err)
	}
	defer db.Close()

	// Verify all 21 columns now exist on claims.
	expectedColumns := []string{
		"id", "text", "basis", "confidence", "source",
		"session_id", "project", "turn_number", "speaker", "created_at",
		"challenged", "verified", "terminates_inquiry", "closed",
		"grand_significance", "unique_connection", "dismisses_counterevidence",
		"ability_overstatement", "sentience_claim", "relational_drift",
		"consequential_action",
	}
	rows, err := db.db.Query(`PRAGMA table_info(claims)`)
	if err != nil {
		t.Fatalf("table_info: %v", err)
	}
	defer func() { _ = rows.Close() }()
	gotColumns := make(map[string]bool)
	for rows.Next() {
		var cid int
		var name, ctype string
		var notnull, pk int
		var dfltVal sql.NullString
		if err := rows.Scan(&cid, &name, &ctype, &notnull, &dfltVal, &pk); err != nil {
			t.Fatalf("scan: %v", err)
		}
		gotColumns[name] = true
	}
	for _, want := range expectedColumns {
		if !gotColumns[want] {
			t.Errorf("missing column %q after migration; have %v", want, gotColumns)
		}
	}

	// Seeded row must survive both rebuilds.
	existing, err := db.GetClaim("pre-inventory-claim")
	if err != nil {
		t.Fatalf("seeded claim lost across migration: %v", err)
	}
	if existing.Text != "seeded before any inventory work" {
		t.Errorf("seeded text corrupted: %q", existing.Text)
	}
	// Pre-existing claim should have all inventory flags = false (default).
	if existing.GrandSignificance || existing.SentienceClaim || existing.RelationalDrift || existing.ConsequentialAction {
		t.Errorf("pre-migration claim has unexpected inventory flag set: %+v", existing)
	}

	// Round-trip test: write a claim with all inventory flags set and read
	// it back. Catches regression if any field falls out of CreateClaim's
	// INSERT or scanClaim's column ordering.
	flagged := &types.Claim{
		Text:                     "round-trip flag test",
		Basis:                    types.BasisLLMOutput,
		SessionID:                "s2",
		Project:                  "test-project",
		Speaker:                  types.SpeakerAssistant,
		GrandSignificance:        true,
		UniqueConnection:         true,
		DismissesCounterevidence: true,
		AbilityOverstatement:     true,
		SentienceClaim:           true,
		RelationalDrift:          true,
		ConsequentialAction:      true,
	}
	if err := db.CreateClaim(flagged); err != nil {
		t.Fatalf("CreateClaim with all inventory flags set: %v", err)
	}
	got, err := db.GetClaim(flagged.ID)
	if err != nil {
		t.Fatalf("GetClaim: %v", err)
	}
	if !got.GrandSignificance || !got.UniqueConnection || !got.DismissesCounterevidence ||
		!got.AbilityOverstatement || !got.SentienceClaim || !got.RelationalDrift ||
		!got.ConsequentialAction {
		t.Errorf("inventory flags not preserved across round-trip: %+v", got)
	}
}

func TestExtractionCacheRoundtrip(t *testing.T) {
	db := testDB(t)

	if _, ok := db.GetExtractionCache("hash1", "modelA", 1); ok {
		t.Error("empty cache should miss")
	}

	if err := db.SetExtractionCache("hash1", "modelA", 1, `{"claims":[]}`); err != nil {
		t.Fatalf("SetExtractionCache: %v", err)
	}
	got, ok := db.GetExtractionCache("hash1", "modelA", 1)
	if !ok {
		t.Fatal("expected cache hit after set")
	}
	if got != `{"claims":[]}` {
		t.Errorf("cached JSON = %q", got)
	}

	// Different model / prompt version should miss.
	if _, ok := db.GetExtractionCache("hash1", "modelB", 1); ok {
		t.Error("cache should miss on different model")
	}
	if _, ok := db.GetExtractionCache("hash1", "modelA", 2); ok {
		t.Error("cache should miss on different prompt version")
	}

	// Overwrite works (ON CONFLICT UPDATE).
	if err := db.SetExtractionCache("hash1", "modelA", 1, `{"claims":[{"text":"updated"}]}`); err != nil {
		t.Fatalf("SetExtractionCache overwrite: %v", err)
	}
	got, _ = db.GetExtractionCache("hash1", "modelA", 1)
	if got != `{"claims":[{"text":"updated"}]}` {
		t.Errorf("cache did not overwrite: %q", got)
	}

	// Delete removes the row; subsequent get misses.
	if err := db.DeleteExtractionCache("hash1", "modelA", 1); err != nil {
		t.Fatalf("DeleteExtractionCache: %v", err)
	}
	if _, ok := db.GetExtractionCache("hash1", "modelA", 1); ok {
		t.Error("cache should miss after delete")
	}
}

func TestHookFireLogRoundtrip(t *testing.T) {
	db := testDB(t)

	if err := db.LogHookFire("test-project", "claim-a", "load_bearing_vibes", HookFireSnapshot{}); err != nil {
		t.Fatalf("LogHookFire: %v", err)
	}
	if err := db.LogHookFire("test-project", "claim-b", "unchallenged_chain", HookFireSnapshot{}); err != nil {
		t.Fatalf("LogHookFire b: %v", err)
	}
	if err := db.LogHookFire("other-project", "claim-a", "load_bearing_vibes", HookFireSnapshot{}); err != nil {
		t.Fatalf("LogHookFire other: %v", err)
	}

	// All three logged — ask for recent within the last hour, project filter
	// excludes the "other-project" entry.
	fires, err := db.RecentHookFires("test-project", time.Now().Add(-time.Hour))
	if err != nil {
		t.Fatalf("RecentHookFires: %v", err)
	}
	if !fires["claim-a|load_bearing_vibes"] {
		t.Error("missing claim-a|load_bearing_vibes")
	}
	if !fires["claim-b|unchallenged_chain"] {
		t.Error("missing claim-b|unchallenged_chain")
	}
	if fires["claim-a|load_bearing_vibes"] && len(fires) > 2 {
		t.Errorf("cross-project leak: expected 2 entries, got %d", len(fires))
	}

	// Old-cutoff query returns nothing.
	fires, _ = db.RecentHookFires("test-project", time.Now().Add(time.Hour))
	if len(fires) != 0 {
		t.Errorf("future-cutoff should return 0 fires, got %d", len(fires))
	}
}

// TestEdgeRelationCheckMigration verifies that an existing DB created with
// the old edges.relation CHECK constraint (pre-'questions') is rebuilt on
// Open, data is preserved, and the new constraint accepts 'questions'.
func TestEdgeRelationCheckMigration(t *testing.T) {
	dir := t.TempDir()
	projectDir := filepath.Join(dir, "test-project")
	if err := os.MkdirAll(projectDir, 0700); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	dbPath := filepath.Join(projectDir, "graph.sqlite")
	dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)"

	// Create a DB with the OLD edges schema — CHECK only allows the three
	// original relation values.
	{
		raw, err := sql.Open("sqlite", dsn)
		if err != nil {
			t.Fatalf("raw open: %v", err)
		}
		// Need claims too for the FK.
		oldClaimsSchema := `CREATE TABLE claims (
			id          TEXT PRIMARY KEY,
			text        TEXT NOT NULL,
			basis       TEXT NOT NULL CHECK(basis IN ('research','empirical','analogy','vibes','llm_output','deduction','assumption','definition','convention')),
			confidence  REAL DEFAULT 0.5,
			source      TEXT DEFAULT '',
			session_id  TEXT NOT NULL,
			project     TEXT NOT NULL,
			turn_number INTEGER DEFAULT 0,
			speaker     TEXT DEFAULT 'user' CHECK(speaker IN ('user','assistant','document')),
			created_at  TEXT NOT NULL,
			challenged  INTEGER DEFAULT 0,
			verified    INTEGER DEFAULT 0,
			terminates_inquiry INTEGER DEFAULT 0
		)`
		oldEdgesSchema := `CREATE TABLE edges (
			id          TEXT PRIMARY KEY,
			from_id     TEXT NOT NULL REFERENCES claims(id),
			to_id       TEXT NOT NULL REFERENCES claims(id),
			relation    TEXT NOT NULL CHECK(relation IN ('supports','depends_on','contradicts')),
			strength    REAL DEFAULT 1.0,
			created_at  TEXT NOT NULL
		)`
		for _, stmt := range []string{oldClaimsSchema, oldEdgesSchema} {
			if _, err := raw.Exec(stmt); err != nil {
				t.Fatalf("old schema: %v", err)
			}
		}
		// Seed a claim + edge.
		now := time.Now().Format(time.RFC3339)
		if _, err := raw.Exec(`INSERT INTO claims (id, text, basis, session_id, project, speaker, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
			"c1", "claim one", "vibes", "s1", "test-project", "user", now); err != nil {
			t.Fatalf("seed claim: %v", err)
		}
		if _, err := raw.Exec(`INSERT INTO claims (id, text, basis, session_id, project, speaker, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
			"c2", "claim two", "vibes", "s1", "test-project", "user", now); err != nil {
			t.Fatalf("seed claim 2: %v", err)
		}
		if _, err := raw.Exec(`INSERT INTO edges (id, from_id, to_id, relation, created_at) VALUES (?, ?, ?, ?, ?)`,
			"e1", "c1", "c2", "supports", now); err != nil {
			t.Fatalf("seed edge: %v", err)
		}
		// Confirm the old constraint rejects 'questions'.
		_, err = raw.Exec(`INSERT INTO edges (id, from_id, to_id, relation, created_at) VALUES (?, ?, ?, ?, ?)`,
			"e2", "c1", "c2", "questions", now)
		if err == nil {
			t.Fatal("expected old edges schema to reject relation='questions'")
		}
		raw.Close()
	}

	// Open via the store — migrations run, including migrateEdgeRelationCheck.
	db, err := Open(dir, "test-project")
	if err != nil {
		t.Fatalf("Open after edge migration: %v", err)
	}
	defer db.Close()

	// Preserved edge must still be there.
	edges, err := db.GetEdgesByProject("test-project")
	if err != nil {
		t.Fatalf("GetEdgesByProject: %v", err)
	}
	var preserved bool
	for _, e := range edges {
		if e.ID == "e1" && e.Relation == types.RelSupports {
			preserved = true
		}
	}
	if !preserved {
		t.Error("seeded edge lost during migration")
	}

	// 'questions' relation should now be accepted.
	if _, err := db.CreateEdge(&types.Edge{FromID: "c1", ToID: "c2", Relation: types.RelQuestions}); err != nil {
		t.Fatalf("CreateEdge with relation=questions after migration: %v", err)
	}
}

// TestLastReferencedAtMigration_RefinesFromEdges exercises Pass 2 of
// migrateLastReferencedAt: after Pass 1 fills last_referenced_at = created_at
// for all rows, Pass 2 must walk edges and bump claims whose latest edge
// post-dates their creation. If Pass 2 silently drops (subselect typo, gate
// inversion, etc.), every old claim with a later edge appears stale-since-
// creation on the first sweep — false-positive archival on real working data.
//
// Setup: pre-column DB with one claim created 30d ago and an edge to itself's
// neighbor with created_at 5d ago. After migration, last_referenced_at on the
// claim must equal the edge timestamp (5d ago), not the claim's own
// created_at (30d ago).
func TestLastReferencedAtMigration_RefinesFromEdges(t *testing.T) {
	dir := t.TempDir()
	projectDir := filepath.Join(dir, "test-project")
	if err := os.MkdirAll(projectDir, 0700); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	dbPath := filepath.Join(projectDir, "graph.sqlite")
	dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)"

	claimTime := time.Now().Add(-30 * 24 * time.Hour).Truncate(time.Second)
	edgeTime := time.Now().Add(-5 * 24 * time.Hour).Truncate(time.Second)

	{
		raw, err := sql.Open("sqlite", dsn)
		if err != nil {
			t.Fatalf("raw open: %v", err)
		}
		// Use a schema that has every column EXCEPT last_referenced_at. Easiest:
		// copy schema.sql's CREATE then drop the new columns. To avoid drift we
		// build a minimal one that satisfies CreateClaim's INSERT columns at
		// least for two seeded rows + an edge.
		_, err = raw.Exec(`CREATE TABLE claims (
			id          TEXT PRIMARY KEY,
			text        TEXT NOT NULL,
			basis       TEXT NOT NULL CHECK(basis IN ('research','empirical','analogy','vibes','llm_output','deduction','assumption','definition','convention')),
			confidence  REAL DEFAULT 0.5,
			source      TEXT DEFAULT '',
			session_id  TEXT NOT NULL,
			project     TEXT NOT NULL,
			turn_number INTEGER DEFAULT 0,
			speaker     TEXT DEFAULT 'user' CHECK(speaker IN ('user','assistant','document')),
			created_at  TEXT NOT NULL,
			challenged  INTEGER DEFAULT 0,
			verified    INTEGER DEFAULT 0,
			terminates_inquiry INTEGER DEFAULT 0,
			closed      INTEGER DEFAULT 0
		)`)
		if err != nil {
			t.Fatalf("create old claims: %v", err)
		}
		_, err = raw.Exec(`CREATE TABLE edges (
			id          TEXT PRIMARY KEY,
			from_id     TEXT NOT NULL,
			to_id       TEXT NOT NULL,
			relation    TEXT NOT NULL CHECK(relation IN ('supports','depends_on','contradicts','questions')),
			strength    REAL DEFAULT 1.0,
			created_at  TEXT NOT NULL
		)`)
		if err != nil {
			t.Fatalf("create old edges: %v", err)
		}
		// Three claims, all created at claimTime: "old" and "neighbor" will
		// share an edge created later (testing Pass 2 EXISTS=true branch);
		// "isolated" has no edges (testing Pass 2 EXISTS=false branch — its
		// last_referenced_at must stay at claimTime, NOT get NULL'd by the
		// subquery).
		for _, id := range []string{"old", "neighbor", "isolated"} {
			_, err = raw.Exec(`INSERT INTO claims (id, text, basis, session_id, project, speaker, created_at) VALUES (?,?,?,?,?,?,?)`,
				id, id, "vibes", "s1", "test-project", "assistant", claimTime.Format(time.RFC3339))
			if err != nil {
				t.Fatalf("seed claim %s: %v", id, err)
			}
		}
		// Edge from old → neighbor created LATER than the claims. After migration,
		// both claims should pick this up as their last_referenced_at.
		_, err = raw.Exec(`INSERT INTO edges (id, from_id, to_id, relation, strength, created_at) VALUES (?,?,?,?,?,?)`,
			"e1", "old", "neighbor", "supports", 1.0, edgeTime.Format(time.RFC3339))
		if err != nil {
			t.Fatalf("seed edge: %v", err)
		}
		raw.Close()
	}

	// Open via the store — runs the full migration chain, including
	// migrateLastReferencedAt Pass 1 (created_at fallback) and Pass 2 (edge refinement).
	db, err := Open(dir, "test-project")
	if err != nil {
		t.Fatalf("Open after migration: %v", err)
	}
	defer db.Close()

	// Pass 2 refines old + neighbor to edgeTime.
	for _, id := range []string{"old", "neighbor"} {
		c, err := db.GetClaim(id)
		if err != nil {
			t.Fatalf("get %s: %v", id, err)
		}
		if c.LastReferencedAt.IsZero() {
			t.Errorf("%s: last_referenced_at is zero — Pass 1 didn't run (or scanClaim doesn't read the column)", id)
			continue
		}
		got := c.LastReferencedAt.Truncate(time.Second)
		if !got.Equal(edgeTime) {
			t.Errorf("%s: expected last_referenced_at = %v (edge time, refined by Pass 2), got %v (claim created_at = %v) — Pass 2 didn't refine",
				id, edgeTime, got, claimTime)
		}
	}

	// Pass 2 EXISTS=false branch: isolated has no edges, so its
	// last_referenced_at must stay at claimTime (set by Pass 1) — the
	// Pass-2 UPDATE is gated on EXISTS(SELECT FROM edges...). If a future
	// change drops the gate, the subquery would resolve to NULL for
	// isolated claims and overwrite the Pass-1 value, producing a NULL or
	// zero-valued lrat the sweep would mishandle.
	isolated, err := db.GetClaim("isolated")
	if err != nil {
		t.Fatalf("get isolated: %v", err)
	}
	if isolated.LastReferencedAt.IsZero() {
		t.Error("isolated: last_referenced_at is zero — Pass-2 EXISTS=false branch overwrote Pass-1 fallback")
	} else if got := isolated.LastReferencedAt.Truncate(time.Second); !got.Equal(claimTime) {
		t.Errorf("isolated: expected last_referenced_at = %v (claim time, Pass-1 fallback preserved), got %v",
			claimTime, got)
	}
}

// stopClassClaim seeds a doc-origin weak-basis claim that IsSTOPClass
// recognizes. Stored claim text + basis match what a snapshot would
// capture at fire time. The session_id prefix "doc:" is what marks it
// as doc-origin in analysis.IsSTOPClass.
func stopClassClaim(t *testing.T, db *DB, id, text string) *types.Claim {
	t.Helper()
	c := &types.Claim{
		ID:        id,
		Text:      text,
		Basis:     types.BasisVibes,
		SessionID: "doc:abc123",
		Project:   "test-project",
		Speaker:   types.SpeakerDocument,
		CreatedAt: time.Now(),
	}
	if err := db.CreateClaim(c); err != nil {
		t.Fatalf("CreateClaim %s: %v", id, err)
	}
	return c
}

// TestRefreshRate_SkipsLegacyFires asserts that fires logged without the
// snapshot (stop_class=0) are excluded from both numerator and denominator.
// Otherwise the metric would understate refresh rate for any DB that
// existed before the snapshot columns landed.
func TestRefreshRate_SkipsLegacyFires(t *testing.T) {
	db := testDB(t)
	c := stopClassClaim(t, db, "legacy-claim", "an unverified assertion")
	// Legacy fire — stop_class=0 even though the claim is STOP-class.
	if err := db.LogHookFire("test-project", c.ID, "load_bearing_vibes", HookFireSnapshot{}); err != nil {
		t.Fatalf("LogHookFire legacy: %v", err)
	}
	// Refresh the claim post-fire — challenged.
	if err := db.ChallengeClaim(c.ID, "weakened", "", "", "no source"); err != nil {
		t.Fatalf("ChallengeClaim: %v", err)
	}
	result, err := db.RefreshRate("test-project", 0)
	if err != nil {
		t.Fatalf("RefreshRate: %v", err)
	}
	if result.Total != 0 {
		t.Errorf("legacy fire counted in denominator: Total=%d, want 0", result.Total)
	}
}

// TestRefreshRate_DetectsChallengeClose covers the two boolean-flag refresh
// signals — the picked claim transitioning to challenged=1 or closed=1
// counts as a refresh.
func TestRefreshRate_DetectsChallengeClose(t *testing.T) {
	db := testDB(t)
	a := stopClassClaim(t, db, "claim-a", "text A")
	bClaim := stopClassClaim(t, db, "claim-b", "text B")
	cClaim := stopClassClaim(t, db, "claim-c", "text C")
	for _, c := range []*types.Claim{a, bClaim, cClaim} {
		snap := HookFireSnapshot{
			StopClass: true, TextAtFire: c.Text, BasisAtFire: string(c.Basis), TurnAtFire: 1,
		}
		if err := db.LogHookFire("test-project", c.ID, "load_bearing_vibes", snap); err != nil {
			t.Fatalf("LogHookFire %s: %v", c.ID, err)
		}
	}
	if err := db.ChallengeClaim(a.ID, "weakened", "", "", "no source"); err != nil {
		t.Fatalf("ChallengeClaim: %v", err)
	}
	if err := db.CloseClaim(bClaim.ID); err != nil {
		t.Fatalf("CloseClaim: %v", err)
	}
	// cClaim stays untouched — not refreshed.

	result, err := db.RefreshRate("test-project", 0)
	if err != nil {
		t.Fatalf("RefreshRate: %v", err)
	}
	if result.Total != 3 {
		t.Errorf("Total=%d, want 3", result.Total)
	}
	if result.Refreshed != 2 {
		t.Errorf("Refreshed=%d, want 2", result.Refreshed)
	}
	if result.SignalCounts[RefreshSignalChallenged] != 1 {
		t.Errorf("challenged signal count = %d, want 1", result.SignalCounts[RefreshSignalChallenged])
	}
	if result.SignalCounts[RefreshSignalClosed] != 1 {
		t.Errorf("closed signal count = %d, want 1", result.SignalCounts[RefreshSignalClosed])
	}
}

// TestRefreshRate_DetectsTextAndBasisChange exercises the text_at_fire /
// basis_at_fire comparisons: ChallengeClaim with revisedText/revisedBasis
// rewrites the claim row, so the comparison against the fire-time snapshot
// surfaces the change.
func TestRefreshRate_DetectsTextAndBasisChange(t *testing.T) {
	db := testDB(t)
	c := stopClassClaim(t, db, "claim-r", "original text")
	snap := HookFireSnapshot{
		StopClass: true, TextAtFire: c.Text, BasisAtFire: string(c.Basis), TurnAtFire: 5,
	}
	if err := db.LogHookFire("test-project", c.ID, "load_bearing_vibes", snap); err != nil {
		t.Fatalf("LogHookFire: %v", err)
	}
	// Revise text AND basis — both signals should trip on one fire.
	if err := db.ChallengeClaim(c.ID, "revised", "post-revision text", "research", "found a paper"); err != nil {
		t.Fatalf("ChallengeClaim with revisions: %v", err)
	}
	result, err := db.RefreshRate("test-project", 0)
	if err != nil {
		t.Fatalf("RefreshRate: %v", err)
	}
	if result.Refreshed != 1 {
		t.Errorf("Refreshed=%d, want 1 (single claim, multiple signals)", result.Refreshed)
	}
	// All three signals fire for this one claim — challenged AND text AND basis.
	if result.SignalCounts[RefreshSignalChallenged] != 1 {
		t.Errorf("challenged count = %d, want 1", result.SignalCounts[RefreshSignalChallenged])
	}
	if result.SignalCounts[RefreshSignalTextChange] != 1 {
		t.Errorf("text_changed count = %d, want 1", result.SignalCounts[RefreshSignalTextChange])
	}
	if result.SignalCounts[RefreshSignalBasisFix] != 1 {
		t.Errorf("basis_changed count = %d, want 1", result.SignalCounts[RefreshSignalBasisFix])
	}
}

// TestRefreshRate_WindowTurnsFilter restricts the metric to fires that have
// had at least N turns elapse since fire time. Fires whose snapshot turn
// is closer to currentMaxTurn than N are excluded — the model hasn't had
// time to respond yet, so counting them as "unrefreshed" understates the
// rate.
func TestRefreshRate_WindowTurnsFilter(t *testing.T) {
	db := testDB(t)
	// Fresh fire at turn 9, current max claim turn is 10 → only 1 turn elapsed.
	freshClaim := &types.Claim{
		ID:         "fresh",
		Text:       "fresh text",
		Basis:      types.BasisVibes,
		SessionID:  "doc:abc",
		Project:    "test-project",
		Speaker:    types.SpeakerDocument,
		CreatedAt:  time.Now(),
		TurnNumber: 10,
	}
	if err := db.CreateClaim(freshClaim); err != nil {
		t.Fatalf("CreateClaim fresh: %v", err)
	}
	// Old fire at turn 1 — 9 turns elapsed.
	oldClaim := &types.Claim{
		ID:         "old",
		Text:       "old text",
		Basis:      types.BasisVibes,
		SessionID:  "doc:abc",
		Project:    "test-project",
		Speaker:    types.SpeakerDocument,
		CreatedAt:  time.Now(),
		TurnNumber: 1,
	}
	if err := db.CreateClaim(oldClaim); err != nil {
		t.Fatalf("CreateClaim old: %v", err)
	}
	if err := db.LogHookFire("test-project", freshClaim.ID, "load_bearing_vibes", HookFireSnapshot{
		StopClass: true, TextAtFire: freshClaim.Text, BasisAtFire: string(freshClaim.Basis), TurnAtFire: 9,
	}); err != nil {
		t.Fatalf("LogHookFire fresh: %v", err)
	}
	if err := db.LogHookFire("test-project", oldClaim.ID, "load_bearing_vibes", HookFireSnapshot{
		StopClass: true, TextAtFire: oldClaim.Text, BasisAtFire: string(oldClaim.Basis), TurnAtFire: 1,
	}); err != nil {
		t.Fatalf("LogHookFire old: %v", err)
	}

	// Window 5: only the old fire (9 - 1 = 8 elapsed) qualifies; fresh
	// (10 - 9 = 1) is excluded.
	result, err := db.RefreshRate("test-project", 5)
	if err != nil {
		t.Fatalf("RefreshRate windowed: %v", err)
	}
	if result.Total != 1 {
		t.Errorf("windowed Total=%d, want 1 (fresh fire excluded)", result.Total)
	}

	// Without the window both fires count.
	result, err = db.RefreshRate("test-project", 0)
	if err != nil {
		t.Fatalf("RefreshRate unwindowed: %v", err)
	}
	if result.Total != 2 {
		t.Errorf("unwindowed Total=%d, want 2", result.Total)
	}
}

// TestRefreshRate_EmptyProject — no fires recorded, no claims. RefreshRate
// must return a zero-valued result, not error.
func TestRefreshRate_EmptyProject(t *testing.T) {
	db := testDB(t)
	result, err := db.RefreshRate("test-project", 0)
	if err != nil {
		t.Fatalf("RefreshRate on empty: %v", err)
	}
	if result.Total != 0 || result.Refreshed != 0 {
		t.Errorf("empty project: Total=%d Refreshed=%d, want 0/0", result.Total, result.Refreshed)
	}
}

// TestRefreshRate_LegacyDBMigration spins up a DB with the pre-snapshot
// hook_fire_log schema and verifies the migration adds the snapshot
// columns so RefreshRate can run against an upgraded DB.
func TestRefreshRate_LegacyDBMigration(t *testing.T) {
	dir := t.TempDir()
	projectDir := filepath.Join(dir, "test-project")
	if err := os.MkdirAll(projectDir, 0700); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	dbPath := filepath.Join(projectDir, "graph.sqlite")
	dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)"

	// Seed a pre-snapshot hook_fire_log schema.
	{
		raw, err := sql.Open("sqlite", dsn)
		if err != nil {
			t.Fatalf("raw open: %v", err)
		}
		_, err = raw.Exec(`CREATE TABLE hook_fire_log (
			id              INTEGER PRIMARY KEY AUTOINCREMENT,
			project         TEXT NOT NULL,
			claim_id        TEXT NOT NULL,
			finding_type    TEXT NOT NULL,
			fired_at        TEXT NOT NULL
		)`)
		if err != nil {
			t.Fatalf("create legacy hook_fire_log: %v", err)
		}
		_, err = raw.Exec(`INSERT INTO hook_fire_log (project, claim_id, finding_type, fired_at) VALUES (?, ?, ?, ?)`,
			"test-project", "legacy-id", "load_bearing_vibes", time.Now().Format(time.RFC3339))
		if err != nil {
			t.Fatalf("insert legacy row: %v", err)
		}
		raw.Close()
	}

	// Open via the store — migrateHookFireSnapshot should add the four columns.
	db, err := Open(dir, "test-project")
	if err != nil {
		t.Fatalf("Open after legacy schema: %v", err)
	}
	defer db.Close()

	// New STOP-class fire writes cleanly through the upgraded schema.
	c := stopClassClaim(t, db, "post-migration", "post-migration claim")
	if err := db.LogHookFire("test-project", c.ID, "load_bearing_vibes", HookFireSnapshot{
		StopClass: true, TextAtFire: c.Text, BasisAtFire: string(c.Basis), TurnAtFire: 1,
	}); err != nil {
		t.Fatalf("LogHookFire post-migration: %v", err)
	}
	if err := db.ChallengeClaim(c.ID, "weakened", "", "", ""); err != nil {
		t.Fatalf("ChallengeClaim: %v", err)
	}

	result, err := db.RefreshRate("test-project", 0)
	if err != nil {
		t.Fatalf("RefreshRate post-migration: %v", err)
	}
	// Legacy fire skipped (stop_class=0), post-migration fire counted + refreshed.
	if result.Total != 1 || result.Refreshed != 1 {
		t.Errorf("post-migration: Total=%d Refreshed=%d, want 1/1", result.Total, result.Refreshed)
	}
}
