package pipeline

import (
	"context"
	"math"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"testing"
)

// TestStatusAndRedrive exercises the D15.3 read-model + targeted re-attack end-to-end against
// the mock provider ($0, no real LLM): a book with one flagged chapter and one ok chapter →
// status projects the honest N/M + per-chapter passports + money; redrive dry-run identifies
// the flagged chunk without touching it; a real redrive resets ONLY the flagged stages, re-runs
// with a fresh budget (ok work resumes at $0), and the previously-spent money stays committed.
func TestStatusAndRedrive(t *testing.T) {
	rec := &reqRec{}
	var mu sync.Mutex
	refuse := true // ch1 draft refuses until flipped
	srv := newJSONProvider(rec, func(body string) (string, string) {
		mu.Lock()
		r := refuse
		mu.Unlock()
		if r && strings.Contains(body, "ОТКАЗНАЯГЛАВА") && !isEditBody(body) {
			return "Извините, я не могу перевести это.", "stop" // soft refusal → flagged, non-retryable
		}
		return draftEdit(body)
	})
	defer srv.Close()
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗНАЯГЛАВА\fНОРМАЛЬНАЯГЛАВА", regenerate: 0})
	ctx := context.Background()

	// --- Run 1: ch1 flagged (soft_refusal), ch2 done. ---
	r1 := newRunner(t, bookPath)
	res1, err := r1.TranslateBook(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if res1.Flagged != 1 {
		t.Fatalf("want 1 flagged chunk, got %d", res1.Flagged)
	}
	callsAfterRun1 := rec.count() // draft(ch1)=refusal, draft(ch2)+edit(ch2)=ok → 3
	if callsAfterRun1 != 3 {
		t.Fatalf("want 3 provider calls after run1, got %d", callsAfterRun1)
	}

	// --- STATUS projection (read-only). ---
	rep, err := r1.Status(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if rep.TotalChunks != 2 || rep.Done != 1 || rep.Flagged != 1 || rep.Pending != 0 || rep.InProgress != 0 {
		t.Fatalf("status counts wrong: %+v", rep)
	}
	if math.Abs(rep.PercentDone-50) > 1e-9 {
		t.Errorf("percent_done = %v, want 50", rep.PercentDone)
	}
	if len(rep.Chapters) != 2 {
		t.Fatalf("want 2 chapter passports, got %d", len(rep.Chapters))
	}
	if rep.Chapters[0].Verdict != "attention" || rep.Chapters[0].WorstFlagReason != string(FlagSoftRefusal) || rep.Chapters[0].ChunksFlagged != 1 {
		t.Errorf("ch1 passport = %+v (want attention/soft_refusal/1 flagged)", rep.Chapters[0])
	}
	if rep.Chapters[1].Verdict != "pass" || rep.Chapters[1].ChunksDone != 1 {
		t.Errorf("ch2 passport = %+v (want pass/1 done)", rep.Chapters[1])
	}
	wantCommitted := 3 * fakeCallUSD
	if math.Abs(rep.CommittedUSD-wantCommitted) > 1e-9 {
		t.Errorf("committed = %v, want %v", rep.CommittedUSD, wantCommitted)
	}
	// Projected extrapolates the per-processed-chunk average over the whole book (here both
	// chunks are processed, so it equals committed).
	if math.Abs(rep.ProjectedBookUSD-wantCommitted) > 1e-9 {
		t.Errorf("projected = %v, want %v", rep.ProjectedBookUSD, wantCommitted)
	}
	r1.Close()
	if rec.count() != callsAfterRun1 {
		t.Fatalf("status made provider calls: %d -> %d", callsAfterRun1, rec.count())
	}

	// --- REDRIVE dry-run: identify the target, mutate nothing. ---
	r2 := newRunner(t, bookPath)
	sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, Reason: string(FlagSoftRefusal), DryRun: true})
	if err != nil {
		t.Fatal(err)
	}
	if res != nil || sum.ResetRun {
		t.Error("dry-run must not re-run the book")
	}
	if len(sum.Targets) != 1 || sum.Targets[0].Chapter != 1 || sum.Targets[0].ChunkIdx != 0 {
		t.Fatalf("dry-run targets = %+v", sum.Targets)
	}
	// The flagged stage AND its skipped downstream are both selected for reset.
	if strings.Join(sum.Targets[0].Stages, ",") != "draft,edit" {
		t.Errorf("dry-run reset stages = %v, want draft,edit", sum.Targets[0].Stages)
	}
	if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" {
		t.Fatalf("dry-run mutated chunk_status: %+v", cs)
	}
	r2.Close()
	if rec.count() != callsAfterRun1 {
		t.Fatal("dry-run made provider calls")
	}

	// --- REDRIVE real: flip the provider to success, reset ch1, re-run. ---
	mu.Lock()
	refuse = false
	mu.Unlock()
	r3 := newRunner(t, bookPath)
	defer r3.Close()
	sum3, res3, err := r3.Redrive(ctx, RedriveSelector{Chapter: 1, ChunkIdx: 0, Reason: ""})
	if err != nil {
		t.Fatal(err)
	}
	if !sum3.ResetRun || res3 == nil {
		t.Fatal("real redrive should reset and re-run")
	}
	if res3.Flagged != 0 {
		t.Fatalf("redrive should have fixed the flag, still %d flagged", res3.Flagged)
	}
	// Exactly two FRESH calls (ch1 draft + ch1 edit); ch2 resumes at $0.
	if got := rec.count() - callsAfterRun1; got != 2 {
		t.Fatalf("redrive should make exactly 2 fresh calls (ch1 draft+edit), made %d", got)
	}
	if cs, _ := r3.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "ok" {
		t.Fatalf("redrive did not fix ch1 draft: %+v", cs)
	}
	// MONEY (documented): the discarded refusal's spend STAYS committed; redrive bills the two
	// new calls on top → committed reflects total lifetime spend (5 calls), ceilings stay honest.
	committed, _, _ := r3.Store.SpentUSD("test-book")
	if math.Abs(committed-5*fakeCallUSD) > 1e-9 {
		t.Errorf("committed after redrive = %v, want %v (3 original + 2 redrive)", committed, 5*fakeCallUSD)
	}
	// A clean status now: everything done.
	rep2, err := r3.Status(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if rep2.Done != 2 || rep2.Flagged != 0 {
		t.Errorf("post-redrive status: done=%d flagged=%d, want 2/0", rep2.Done, rep2.Flagged)
	}
}

