package main

import (
	"encoding/json"
	"errors"
	"strings"
	"testing"
	"unicode/utf8"

	"textmachine/backend/internal/llm"
	"textmachine/backend/internal/pipeline"
	"textmachine/backend/internal/store"
)

// render_test.go pins the human/JSON output contracts of the extracted renders
// (пакет №4): sentinel-возвраты (exit 2), советы «что делать», секции только при
// непустых данных, err-хвост report, dry-run redrive.

func okLedger() (float64, float64, error) { return 0.123456, 0, nil }

func TestRenderTranslateOKAndLedger(t *testing.T) {
	var b strings.Builder
	res := &pipeline.BookResult{BookID: "b1", TotalUSD: 0.01, Chunks: []pipeline.ChunkOutcome{{
		Chapter: 1, ChunkIdx: 0, Disposition: pipeline.DispOK, FinalText: "ТЕКСТ",
		Stages: []pipeline.StageResult{{Stage: "draft", Model: "m", Disposition: pipeline.DispOK,
			Usage: llm.Usage{PromptTokens: 10, CompletionTokens: 5}, Attempts: 1, FinishReason: "stop"}},
	}}}
	if err := renderTranslate(&b, res, okLedger); err != nil {
		t.Fatal(err)
	}
	out := b.String()
	for _, want := range []string{
		"=== ГЛАВА 1 ЧАНК 0 — ok ===", "ТЕКСТ",
		"ИТОГО (этот запуск): $0.010000 — чанков 1, флагов 0",
		"Ledger книги: committed=$0.123456 reserved=$0.000000",
	} {
		if !strings.Contains(out, want) {
			t.Fatalf("translate output must contain %q, got:\n%s", want, out)
		}
	}
}

func TestRenderTranslateFlaggedSentinel(t *testing.T) {
	var b strings.Builder
	res := &pipeline.BookResult{BookID: "b1", Flagged: 1, Chunks: []pipeline.ChunkOutcome{{
		Chapter: 2, ChunkIdx: 1, Disposition: pipeline.DispFlagged, FlagReason: pipeline.FlagSoftRefusal,
	}}}
	err := renderTranslate(&b, res, okLedger)
	var flagged *pipeline.CompletedWithFlags
	if !errors.As(err, &flagged) || flagged.Flagged != 1 {
		t.Fatalf("flagged run must return the exit-2 sentinel, got: %v", err)
	}
	if !strings.Contains(b.String(), "[ФЛАГ soft_refusal]") {
		t.Fatalf("flagged chunk banner missing:\n%s", b.String())
	}
}

func TestRenderTranslateLedgerErrorAfterTotals(t *testing.T) {
	// Замороженный порядок частичного вывода: итоговая строка уже напечатана,
	// ошибка чтения ledger обрывает ПОСЛЕ неё (подъём чтения до рендера менял бы
	// байты частичного вывода — риск-карта извлечения, п.4).
	var b strings.Builder
	res := &pipeline.BookResult{BookID: "b1"}
	err := renderTranslate(&b, res, func() (float64, float64, error) { return 0, 0, errors.New("boom") })
	if err == nil || err.Error() != "boom" {
		t.Fatalf("ledger error must propagate, got %v", err)
	}
	if !strings.Contains(b.String(), "ИТОГО") || strings.Contains(b.String(), "Ledger книги") {
		t.Fatalf("totals must be printed, ledger line must not:\n%s", b.String())
	}
}

func TestRenderReportColumnsAndErrTail(t *testing.T) {
	var b strings.Builder
	rows := []store.RequestLogView{
		{TS: "2026-07-10 17:00:00", Chapter: 3, ChunkIdx: 2, Stage: "draft", Role: "translator",
			ModelRequested: "fake-model", ModelActual: "", Err: "connect: refused", OK: 0},
		{TS: "2026-07-10 17:00:05", Chapter: 3, ChunkIdx: 2, Stage: "draft", Role: "translator",
			ModelRequested: "fake-model", ModelActual: "fake-model", Degraded: "cjk_artifact", OK: 0},
	}
	if err := renderReport(&b,
		func() ([]store.RequestLogView, error) { return rows, nil },
		func() ([]store.ChunkStatus, error) { return nil, nil },
		func() ([]store.RetrievalState, error) { return nil, nil },
		okLedger); err != nil {
		t.Fatal(err)
	}
	out := b.String()
	// Пакет №4: у упавшего вызова model_actual пуст — строка обязана нести
	// запрошенную модель, позицию и текст ошибки (пост-мортем без sqlite3).
	if !strings.Contains(out, "fake-model(req)") {
		t.Fatalf("failed call must show the requested model, got:\n%s", out)
	}
	if !strings.Contains(out, "connect: refused") || !strings.Contains(out, "cjk_artifact") {
		t.Fatalf("err/degraded tail missing:\n%s", out)
	}
	// Секции флагов/памяти не печатаются без данных.
	if strings.Contains(out, "ФЛАГИ") || strings.Contains(out, "ПАМЯТЬ") {
		t.Fatalf("empty sections must not print headers:\n%s", out)
	}
}

