package pipeline

import (
	"context"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"textmachine/backend/internal/obs"
)

// export_test.go pins the read-only export surface (D39 слой 6 / D39.2-open №1): `tmctl export` must
// emit each chunk's FINAL text EXACTLY as `tmctl translate` ships it (export-normalised), for BOTH a
// fresh run and a $0 resume — the whole point being that the polygon extractor reads the SAME bytes
// the backend does, not the RAW stored checkpoint (which skips the export contract for a clean chunk).

// TestExportMatchesTranslateFinalText runs a real 3-chunk book across the three export outcomes and
// asserts Export() reproduces BookResult's FinalText byte-for-byte — and that a clean chunk carrying a
// normalize-relevant artifact is exported NORMALISED (not the raw checkpoint), which is the fix.
func TestExportMatchesTranslateFinalText(t *testing.T) {
	rec := &reqRec{}
	// Three chapters (\f-separated) → three 1-chunk chapters. The editor (final stage) emits:
	//   ch1: clean prose carrying a U+3000 indent + fullwidth digits → OK, but export must NORMALISE it;
	//   ch2: a sparse contentless CJK-leak run (below the echo threshold) → cosmetic sanitizer strip;
	//   ch3: a leaked service preamble → SUBSTANTIVE sanitizer defect → dropped (FinalText "").
	const (
		src1 = "ГЛАВААА"
		src2 = "ГЛАВАББ"
		src3 = "ГЛАВАВВ"
	)
	editCleanArtifact := "Судзуки шёл по коридору.　１２３ шага до двери." // U+3000 + «１２３»
	editCosmeticLeak := "Он поднял кулак 羅漢 и ударил врага."           // sparse Han run → strip
	editPreamble := "Вот перевод фрагмента:\nОн молча ушёл в туман."   // preamble → dropped
	srv := newJSONProvider(rec, func(body string) (string, string) {
		if !isEditBody(body) {
			return "черновик перевода этой главы.", "stop" // any draft — clean, non-echo
		}
		switch {
		case strings.Contains(body, src1):
			return editCleanArtifact, "stop"
		case strings.Contains(body, src2):
			return editCosmeticLeak, "stop"
		default: // src3
			return editPreamble, "stop"
		}
	})
	defer srv.Close()

	// Enable the output-sanitizer so the cosmetic-strip (ch2) and substantive-drop (ch3) paths engage.
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{
		source:    src1 + "\f" + src2 + "\f" + src3,
		gatesYAML: "gates:\n  sanitizer:\n    enabled: true\n",
	})
	ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})

	r1 := newRunner(t, bookPath)
	res, err := r1.TranslateBook(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if len(res.Chunks) != 3 {
		t.Fatalf("want 3 chunks, got %d: %+v", len(res.Chunks), res.Chunks)
	}

	// The authoritative FinalText per chunk, straight from the translate result.
	type want struct {
		final string
		disp  Disposition
		flag  FlagReason
	}
	byChunk := map[[2]int]want{}
	for _, ch := range res.Chunks {
		byChunk[[2]int{ch.Chapter, ch.ChunkIdx}] = want{ch.FinalText, ch.Disposition, ch.FlagReason}
	}

	// The clean chunk MUST be export-normalised, not raw: no U+3000, no fullwidth digits survive.
	ch1 := byChunk[[2]int{1, 0}]
	if strings.ContainsRune(ch1.final, '　') || strings.Contains(ch1.final, "１２３") {
		t.Fatalf("ch1 FinalText was not export-normalised: %q", ch1.final)
	}
	if ch1.final != "Судзуки шёл по коридору. 123 шага до двери." {
		t.Fatalf("ch1 FinalText = %q, want the normalised form", ch1.final)
	}
	// The cosmetic-leak chunk is flagged-stripped with the Han run removed but the prose kept.
	ch2 := byChunk[[2]int{2, 0}]
	if ch2.disp != DispFlagged || ch2.flag != FlagSanitizerStripped || strings.Contains(ch2.final, "羅漢") || ch2.final == "" {
		t.Fatalf("ch2 expected stripped-non-empty flagged export, got %+v", ch2)
	}
	// The preamble chunk is a substantive drop: flagged with an EMPTY export.
	ch3 := byChunk[[2]int{3, 0}]
	if ch3.disp != DispFlagged || ch3.final != "" {
		t.Fatalf("ch3 expected substantive drop with empty export, got %+v", ch3)
	}

	assertExportEquals := func(tag string, r *Runner) {
		t.Helper()
		exp, err := r.Export()
		if err != nil {
			t.Fatalf("%s: Export: %v", tag, err)
		}
		if len(exp.Chunks) != 3 {
			t.Fatalf("%s: want 3 export chunks, got %d", tag, len(exp.Chunks))
		}
		for _, ce := range exp.Chunks {
			w, ok := byChunk[[2]int{ce.Chapter, ce.ChunkIdx}]
			if !ok {
				t.Fatalf("%s: export emitted an unknown chunk %d/%d", tag, ce.Chapter, ce.ChunkIdx)
			}
			if ce.FinalText != w.final {
				t.Errorf("%s: ch%d/%d export text = %q, want translate FinalText %q", tag, ce.Chapter, ce.ChunkIdx, ce.FinalText, w.final)
			}
			if ce.Disposition != string(w.disp) || ce.FlagReason != string(w.flag) {
				t.Errorf("%s: ch%d/%d export disp/flag = %s/%s, want %s/%s", tag, ce.Chapter, ce.ChunkIdx, ce.Disposition, ce.FlagReason, w.disp, w.flag)
			}
		}
	}

	assertExportEquals("fresh", r1)
	r1.Close()

	// Resume (a new process): the book is served from checkpoints at $0, and Export must reproduce the
	// identical bytes — the derived stripped checkpoint and the raw ones both re-normalise deterministically.
	callsBefore := rec.count()
	r2 := newRunner(t, bookPath)
	defer r2.Close()
	res2, err := r2.TranslateBook(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if rec.count() != callsBefore {
		t.Fatalf("resume must not call the provider again: %d -> %d", callsBefore, rec.count())
	}
	if res2.TotalUSD != 0 {
		t.Fatalf("resume must be $0, got %v", res2.TotalUSD)
	}
	assertExportEquals("resume", r2)
}

// TestExportGlossaryGateWithheld pins the chunk-level glossary post-check gate case (adversarial-review
// finding): the gate flips a chunk to flagged/glossary_miss AFTER the stage loop and never writes it to
// chunk_status, so a raw chunk_status read would report the chunk as clean ok and ship the text
// `translate` withheld. Export must re-derive the gate flag from retrieval_state (like Status) so it
// matches translate byte-for-byte: flagged, glossary_miss, EMPTY export.
func TestExportGlossaryGateWithheld(t *testing.T) {
	rec := &reqRec{}
	srv := newJSONProvider(rec, func(body string) (string, string) {
		if isEditBody(body) {
			return "ОТРЕДАКТИРОВАННЫЙ.", "stop" // final text drops the approved name → confirmed miss
		}
		return "Некто пошёл в библиотеку.", "stop"
	})
	defer srv.Close()
	// Opt-in hard gate + a seed whose approved 鈴木→Судзуки is missed in the final.
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed, postcheckGate: true})
	ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})

	r := newRunner(t, bookPath)
	defer r.Close()
	res, err := r.TranslateBook(ctx)
	if err != nil {
		t.Fatal(err)
	}
	// Precondition: translate withheld this chunk (flagged glossary_miss, empty text).
	if len(res.Chunks) != 1 || res.Chunks[0].FlagReason != FlagGlossaryMiss || res.Chunks[0].FinalText != "" {
		t.Fatalf("precondition: want 1 chunk flagged glossary_miss with empty text, got %+v", res.Chunks)
	}
	exp, err := r.Export()
	if err != nil {
		t.Fatalf("Export: %v", err)
	}
	if len(exp.Chunks) != 1 {
		t.Fatalf("want 1 export chunk, got %d", len(exp.Chunks))
	}
	ce := exp.Chunks[0]
	// Export MUST match translate — NOT ship the withheld text as clean ok.
	if ce.Disposition != string(DispFlagged) || ce.FlagReason != string(FlagGlossaryMiss) || ce.FinalText != "" {
		t.Fatalf("export leaked a gate-withheld chunk: disp=%s reason=%s final=%q (want flagged/glossary_miss/\"\")",
			ce.Disposition, ce.FlagReason, ce.FinalText)
	}
}

