// Package pipeline is the C-core runner: deterministic prompt rendering,
// request hashing, chunk checkpoints and the stage loop. Фаза 0 — мини-раннер
// C1 (draft→edit, один чанк); циклы по главам/чанкам, гейты и эскалация
// нарастают здесь в Фазе 1 (Р2: это код раннера, не конфиг).
package pipeline

import (
	"crypto/sha256"
	"encoding/binary"
	"encoding/hex"
	"fmt"
	"os"
	"strconv"
	"strings"
	"unicode"

	"golang.org/x/text/unicode/norm"

	"textmachine/backend/internal/config"
	"textmachine/backend/internal/llm"
)

// render.go enforces the determinism invariant (03-implementation-notes §3.1):
// the rendered prompt is a PURE function of (snapshot, committed prior
// outputs, chunk). Запрещены timestamps/UUID; никаких map-итераций — только
// фиксированный список плейсхолдеров. Проверяется тестом «два рендера
// байт-в-байт идентичны». Ломается детерминизм → ломается request-hash →
// resume пере-переводит и пере-оплачивает главу, а кэш DeepSeek (byte prefix
// match) промахивается.

// chunkerVersion versions the segmentation/ingestion rules (включая
// нормализацию источника NormalizeSource, ingest-слой txt/epub и целевой размер
// чанка targetChunkTokens); it is part of the snapshot, so re-chunking a book is
// an explicit re-translation, not a silent cache miss (§3.4 — [НУЖНО РЕШЕНИЕ]
// п.2в). Шаг 3a replaced the Веха-2 char-budget paragraph packer with the real
// source segmenter (sentence-aware, ~1–2k-token packing over ingest-split
// chapters — chunker.go/ingest.go), so the version bumps: any earlier project DB
// re-pins loudly via the --resnapshot gate (a re-chunk is a deliberate
// re-translation), never a silent divergent re-pay. NOTE: the chunk target is a
// const covered by THIS string; if it ever becomes config-tunable it must ALSO be
// folded into the top-level snapshot (like coverageSnap), never left to the
// version alone (§7d). Bumped v3→v4 closing external-review 3a: invalid-UTF-8 is
// now normalized once in NormalizeSource (#3), the ASCII sentence boundary honors
// quote-depth (#4), and <br> inside <ruby> no longer leaks a newline into the body
// (#5) — all segmentation/ingest behaviour changes → a loud --resnapshot.
const chunkerVersion = "chunker-v4-utf8-quotedepth"

// estimatorVersion versions EstimateTokens: его выход входит в max_tokens и
// через него в request-hash, поэтому перекалибровка весов — тоже явная
// инвалидация через snapshot, а не тихий промах всех чекпоинтов (находка
// ревью: code-only правка эстиматора пере-оплатила бы полкниги).
const estimatorVersion = "estimator-v0"

// maxTokensPolicyVersion versions the attempt→max_tokens scaling
// (maxTokensForAttempt, disposition.go). attempt-0 budget is already covered by
// estimatorVersion + defaults, but a change to the RETRY scaling would silently
// shift the request_hash of every attempt≥1 (a missed checkpoint → re-pay on
// retried chunks). Folding this version into the snapshot makes such a change a
// loud --resnapshot instead — the same discipline as estimatorVersion, applied
// to the regeneration axis (Веха 2).
const maxTokensPolicyVersion = "maxtok-v1-double-per-attempt"

// userSeparator splits a prompt template file into the system part and the
// user part. Обе части — версионируемые шаблоны в prompts/ («Редакция» позже
// правит файлы, не код).
const userSeparator = "\n---USER---\n"

// PromptTemplate is one loaded stage template.
type PromptTemplate struct {
	System string
	User   string
	SHA256 string // hash of the raw file — участвует в snapshot
}