// TestErrTailTruncatesOnRuneBoundary pins the D23.4 fix: errTail carries raw provider
// body snippets (CJK/Cyrillic), so a >120-byte tail must be cut on a RUNE boundary — a
// bare byte slice s[:120] would split a multibyte rune into invalid UTF-8. The existing
// column test does not exercise the boundary (its strings are short/ASCII), so a revert of
// the fix survived it — this closes that gap. Mutation: replace the RuneStart walk-back in
// errTail with `s = s[:120] + "…"` and this goes red.
func TestErrTailTruncatesOnRuneBoundary(t *testing.T) {
	// One ASCII byte then 100 Cyrillic runes (2 bytes each): rune starts fall at offset 0
	// then the ODD offsets, so byte 120 (even) lands on a continuation byte — a mid-rune cut.
	long := "x" + strings.Repeat("я", 100) // 201 bytes, well over the 120 cap
	got := errTail("", long)
	if !utf8.ValidString(got) {
		t.Fatalf("errTail produced invalid UTF-8 — a byte-boundary cut split a rune: %q", got)
	}
	if strings.ContainsRune(got, '�') {
		t.Fatalf("errTail leaked a replacement char (broken rune): %q", got)
	}
	if !strings.HasSuffix(got, "…") {
		t.Fatalf("a truncated tail must end with the ellipsis, got %q", got)
	}
	// A short tail is returned verbatim (degraded + err joined, no truncation, no ellipsis).
	if s := errTail("cjk_artifact", "boom"); s != "cjk_artifact boom" {
		t.Fatalf("short tail must be returned verbatim, got %q", s)
	}
}

func TestRenderReportSections(t *testing.T) {
	var b strings.Builder
	flags := []store.ChunkStatus{
		{Chapter: 1, ChunkIdx: 0, Stage: "draft", Disposition: "ok"},
		{Chapter: 2, ChunkIdx: 1, Stage: "draft", Disposition: "flagged", FlagReason: "hard_refusal", Attempts: 1, Detail: "provider refused"},
	}
	states := []store.RetrievalState{{Chapter: 2, ChunkIdx: 1, NExactHits: 3, NPostcheckMiss: 1,
		PostcheckDetail: `[{"src":"鈴木"}]`, NStyleFlags: 2, StyleDetail: `{"yo":2}`}}
	if err := renderReport(&b,
		func() ([]store.RequestLogView, error) { return nil, nil },
		func() ([]store.ChunkStatus, error) { return flags, nil },
		func() ([]store.RetrievalState, error) { return states, nil },
		okLedger); err != nil {
		t.Fatal(err)
	}
	out := b.String()
	for _, want := range []string{"=== ФЛАГИ (disposition ≠ ok) ===", "hard_refusal",
		"=== ПАМЯТЬ (retrieval-state) ===", "post-check-промахов=1", `[{"src":"鈴木"}]`,
		"— всего 2 ===", "Ledger книги"} {
		if !strings.Contains(out, want) {
			t.Fatalf("report must contain %q, got:\n%s", want, out)
		}
	}
	if strings.Contains(out, "1    0") && strings.Contains(out, "ok") && strings.Count(out, "draft") > 1 {
		t.Fatalf("ok rows must not enter the flag section:\n%s", out)
	}
}

func TestRenderReportPartialOutputOnMidAuditError(t *testing.T) {
	// Замороженное поведение (селфревью №4): чтения store интерливятся с печатью,
	// провал ПОСЛЕ первой секции оставляет её на stdout (аудит не «пустеет» молча).
	var b strings.Builder
	rows := []store.RequestLogView{{TS: "t", Chapter: 1, Stage: "draft", Role: "translator", ModelActual: "m", OK: 1}}
	err := renderReport(&b,
		func() ([]store.RequestLogView, error) { return rows, nil },
		func() ([]store.ChunkStatus, error) { return nil, errors.New("chunk_status corrupted") },
		func() ([]store.RetrievalState, error) { t.Fatal("must not be reached"); return nil, nil },
		okLedger)
	if err == nil || err.Error() != "chunk_status corrupted" {
		t.Fatalf("mid-audit error must propagate, got %v", err)
	}
	if !strings.Contains(b.String(), "draft") {
		t.Fatalf("the request_log table printed before the failure must remain on the writer:\n%s", b.String())
	}
}

