package main

import (
	"encoding/json"
	"fmt"
	"io"
	"strings"
	"unicode/utf8"

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

// render.go: рендеры человеческого/JSON-вывода команд, выделенные из main.go в
// чистые функции над структурами pipeline/store (пакет №4). Все байты вывода —
// ЗАМОРОЖЕННЫЙ контракт (стабильные enum в --json — D12; человеческие таблицы —
// поверхность аудита D20.4): fmt.Printf заменён на fmt.Fprintf(w, …) с
// w=os.Stdout — байт-в-байт; каждое сознательное изменение перечислено в
// PROGRESS (в этом пакете одно: report несёт ch/chunk/model_requested/err —
// пост-мортем упавшего вызова был «пустой строкой», боль smoke-прогона).
// ledger-коллбеки сохраняют ТОЧНЫЙ порядок «печать → чтение SpentUSD → печать»
// исходного кода: подъём чтения до рендера менял бы частичный вывод на ошибке.

// renderTranslate prints the per-chunk translation report and returns the
// CompletedWithFlags sentinel when chunks were flagged (exit 2).
func renderTranslate(w io.Writer, res *pipeline.BookResult, ledger func() (committed, reserved float64, err error)) error {
	for _, ch := range res.Chunks {
		fmt.Fprintf(w, "=== ГЛАВА %d ЧАНК %d — %s%s ===\n", ch.Chapter, ch.ChunkIdx, ch.Disposition, flagSuffix(ch.FlagReason))
		if ch.Disposition == pipeline.DispOK {
			fmt.Fprintln(w, ch.FinalText)
		} else {
			fmt.Fprintf(w, "[ФЛАГ %s] чанк не переведён — черновик/редактура непригодны, помечен для человека\n", ch.FlagReason)
		}
		for _, st := range ch.Stages {
			how := "call"
			switch {
			case st.Disposition == pipeline.DispSkipped:
				how = "skipped"
			case st.FromResume:
				how = "resume"
			}
			fmt.Fprintf(w, "  %-8s %-22s %-8s %-8s%s $%.6f (cum $%.6f) in=%d (cached=%d) out=%d+%d att=%d %dms finish=%s\n",
				st.Stage, st.Model, how, st.Disposition, flagSuffix(st.FlagReason),
				st.CostUSD, st.CumCostUSD,
				st.Usage.PromptTokens, st.Usage.CachedTokens,
				st.Usage.CompletionTokens, st.Usage.ReasoningTokens, st.Attempts, st.LatencyMS, st.FinishReason)
		}
		fmt.Fprintln(w)
	}

	fmt.Fprintf(w, "ИТОГО (этот запуск): $%.6f — чанков %d, флагов %d\n", res.TotalUSD, len(res.Chunks), res.Flagged)
	committed, reserved, err := ledger()
	if err != nil {
		return err
	}
	fmt.Fprintf(w, "Ledger книги: committed=$%.6f reserved=$%.6f\n", committed, reserved)

	// Exit code 2 is a typed sentinel, not an infra error: the report above is
	// already on stdout; main() maps this to a non-zero exit for the operator.
	if res.Flagged > 0 {
		return &pipeline.CompletedWithFlags{Flagged: res.Flagged, Total: len(res.Chunks)}
	}
	return nil
}

// flagSuffix renders a non-empty flag reason as "(reason)".
func flagSuffix(r pipeline.FlagReason) string {
	if r == "" {
		return ""
	}
	return "(" + string(r) + ")"
}

// renderReport prints the request_log table, the flag/memory/style sections and
// the ledger line. Осознанное изменение пакета №4: колонки ch/chunk, модель с
// фолбэком на ЗАПРОШЕННУЮ (у упавшего вызова model_actual пуст — строка была
// анонимной) и хвост degraded/err — БД эти колонки хранит с Вехи 2, принтер их
// терял, и пост-мортем требовал лезть в sqlite3.
//
// Все три чтения store — КОЛЛБЕКАМИ, как ledger: исторический report читал и
// печатал вперемешку, и провал чтения посреди аудита оставлял на stdout уже
// напечатанную часть; подъём чтений до рендера молча менял бы этот частичный
// вывод на пустой (находка селфревью — тот же принцип, что задокументирован
// ниже для ledger-строки).
func renderReport(w io.Writer,
	fetchRows func() ([]store.RequestLogView, error),
	fetchFlags func() ([]store.ChunkStatus, error),
	fetchStates func() ([]store.RetrievalState, error),
	ledger func() (committed, reserved float64, err error)) error {
	rows, err := fetchRows()
	if err != nil {
		return err
	}
	fmt.Fprintf(w, "%-20s %-4s %-5s %-8s %-12s %-22s %8s %8s %8s %8s %8s %10s %8s %-8s %-5s %-3s %s\n",
		"ts", "ch", "chunk", "stage", "role", "model", "prompt", "cached", "cwrite", "compl", "reason", "cost_usd", "ms", "finish", "tmhit", "ok", "err")
	for _, row := range rows {
		model := row.ModelActual
		if model == "" && row.ModelRequested != "" {
			model = row.ModelRequested + "(req)"
		}
		fmt.Fprintf(w, "%-20s %-4d %-5d %-8s %-12s %-22s %8d %8d %8d %8d %8d %10.6f %8d %-8s %-5d %-3d %s\n",
			row.TS, row.Chapter, row.ChunkIdx, row.Stage, row.Role, model, row.PromptTokens, row.CachedTokens,
			row.CacheCreationTokens, row.CompletionTokens, row.ReasoningTokens, row.CostUSD,
			row.LatencyMS, row.FinishReason, row.TMHit, row.OK, errTail(row.Degraded, row.Err))
	}

	// Flag section (Веха 2): every chunk×stage whose disposition ≠ ok — the
	// "флаг редактору" the plan requires (02-mvp Фаза-1 приёмка допускает N).
	flags, err := fetchFlags()
	if err != nil {
		return err
	}
	headerPrinted := false
	for _, f := range flags {
		if f.Disposition == "ok" {
			continue
		}
		if !headerPrinted {
			fmt.Fprintf(w, "\n=== ФЛАГИ (disposition ≠ ok) ===\n")
			fmt.Fprintf(w, "%-4s %-6s %-8s %-9s %-16s %5s %10s  %s\n",
				"ch", "chunk", "stage", "disp", "reason", "att", "cost_usd", "detail")
			headerPrinted = true
		}
		fmt.Fprintf(w, "%-4d %-6d %-8s %-9s %-16s %5d %10.6f  %s\n",
			f.Chapter, f.ChunkIdx, f.Stage, f.Disposition, f.FlagReason, f.Attempts, f.CostUSD, f.Detail)
	}

	// Memory section (шаг 4): the per-chunk retrieval-state, surfaced so silent glossary
	// degradation is LOUD. The aggregate line always prints when a glossary is in use;
	// each chunk with a post-check miss is listed (the flagger-mode signal — the model
	// ignored an approved term, or we injected the wrong dst) with the offending src→dst.
	states, err := fetchStates()
	if err != nil {
		return err
	}
	if len(states) > 0 {
		var injected, sticky, ambiguous, spoiler, evicted, misses, style int
		for _, rs := range states {
			injected += rs.NExactHits
			sticky += rs.NSticky
			ambiguous += rs.NAmbiguousFlagged
			spoiler += rs.NSpoilerBlocked
			evicted += rs.NEvicted
			misses += rs.NPostcheckMiss
			style += rs.NStyleFlags
		}
		fmt.Fprintf(w, "\n=== ПАМЯТЬ (retrieval-state) ===\n")
		fmt.Fprintf(w, "инъекций(точных)=%d sticky=%d ambiguous=%d спойлер-блок=%d вытеснено=%d post-check-промахов=%d\n",
			injected, sticky, ambiguous, spoiler, evicted, misses)
		printed := false
		for _, rs := range states {
			if rs.NPostcheckMiss == 0 {
				continue
			}
			if !printed {
				fmt.Fprintf(w, "%-4s %-6s %6s  %s\n", "ch", "chunk", "misses", "detail (src→dst не найден в выводе)")
				printed = true
			}
			fmt.Fprintf(w, "%-4d %-6d %6d  %s\n", rs.Chapter, rs.ChunkIdx, rs.NPostcheckMiss, rs.PostcheckDetail)
		}
		// Cheap style/number flaggers (observability, not gates): total + per-chunk detail.
		fmt.Fprintf(w, "\n=== СТИЛЬ-ГЕЙТЫ (наблюдаемость: тире-диалоги, ё, транслит-междометия, разряды 万/億) — всего %d ===\n", style)
		stylePrinted := false
		for _, rs := range states {
			if rs.NStyleFlags == 0 {
				continue
			}
			if !stylePrinted {
				fmt.Fprintf(w, "%-4s %-6s %6s  %s\n", "ch", "chunk", "flags", "detail")
				stylePrinted = true
			}
			fmt.Fprintf(w, "%-4d %-6d %6d  %s\n", rs.Chapter, rs.ChunkIdx, rs.NStyleFlags, rs.StyleDetail)
		}
	}

	committed, reserved, err := ledger()
	if err != nil {
		return err
	}
	fmt.Fprintf(w, "\nLedger книги: committed=$%.6f reserved=$%.6f\n", committed, reserved)
	return nil
}

// errTail collapses the degraded/err columns into one bounded table tail (empty
// when the call was clean). Усечение — по границе руны: err несёт сырые куски
// тел провайдеров (CJK/кириллица), байтовый срез печатал бы битый UTF-8
// (находка селфревью).
func errTail(degraded, errText string) string {
	s := degraded
	if errText != "" {
		if s != "" {
			s += " "
		}
		s += errText
	}
	if len(s) > 120 {
		cut := 120
		for cut > 0 && !utf8.RuneStart(s[cut]) {
			cut--
		}
		s = s[:cut] + "…"
	}
	return s
}

// renderStatusJSON emits the StatusReport as indented JSON (stable enums — the
// ratified CI/IDE contract, D12/D15.3) and maps flagged>0 to the exit-2 sentinel.
func renderStatusJSON(w io.Writer, rep *pipeline.StatusReport) error {
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	if err := enc.Encode(rep); err != nil {
		return err
	}
	// Symmetric with the human mode (minor 1d): flagged>0 is exit 2 (completed-with-flags), not
	// exit 0. The full JSON is already on stdout; the sentinel only sets the shell disposition
	// (its message goes to stderr in main), so a CI/IDE consumer both parses the projection AND
	// sees "attention needed" in the exit code instead of silently reading 0.
	if rep.Flagged > 0 {
		return &pipeline.CompletedWithFlags{Flagged: rep.Flagged, Total: rep.TotalChunks}
	}
	return nil
}

// renderStatusHuman prints the operator dashboard (unit counts, per-chapter
// passports, money, secondary ETA) and maps flagged>0 to the exit-2 sentinel.
// cfgPath попадает в советы «что делать» (командные строки redrive/translate).
func renderStatusHuman(w io.Writer, rep *pipeline.StatusReport, cfgPath string) error {
	snap := rep.Snapshot
	if snap == "" {
		snap = "—"
	} else if len(snap) > 12 {
		snap = snap[:12]
	}
	drift := ""
	if rep.SnapshotDrift {
		drift += " ⚠ SNAPSHOT-DRIFT (несколько снапшотов в chunk_status — конфиг менялся; --resnapshot)"
	}
	if rep.ConfigDrift {
		drift += " ⚠ CONFIG-DRIFT (текущий конфиг рендерит другой снапшот — правка после прогона; translate потребует --resnapshot = переоплата книги)"
	}
	fmt.Fprintf(w, "=== СТАТУС: %s (snapshot %s)%s ===\n", rep.BookID, snap, drift)
	fmt.Fprintf(w, "Прогресс: %d/%d чанков (%.1f%%) — done=%d in_progress=%d flagged=%d pending=%d\n",
		rep.Done, rep.TotalChunks, rep.PercentDone, rep.Done, rep.InProgress, rep.Flagged, rep.Pending)
	fmt.Fprintf(w, "Эскалаций: %d · post-check промахов (confirmed): %d · стиль-флагов (наблюдаемость): %d\n",
		rep.Escalations, rep.PostcheckMisses, rep.StyleFlags)
	ceil := ""
	if rep.BookCeilingUSD > 0 {
		ceil = fmt.Sprintf(" (потолок $%.2f — %.1f%%)", rep.BookCeilingUSD, rep.CeilingPct)
	}
	fmt.Fprintf(w, "Деньги: committed=$%.6f reserved=$%.6f%s · прогноз книги ~$%.6f\n",
		rep.CommittedUSD, rep.ReservedUSD, ceil, rep.ProjectedBookUSD)
	if rep.ETASeconds > 0 {
		fmt.Fprintf(w, "ETA: ~%.0fс (средний throughput fresh-вызовов, НЕ EWMA — D12-отступление; вторично)\n", rep.ETASeconds)
	}

	if len(rep.Chapters) > 0 {
		// style = cheap deterministic style/number flags (observability, not a disposition) — a per-
		// chapter glance count so a human sees where the linters fired without opening `report` (D20.4).
		fmt.Fprintf(w, "\n%-5s %-9s %-10s %-6s %-4s %-6s %-16s %10s\n", "ch", "done/all", "verdict", "flag", "esc", "style", "worst_flag", "cost_usd")
		for _, p := range rep.Chapters {
			fmt.Fprintf(w, "%-5d %d/%-7d %-10s %-6d %-4d %-6d %-16s %10.6f\n",
				p.Chapter, p.ChunksDone, p.ChunksTotal, p.Verdict, p.ChunksFlagged, p.Escalations, p.StyleFlags,
				dashIfEmpty(p.WorstFlagReason), p.CostUSD)
		}
	}
	// flagged≠failed (jobs.status.failed = infra-only, D12): flagged chunks are a "clean run,
	// attention needed" signal. Two kinds need DIFFERENT actions (minor 1d): a chunk_status flag
	// (soft_refusal, length, …) is re-drivable with `tmctl redrive`; a gate-promoted glossary_miss
	// is NOT (redrive is a no-op — the miss re-derives from the unchanged glossary), so it needs a
	// seed fix + `translate --resnapshot`. Advise each set separately instead of one blanket hint.
	if rep.Flagged > 0 {
		if redrivable := rep.Flagged - rep.GlossaryMissFlagged; redrivable > 0 {
			fmt.Fprintf(w, "\n%d флагнутых чанк(ов) ждут внимания — переатаковать: tmctl redrive --config %s [--chapter N --chunk M --reason R]\n",
				redrivable, cfgPath)
		}
		if rep.GlossaryMissFlagged > 0 {
			fmt.Fprintf(w, "\n%d чанк(ов) флагнуты post-check-гейтом (glossary_miss) — redrive для них no-op: исправьте сид-глоссарий (термин/dst) и перезапустите `tmctl translate --config %s --resnapshot` (переоплата затронутых чанков)\n",
				rep.GlossaryMissFlagged, cfgPath)
		}
		return &pipeline.CompletedWithFlags{Flagged: rep.Flagged, Total: rep.TotalChunks}
	}
	return nil
}

// dashIfEmpty renders "" as "—" for a table cell.
func dashIfEmpty(s string) string {
	if s == "" {
		return "—"
	}
	return s
}

// renderRedrive prints the redrive plan and (after a live re-run) the re-run
// totals. ledger может вернуть ошибку — она СОЗНАТЕЛЬНО глотается без ledger-
// строки (исходное поведение: флаки чтения не должны менять exit-код redrive).
func renderRedrive(w io.Writer, bookID string, summary *pipeline.RedriveSummary, res *pipeline.BookResult, ledger func() (committed, reserved float64, err error)) error {
	fmt.Fprintf(w, "=== REDRIVE: %s ===\n", bookID)
	if len(summary.Targets) == 0 {
		fmt.Fprintln(w, "Флагнутых чанков под селектор не найдено — нечего переатаковать.")
		return nil
	}
	fmt.Fprintf(w, "Целей: %d флагнутых чанк(ов) — сброс терминального флага + свежий retry/escalation-бюджет (DispOK не тронут):\n", len(summary.Targets))
	for _, t := range summary.Targets {
		fmt.Fprintf(w, "  ch%d/chunk%d (%s) → стадии: %s\n", t.Chapter, t.ChunkIdx, t.FlagReason, strings.Join(t.Stages, ", "))
	}
	if summary.DryRun {
		fmt.Fprintln(w, "[dry-run: ничего не сброшено, вызовов не сделано]")
		return nil
	}
	fmt.Fprintln(w, "[сброшено; перезапуск durable-цикла — DispOK чанки резюмируются за $0, сброшенные переатакуются]")
	fmt.Fprintln(w)

	if res != nil {
		fmt.Fprintf(w, "ИТОГО re-run (этот запуск): $%.6f — чанков %d, флагов %d\n", res.TotalUSD, len(res.Chunks), res.Flagged)
		committed, reserved, serr := ledger()
		if serr == nil {
			fmt.Fprintf(w, "Ledger книги: committed=$%.6f reserved=$%.6f\n", committed, reserved)
		}
		if res.Flagged > 0 {
			return &pipeline.CompletedWithFlags{Flagged: res.Flagged, Total: len(res.Chunks)}
		}
	}
	return nil
}
