package config

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"gopkg.in/yaml.v3"
)

// book.go: translation brief — конфиг проекта книги (Фаза 0 плана: языковая
// пара, жанр, аудитория, 18+, слайдер Venuti, хонорифики, транскрипция,
// сноски). brief_hash входит в ключ TM и в snapshot: изменение брифа посреди
// книги — явная команда с показом стоимости пере-перевода (Р6).

// Book is the parsed book.yaml.
type Book struct {
	BookID     string `yaml:"book_id"`
	Title      string `yaml:"title"`
	SourceLang string `yaml:"source_lang"` // zh | ja | en
	TargetLang string `yaml:"target_lang"` // ru
	Genre      string `yaml:"genre"`
	Audience   string `yaml:"audience"`
	// Adult: интерим-правило 18+ Фазы 1 — true принудительно исключает
	// Anthropic из роутинга книги (эскалация без Opus/Sonnet).
	Adult bool `yaml:"adult"`
	// Venuti is the foreignization↔domestication slider, 0.0 (доместикация)
	// … 1.0 (форенизация).
	Venuti        float64 `yaml:"venuti"`
	Honorifics    string  `yaml:"honorifics"`    // keep | adapt
	Transcription string  `yaml:"transcription"` // e.g. polivanov | palladius
	Footnotes     string  `yaml:"footnotes"`     // none | minimal | rich
	// YoPolicy is the brief's ё-policy for the cheap yofikator gate: auto (flag only inconsistency
	// — the same word both with ё and е) | all-yo | all-e. "" defaults to auto. A brief field: it
	// changes the style-flag verdict, so it is folded into BriefHash (a change re-pins the snapshot).
	YoPolicy string `yaml:"yo_policy"`
	// StyleAllowlist exempts surfaces from the translit-interjection blocklist (a per-project
	// allowlist, 04-unhappy §6): a character actually named «Ара» is not an untranslated filler.
	// Lower-cased on load. Verdict-affecting → also folded into BriefHash.
	StyleAllowlist []string `yaml:"style_allowlist"`

	// Wiring: paths are resolved relative to the book.yaml location.
	Pipeline   string  `yaml:"pipeline"`
	ModelsFile string  `yaml:"models"`
	SourceFile string  `yaml:"source_file"` // Фаза 0: один файл = один чанк
	// Encoding of the txt source: auto|utf8|gb18030 ("" defaults to auto). Real zh .txt are often
	// GB18030 (the 蛊真人 acceptance book is), which the auto-detect handles; declare it explicitly
	// to skip detection. Ignored for epub (its documents carry their own charset). See ingest.go.
	Encoding  string  `yaml:"encoding"`
	ProjectDB string  `yaml:"project_db"` // default: <book_id>.db рядом с book.yaml
	// GlossarySeed is the optional path to the manual glossary seed YAML (memory v2,
	// шаг 4). Its curated approved/draft terms + the classified ruby readings are the
	// deterministic inputs the glossary is REPLACED from each run; editing it changes the
	// materialized approved set → a loud --resnapshot (F1). Empty = ruby-seed only (or an
	// empty glossary → the injection is inert, a safe no-op).
	GlossarySeed string  `yaml:"glossary_seed"`
	Ceilings     BookCap `yaml:"ceilings"`
}

// BookCap are the ledger admission limits (Р7: потолок $ на книгу/день).
type BookCap struct {
	BookUSD float64 `yaml:"book_usd"`
	DayUSD  float64 `yaml:"day_usd"`
}