// TestStatusSurfacesGlossaryMissGate covers self-review finding #2: the post-check GATE flags a
// chunk at the CHUNK level (never a chunk_status row), so status must promote a gate-on chunk
// with a CONFIRMED post-check miss to flagged/glossary_miss — otherwise it reports done/pass
// while `tmctl translate` exits 2, contradicting the run.
func TestStatusSurfacesGlossaryMissGate(t *testing.T) {
	rec := &reqRec{}
	srv := newJSONProvider(rec, func(body string) (string, string) {
		if isEditBody(body) {
			return "ОТРЕДАКТИРОВАННЫЙ.", "stop"
		}
		return "Некто пошёл в библиотеку.", "stop" // drops the approved name → CONFIRMED post-check miss
	})
	defer srv.Close()
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed, postcheckGate: true})
	ctx := context.Background()
	r := newRunner(t, bookPath)
	defer r.Close()
	res, err := r.TranslateBook(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if res.Flagged != 1 {
		t.Fatalf("translate must flag the glossary_miss chunk: flagged=%d", res.Flagged)
	}
	rep, err := r.Status(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if rep.Flagged != 1 || rep.Done != 0 {
		t.Fatalf("status must match translate (surface glossary_miss): done=%d flagged=%d, want 0/1", rep.Done, rep.Flagged)
	}
	// The gate-promoted flag is counted as a glossary_miss (NOT re-drivable) so the CLI advises a
	// seed fix + --resnapshot, not a redrive dead-end (minor 1d). Flagged − GlossaryMissFlagged = 0
	// re-drivable here.
	if rep.GlossaryMissFlagged != 1 {
		t.Fatalf("the gate flag must be counted as glossary_miss: GlossaryMissFlagged=%d, want 1", rep.GlossaryMissFlagged)
	}
	if len(rep.Chapters) != 1 || rep.Chapters[0].WorstFlagReason != string(FlagGlossaryMiss) {
		t.Fatalf("passport must show glossary_miss: %+v", rep.Chapters)
	}
	if rep.Chapters[0].Verdict == "pass" {
		t.Error("a chapter with a glossary_miss must not be 'pass'")
	}
}

// TestStatusDetectsConfigDrift covers self-review finding #3: after a wire-affecting config edit
// (a prompt_version bump) since the last run, status must flag ConfigDrift — the current config
// renders a different snapshot than the stored rows, which translate would --resnapshot (re-bill).
// Without it status shows a misleading "done/consistent".
func TestStatusDetectsConfigDrift(t *testing.T) {
	rec := &reqRec{}
	srv := newJSONProvider(rec, draftEdit)
	defer srv.Close()
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})
	ctx := context.Background()

	r1 := newRunner(t, bookPath)
	if _, err := r1.TranslateBook(ctx); err != nil {
		t.Fatal(err)
	}
	r1.Close()

	// No config change → no drift.
	r2 := newRunner(t, bookPath)
	rep, err := r2.Status(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if rep.ConfigDrift {
		t.Fatalf("an unchanged config must not drift (current=%s)", rep.CurrentSnapshot)
	}
	r2.Close()

	// Bump a wire-affecting field (draft prompt_version) → the current config renders a new
	// snapshot → drift.
	dir := filepath.Dir(bookPath)
	body, err := os.ReadFile(filepath.Join(dir, "pipeline.yaml"))
	if err != nil {
		t.Fatal(err)
	}
	changed := strings.Replace(string(body), "prompt_version: v-test", "prompt_version: v-test2", 1)
	if changed == string(body) {
		t.Fatal("setup: prompt_version token not found to change")
	}
	writeFile(t, filepath.Join(dir, "pipeline.yaml"), changed)

	r3 := newRunner(t, bookPath)
	defer r3.Close()
	rep3, err := r3.Status(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if !rep3.ConfigDrift || rep3.CurrentSnapshot == "" {
		t.Errorf("a prompt_version bump since the run must surface as ConfigDrift: %+v", rep3)
	}
}

// TestRedriveResetsOnlyFlaggedStageNotDispOK mutation-locks the D12 invariant "a redrive NEVER
// resets DispOK work" against a mutant that resets EVERY stage (external-review 1c). The existing
// end-to-end test flags the FIRST stage (draft), so its reset set == the full set and a
// reset-everything mutant survives. Here draft is OK and only EDIT is flagged, so the reset set is
// {edit} alone: the mutant (reset draft too) is caught by both the plan (Stages == [edit]) and the
// re-run cost (draft must resume at $0 → exactly ONE fresh edit call). Reverting the
// Flagged||Skipped stage filter to `true` makes draft re-bill and Stages == [draft,edit].
func TestRedriveResetsOnlyFlaggedStageNotDispOK(t *testing.T) {
	rec := &reqRec{}
	var mu sync.Mutex
	refuseEdit := true // the EDIT stage refuses until flipped; draft is always OK
	srv := newJSONProvider(rec, func(body string) (string, string) {
		if isEditBody(body) {
			mu.Lock()
			r := refuseEdit
			mu.Unlock()
			if r {
				return "Извините, я не могу продолжить.", "stop" // soft refusal (matches the blacklist) → edit flagged, non-retryable
			}
			return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
		}
		return "ЧЕРНОВИК ПЕРЕВОДА", "stop"
	})
	defer srv.Close()
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОДНАГЛАВА", regenerate: 0})
	ctx := context.Background()

	r1 := newRunner(t, bookPath)
	if _, err := r1.TranslateBook(ctx); err != nil {
		t.Fatal(err)
	}
	callsAfterRun1 := rec.count() // draft(ok) + edit(refusal) = 2
	if callsAfterRun1 != 2 {
		t.Fatalf("want 2 provider calls after run1, got %d", callsAfterRun1)
	}
	// draft is OK, edit is flagged.
	if cs, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "ok" {
		t.Fatalf("draft must be ok: %+v", cs)
	}
	if cs, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "edit"); cs == nil || cs.Disposition != "flagged" {
		t.Fatalf("edit must be flagged: %+v", cs)
	}
	r1.Close()

	// Dry-run plan: only the EDIT stage is reset — the DispOK draft is untouched (the invariant).
	r2 := newRunner(t, bookPath)
	sum, _, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, DryRun: true})
	if err != nil {
		t.Fatal(err)
	}
	if len(sum.Targets) != 1 {
		t.Fatalf("want 1 target, got %+v", sum.Targets)
	}
	if got := strings.Join(sum.Targets[0].Stages, ","); got != "edit" {
		t.Fatalf("reset plan = %q, want just \"edit\" (draft is DispOK and must not be reset)", got)
	}
	r2.Close()

	// Real redrive with the editor fixed: only edit re-runs (1 fresh call); draft resumes at $0.
	mu.Lock()
	refuseEdit = false
	mu.Unlock()
	r3 := newRunner(t, bookPath)
	defer r3.Close()
	sum3, res3, err := r3.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
	if err != nil {
		t.Fatal(err)
	}
	if !sum3.ResetRun || res3 == nil || res3.Flagged != 0 {
		t.Fatalf("redrive should reset+re-run and clear the flag: %+v", res3)
	}
	if got := rec.count() - callsAfterRun1; got != 1 {
		t.Fatalf("redrive must make exactly ONE fresh call (edit only; draft resumes $0), made %d", got)
	}
	// The draft checkpoint was never discarded — its cost is not re-billed.
	committed, _, _ := r3.Store.SpentUSD("test-book")
	if math.Abs(committed-3*fakeCallUSD) > 1e-9 {
		t.Errorf("committed = %v, want %v (draft + refused-edit + redriven-edit)", committed, 3*fakeCallUSD)
	}
}