// TestExportManifestPending pins F4: a PARTIAL book (a ceiling stopped the run mid-way) exports the
// untranslated chunks as explicit "pending" rows against the $0 manifest — not silently as a complete
// book (the D37-skew the polygon extractor must not inherit). Chunks is non-nil.
func TestExportManifestPending(t *testing.T) {
	srv := newJSONProvider(&reqRec{}, draftEdit)
	defer srv.Close()
	// Three chapters; a book ceiling that pays for ~one chapter (2 calls ≈ $0.0036) then denies.
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{
		source: "ГЛАВАА\fГЛАВАБ\fГЛАВАВ", regenerate: 0, bookUSD: 0.005,
	})
	ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})

	r := newRunner(t, bookPath)
	defer r.Close()
	// The ceiling stop is an infra error — expected; we only need the partial store state it leaves.
	_, _ = r.TranslateBook(ctx)

	exp, err := r.Export()
	if err != nil {
		t.Fatalf("Export: %v", err)
	}
	if exp.TotalChunks != 3 {
		t.Fatalf("manifest must be 3 chunks, got total=%d", exp.TotalChunks)
	}
	if exp.PendingChunks < 1 {
		t.Fatalf("a ceiling-stopped book must have pending chunks, got pending=%d (%+v)", exp.PendingChunks, exp.Chunks)
	}
	if len(exp.Chunks) != exp.TotalChunks {
		t.Fatalf("every manifest chunk must have a row: len=%d total=%d", len(exp.Chunks), exp.TotalChunks)
	}
	var pending int
	for _, ce := range exp.Chunks {
		if ce.Disposition == "pending" {
			pending++
			if ce.FinalText != "" {
				t.Errorf("a pending chunk must export no text: %+v", ce)
			}
		}
	}
	if pending != exp.PendingChunks {
		t.Errorf("pending rows (%d) must match the counter (%d)", pending, exp.PendingChunks)
	}
}