func TestRenderStatusJSONSchemaAndSentinel(t *testing.T) {
	var b strings.Builder
	rep := &pipeline.StatusReport{BookID: "b1", TotalChunks: 4, Done: 3, Flagged: 1}
	err := renderStatusJSON(&b, rep)
	var flagged *pipeline.CompletedWithFlags
	if !errors.As(err, &flagged) {
		t.Fatalf("--json with flags must return the exit-2 sentinel (minor 1d), got %v", err)
	}
	var decoded map[string]any
	if jerr := json.Unmarshal([]byte(b.String()), &decoded); jerr != nil {
		t.Fatalf("output must be valid JSON: %v", jerr)
	}
	if decoded["book_id"] != "b1" {
		t.Fatalf("stable schema field book_id missing: %v", decoded)
	}
	// Clean book → nil error → exit 0.
	if err := renderStatusJSON(&strings.Builder{}, &pipeline.StatusReport{TotalChunks: 4, Done: 4}); err != nil {
		t.Fatalf("clean book must exit 0, got %v", err)
	}
}

func TestRenderStatusHumanDriftAndAdvice(t *testing.T) {
	var b strings.Builder
	rep := &pipeline.StatusReport{
		BookID: "b1", Snapshot: "abcdef0123456789", TotalChunks: 10, Done: 7,
		Flagged: 3, GlossaryMissFlagged: 1, SnapshotDrift: true, ConfigDrift: true,
	}
	err := renderStatusHuman(&b, rep, "book.yaml")
	var flagged *pipeline.CompletedWithFlags
	if !errors.As(err, &flagged) || flagged.Flagged != 3 {
		t.Fatalf("flagged status must return the exit-2 sentinel, got %v", err)
	}
	out := b.String()
	for _, want := range []string{
		"snapshot abcdef012345", "⚠ SNAPSHOT-DRIFT", "⚠ CONFIG-DRIFT",
		// Совет расщеплён (minor 1d): re-drivable отдельно от glossary_miss (redrive там no-op).
		"2 флагнутых чанк(ов) ждут внимания — переатаковать: tmctl redrive --config book.yaml",
		"1 чанк(ов) флагнуты post-check-гейтом (glossary_miss)",
	} {
		if !strings.Contains(out, want) {
			t.Fatalf("status must contain %q, got:\n%s", want, out)
		}
	}
}

func TestRenderRedriveDryRunAndSentinel(t *testing.T) {
	var b strings.Builder
	sum := &pipeline.RedriveSummary{DryRun: true, Targets: []pipeline.RedriveTarget{
		{Chapter: 1, ChunkIdx: 2, FlagReason: "cjk_artifact", Stages: []string{"draft", "edit"}}}}
	if err := renderRedrive(&b, "b1", sum, nil, okLedger); err != nil {
		t.Fatalf("dry-run must exit 0, got %v", err)
	}
	if !strings.Contains(b.String(), "[dry-run: ничего не сброшено, вызовов не сделано]") {
		t.Fatalf("dry-run banner missing:\n%s", b.String())
	}

	// No targets → informational exit 0.
	b.Reset()
	if err := renderRedrive(&b, "b1", &pipeline.RedriveSummary{}, nil, okLedger); err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(b.String(), "нечего переатаковать") {
		t.Fatalf("no-targets banner missing:\n%s", b.String())
	}

	// Live re-run that still has flags → sentinel; a flaky ledger read is
	// swallowed (no ledger line, exit code unchanged) — замороженное поведение.
	b.Reset()
	sum = &pipeline.RedriveSummary{Targets: []pipeline.RedriveTarget{{Chapter: 1, ChunkIdx: 2, FlagReason: "x", Stages: []string{"draft"}}}}
	res := &pipeline.BookResult{BookID: "b1", Flagged: 1, Chunks: make([]pipeline.ChunkOutcome, 3)}
	err := renderRedrive(&b, "b1", sum, res, func() (float64, float64, error) { return 0, 0, errors.New("flaky") })
	var flagged *pipeline.CompletedWithFlags
	if !errors.As(err, &flagged) {
		t.Fatalf("re-run with flags must return the exit-2 sentinel, got %v", err)
	}
	if strings.Contains(b.String(), "Ledger книги") {
		t.Fatalf("flaky ledger read must swallow the ledger line, not fail:\n%s", b.String())
	}
}