// TestRedriveReasonSelectorMismatch locks the --reason selector: a redrive whose reason does not
// match the flagged chunk's flag_reason must find NO target and touch nothing (mutating
// RedriveSelector.matches to ignore Reason would wrongly re-attack the chunk). The matching reason
// then does select it (so the test also proves the selector is live, not a no-op that always misses).
func TestRedriveReasonSelectorMismatch(t *testing.T) {
	rec := &reqRec{}
	srv := newJSONProvider(rec, func(body string) (string, string) {
		if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
			return "Извините, я не могу перевести это.", "stop" // soft_refusal
		}
		return draftEdit(body)
	})
	defer srv.Close()
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0})
	ctx := context.Background()

	r := newRunner(t, bookPath)
	defer r.Close()
	if _, err := r.TranslateBook(ctx); err != nil {
		t.Fatal(err)
	}
	calls := rec.count()

	// Non-matching reason → no target, no reset, no re-run.
	sum, res, err := r.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, Reason: string(FlagLength)})
	if err != nil {
		t.Fatal(err)
	}
	if len(sum.Targets) != 0 || sum.ResetRun || res != nil {
		t.Fatalf("a non-matching --reason must select nothing, got %+v", sum)
	}
	if rec.count() != calls {
		t.Fatal("non-matching redrive made provider calls")
	}
	if cs, _ := r.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" {
		t.Fatalf("the flagged row must be untouched after a non-matching redrive: %+v", cs)
	}

	// The MATCHING reason does select it (dry-run, so nothing is mutated) — proves the selector fires.
	sum2, _, err := r.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, Reason: string(FlagSoftRefusal), DryRun: true})
	if err != nil {
		t.Fatal(err)
	}
	if len(sum2.Targets) != 1 {
		t.Fatalf("the matching --reason must select the flagged chunk, got %+v", sum2.Targets)
	}
}