// LoadPromptTemplate reads and splits a template file.
func LoadPromptTemplate(path string) (*PromptTemplate, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("pipeline: read prompt %s: %w", path, err)
	}
	sum := sha256.Sum256(raw)
	parts := strings.SplitN(string(raw), userSeparator, 2)
	t := &PromptTemplate{System: strings.TrimSpace(parts[0]), SHA256: hex.EncodeToString(sum[:])}
	if len(parts) == 2 {
		t.User = strings.TrimSpace(parts[1])
	} else {
		return nil, fmt.Errorf("pipeline: prompt %s lacks the %q separator between system and user parts", path, strings.TrimSpace(userSeparator))
	}
	return t, nil
}

// RenderVars are the ONLY placeholders a template may use. A fixed struct, not
// a map: map iteration order would randomize the render.
type RenderVars struct {
	Book  *config.Book
	Text  string // the source chunk
	Draft string // prior stage output ("" on the first stage)
}

// Render substitutes placeholders in a SINGLE pass over the template: values
// are inserted verbatim and never re-scanned, so literal «{{…}}» внутри
// исходника или черновика LLM — это просто текст, а не маркер (находка ревью:
// последовательный ReplaceAll подставлял черновик в «{{draft}}» из сорса и
// падал на «{{TN: примечание}}» в выводе модели). Unknown {{…}} markers in
// the TEMPLATE are a hard error — молчаливо пустой плейсхолдер хуже падения.
func Render(tpl string, v RenderVars) (string, error) {
	// Lookup-only map (никакой итерации — детерминизм не страдает).
	vals := map[string]string{
		"book_id":       v.Book.BookID,
		"title":         v.Book.Title,
		"source_lang":   v.Book.SourceLang,
		"target_lang":   v.Book.TargetLang,
		"genre":         v.Book.Genre,
		"audience":      v.Book.Audience,
		"venuti":        strconv.FormatFloat(v.Book.Venuti, 'f', 2, 64),
		"honorifics":    v.Book.Honorifics,
		"transcription": v.Book.Transcription,
		"footnotes":     v.Book.Footnotes,
		"text":          v.Text,
		"draft":         v.Draft,
	}
	var b strings.Builder
	rest := tpl
	for {
		i := strings.Index(rest, "{{")
		if i < 0 {
			b.WriteString(rest)
			return b.String(), nil
		}
		b.WriteString(rest[:i])
		rest = rest[i:]
		end := strings.Index(rest, "}}")
		if end < 0 {
			return "", fmt.Errorf("pipeline: unterminated placeholder %q in template", snippetStr(rest))
		}
		name := rest[2:end]
		val, ok := vals[name]
		if !ok {
			return "", fmt.Errorf("pipeline: unknown placeholder {{%s}} in template", name)
		}
		b.WriteString(val)
		rest = rest[end+2:]
	}
}

func snippetStr(s string) string {
	if len(s) > 40 {
		return s[:40] + "…"
	}
	return s
}

// Messages builds the wire-neutral message list for a stage with no memory injection
// (the Веха-0/2 behaviour). Layout по Р5: system (стабильный префикс, кэш-граница) →
// user (волатильный хвост). Kept as the zero-injection convenience over
// MessagesWithInjection so existing callers/tests are untouched.
func Messages(tpl *PromptTemplate, v RenderVars) ([]llm.Message, error) {
	return MessagesWithInjection(tpl, v, "")
}