// TestExportDetectsConfigDrift pins F3: after a wire-affecting config edit (a prompt_version bump) since
// the run, Export flags ConfigDrift — the gate/stage re-derivation may not match what translate did.
func TestExportDetectsConfigDrift(t *testing.T) {
	srv := newJSONProvider(&reqRec{}, draftEdit)
	defer srv.Close()
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})
	ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})

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

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

	// Bump a wire-affecting field → 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")
	}
	writeFile(t, filepath.Join(dir, "pipeline.yaml"), changed)

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

// TestExportFailLoudOnInconsistentStore pins F9: a DispOK final row with an EMPTY final_hash is an
// inconsistent store; Export fails LOUD rather than silently exporting "".
func TestExportFailLoudOnInconsistentStore(t *testing.T) {
	srv := newJSONProvider(&reqRec{}, draftEdit)
	defer srv.Close()
	bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})
	ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})

	r := newRunner(t, bookPath)
	defer r.Close()
	res, err := r.TranslateBook(ctx)
	if err != nil {
		t.Fatal(err)
	}
	// Corrupt the final-stage row: DispOK but no final_hash (simulating store inconsistency).
	lastStage := r.Pipeline.Stages[len(r.Pipeline.Stages)-1].Name
	ch := res.Chunks[0]
	cs, err := r.Store.GetChunkStatus(r.Book.BookID, ch.Chapter, ch.ChunkIdx, lastStage)
	if err != nil || cs == nil {
		t.Fatalf("read final row: %v cs=%v", err, cs)
	}
	cs.FinalHash = ""
	if err := r.Store.UpsertChunkStatus(*cs); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Export(); err == nil {
		t.Fatal("Export must fail loud on a DispOK row with an empty final_hash")
	} else if !strings.Contains(err.Error(), "empty final_hash") {
		t.Fatalf("unexpected error: %v", err)
	}
}