// TestRedriveAbortsOnConfigDriftPreservingTelemetry locks Task 1c: a redrive under a DRIFTED config
// must refuse loudly BEFORE the destructive reset, leaving the flag telemetry intact. The old code
// reset (deleted) the flagged chunk_status + checkpoints first, then TranslateBook failed loud on
// the snapshot mismatch — so the flagged row was gone and status read the chunk as pending/pass.
// Reverting the guard deletes the flagged draft row here; the "still flagged" assertion catches it.
func TestRedriveAbortsOnConfigDriftPreservingTelemetry(t *testing.T) {
	rec := &reqRec{}
	srv := newJSONProvider(rec, func(body string) (string, string) {
		if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
			return "Извините, я не могу перевести это.", "stop"
		}
		return draftEdit(body)
	})
	defer srv.Close()
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0})
	ctx := context.Background()

	r1 := newRunner(t, bookPath)
	if _, err := r1.TranslateBook(ctx); err != nil {
		t.Fatal(err)
	}
	r1.Close()
	callsAfterRun := rec.count()

	// Drift the config: bump the draft prompt_version → the current config renders a new snapshot.
	dir := filepath.Dir(bookPath)
	body, err := os.ReadFile(filepath.Join(dir, "pipeline.yaml"))
	if err != nil {
		t.Fatal(err)
	}
	changed := strings.Replace(string(body), "prompt_version: v-test", "prompt_version: v-test-drift", 1)
	if changed == string(body) {
		t.Fatal("setup: prompt_version token not found")
	}
	writeFile(t, filepath.Join(dir, "pipeline.yaml"), changed)

	r2 := newRunner(t, bookPath)
	defer r2.Close()
	sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
	if err == nil {
		t.Fatal("a drifted config must make redrive fail loud")
	}
	if !strings.Contains(err.Error(), "snapshot") && !strings.Contains(err.Error(), "снапшот") {
		t.Errorf("drift error should mention the snapshot mismatch, got: %v", err)
	}
	if res != nil || sum.ResetRun {
		t.Errorf("a drift-aborted redrive must not re-run: res=%v resetRun=%v", res, sum.ResetRun)
	}
	// The whole point: the flag telemetry is intact (the row was NOT reset).
	if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" {
		t.Fatalf("the flagged row must survive a drift-aborted redrive, got: %+v", cs)
	}
	if rec.count() != callsAfterRun {
		t.Fatal("a drift-aborted redrive must make no provider calls")
	}
}