// MessagesWithInjection builds the stage messages with the memory bank v2 injection
// surface (шаг 4 §C, orchestrator variant "а" — a code-assembled message, NOT a new
// RenderVars placeholder, so the fixed template stays stable). Layout по Р5/A6:
//
//	system(стабильный префикс, CacheBoundary) → [injection: глоссарий/STM] → user(волатильный хвост)
//
// The injection is its OWN message placed AFTER the cache boundary (which stays on the
// stable system prefix — the DeepSeek byte-prefix cache still hits; Anthropic's removed,
// no cache_control concern). It is NOT part of ch.Text, so it never leaks into the
// coverage len_ratio or the {{text}} placeholder (the 3a ch.Text contract). Because the
// injection is a message, it is folded into request_hash/content_hash automatically — a
// changed injection is a changed request (a resumed chunk reproduces it deterministically
// from the frozen bank). An empty injection yields the exact 2-message list of Messages.
func MessagesWithInjection(tpl *PromptTemplate, v RenderVars, injection string) ([]llm.Message, error) {
	sys, err := Render(tpl.System, v)
	if err != nil {
		return nil, err
	}
	user, err := Render(tpl.User, v)
	if err != nil {
		return nil, err
	}
	msgs := make([]llm.Message, 0, 3)
	msgs = append(msgs, llm.Message{Role: "system", Content: sys, CacheBoundary: true})
	if strings.TrimSpace(injection) != "" {
		msgs = append(msgs, llm.Message{Role: "system", Content: injection, CacheBoundary: false})
	}
	msgs = append(msgs, llm.Message{Role: "user", Content: user})
	return msgs, nil
}

// RequestHash is the checkpoint/resume key (§3.1): a stable hash of everything
// that determines the call. The snapshot id freezes the volatile context, so
// identical re-renders after a restart find their checkpoint and are neither
// repeated nor re-billed. attempt — измерение регенераций (Фаза 1: гейт
// провалился → attempt+1 → новый ключ; без него регенерация попадала бы в
// только что провалившийся чекпоинт и мгновенно эскалировала в премиум).
func RequestHash(bookID string, chapter, chunkIdx, attempt int, stage, role, model string, temperature float64, reasoning string, jsonOnly bool, maxTokens int, snapshotID string, msgs []llm.Message) string {
	h := sha256.New()
	// Каждое поле длина-префиксовано (8 байт LE) вместо NUL-разделителя:
	// контент с байтом \x00 больше не может сдвинуть границы полей или
	// подделать лишние сообщения (находка ревью). Версия v2 фиксирует смену
	// формата хеша.
	var lb [8]byte
	w := func(parts ...string) {
		for _, p := range parts {
			binary.LittleEndian.PutUint64(lb[:], uint64(len(p)))
			h.Write(lb[:])
			h.Write([]byte(p))
		}
	}
	w("tm-request-v2", bookID, strconv.Itoa(chapter), strconv.Itoa(chunkIdx), strconv.Itoa(attempt), stage, role, model,
		strconv.FormatFloat(temperature, 'f', -1, 64), reasoning, strconv.FormatBool(jsonOnly),
		strconv.Itoa(maxTokens), snapshotID)
	// Явный счётчик сообщений: длина списка — тоже часть ключа.
	binary.LittleEndian.PutUint64(lb[:], uint64(len(msgs)))
	h.Write(lb[:])
	for _, m := range msgs {
		w(m.Role, m.Content, strconv.FormatBool(m.CacheBoundary))
	}
	return hex.EncodeToString(h.Sum(nil))
}

// msgsContentHash is a content signature of the rendered messages, INDEPENDENT
// of attempt/max_tokens. It guards the chunk_status resume fast-path (stagerun.go):
// the source bytes are NOT folded into the snapshot (only the chunker RULES and
// the semantic brief are), so a positional chunk_status row must be re-validated
// against the current rendered content — otherwise an edited source would serve
// a stale, divergent translation with no cache miss (the determinism invariant's
// named failure; self-review Веха 2). Length-prefixed like RequestHash so
// content with NUL bytes cannot forge field/message boundaries. The template is
// already snapshot-pinned, so this only has to catch source/draft edits.
func msgsContentHash(msgs []llm.Message) string {
	h := sha256.New()
	var lb [8]byte
	w := func(s string) {
		binary.LittleEndian.PutUint64(lb[:], uint64(len(s)))
		h.Write(lb[:])
		h.Write([]byte(s))
	}
	w("tm-content-v1")
	binary.LittleEndian.PutUint64(lb[:], uint64(len(msgs)))
	h.Write(lb[:])
	for _, m := range msgs {
		w(m.Role)
		w(m.Content)
	}
	return hex.EncodeToString(h.Sum(nil))
}

