package pipeline

import (
	"context"
	"fmt"
	"sort"
	"strings"
	"textmachine/backend/internal/store"
)

// seeding.go: детерминированные $0-прелюдии джобы — REPLACE-сид глоссария из
// ручного файла + ruby-чтений (D16.4) и персист ruby-агрегатов. Выполняются ДО
// snapshotID: замороженные approved-строки входят в memoryVersion (F1).

// seedGlossary REPLACES the book's glossary from its deterministic inputs — the manual
// seed file (curated approved/draft terms) and the captured ruby readings (classified
// into auto candidates) — then MATERIALIZES the frozen bank for this job (r.memory). Run
// once before snapshotID: the frozen APPROVED rows are hashed into memoryVersion (F1), so
// editing the seed is a loud --resnapshot. Idempotent (full replace), $0, no LLM. B2
// approved dst-collisions are logged (not fatal — some collisions are legitimate).
func (r *Runner) seedGlossary(ctx context.Context) error {
	var entries []store.GlossaryEntry
	if r.Book.GlossarySeed != "" {
		seed, err := loadGlossarySeed(r.Book.GlossarySeed)
		if err != nil {
			return err
		}
		entries = append(entries, seed...)
	}
	manualSrcs := map[string]bool{}
	for _, e := range entries {
		manualSrcs[e.Src] = true
	}
	ruby, err := r.Store.RubyReadingsForBook(r.Book.BookID)
	if err != nil {
		return fmt.Errorf("pipeline: read ruby readings for %s: %w", r.Book.BookID, err)
	}
	// D16.4: attach a manual term's kana ruby-reading as an alias (kana spelling matchable)
	// BEFORE appending the auto-candidates, so it only touches the curated manual entries. A
	// reading that would collide with a different seeded term (homophone) is skipped+logged,
	// not attached (which would fail the book loud on an alias the operator cannot edit out).
	if skipped := attachRubyAliasesToManual(entries, ruby); len(skipped) > 0 {
		r.Log.WarnContext(ctx, "ruby kana-alias skipped as a homophone collision (kana form left unmatchable; disambiguate in the seed if needed)",
			"skipped", strings.Join(skipped, "; "))
	}
	entries = append(entries, rubyToCandidates(ruby, manualSrcs)...)
	for i := range entries {
		entries[i].BookID = r.Book.BookID
	}
	// Task-6 re-audit: fail loud on a firing key shared by two DIFFERENT approved terms with
	// different dst + overlapping windows (the alias generalization of the D16.1 polysemy
	// livelock) — checked over the FULL entry set (incl. ruby-attached aliases) before persisting.
	if cols := approvedSharedKeyCollisions(entries); len(cols) > 0 {
		return fmt.Errorf("pipeline: glossary shared-key collisions (A2 / D16.1 livelock class):\n  - %s", strings.Join(cols, "\n  - "))
	}
	if err := r.Store.ReplaceGlossary(r.Book.BookID, entries); err != nil {
		return fmt.Errorf("pipeline: replace glossary for %s (%d entries): %w", r.Book.BookID, len(entries), err)
	}
	rows, err := r.Store.GlossaryForBook(r.Book.BookID)
	if err != nil {
		return fmt.Errorf("pipeline: read glossary for %s: %w", r.Book.BookID, err)
	}
	r.memory = materializeMemory(rows, r.Pipeline.Gates.Glossary.PostcheckGate)
	if cols := injectivityCollisions(rows); len(cols) > 0 {
		r.Log.WarnContext(ctx, "glossary approved dst-collisions (B2: two source terms share one Russian surface — the reader cannot tell them apart)",
			"collisions", strings.Join(cols, "; "))
	}
	r.Log.InfoContext(ctx, "glossary materialized", "book", r.Book.BookID,
		"entries", len(rows), "memory_version", r.memory.Version()[:12])
	return nil
}

// persistRuby aggregates the ingested ruby occurrences into one row per
// (base, reading) — first_chapter = MIN, occurrences = full-book count — and
// REPLACES the book's whole ruby set (store.ReplaceRubyReadings). Idempotent: the
// aggregation is recomputed identically on every ingest, so a resume re-writes the
// same rows; a source edit converges every column — a pair removed by the edit
// disappears instead of lingering as a phantom (external-review #6). Called
// unconditionally (even for zero readings — a txt book or a source that dropped its
// furigana) so the full-replace clears any stale rows. The write order is sorted for
// deterministic, test-stable behavior. Memory v2 (шаг 4) consumes ruby_readings into
// a glossary name-lock (D9); nothing here injects into a prompt (§7d).
func (r *Runner) persistRuby(readings []RubyReading) error {
	type agg struct {
		first int
		count int
	}
	seen := map[[2]string]*agg{}
	order := make([][2]string, 0, len(readings))
	for _, rr := range readings {
		key := [2]string{rr.Base, rr.Reading}
		a, ok := seen[key]
		if !ok {
			a = &agg{first: rr.Chapter}
			seen[key] = a
			order = append(order, key)
		}
		if rr.Chapter < a.first {
			a.first = rr.Chapter
		}
		a.count++
	}
	sort.Slice(order, func(i, j int) bool {
		if order[i][0] != order[j][0] {
			return order[i][0] < order[j][0]
		}
		return order[i][1] < order[j][1]
	})
	rows := make([]store.RubyReading, 0, len(order))
	for _, key := range order {
		a := seen[key]
		rows = append(rows, store.RubyReading{
			BookID: r.Book.BookID, Base: key[0], Reading: key[1],
			FirstChapter: a.first, Occurrences: a.count,
		})
	}
	return r.Store.ReplaceRubyReadings(r.Book.BookID, rows)
}