// TestRedriveResnapshotAcceptsDrift is the D20.4 counterpart: `redrive --resnapshot` (which the CLI
// now wires into r.Resnapshot — previously the flag was parsed but silently ignored) must SKIP the
// drift guard and accept the re-pin, resetting + re-attacking instead of failing loud. Same drifted
// setup as TestRedriveAbortsOnConfigDriftPreservingTelemetry, but with Resnapshot=true. Reverting the
// `if !r.Resnapshot` guard-skip makes this fail loud like the abort case.
func TestRedriveResnapshotAcceptsDrift(t *testing.T) {
	rec := &reqRec{}
	srv := newJSONProvider(rec, func(body string) (string, string) {
		if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
			return "Извините, я не могу перевести это.", "stop"
		}
		return draftEdit(body)
	})
	defer srv.Close()
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0})
	ctx := context.Background()

	r1 := newRunner(t, bookPath)
	if _, err := r1.TranslateBook(ctx); err != nil {
		t.Fatal(err)
	}
	r1.Close()

	// Drift the config (a prompt_version bump renders a new snapshot).
	dir := filepath.Dir(bookPath)
	body, err := os.ReadFile(filepath.Join(dir, "pipeline.yaml"))
	if err != nil {
		t.Fatal(err)
	}
	writeFile(t, filepath.Join(dir, "pipeline.yaml"),
		strings.Replace(string(body), "prompt_version: v-test", "prompt_version: v-test-drift", 1))

	r2 := newRunner(t, bookPath)
	defer r2.Close()
	r2.Resnapshot = true // <-- what `redrive --resnapshot` sets
	sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
	if err != nil {
		t.Fatalf("redrive --resnapshot must NOT abort on drift, got: %v", err)
	}
	if !sum.ResetRun || res == nil {
		t.Fatalf("redrive --resnapshot must reset + re-run under the new snapshot: resetRun=%v res=%v", sum.ResetRun, res)
	}
}

