package pipeline

import (
	"encoding/json"
	"strings"
)

// quality.go: the DETERMINISTIC per-run quality-report (D39 слой 5, H5-no-in-loop-quality-signal) —
// the online/offline quality telemetry the owner asked for "from day one" (п.25/п.31), which
// research/18 §C1 #10 flagged as a NOW lever but was silently deferred to Ф2. It AGGREGATES signals
// that are ALREADY computed and stored (retrieval_state: glossary post-check misses, cheap style
// flaggers, trust-gated suppressions; chunk_status: echo/CJK/sanitizer flags) plus ONE cheap
// deterministic structural KPI (sentences per narrative paragraph — the "рубленые абзацы" claim-1
// signal) recomputed from the exported final text. It is a PURE READ-ONLY projection like Status: $0,
// no LLM, no snapshot touch, no checkpoint replay — only OBSERVABILITY, never a gate. The semantic
// span-judge (inversion/omission backstop for claim-2) is NOT here — it is research-dependent (пак-2).

// QualityReport is the whole-book per-run quality projection.
type QualityReport struct {
	BookID      string `json:"book_id"`
	TotalChunks int    `json:"total_chunks"`
	// TextChunks is the number of chunks whose exported final text was available for the structural
	// KPI (done or cosmetically-stripped); a flagged-empty chunk contributes no prose.
	TextChunks int `json:"text_chunks"`
	// ProcessedChunks is the number of chunks that REACHED the final stage (a final-stage row exists:
	// ok, cosmetic-strip, or skipped-because-upstream-flagged) — the rate denominator, so echo/CJK
	// rates are a bounded [0,1] fraction of processed chunks (an echo chunk is a skipped final row,
	// disjoint from the exported-text set, which is why dividing by TextChunks alone could exceed 1).
	ProcessedChunks int `json:"processed_chunks"`

	// Claim-1 structural KPI (рубленые абзацы). MeanSentPerNarrPara ≈ 1 is choppy (one sentence per
	// paragraph — the owner's exact complaint); higher is merged discourse prose. Aggregated as
	// total sentences / total narrative paragraphs across the book, so it is a true book-wide mean.
	NarrativeSentences  int     `json:"narrative_sentences"`
	NarrativeParagraphs int     `json:"narrative_paragraphs"`
	MeanSentPerNarrPara float64 `json:"mean_sentences_per_narrative_paragraph"`

	// Deterministic signal aggregates (all observability, never a disposition).
	DialogueDashFlags   int `json:"dialogue_dash_flags"`   // Rosenthal dialogue-dash inconsistencies
	GlossaryMisses      int `json:"glossary_misses"`       // CONFIRMED post-check misses (D10 consistency)
	NumberDriftFlags    int `json:"number_drift_flags"`    // reflow number drift + 万/億 magnitude drift
	TrustGated          int `json:"trust_gated"`           // lower-trust suppressions refused (seed hygiene)
	CosmeticStripChunks int `json:"cosmetic_strip_chunks"` // chunks the sanitizer auto-stripped (markdown header OR CJK leak — F6: was mislabeled cjk_leak)
	EchoChunks          int `json:"echo_chunks"`           // chunks flagged cjk_artifact (untranslated echo)

	// Rates over ProcessedChunks (0..1) for the two "instant unreadability" families, so the numbers
	// read as a bounded fraction of processed chunks rather than a bare count. CosmeticStripRate covers
	// BOTH strip classes (a markdown-only strip is NOT a CJK leak — F6, D39.4: the old cjk_leak_rate
	// counted every sanitizer_stripped chunk and read as false CJK leakage).
	CosmeticStripRate float64 `json:"cosmetic_strip_rate"`
	EchoRate          float64 `json:"echo_rate"`

	Chunks []ChunkQuality `json:"chunks,omitempty"`
}

// ChunkQuality is one chunk's per-chunk quality row (the "where did quality slip" signal).
type ChunkQuality struct {
	Chapter             int `json:"chapter"`
	ChunkIdx            int `json:"chunk_idx"`
	NarrativeSentences  int `json:"narrative_sentences"`
	NarrativeParagraphs int `json:"narrative_paragraphs"`
	DialogueDashFlags   int `json:"dialogue_dash_flags"`
	GlossaryMisses      int `json:"glossary_misses"`
	NumberDriftFlags    int `json:"number_drift_flags"`
	TrustGated          int `json:"trust_gated"`
}

// narrativeStructure computes the claim-1 structural KPI over a FINAL Russian text: the count of
// NARRATIVE paragraphs (non-empty lines that are NOT a dialogue turn opened with a dash «—»/«–»/«-»)
// and the total sentences within them (the oracle-parity splitSentences). Dialogue turns are excluded
// (they are legitimately one short line). Deterministic and pure — the same signal the reflow lever
// is supposed to move (exp14 mean-sentences-per-paragraph), reused here as in-loop observability.
func narrativeStructure(final string) (sentences, paragraphs int) {
	for _, line := range strings.Split(final, "\n") {
		t := strings.TrimSpace(line)
		if t == "" {
			continue
		}
		if rs := []rune(t); rs[0] == '—' || rs[0] == '–' || rs[0] == '-' {
			continue // a dialogue turn — not a narrative paragraph
		}
		paragraphs++
		sentences += len(splitSentences(t))
	}
	return sentences, paragraphs
}