// NormalizeSource canonicalizes the source chunk so the request-hash is stable
// across editors and operating systems (находка ревью): strip a UTF-8 BOM,
// CRLF/CR → LF, Unicode NFC, trim surrounding whitespace. Без этого файл,
// сохранённый в другом редакторе (BOM) или на Windows (CRLF), давал бы иной
// hash и молча пере-переводил бы уже оплаченную книгу.
func NormalizeSource(s string) string {
	s = strings.TrimPrefix(s, "\uFEFF") // UTF-8 BOM
	s = strings.ReplaceAll(s, "\r\n", "\n")
	s = strings.ReplaceAll(s, "\r", "\n")
	// Replace invalid UTF-8 with U+FFFD exactly ONCE, here, so all downstream
	// segmentation sees byte-valid text. \u0411\u0435\u0437 \u044D\u0442\u043E\u0433\u043E \u0447\u0430\u043D\u043A\u0435\u0440 \u0440\u0430\u0441\u0445\u043E\u0434\u0438\u0442\u0441\u044F \u0441 \u0441\u0430\u043C\u0438\u043C \u0441\u043E\u0431\u043E\u0439:
	// \u0435\u0433\u043E []rune-\u043F\u0443\u0442\u044C \u043F\u043E \u043F\u0440\u0435\u0434\u043B\u043E\u0436\u0435\u043D\u0438\u044F\u043C (chunker.go) \u043C\u043E\u043B\u0447\u0430 \u0437\u0430\u043C\u0435\u043D\u044F\u043B \u0431\u044B \u0441\u0442\u0440\u0435\u0439-\u0431\u0430\u0439\u0442 \u043D\u0430
	// U+FFFD, \u0430 \u0441\u0442\u0440\u043E\u043A\u043E\u0432\u044B\u0439 \u043F\u0443\u0442\u044C \u043F\u043E \u0430\u0431\u0437\u0430\u0446\u0430\u043C \u0441\u043E\u0445\u0440\u0430\u043D\u044F\u043B \u0431\u044B \u0441\u044B\u0440\u043E\u0439 \u2014 size-\u0437\u0430\u0432\u0438\u0441\u0438\u043C\u0430\u044F \u043F\u043E\u0440\u0447\u0430,
	// \u0442\u0435\u043A\u0443\u0449\u0430\u044F \u0432 {{text}} \u0438 request_hash (\u0432\u043D\u0435\u0448\u043D\u0435\u0435 \u0440\u0435\u0432\u044C\u044E #3). \u042D\u0442\u043E \u043F\u0440\u0430\u0432\u0438\u043B\u043E \u0438\u043D\u0436\u0435\u0441\u0442\u0430 \u2192
	// \u043F\u043E\u043A\u0440\u044B\u0442\u043E chunkerVersion (\u043E\u0441\u043E\u0437\u043D\u0430\u043D\u043D\u0430\u044F \u0437\u0430\u043C\u0435\u043D\u0430, \u043D\u0435 \u0442\u0438\u0445\u0430\u044F).
	s = strings.ToValidUTF8(s, "\uFFFD")
	s = norm.NFC.String(s)
	return strings.TrimSpace(s)
}

// EstimateTokens is a cheap, deterministic token estimate for reservation
// sizing and max_tokens derivation (NOT for billing — billing uses the API's
// usage). Калибровка полигона: иероглиф ≈0.9 ток., русский/латиница ≈3 симв.
// на токен.
func EstimateTokens(s string) int {
	cjk, other := 0, 0
	for _, r := range s {
		switch {
		case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul):
			cjk++
		case unicode.IsSpace(r):
			// whitespace mostly folds into neighbouring tokens
		default:
			other++
		}
	}
	est := cjk + other/3
	if est < 16 {
		est = 16
	}
	return est
}