// TestRedriveResnapshotSeedCollisionAbortsBeforeReset is the self-review CONFIRMED (fixes-safety):
// even under --resnapshot, a seed-FILE edit that introduces an approved shared-key COLLISION must
// fail loud BEFORE the destructive reset, not after (which would delete the flag telemetry, then the
// re-seed would fail loud — the external-review 1c torn state, re-opened for the resnapshot path by
// wiring r.Resnapshot into redrive). Reverting the seedGlossary hoist (nesting it back inside
// `if !r.Resnapshot`) deletes the flagged row here.
func TestRedriveResnapshotSeedCollisionAbortsBeforeReset(t *testing.T) {
	rec := &reqRec{}
	srv := newJSONProvider(rec, func(body string) (string, string) {
		if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
			return "Извините, я не могу перевести это.", "stop"
		}
		return draftEdit(body)
	})
	defer srv.Close()
	clean := "terms:\n  - src: Гуюэ\n    dst: Гу\n    status: approved\n"
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0, glossarySeed: clean})
	ctx := context.Background()

	r1 := newRunner(t, bookPath)
	if _, err := r1.TranslateBook(ctx); err != nil {
		t.Fatal(err)
	}
	r1.Close()

	// Introduce an approved shared-key collision: same ELIGIBLE firing src (phonetic ≥3), different
	// sense+dst, overlapping windows → the D16.1 polysemy livelock class → seedGlossary fails loud.
	dir := filepath.Dir(bookPath)
	writeFile(t, filepath.Join(dir, "glossary-seed.yaml"),
		"terms:\n  - src: Гуюэ\n    dst: Гу\n    sense: a\n    status: approved\n  - src: Гуюэ\n    dst: Губец\n    sense: b\n    status: approved\n")

	r2 := newRunner(t, bookPath)
	defer r2.Close()
	r2.Resnapshot = true // --resnapshot: skips the snapshot COMPARISON, but NOT the seed-error-before-reset guard
	sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
	if err == nil {
		t.Fatal("a seed collision must make redrive --resnapshot fail loud (before the reset)")
	}
	if res != nil || sum.ResetRun {
		t.Errorf("a seed-collision-aborted redrive must not re-run: res=%v resetRun=%v", res, sum.ResetRun)
	}
	// The whole point: the reset never fired, so the flag telemetry is intact.
	if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" {
		t.Fatalf("the flagged row must survive a seed-collision-aborted redrive --resnapshot, got: %+v", cs)
	}
}

// TestRedriveAbortsOnSeedFileDriftPreservingTelemetry locks the self-review MAJOR: the redrive guard
// must catch a SEED-FILE edit (not just a config-file edit), because that is exactly what the
// glossary_miss CLI hint tells the operator to make. The guard re-seeds (like TranslateBook will) so
// an approved-term change shifts the snapshot and the redrive aborts BEFORE the destructive reset —
// leaving the flag telemetry intact. A read-only stored-glossary projection would miss it, reset,
// then fail loud on the re-seed AFTER deleting the flagged rows.
func TestRedriveAbortsOnSeedFileDriftPreservingTelemetry(t *testing.T) {
	seed := "terms:\n  - src: 蛊\n    dst: Гу\n    status: approved\n"
	rec := &reqRec{}
	srv := newJSONProvider(rec, func(body string) (string, string) {
		if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
			return "Извините, я не могу перевести это.", "stop"
		}
		return draftEdit(body)
	})
	defer srv.Close()
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0, glossarySeed: seed})
	ctx := context.Background()

	r1 := newRunner(t, bookPath)
	if _, err := r1.TranslateBook(ctx); err != nil {
		t.Fatal(err)
	}
	r1.Close()

	// Edit the seed FILE — change the approved dst → different memoryVersion → different snapshot.
	dir := filepath.Dir(bookPath)
	writeFile(t, filepath.Join(dir, "glossary-seed.yaml"), "terms:\n  - src: 蛊\n    dst: Гу-изменённый\n    status: approved\n")

	r2 := newRunner(t, bookPath)
	defer r2.Close()
	sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
	if err == nil {
		t.Fatal("a seed-FILE edit must make redrive fail loud (re-seed drift guard)")
	}
	if res != nil || sum.ResetRun {
		t.Errorf("a seed-drift-aborted redrive must not re-run: res=%v resetRun=%v", res, sum.ResetRun)
	}
	if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" {
		t.Fatalf("the flagged row must survive a seed-drift-aborted redrive, got: %+v", cs)
	}
}

// TestRedriveNoTargets confirms a selector that matches nothing is a clean no-op (no re-run).
func TestRedriveNoTargets(t *testing.T) {
	rec := &reqRec{}
	srv := newJSONProvider(rec, draftEdit)
	defer srv.Close()
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАОДИН\fГЛАВАДВА"})
	ctx := context.Background()

	r := newRunner(t, bookPath)
	defer r.Close()
	if _, err := r.TranslateBook(ctx); err != nil {
		t.Fatal(err)
	}
	calls := rec.count()
	// Nothing is flagged → redrive finds no targets and never re-runs.
	sum, res, err := r.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
	if err != nil {
		t.Fatal(err)
	}
	if len(sum.Targets) != 0 || sum.ResetRun || res != nil {
		t.Fatalf("redrive with no flagged chunks must be a no-op: %+v", sum)
	}
	if rec.count() != calls {
		t.Fatal("no-target redrive made provider calls")
	}
}