// QualityReport builds the read-only per-run quality projection. It opens no jobs, reserves nothing,
// makes no LLM call — it reads the persisted chunk_status / retrieval_state and, for each chunk with
// an exported final text, the $0 final checkpoint to recompute the structural KPI. Safe to run
// whenever `report`/`status` are (the same exclusive-lock rule). Deterministic over the store.
// CAVEAT (same class as Status's config-drift): the exported-text signals key on the CURRENT config's
// final-stage NAME; if a config edit renamed the final stage since the run, the stored rows use the
// old name and the structural KPI / echo / CJK rates read 0 (the underlying spend/verdict rows are
// untouched — surface `status` shows the drift). A run under the same config reads correctly.
func (r *Runner) QualityReport() (*QualityReport, error) {
	statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
	if err != nil {
		return nil, err
	}
	states, err := r.Store.RetrievalStatesForBook(r.Book.BookID)
	if err != nil {
		return nil, err
	}

	rep := &QualityReport{BookID: r.Book.BookID}
	byChunk := map[chunkKey]*ChunkQuality{}
	order := []chunkKey{}
	chunkOf := func(k chunkKey) *ChunkQuality {
		if q := byChunk[k]; q != nil {
			return q
		}
		q := &ChunkQuality{Chapter: k.chapter, ChunkIdx: k.chunkIdx}
		byChunk[k] = q
		order = append(order, k)
		return q
	}

	// The glossary post-check GATE flips a chunk to withheld (flagged glossary_miss, empty export) at
	// the CHUNK level without a chunk_status row (like Export/Status re-derive it). F5 (D39.4): the
	// structural KPI must EXCLUDE these — their export is "", so counting their (withheld) text in
	// TextChunks/KPI diverges from what `tmctl export` and `translate` actually ship.
	gateOn := r.Pipeline.Gates.Glossary.PostcheckGate
	withheld := map[chunkKey]bool{}

	// Aggregate the stored retrieval-state signals (glossary consistency, style breakdown, trust-gated).
	for _, rs := range states {
		q := chunkOf(chunkKey{rs.Chapter, rs.ChunkIdx})
		q.GlossaryMisses += rs.NPostcheckMiss
		q.TrustGated += rs.NTrustGatedSuppress
		rep.GlossaryMisses += rs.NPostcheckMiss
		rep.TrustGated += rs.NTrustGatedSuppress
		if gateOn && rs.NPostcheckMiss > 0 {
			withheld[chunkKey{rs.Chapter, rs.ChunkIdx}] = true
		}
		if rs.NStyleFlags > 0 && rs.StyleDetail != "" {
			var cg cheapGateResult
			if json.Unmarshal([]byte(rs.StyleDetail), &cg) == nil {
				dash := cg.DialogueDash
				drift := cg.NumberDrift + cg.NumberMagnitude
				q.DialogueDashFlags += dash
				q.NumberDriftFlags += drift
				rep.DialogueDashFlags += dash
				rep.NumberDriftFlags += drift
			}
		}
	}

	// The exported-text chunks (the FINAL stage's row): echo/CJK flag rates + the structural KPI.
	lastStage := ""
	if n := len(r.Pipeline.Stages); n > 0 {
		lastStage = r.Pipeline.Stages[n-1].Name
	}
	seen := map[chunkKey]bool{}
	for _, cs := range statuses {
		k := chunkKey{cs.Chapter, cs.ChunkIdx}
		if !seen[k] {
			seen[k] = true
			rep.TotalChunks++
		}
		if cs.Stage != lastStage {
			continue // the exported text and the final verdict live on the final stage's row
		}
		// Every chunk that REACHED the final stage has exactly one lastStage row (ok, cosmetic-strip,
		// or skipped-because-an-upstream-stage-flagged). This is the common rate denominator so the
		// echo/CJK rates are a bounded fraction of processed chunks (an echo chunk is a skipped final
		// row, disjoint from the exported-text set — dividing by TextChunks alone could exceed 100%).
		rep.ProcessedChunks++
		if cs.FlagReason == string(FlagCJKArtifact) {
			rep.EchoChunks++
			chunkOf(k) // ensure the chunk row exists even if it has no retrieval-state signals
		}
		if cs.FlagReason == string(FlagSanitizerStripped) {
			rep.CosmeticStripChunks++ // a stripped chunk carried a markdown OR CJK cosmetic leak (F6)
		}
		// Structural KPI: recompute over the exported final text (ok, or the cosmetic-stripped export).
		if cs.FinalHash == "" {
			continue
		}
		if cs.Disposition != string(DispOK) && cs.FlagReason != string(FlagSanitizerStripped) {
			continue // a dropped chunk exported nothing
		}
		if withheld[k] {
			continue // F5: the glossary gate withheld this chunk's text — it exports nothing
		}
		cp, cperr := r.Store.GetCheckpoint(cs.FinalHash)
		if cperr != nil {
			return nil, cperr
		}
		if cp == nil || strings.TrimSpace(cp.ResponseText) == "" {
			continue
		}
		sent, para := narrativeStructure(exportNormalize(cp.ResponseText))
		q := chunkOf(k)
		q.NarrativeSentences, q.NarrativeParagraphs = sent, para
		rep.NarrativeSentences += sent
		rep.NarrativeParagraphs += para
		rep.TextChunks++
	}

	if rep.NarrativeParagraphs > 0 {
		rep.MeanSentPerNarrPara = float64(rep.NarrativeSentences) / float64(rep.NarrativeParagraphs)
	}
	if rep.ProcessedChunks > 0 {
		rep.CosmeticStripRate = float64(rep.CosmeticStripChunks) / float64(rep.ProcessedChunks)
		rep.EchoRate = float64(rep.EchoChunks) / float64(rep.ProcessedChunks)
	}
	for _, k := range order {
		rep.Chunks = append(rep.Chunks, *byChunk[k])
	}
	return rep, nil
}