// LoadBook reads and validates book.yaml, resolving relative paths against
// its directory.
func LoadBook(path string) (*Book, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("config: read %s: %w", path, err)
	}
	var b Book
	if err := yaml.Unmarshal(raw, &b); err != nil {
		return nil, fmt.Errorf("config: parse %s: %w", path, err)
	}
	dir := filepath.Dir(path)
	resolve := func(p string) string {
		if p == "" || filepath.IsAbs(p) {
			return p
		}
		return filepath.Join(dir, p)
	}
	b.Pipeline = resolve(b.Pipeline)
	b.ModelsFile = resolve(b.ModelsFile)
	b.SourceFile = resolve(b.SourceFile)
	b.GlossarySeed = resolve(b.GlossarySeed)
	if b.ProjectDB == "" {
		b.ProjectDB = filepath.Join(dir, b.BookID+".db")
	} else {
		b.ProjectDB = resolve(b.ProjectDB)
	}

	var problems []string
	bad := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
	if b.BookID == "" {
		bad("book_id is required")
	}
	if b.SourceLang == "" || b.TargetLang == "" {
		bad("source_lang and target_lang are required")
	}
	if b.Venuti < 0 || b.Venuti > 1 {
		bad("venuti must be in [0,1], got %v", b.Venuti)
	}
	if b.Pipeline == "" {
		bad("pipeline config path is required")
	}
	if b.ModelsFile == "" {
		bad("models config path is required")
	}
	if b.SourceFile == "" {
		bad("source_file is required")
	} else if _, err := os.Stat(b.SourceFile); err != nil {
		bad("source_file %s is not readable: %v", b.SourceFile, err)
	}
	if b.GlossarySeed != "" {
		if _, err := os.Stat(b.GlossarySeed); err != nil {
			bad("glossary_seed %s is not readable: %v", b.GlossarySeed, err)
		}
	}
	if b.Encoding == "" {
		b.Encoding = "auto"
	}
	switch strings.ToLower(b.Encoding) {
	case "auto", "utf8", "utf-8", "gb18030", "gbk", "gb2312":
	default:
		bad("encoding %q is not supported (use auto|utf8|gb18030)", b.Encoding)
	}
	if b.YoPolicy == "" {
		b.YoPolicy = "auto"
	}
	switch b.YoPolicy {
	case "auto", "all-yo", "all-e":
	default:
		bad("yo_policy %q is not supported (use auto|all-yo|all-e)", b.YoPolicy)
	}
	for i, s := range b.StyleAllowlist {
		b.StyleAllowlist[i] = strings.ToLower(strings.TrimSpace(s))
	}
	// Canonicalize (sort) the allowlist so a cosmetic reorder in book.yaml does not change BriefHash
	// → the snapshot → force an unnecessary re-pin (self-review: it is a set, consumed order-
	// independently in cheapGateConfig).
	sort.Strings(b.StyleAllowlist)
	if b.Ceilings.BookUSD <= 0 && b.Ceilings.DayUSD <= 0 {
		bad("ceilings: at least one of book_usd/day_usd must be set (ledger без потолка запрещён Р7)")
	}
	if len(problems) > 0 {
		return nil, fmt.Errorf("config %s:\n  - %s", path, strings.Join(problems, "\n  - "))
	}
	return &b, nil
}

// BriefHash is the canonical hash of the SEMANTIC brief fields — the ones
// whose change legitimately invalidates the TM (Р6: brief_hash входит в ключ
// TM). Wiring fields (paths, ceilings, db) deliberately excluded: перенос
// проекта в другую директорию не должен пере-переводить книгу. JSON с
// фиксированным порядком полей → детерминированный байтовый рендер.
func (b *Book) BriefHash() string {
	// Title входит в бриф: это смысловое поле (используется промптами через
	// {{title}} и попадает в рендер → request-hash), не wiring-идентификатор.
	// Без него правка заголовка молча инвалидировала бы чекпоинты в обход
	// snapshot-гейта (находка ревью).
	canon := struct {
		BookID         string   `json:"book_id"`
		Title          string   `json:"title"`
		SourceLang     string   `json:"source_lang"`
		TargetLang     string   `json:"target_lang"`
		Genre          string   `json:"genre"`
		Audience       string   `json:"audience"`
		Adult          bool     `json:"adult"`
		Venuti         float64  `json:"venuti"`
		Honorifics     string   `json:"honorifics"`
		Transcription  string   `json:"transcription"`
		Footnotes      string   `json:"footnotes"`
		YoPolicy       string   `json:"yo_policy"`
		StyleAllowlist []string `json:"style_allowlist"`
	}{b.BookID, b.Title, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes, b.YoPolicy, b.StyleAllowlist}
	data, err := json.Marshal(canon)
	if err != nil {
		// A struct of scalars cannot fail to marshal; keep the signature clean.
		panic(fmt.Sprintf("config: brief hash marshal: %v", err))
	}
	sum := sha256.Sum256(data)
	return hex.EncodeToString(sum[:])
}

// LangPair returns "ja-ru"-style pair key (ключи порогов coverage-гейта).
func (b *Book) LangPair() string {
	return b.SourceLang + "-" + b.TargetLang
}
