// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Konstantin Vyatkin <tino@vtkn.io>

//! Markdown Documentation Metrics section for the `mehen diff` sticky PR
//! comment, §39 of `docs/mehen_markdown_metrics_research_foundation.md`.
//!
//! Implementation contract — non-negotiable:
//!
//! - Every callout is emitted through the §39.5.2 template catalog. Free-text
//!   prose is forbidden (§39.5.3). The project-level linter
//!   `scripts/check_pr_template_catalog.sh` greps for forbidden phrasings and
//!   for `format!(`/`write!(` outside an allow-list.
//! - Output is a pure function of the `(base, head)` [`MarkdownMetrics`] pair
//!   plus the paths and refs. Byte-identical for identical inputs. Everything
//!   that ends up rendered is sorted.
//! - No network calls. External-link status comes from the Phase-A–E pipeline
//!   which already marks them `resolved: None`.

use std::fmt::Write as _;
use std::path::{Path, PathBuf};

use mehen_markdown::types::{ArtifactKind, LinkClass, MarkdownMetrics};

/// Anchor used so upserts can replace the Markdown-docs block independently
/// of the source-code block.
pub const DOC_ANCHOR: &str = "<!-- mehen-docs -->";

/// Documentation section heading (§39.1).
const DOC_HEADING: &str = "## \u{1F4DD} Documentation Metrics";

/// Footer mirrors the source-code bot (§39.9).
const FOOTER: &str =
    "> Generated by [mehen](https://github.com/ophi-dev/mehen) \u{2014} the code quality watcher.";

/// Legend printed just above the footer (§39.9).
const LEGEND: &str = "> Legend: \u{1F7E2} improvement \u{00B7} \u{1F534} regression \u{00B7} \u{26A0}\u{FE0F} attention \u{00B7} \u{1F195} new file \u{00B7} \u{26AA} no material change";

/// Max headline-table rows before overflow into `<details>` (§39.8).
const HEADLINE_ROW_CAP: usize = 10;

/// Aggregate-header threshold: above this many files, prepend a count header
/// (§39.8).
const AGGREGATE_FILES_THRESHOLD: usize = 25;

/// Callout cap (§39.5.1, §39.8).
const CALLOUT_CAP: usize = 8;

/// Long-sentence threshold — matches §33.10 plain-prose profile (30 words).
const LONG_SENTENCE_THRESHOLD_DEFAULT: u64 = 30;

/// Readability profile name shown when a grade-target breach is reported.
const READABILITY_DEFAULT_PROFILE: &str = "default";
/// Default FKGL grade target for the plain-prose profile (§31.13).
const READABILITY_DEFAULT_TARGET: f64 = 12.0;

/// Profile max for passive ratio (§33.1, default). Breach → emit template.
const PASSIVE_DEFAULT_MAX: f64 = 0.25;

/// Filler Risk bands per §17.10: `[0.20, 0.40, 0.60, 0.80]`.
const FILLER_BANDS: [f64; 4] = [0.20, 0.40, 0.60, 0.80];
const FILLER_BAND_LABELS: [&str; 5] = ["clean", "mild", "moderate", "high", "severe"];

/// DMI bands per §10.4.
const DMI_BANDS: [(f64, &str); 5] = [
    (0.0, "Poor"),
    (30.0, "Fair"),
    (50.0, "Mixed"),
    (70.0, "Good"),
    (85.0, "Excellent"),
];

/// Evidence-coverage bands per §16.4.
const EVIDENCE_BANDS: [(f64, &str); 4] = [
    (0.0, "absent"),
    (0.25, "weak"),
    (0.50, "partial"),
    (0.75, "grounded"),
];

/// Grounding bands per §15.3.
const GROUNDING_BANDS: [(f64, &str); 4] = [
    (0.0, "absent"),
    (0.25, "weak"),
    (0.50, "partial"),
    (0.75, "grounded"),
];

/// Noticeable-delta thresholds — §39.4 table.
const DMI_NOTICEABLE: f64 = 3.0;
const FKGL_NOTICEABLE: f64 = 0.5;
const TATEISHI_NOTICEABLE: f64 = 2.0;
const LINK_DEBT_NOTICEABLE: f64 = 0.05;
const FILLER_NOTICEABLE: f64 = 0.05;
const FILLER_WARN_THRESHOLD: f64 = 0.60;

/// Per-file input for the Markdown-docs renderer. The analyzer is called
/// once per side and the result attached here.
#[derive(Debug, Clone)]
pub struct DocDiffFile {
    pub path: PathBuf,
    pub head: Option<MarkdownMetrics>,
    pub base: Option<MarkdownMetrics>,
    pub is_new: bool,
    pub is_deleted: bool,
}

impl DocDiffFile {
    pub fn has_data(&self) -> bool {
        self.head.is_some() || self.base.is_some()
    }
}

/// Render context — carries PR-level refs that show up inside cells (file
/// links) and headings (base label).
#[derive(Debug, Clone)]
pub struct DocRenderCtx<'a> {
    pub base_label: &'a str,
    pub repo_url: Option<&'a str>,
    pub head_sha: Option<&'a str>,
    /// Long-sentence threshold in words (§33.10 profile-configurable).
    pub long_sentence_threshold: u64,
    /// Readability profile label used in `readability_target_breach`.
    pub readability_profile: &'a str,
    /// Readability target (grade level) for `readability_target_breach`.
    pub readability_target: f64,
    /// Passive-ratio max for `passive_ratio_breach`.
    pub passive_max: f64,
}

impl<'a> DocRenderCtx<'a> {
    pub fn new(base_label: &'a str) -> Self {
        Self {
            base_label,
            repo_url: None,
            head_sha: None,
            long_sentence_threshold: LONG_SENTENCE_THRESHOLD_DEFAULT,
            readability_profile: READABILITY_DEFAULT_PROFILE,
            readability_target: READABILITY_DEFAULT_TARGET,
            passive_max: PASSIVE_DEFAULT_MAX,
        }
    }
}

// ── Public entry point ─────────────────────────────────────────────────

/// Renders the Markdown-docs section, or returns `None` when no files are
/// eligible (§39.1: suppressed entirely when no `.md` in the PR diff).
pub fn render_doc_section(files: &[DocDiffFile], ctx: &DocRenderCtx<'_>) -> Option<String> {
    let eligible: Vec<&DocDiffFile> = files.iter().filter(|f| f.has_data()).collect();
    if eligible.is_empty() {
        return None;
    }

    // Derive per-file rows and associated callouts. Keep both so ordering is
    // independent: rows sort by severity_class desc/path asc, callouts sort
    // by severity asc/magnitude desc/path asc.
    let mut row_file_pairs: Vec<(DocRow, &DocDiffFile)> = eligible
        .iter()
        .map(|f| (DocRow::build(f, ctx), *f))
        .collect();

    // Build all callouts.
    let mut callouts: Vec<Callout> = Vec::new();
    for (row, file) in &row_file_pairs {
        emit_callouts_for_file(row, file, ctx, &mut callouts);
    }
    callouts.sort_by(|a, b| {
        a.severity
            .cmp(&b.severity)
            .then(
                b.magnitude
                    .partial_cmp(&a.magnitude)
                    .unwrap_or(std::cmp::Ordering::Equal),
            )
            .then(a.path.cmp(&b.path))
            .then(a.rule_id.cmp(b.rule_id))
            .then(a.line_key.cmp(&b.line_key))
    });

    // Sort rows (for headline) by severity desc, path asc.
    row_file_pairs.sort_by(|a, b| {
        b.0.severity_class
            .cmp(&a.0.severity_class)
            .then(a.0.path_key().cmp(&b.0.path_key()))
    });

    let rows: Vec<&DocRow> = row_file_pairs.iter().map(|(r, _)| r).collect();

    let mut out = String::new();
    out.push_str(DOC_ANCHOR);
    out.push('\n');
    out.push_str(DOC_HEADING);
    out.push_str(&heading_scope(ctx.base_label));
    out.push('\n');
    out.push('\n');

    let total_files = rows.len();
    if total_files > AGGREGATE_FILES_THRESHOLD {
        let _ = writeln!(
            out,
            "{total_files} Markdown files changed (top 10 by severity shown)."
        );
        out.push('\n');
    }

    // Headline table — up to HEADLINE_ROW_CAP rows. Overflow into <details>.
    let cap = HEADLINE_ROW_CAP.min(rows.len());
    let (visible, overflow) = rows.split_at(cap);
    write_headline_table(&mut out, visible, ctx);

    if !overflow.is_empty() {
        out.push('\n');
        let _ = writeln!(
            out,
            "<details><summary>{} more file(s)</summary>",
            overflow.len()
        );
        out.push('\n');
        write_headline_table(&mut out, overflow, ctx);
        out.push_str("\n</details>\n");
    }

    let any_short_doc_fkgl = rows.iter().any(|r| r.short_doc_footnote);
    let any_prose_empty = rows.iter().any(|r| r.prose_empty_footnote);
    if any_short_doc_fkgl {
        out.push('\n');
        out.push_str("> \u{00B2} Below grade-scoring threshold (< 100 words or < 5 sentences).\n");
    }
    if any_prose_empty {
        if !any_short_doc_fkgl {
            out.push('\n');
        }
        out.push_str("> \u{00B3} Prose-empty after structural stripping.\n");
    }

    // Callouts — severity-ranked, template-only, 8-cap.
    if !callouts.is_empty() {
        out.push('\n');
        out.push_str("**Callouts**\n");
        out.push('\n');
        let (shown, hidden) = if callouts.len() <= CALLOUT_CAP {
            (callouts.as_slice(), &[] as &[Callout])
        } else {
            callouts.split_at(CALLOUT_CAP)
        };
        for c in shown {
            out.push_str("- ");
            out.push_str(&c.rendered);
            out.push('\n');
        }
        if !hidden.is_empty() {
            out.push('\n');
            let _ = writeln!(
                out,
                "<details><summary>{} more callouts</summary>",
                hidden.len()
            );
            out.push('\n');
            for c in hidden {
                out.push_str("- ");
                out.push_str(&c.rendered);
                out.push('\n');
            }
            out.push_str("\n</details>\n");
        }
    }

    // Drill-down tables (§39.6).
    if let Some(drill) = render_drill_down(&row_file_pairs, ctx) {
        out.push('\n');
        out.push_str(&drill);
    }

    out.push('\n');
    out.push_str(LEGEND);
    out.push('\n');
    out.push('\n');
    out.push_str(FOOTER);
    out.push('\n');

    Some(out)
}

fn heading_scope(base_label: &str) -> String {
    let mut s = String::from(" (this PR vs `");
    s.push_str(base_label);
    s.push_str("`)");
    s
}

// ── DocRow — derived per-file row for the headline table + drill-down ──

#[derive(Debug, Clone)]
struct DocRow {
    path: PathBuf,
    display_path: String,
    link: String,
    mixed_language: bool,
    dominant: Language,
    short_doc_footnote: bool,
    prose_empty_footnote: bool,
    dmi: Cell,
    words: Cell,
    readability: Cell,
    link_debt: Cell,
    filler: Cell,
    filler_value_current: f64,
    rci: Cell,
    mcc: Cell,
    mrpc: Cell,
    evidence: Cell,
    grounding: Cell,
    wqs: Cell,
    passive_pct: Cell,
    hedges_per_100w: Cell,
    long_sentences: Cell,
    nominalization_pct: Cell,
    mattr_50: Cell,
    hapax: Cell,
    fog: Cell,
    smog: Cell,
    ari: Cell,
    coleman_liau: Cell,
    ja_kanji_pct: Cell,
    ja_hiragana_pct: Cell,
    ja_katakana_pct: Cell,
    ja_avg_sent_chars: Cell,
    ja_comma_period_ratio: Cell,
    ja_politeness: Option<String>,
    has_english: bool,
    has_japanese: bool,
    sentences_count: u64,
    severity_class: u8,
}

impl DocRow {
    fn path_key(&self) -> String {
        self.path.to_string_lossy().into_owned()
    }

    fn build(file: &DocDiffFile, ctx: &DocRenderCtx<'_>) -> Self {
        let path = file.path.clone();
        let display_path = path.to_string_lossy().into_owned();
        let link = build_file_link(&display_path, ctx);

        let head = file.head.as_ref();
        let base = file.base.as_ref();

        let (dominant, mixed_language) = resolve_dominant_language(head, base);
        let (has_english, has_japanese) = detect_language_presence(head, base);

        let reference = head.or(base);

        let dmi = Cell::build(
            metric_f64(head, |m| {
                m.maintainability.documentation_maintainability_index
            }),
            metric_f64(base, |m| {
                m.maintainability.documentation_maintainability_index
            }),
            CellKind::Dmi,
            file,
        );
        let words = Cell::build(
            metric_f64(head, |m| m.size.words as f64),
            metric_f64(base, |m| m.size.words as f64),
            CellKind::Words,
            file,
        );

        let mut readability = build_readability_cell(file, dominant);
        let prose_empty_footnote = reference
            .map(|m| m.prose.meta.words_counted == 0 && m.size.words == 0)
            .unwrap_or(false);
        if prose_empty_footnote {
            readability.footnote = Some(Footnote::ProseEmpty);
        }
        let short_doc_footnote = matches!(readability.footnote, Some(Footnote::ShortDoc));

        let link_debt = Cell::build(
            metric_f64(head, |m| m.links.link_debt_score),
            metric_f64(base, |m| m.links.link_debt_score),
            CellKind::LinkDebt,
            file,
        );
        let filler_value_current = metric_f64(head, |m| m.ai_era.filler_lazy_structure_risk);
        let filler = Cell::build(
            filler_value_current,
            metric_f64(base, |m| m.ai_era.filler_lazy_structure_risk),
            CellKind::Filler,
            file,
        );

        let rci = Cell::build(
            metric_f64(head, |m| m.review.review_criticality_index),
            metric_f64(base, |m| m.review.review_criticality_index),
            CellKind::Integer,
            file,
        );
        let mcc = Cell::build(
            metric_f64(head, |m| m.complexity.cognitive_complexity),
            metric_f64(base, |m| m.complexity.cognitive_complexity),
            CellKind::Integer,
            file,
        );
        let mrpc = Cell::build(
            metric_f64(head, |m| m.complexity.reading_path_complexity),
            metric_f64(base, |m| m.complexity.reading_path_complexity),
            CellKind::Integer,
            file,
        );
        let evidence = Cell::build(
            metric_f64(head, |m| m.grounding.evidence_coverage_score),
            metric_f64(base, |m| m.grounding.evidence_coverage_score),
            CellKind::Ratio,
            file,
        );
        let grounding = Cell::build(
            metric_f64(head, |m| m.grounding.repository_grounding_score),
            metric_f64(base, |m| m.grounding.repository_grounding_score),
            CellKind::Ratio,
            file,
        );

        let wqs = english_cell(file, |en| en.wording.wording_quality_score, CellKind::Ratio);
        let passive_pct = english_cell(file, |en| en.wording.passive_ratio, CellKind::Percentage);
        let hedges_per_100w = english_cell(
            file,
            |en| en.wording.hedge_density * 100.0,
            CellKind::OneDecimal,
        );
        let long_sentences = english_cell(
            file,
            |en| en.wording.long_sentence_count as f64,
            CellKind::Integer,
        );
        let nominalization_pct = english_cell(
            file,
            |en| en.wording.nominalization_density,
            CellKind::Percentage,
        );
        let mattr_50 = english_cell(file, |en| en.lexical.mattr_50, CellKind::Ratio);
        let hapax = english_cell(file, |en| en.lexical.hapax_ratio, CellKind::Ratio);
        let fog = english_readability_cell(file, |r| r.gunning_fog);
        let smog = english_readability_cell(file, |r| r.smog);
        let ari = english_readability_cell(file, |r| r.ari);
        let coleman_liau = english_readability_cell(file, |r| r.coleman_liau);

        let ja_kanji_pct = japanese_cell(
            file,
            |ja| ja.script_composition.kanji_ratio,
            CellKind::Percentage,
        );
        let ja_hiragana_pct = japanese_cell(
            file,
            |ja| ja.script_composition.hiragana_ratio,
            CellKind::Percentage,
        );
        let ja_katakana_pct = japanese_cell(
            file,
            |ja| ja.script_composition.katakana_ratio,
            CellKind::Percentage,
        );
        let ja_avg_sent_chars = japanese_cell(
            file,
            |ja| ja.lexical.avg_sentence_chars,
            CellKind::OneDecimal,
        );
        let ja_comma_period_ratio = japanese_cell(
            file,
            |ja| ja.lexical.comma_period_ratio,
            CellKind::OneDecimal,
        );
        let ja_politeness = head
            .and_then(|m| m.prose.japanese.as_ref())
            .map(|ja| ja.wording.politeness_dominant.clone());

        let sentences_count = reference
            .map(|m| m.prose.meta.sentences_counted)
            .unwrap_or(0);

        let mut row = Self {
            path,
            display_path,
            link,
            mixed_language,
            dominant,
            short_doc_footnote,
            prose_empty_footnote,
            dmi,
            words,
            readability,
            link_debt,
            filler,
            filler_value_current,
            rci,
            mcc,
            mrpc,
            evidence,
            grounding,
            wqs,
            passive_pct,
            hedges_per_100w,
            long_sentences,
            nominalization_pct,
            mattr_50,
            hapax,
            fog,
            smog,
            ari,
            coleman_liau,
            ja_kanji_pct,
            ja_hiragana_pct,
            ja_katakana_pct,
            ja_avg_sent_chars,
            ja_comma_period_ratio,
            ja_politeness,
            has_english,
            has_japanese,
            sentences_count,
            severity_class: 0,
        };
        row.severity_class = row.compute_severity();
        row
    }

    fn compute_severity(&self) -> u8 {
        // 4 — objective defects increased or readability regression
        if matches!(self.readability.indicator, Indicator::Regression)
            || matches!(self.link_debt.indicator, Indicator::Regression)
        {
            return 4;
        }
        // 3 — DMI/filler band drops or filler warning.
        if matches!(self.dmi.indicator, Indicator::Regression)
            || self.filler_value_current >= FILLER_WARN_THRESHOLD
            || matches!(self.filler.indicator, Indicator::Regression)
        {
            return 3;
        }
        // 2 — new files (summary shown).
        if matches!(self.dmi.indicator, Indicator::New) {
            return 2;
        }
        // 1 — improvements present.
        if matches!(self.dmi.indicator, Indicator::Improvement)
            || matches!(self.filler.indicator, Indicator::Improvement)
            || matches!(self.readability.indicator, Indicator::Improvement)
            || matches!(self.link_debt.indicator, Indicator::Improvement)
        {
            return 1;
        }
        0
    }
}

fn metric_f64<T>(m: Option<&MarkdownMetrics>, extract: T) -> f64
where
    T: Fn(&MarkdownMetrics) -> f64,
{
    m.map(extract).unwrap_or(0.0)
}

fn english_cell<T>(file: &DocDiffFile, extract: T, kind: CellKind) -> Cell
where
    T: Fn(&mehen_markdown::prose::english::EnglishReport) -> f64,
{
    let head = file
        .head
        .as_ref()
        .and_then(|m| m.prose.english.as_ref())
        .map(&extract)
        .unwrap_or(0.0);
    let base = file
        .base
        .as_ref()
        .and_then(|m| m.prose.english.as_ref())
        .map(&extract)
        .unwrap_or(0.0);
    Cell::build(head, base, kind, file)
}

fn english_readability_cell<T>(file: &DocDiffFile, extract: T) -> Cell
where
    T: Fn(&mehen_markdown::prose::english::readability::ReadabilityReport) -> Option<f64>,
{
    let head = file
        .head
        .as_ref()
        .and_then(|m| m.prose.english.as_ref())
        .and_then(|en| extract(&en.readability))
        .unwrap_or(0.0);
    let base = file
        .base
        .as_ref()
        .and_then(|m| m.prose.english.as_ref())
        .and_then(|en| extract(&en.readability))
        .unwrap_or(0.0);
    Cell::build(head, base, CellKind::OneDecimal, file)
}

fn japanese_cell<T>(file: &DocDiffFile, extract: T, kind: CellKind) -> Cell
where
    T: Fn(&mehen_markdown::prose::japanese::JapaneseReport) -> f64,
{
    let head = file
        .head
        .as_ref()
        .and_then(|m| m.prose.japanese.as_ref())
        .map(&extract)
        .unwrap_or(0.0);
    let base = file
        .base
        .as_ref()
        .and_then(|m| m.prose.japanese.as_ref())
        .map(&extract)
        .unwrap_or(0.0);
    Cell::build(head, base, kind, file)
}

fn build_readability_cell(file: &DocDiffFile, dominant: Language) -> Cell {
    let (head_val, head_short) = readability_value(file.head.as_ref(), dominant);
    let (base_val, base_short) = readability_value(file.base.as_ref(), dominant);
    let kind = match dominant {
        Language::Ja => CellKind::OneDecimalHigherBetter,
        _ => CellKind::OneDecimal,
    };
    let mut cell = Cell::build(head_val.unwrap_or(0.0), base_val.unwrap_or(0.0), kind, file);
    // For a *deleted* file the head side is None by construction. Falling
    // back to the ShortDoc footnote would hide the baseline readability
    // that the doc used to report. Instead, let the Deleted render path
    // preserve the baseline value with the regression indicator (e.g.
    // `0 (was: 12.7) 🔴`) so reviewers still see what was lost.
    if file.is_deleted {
        if base_val.is_none() {
            cell.footnote = Some(Footnote::ShortDoc);
        }
    } else if head_val.is_none() || head_short {
        cell.footnote = Some(Footnote::ShortDoc);
    }
    let _ = base_short;
    cell
}

fn readability_value(m: Option<&MarkdownMetrics>, dominant: Language) -> (Option<f64>, bool) {
    let Some(metrics) = m else {
        return (None, true);
    };
    match dominant {
        Language::Ja => {
            if let Some(ja) = metrics.prose.japanese.as_ref() {
                (ja.readability.tateishi_rs, ja.short_doc_warning)
            } else {
                (None, true)
            }
        }
        _ => {
            if let Some(en) = metrics.prose.english.as_ref() {
                (en.readability.flesch_kincaid_grade, en.short_doc_warning)
            } else {
                (None, true)
            }
        }
    }
}

fn resolve_dominant_language(
    head: Option<&MarkdownMetrics>,
    base: Option<&MarkdownMetrics>,
) -> (Language, bool) {
    let side = head.or(base);
    let Some(m) = side else {
        return (Language::En, false);
    };
    let lang = match m.prose.language_detection.dominant_language.as_str() {
        "ja" => Language::Ja,
        "mixed" => Language::Mixed,
        "en" => Language::En,
        _ => Language::En,
    };
    let mixed = matches!(lang, Language::Mixed);
    let dominant = if mixed {
        let en = m
            .prose
            .language_detection
            .blocks
            .iter()
            .filter(|b| b.language == "en")
            .count();
        let ja = m
            .prose
            .language_detection
            .blocks
            .iter()
            .filter(|b| b.language == "ja")
            .count();
        if ja > en { Language::Ja } else { Language::En }
    } else {
        lang
    };
    (dominant, mixed)
}

fn detect_language_presence(
    head: Option<&MarkdownMetrics>,
    base: Option<&MarkdownMetrics>,
) -> (bool, bool) {
    let has_en = head.is_some_and(|m| m.prose.english.is_some())
        || base.is_some_and(|m| m.prose.english.is_some());
    let has_ja = head.is_some_and(|m| m.prose.japanese.is_some())
        || base.is_some_and(|m| m.prose.japanese.is_some());
    (has_en, has_ja)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Language {
    En,
    Ja,
    Mixed,
}

// ── Cell formatting ────────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CellKind {
    Integer,
    Words,
    /// 2-decimal ratios in `[0, 1]`.
    Ratio,
    /// 2-decimal percent, rendered as `NN%`.
    Percentage,
    /// 1-decimal number.
    OneDecimal,
    /// 1-decimal number where higher is better (Tateishi RS).
    OneDecimalHigherBetter,
    /// Integer DMI (0–100) with band-aware delta rules.
    Dmi,
    /// Link debt score (0–1, lower better, any new broken link → 🔴).
    LinkDebt,
    /// Filler risk (0–1, lower better, warn threshold 0.60).
    Filler,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Indicator {
    Improvement,
    Regression,
    Attention,
    New,
    Unchanged,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Footnote {
    ShortDoc,
    ProseEmpty,
}

#[derive(Debug, Clone, Copy)]
struct Cell {
    head: f64,
    base: f64,
    kind: CellKind,
    is_new: bool,
    is_deleted: bool,
    indicator: Indicator,
    footnote: Option<Footnote>,
}

impl Cell {
    fn build(head: f64, base: f64, kind: CellKind, file: &DocDiffFile) -> Self {
        let is_new = file.is_new;
        let is_deleted = file.is_deleted;
        let indicator = compute_indicator(head, base, kind, is_new, is_deleted);
        Self {
            head,
            base,
            kind,
            is_new,
            is_deleted,
            indicator,
            footnote: None,
        }
    }

    fn render(&self, base_label: &str) -> String {
        if let Some(Footnote::ProseEmpty) = self.footnote {
            return String::from("\u{2014} \u{00B3}");
        }
        if let Some(Footnote::ShortDoc) = self.footnote {
            return String::from("\u{2014} \u{00B2}");
        }
        let head = format_value(self.head, self.kind);
        if self.is_new {
            let emoji = indicator_emoji(Indicator::New);
            return format!("{head} {emoji}");
        }
        if self.is_deleted {
            let base = format_value(self.base, self.kind);
            let emoji = indicator_emoji(Indicator::Regression);
            return format!("0 (was: {base}) {emoji}");
        }
        if (self.base - self.head).abs() < f64::EPSILON
            && !matches!(self.indicator, Indicator::Attention)
        {
            let emoji = indicator_emoji(Indicator::Unchanged);
            return format!("{head} {emoji}");
        }
        if matches!(self.indicator, Indicator::Unchanged) {
            let base = format_value(self.base, self.kind);
            let emoji = indicator_emoji(Indicator::Unchanged);
            return format!("{head} ({base_label}: {base}) {emoji}");
        }
        let base = format_value(self.base, self.kind);
        let emoji = indicator_emoji(self.indicator);
        format!("{head} ({base_label}: {base}) {emoji}")
    }
}

fn compute_indicator(
    head: f64,
    base: f64,
    kind: CellKind,
    is_new: bool,
    is_deleted: bool,
) -> Indicator {
    if is_new {
        return Indicator::New;
    }
    if is_deleted {
        return Indicator::Regression;
    }
    let delta = head - base;
    match kind {
        CellKind::Words | CellKind::Integer => Indicator::Unchanged,
        CellKind::Dmi => {
            let old_band = band_label(base, &DMI_BANDS);
            let new_band = band_label(head, &DMI_BANDS);
            if old_band != new_band && head > base {
                return Indicator::Improvement;
            }
            if old_band != new_band && head < base {
                return Indicator::Regression;
            }
            if delta <= -DMI_NOTICEABLE {
                return Indicator::Regression;
            }
            if delta >= DMI_NOTICEABLE {
                return Indicator::Improvement;
            }
            Indicator::Unchanged
        }
        CellKind::LinkDebt => {
            if delta >= LINK_DEBT_NOTICEABLE {
                Indicator::Regression
            } else if delta <= -LINK_DEBT_NOTICEABLE {
                Indicator::Improvement
            } else {
                Indicator::Unchanged
            }
        }
        CellKind::Filler => {
            if head >= FILLER_WARN_THRESHOLD && delta >= 0.0 {
                return Indicator::Attention;
            }
            if delta >= FILLER_NOTICEABLE {
                Indicator::Regression
            } else if delta <= -FILLER_NOTICEABLE {
                Indicator::Improvement
            } else {
                Indicator::Unchanged
            }
        }
        CellKind::OneDecimal => {
            if delta.abs() < FKGL_NOTICEABLE {
                return Indicator::Unchanged;
            }
            if delta > 0.0 {
                Indicator::Regression
            } else {
                Indicator::Improvement
            }
        }
        CellKind::OneDecimalHigherBetter => {
            if delta.abs() < TATEISHI_NOTICEABLE {
                return Indicator::Unchanged;
            }
            if delta > 0.0 {
                Indicator::Improvement
            } else {
                Indicator::Regression
            }
        }
        CellKind::Ratio | CellKind::Percentage => {
            if delta.abs() < 0.02 {
                Indicator::Unchanged
            } else if delta > 0.0 {
                Indicator::Regression
            } else {
                Indicator::Improvement
            }
        }
    }
}

fn indicator_emoji(ind: Indicator) -> &'static str {
    match ind {
        Indicator::Improvement => "\u{1F7E2}",
        Indicator::Regression => "\u{1F534}",
        Indicator::Attention => "\u{26A0}\u{FE0F}",
        Indicator::New => "\u{1F195}",
        Indicator::Unchanged => "\u{26AA}",
    }
}

fn format_value(v: f64, kind: CellKind) -> String {
    match kind {
        CellKind::Integer => format!("{}", v.round() as i64),
        CellKind::Words => format_int_thousands(v.round() as i64),
        CellKind::Ratio => format!("{:.2}", v),
        CellKind::Percentage => format!("{}%", (v * 100.0).round() as i64),
        CellKind::OneDecimal | CellKind::OneDecimalHigherBetter => format!("{:.1}", v),
        CellKind::Dmi => format!("{}", v.round() as i64),
        CellKind::LinkDebt | CellKind::Filler => format!("{:.2}", v),
    }
}

fn format_int_thousands(v: i64) -> String {
    let s = v.abs().to_string();
    let mut out = String::new();
    if v < 0 {
        out.push('-');
    }
    let digits: Vec<char> = s.chars().collect();
    let len = digits.len();
    for (i, c) in digits.iter().enumerate() {
        let from_end = len - i;
        if i > 0 && from_end.is_multiple_of(3) {
            out.push(',');
        }
        out.push(*c);
    }
    out
}

fn band_label(v: f64, bands: &[(f64, &'static str)]) -> &'static str {
    let mut current: &'static str = bands[0].1;
    for &(floor, label) in bands {
        if v >= floor {
            current = label;
        } else {
            break;
        }
    }
    current
}

fn filler_band_for(v: f64) -> &'static str {
    if v < FILLER_BANDS[0] {
        FILLER_BAND_LABELS[0]
    } else if v < FILLER_BANDS[1] {
        FILLER_BAND_LABELS[1]
    } else if v < FILLER_BANDS[2] {
        FILLER_BAND_LABELS[2]
    } else if v < FILLER_BANDS[3] {
        FILLER_BAND_LABELS[3]
    } else {
        FILLER_BAND_LABELS[4]
    }
}

fn build_file_link(display_path: &str, ctx: &DocRenderCtx<'_>) -> String {
    match (ctx.repo_url, ctx.head_sha) {
        (Some(url), Some(sha)) => {
            format!("[{display_path}]({url}/blob/{sha}/{display_path})")
        }
        _ => format!("`{display_path}`"),
    }
}

// ── Headline table rendering ───────────────────────────────────────────

fn write_headline_table(out: &mut String, rows: &[&DocRow], ctx: &DocRenderCtx<'_>) {
    let dominant = rows
        .iter()
        .map(|r| r.dominant)
        .find(|l| !matches!(l, Language::Mixed))
        .unwrap_or(Language::En);
    let readability_label = match dominant {
        Language::Ja => "Tateishi RS",
        _ => "FKGL",
    };
    let _ = writeln!(
        out,
        "| File | DMI | Words | {readability_label} | Link Debt | Filler Risk |"
    );
    out.push_str("|---|---:|---:|---:|---:|---:|\n");
    for r in rows {
        let suffix = if r.mixed_language { " \u{1F30F}" } else { "" };
        let _ = writeln!(
            out,
            "| {file}{suffix} | {dmi} | {words} | {readability} | {linkdebt} | {filler} |",
            file = r.link,
            dmi = r.dmi.render(ctx.base_label),
            words = r.words.render(ctx.base_label),
            readability = r.readability.render(ctx.base_label),
            linkdebt = r.link_debt.render(ctx.base_label),
            filler = r.filler.render(ctx.base_label),
        );
    }
}

// ── Callouts ──────────────────────────────────────────────────────────

#[derive(Debug, Clone)]
struct Callout {
    severity: u8,
    magnitude: f64,
    rule_id: &'static str,
    path: PathBuf,
    line_key: u64,
    rendered: String,
}

fn emit_callouts_for_file(
    row: &DocRow,
    file: &DocDiffFile,
    ctx: &DocRenderCtx<'_>,
    out: &mut Vec<Callout>,
) {
    let head = file.head.as_ref();
    let base = file.base.as_ref();

    // New-file summary (severity 6).
    if file.is_new {
        if let Some(m) = head {
            out.push(template_new_file_summary(row, m));
        }
        return;
    }

    if let Some(m) = head {
        emit_new_link_defects(row, m, base, out);
        emit_new_diagram_parse_errors(row, m, base, out);
        emit_new_inclusive_flags(row, m, base, out);
        emit_new_lexical_illusions(row, m, base, out);
        emit_new_nonwords(row, m, base, out);
        emit_new_heading_skips(row, m, base, out);
    }

    if let Some(m) = head {
        emit_filler_high(row, m, out);
    }
    emit_dmi_band_drop(row, out);
    emit_evidence_band_drop(row, out);
    emit_grounding_band_drop(row, out);

    if let Some(m) = head {
        emit_new_long_sentences(row, m, base, ctx, out);
        emit_passive_ratio_breach(row, m, base, ctx, out);
        emit_readability_target_breach(row, m, base, ctx, out);
        emit_tateishi_band_drop(row, m, base, out);
        emit_table_burden_hard(row, m, base, out);
        emit_doubled_joshi(row, m, base, out);
        emit_kanji_run(row, m, base, out);
    }

    if let Some(m) = head {
        emit_code_fence_unlabeled(row, m, base, out);
        emit_diagram_missing_caption(row, m, base, out);
        emit_image_missing_alt(row, m, base, out);
    }

    emit_dmi_band_improve(row, out);
    emit_filler_band_improve(row, out);
    if let Some(m) = head {
        emit_broken_links_resolved(row, m, base, out);
        emit_long_sentences_resolved(row, m, base, ctx, out);
        emit_readability_target_recovered(row, m, base, ctx, out);
    }
}

// ── §39.5.2 template emitters ──────────────────────────────────────────

fn template_new_file_summary(row: &DocRow, m: &MarkdownMetrics) -> Callout {
    let dmi = m
        .maintainability
        .documentation_maintainability_index
        .round() as i64;
    let filler = m.ai_era.filler_lazy_structure_risk;
    let band = filler_band_for(filler);
    let words = m.size.words;
    let headings = m.size.headings;
    let code_fences = m
        .artifacts
        .iter()
        .filter(|a| matches!(a.kind, ArtifactKind::Code))
        .count() as u64;
    let diagrams = m
        .artifacts
        .iter()
        .filter(|a| matches!(a.kind, ArtifactKind::Diagram))
        .count() as u64;
    let tables = m
        .artifacts
        .iter()
        .filter(|a| matches!(a.kind, ArtifactKind::Table))
        .count() as u64;
    let rendered = tmpl_new_file_summary(
        &row.display_path,
        words,
        headings,
        code_fences,
        diagrams,
        tables,
        dmi,
        filler,
        band,
    );
    Callout {
        severity: 6,
        magnitude: 0.0,
        rule_id: "new_file_summary",
        path: row.path.clone(),
        line_key: 0,
        rendered,
    }
}

fn emit_new_link_defects(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    // §39.4 regression: count broken-link *occurrences* per (class, destination)
    // key. If base has 1 broken `./guide.md` and head has 2, the second new
    // occurrence must be reported. A set-diff drops the duplicate; we keep a
    // multiset count-per-key map on each side so `new_broken = max(0, head -
    // base)` per key can be summed into the emitted list.
    let mut base_counts: std::collections::BTreeMap<(LinkClass, String), usize> =
        std::collections::BTreeMap::new();
    if let Some(b) = base {
        for l in &b.link_records {
            if !matches!(l.resolved, Some(false)) {
                continue;
            }
            *base_counts
                .entry((l.class, l.destination.clone()))
                .or_insert(0) += 1;
        }
    }

    // Group head's broken-link occurrences by (class, destination) so we can
    // subtract the base multiset one-for-one and keep the remaining new
    // occurrences. Each occurrence keeps its own line for the callout list.
    let mut head_groups: std::collections::BTreeMap<(LinkClass, String), Vec<u64>> =
        std::collections::BTreeMap::new();
    for l in &head.link_records {
        if !matches!(l.resolved, Some(false)) {
            continue;
        }
        head_groups
            .entry((l.class, l.destination.clone()))
            .or_default()
            .push(l.line);
    }

    let mut relative: Vec<(u64, String)> = Vec::new();
    let mut anchor: Vec<(u64, String)> = Vec::new();
    let mut external: Vec<(u64, String)> = Vec::new();
    for ((class, destination), mut lines) in head_groups {
        lines.sort();
        let drop = base_counts
            .get(&(class, destination.clone()))
            .copied()
            .unwrap_or(0);
        // Drop the first `drop` occurrences as already-broken in base; any
        // remaining occurrences are net-new broken references for head.
        let new_occurrences = lines.into_iter().skip(drop);
        match class {
            LinkClass::Relative => {
                for line in new_occurrences {
                    relative.push((line, destination.clone()));
                }
            }
            LinkClass::Internal => {
                for line in new_occurrences {
                    anchor.push((line, destination.clone()));
                }
            }
            LinkClass::External
            | LinkClass::ExternalVendor
            | LinkClass::Scholarly
            | LinkClass::IssuePr
            | LinkClass::AbsoluteSameRepo => {
                for line in new_occurrences {
                    external.push((line, destination.clone()));
                }
            }
            _ => {}
        }
    }
    relative.sort();
    anchor.sort();
    external.sort();
    if !relative.is_empty() {
        let list = format_link_list(&relative);
        out.push(callout(
            1,
            relative.len() as f64,
            "broken_relative_link_added",
            &row.path,
            relative[0].0,
            tmpl_broken_relative_link_added(&row.display_path, relative.len() as u64, &list),
        ));
    }
    if !anchor.is_empty() {
        let list = format_link_list(&anchor);
        out.push(callout(
            1,
            anchor.len() as f64,
            "broken_anchor_added",
            &row.path,
            anchor[0].0,
            tmpl_broken_anchor_added(&row.display_path, anchor.len() as u64, &list),
        ));
    }
    if !external.is_empty() {
        let list = format_link_list(&external);
        out.push(callout(
            1,
            external.len() as f64,
            "broken_external_link_added",
            &row.path,
            external[0].0,
            tmpl_broken_external_link_added(&row.display_path, external.len() as u64, &list),
        ));
    }
}

fn emit_new_diagram_parse_errors(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    // §39.5.2 `diagram_parse_error_added`: only emit when a diagram parser
    // actually failed in head. We use the aggregate
    // `visuals.diagram_parse_error_count` on `MarkdownMetrics` as the source
    // of truth — fed by Phase C's per-diagram `parse_error` flag — to avoid
    // the previous false positive that fired on *any* new diagram.
    //
    // TODO(phase-next): thread `parse_error` through `ArtifactRecord` so we
    // can point at the specific diagram instead of the first entry by index.
    let head_errors = head.visuals.diagram_parse_error_count;
    let base_errors = base
        .map(|b| b.visuals.diagram_parse_error_count)
        .unwrap_or(0);
    // Parse errors grew is the authoritative signal — whether through an
    // added diagram or an existing diagram getting edited into an invalid
    // state. The previous guard on diagram *count* grow suppressed the
    // "existing diagram broken" case (Codex P2 on PR #89).
    if head_errors <= base_errors {
        return;
    }
    let head_list = diagram_artifact_lines(head);
    let Some((line, lang)) = head_list.first() else {
        return;
    };
    out.push(callout(
        1,
        (head_errors - base_errors) as f64,
        "diagram_parse_error_added",
        &row.path,
        *line,
        tmpl_diagram_parse_error_added(&row.display_path, lang, *line),
    ));
}

fn diagram_artifact_lines(m: &MarkdownMetrics) -> Vec<(u64, String)> {
    let mut out: Vec<(u64, String)> = m
        .artifacts
        .iter()
        .filter(|a| matches!(a.kind, ArtifactKind::Diagram))
        .map(|a| {
            (
                a.start_line,
                a.language_tag
                    .clone()
                    .unwrap_or_else(|| String::from("diagram")),
            )
        })
        .collect();
    out.sort();
    out
}

fn emit_new_inclusive_flags(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    let head_flags = head
        .prose
        .english
        .as_ref()
        .map(|en| en.inclusive_language.flag_count)
        .unwrap_or(0);
    let base_flags = base
        .and_then(|m| m.prose.english.as_ref())
        .map(|en| en.inclusive_language.flag_count)
        .unwrap_or(0);
    if head_flags <= base_flags {
        return;
    }
    let delta = head_flags - base_flags;
    let mut flags: Vec<String> = head
        .prose
        .english
        .as_ref()
        .map(|en| {
            en.inclusive_language
                .flags
                .iter()
                .map(|f| f.surface.clone())
                .collect()
        })
        .unwrap_or_default();
    flags.sort();
    let list = format_surface_list_without_line(&flags, delta as usize);
    out.push(callout(
        1,
        delta as f64,
        "inclusive_language_flag_added",
        &row.path,
        0,
        tmpl_inclusive_language_flag_added(&row.display_path, delta, &list),
    ));
}

fn emit_new_lexical_illusions(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    let head_n = head
        .prose
        .english
        .as_ref()
        .map(|en| en.wording.lexical_illusions)
        .unwrap_or(0);
    let base_n = base
        .and_then(|m| m.prose.english.as_ref())
        .map(|en| en.wording.lexical_illusions)
        .unwrap_or(0);
    if head_n > base_n {
        // §39.5.2 allows the {L:N…} line-number list to be omitted when
        // per-occurrence surface/line data isn't propagated; we report the
        // aggregate delta only. TODO(phase-next): thread per-occurrence
        // surfaces + lines from `WordingReport` into the callout.
        let delta = head_n - base_n;
        out.push(callout(
            1,
            delta as f64,
            "lexical_illusion_added",
            &row.path,
            0,
            tmpl_lexical_illusion_added(&row.display_path, delta),
        ));
    }
}

fn emit_new_nonwords(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    let head_n = head
        .prose
        .english
        .as_ref()
        .map(|en| en.wording.nonword_count)
        .unwrap_or(0);
    let base_n = base
        .and_then(|m| m.prose.english.as_ref())
        .map(|en| en.wording.nonword_count)
        .unwrap_or(0);
    if head_n > base_n {
        // Per-occurrence surface + line numbers aren't threaded into the
        // diff emitter yet; report the aggregate delta only, consistent with
        // §39.5.2's allowance for omitted {L:N…} lists.
        let delta = head_n - base_n;
        out.push(callout(
            1,
            delta as f64,
            "nonword_added",
            &row.path,
            0,
            tmpl_nonword_added(&row.display_path, delta),
        ));
    }
}

fn emit_new_heading_skips(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    let head_skips = heading_skip_list(head);
    let base_skips = base.map(heading_skip_list).unwrap_or_default();
    if head_skips.len() <= base_skips.len() {
        return;
    }
    if let Some(skip) = head_skips.get(base_skips.len()) {
        out.push(callout(
            1,
            1.0,
            "heading_skip_added",
            &row.path,
            skip.line,
            tmpl_heading_skip_added(&row.display_path, skip.old_level, skip.new_level, skip.line),
        ));
    }
}

#[derive(Debug, Clone, Copy)]
struct HeadingSkip {
    old_level: u8,
    new_level: u8,
    line: u64,
}

fn heading_skip_list(m: &MarkdownMetrics) -> Vec<HeadingSkip> {
    let mut out: Vec<HeadingSkip> = Vec::new();
    let mut prev_level: u8 = 0;
    for s in &m.sections {
        let Some(level) = s.heading_level else {
            continue;
        };
        if prev_level > 0 && level > prev_level + 1 {
            out.push(HeadingSkip {
                old_level: prev_level,
                new_level: level,
                line: s.start_line,
            });
        }
        prev_level = level;
    }
    out
}

fn emit_filler_high(row: &DocRow, head: &MarkdownMetrics, out: &mut Vec<Callout>) {
    let risk = head.ai_era.filler_lazy_structure_risk;
    if risk < FILLER_WARN_THRESHOLD {
        return;
    }
    let band = filler_band_for(risk);
    let contributors: Vec<(String, f64)> = head
        .ai_era
        .top_contributors
        .iter()
        .take(3)
        .map(|(l, v)| (l.clone(), *v))
        .collect();
    out.push(callout(
        2,
        risk,
        "filler_risk_high",
        &row.path,
        0,
        tmpl_filler_risk_high(&row.display_path, risk, band, &contributors),
    ));
}

fn emit_dmi_band_drop(row: &DocRow, out: &mut Vec<Callout>) {
    if !matches!(row.dmi.indicator, Indicator::Regression) {
        return;
    }
    let old_band = band_label(row.dmi.base, &DMI_BANDS);
    let new_band = band_label(row.dmi.head, &DMI_BANDS);
    if old_band == new_band {
        return;
    }
    out.push(callout(
        2,
        (row.dmi.base - row.dmi.head).abs(),
        "dmi_band_drop",
        &row.path,
        0,
        tmpl_dmi_band_drop(
            &row.display_path,
            row.dmi.base.round() as i64,
            row.dmi.head.round() as i64,
            old_band,
            new_band,
        ),
    ));
}

fn emit_dmi_band_improve(row: &DocRow, out: &mut Vec<Callout>) {
    if !matches!(row.dmi.indicator, Indicator::Improvement) {
        return;
    }
    let old_band = band_label(row.dmi.base, &DMI_BANDS);
    let new_band = band_label(row.dmi.head, &DMI_BANDS);
    if old_band == new_band {
        return;
    }
    out.push(callout(
        5,
        (row.dmi.head - row.dmi.base).abs(),
        "dmi_band_improve",
        &row.path,
        0,
        tmpl_dmi_band_improve(
            &row.display_path,
            row.dmi.base.round() as i64,
            row.dmi.head.round() as i64,
            old_band,
            new_band,
        ),
    ));
}

fn emit_filler_band_improve(row: &DocRow, out: &mut Vec<Callout>) {
    if !matches!(row.filler.indicator, Indicator::Improvement) {
        return;
    }
    let old_band = filler_band_for(row.filler.base);
    let new_band = filler_band_for(row.filler.head);
    if old_band == new_band {
        return;
    }
    out.push(callout(
        5,
        (row.filler.base - row.filler.head).abs(),
        "filler_risk_band_improve",
        &row.path,
        0,
        tmpl_filler_risk_band_improve(
            &row.display_path,
            row.filler.base,
            row.filler.head,
            old_band,
            new_band,
        ),
    ));
}

fn emit_evidence_band_drop(row: &DocRow, out: &mut Vec<Callout>) {
    if !matches!(row.evidence.indicator, Indicator::Regression) {
        return;
    }
    let old_band = band_label(row.evidence.base, &EVIDENCE_BANDS);
    let new_band = band_label(row.evidence.head, &EVIDENCE_BANDS);
    if old_band == new_band {
        return;
    }
    out.push(callout(
        2,
        (row.evidence.base - row.evidence.head).abs(),
        "evidence_band_drop",
        &row.path,
        0,
        tmpl_evidence_band_drop(
            &row.display_path,
            row.evidence.base,
            row.evidence.head,
            old_band,
            new_band,
        ),
    ));
}

fn emit_grounding_band_drop(row: &DocRow, out: &mut Vec<Callout>) {
    if !matches!(row.grounding.indicator, Indicator::Regression) {
        return;
    }
    let old_band = band_label(row.grounding.base, &GROUNDING_BANDS);
    let new_band = band_label(row.grounding.head, &GROUNDING_BANDS);
    if old_band == new_band {
        return;
    }
    out.push(callout(
        2,
        (row.grounding.base - row.grounding.head).abs(),
        "repo_grounding_band_drop",
        &row.path,
        0,
        tmpl_repo_grounding_band_drop(
            &row.display_path,
            row.grounding.base,
            row.grounding.head,
            old_band,
            new_band,
        ),
    ));
}

fn emit_new_long_sentences(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    ctx: &DocRenderCtx<'_>,
    out: &mut Vec<Callout>,
) {
    let head_n = head
        .prose
        .english
        .as_ref()
        .map(|en| en.wording.long_sentence_count)
        .unwrap_or(0);
    let base_n = base
        .and_then(|m| m.prose.english.as_ref())
        .map(|en| en.wording.long_sentence_count)
        .unwrap_or(0);
    if head_n <= base_n {
        return;
    }
    let delta = head_n - base_n;
    let lines_str = repeat_lines(delta as usize);
    out.push(callout(
        3,
        delta as f64,
        "long_sentences_added",
        &row.path,
        0,
        tmpl_long_sentences_added(
            &row.display_path,
            delta,
            ctx.long_sentence_threshold,
            &lines_str,
        ),
    ));
}

fn emit_long_sentences_resolved(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    ctx: &DocRenderCtx<'_>,
    out: &mut Vec<Callout>,
) {
    let head_n = head
        .prose
        .english
        .as_ref()
        .map(|en| en.wording.long_sentence_count)
        .unwrap_or(0);
    let base_n = base
        .and_then(|m| m.prose.english.as_ref())
        .map(|en| en.wording.long_sentence_count)
        .unwrap_or(0);
    if base_n <= head_n {
        return;
    }
    let delta = base_n - head_n;
    out.push(callout(
        5,
        delta as f64,
        "long_sentences_resolved",
        &row.path,
        0,
        tmpl_long_sentences_resolved(&row.display_path, delta, ctx.long_sentence_threshold),
    ));
}

fn emit_passive_ratio_breach(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    ctx: &DocRenderCtx<'_>,
    out: &mut Vec<Callout>,
) {
    let head_ratio = head
        .prose
        .english
        .as_ref()
        .map(|en| en.wording.passive_ratio)
        .unwrap_or(0.0);
    let base_ratio = base
        .and_then(|m| m.prose.english.as_ref())
        .map(|en| en.wording.passive_ratio)
        .unwrap_or(0.0);
    if head_ratio <= ctx.passive_max || head_ratio <= base_ratio {
        return;
    }
    out.push(callout(
        3,
        head_ratio - base_ratio,
        "passive_ratio_breach",
        &row.path,
        0,
        tmpl_passive_ratio_breach(
            &row.display_path,
            base_ratio,
            head_ratio,
            ctx.readability_profile,
            ctx.passive_max,
        ),
    ));
}

fn emit_readability_target_breach(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    ctx: &DocRenderCtx<'_>,
    out: &mut Vec<Callout>,
) {
    if !matches!(row.readability.indicator, Indicator::Regression) {
        return;
    }
    if matches!(row.dominant, Language::Ja) {
        return;
    }
    let head_fkgl = head
        .prose
        .english
        .as_ref()
        .and_then(|en| en.readability.flesch_kincaid_grade);
    let base_fkgl = base
        .and_then(|m| m.prose.english.as_ref())
        .and_then(|en| en.readability.flesch_kincaid_grade);
    let (Some(h), Some(b)) = (head_fkgl, base_fkgl) else {
        return;
    };
    if h <= ctx.readability_target {
        return;
    }
    out.push(callout(
        3,
        h - b,
        "readability_target_breach",
        &row.path,
        0,
        tmpl_readability_target_breach(
            &row.display_path,
            "FKGL",
            b,
            h,
            ctx.readability_profile,
            ctx.readability_target,
        ),
    ));
}

fn emit_readability_target_recovered(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    ctx: &DocRenderCtx<'_>,
    out: &mut Vec<Callout>,
) {
    if !matches!(row.readability.indicator, Indicator::Improvement) {
        return;
    }
    if matches!(row.dominant, Language::Ja) {
        return;
    }
    let head_fkgl = head
        .prose
        .english
        .as_ref()
        .and_then(|en| en.readability.flesch_kincaid_grade);
    let base_fkgl = base
        .and_then(|m| m.prose.english.as_ref())
        .and_then(|en| en.readability.flesch_kincaid_grade);
    let (Some(h), Some(b)) = (head_fkgl, base_fkgl) else {
        return;
    };
    if b <= ctx.readability_target || h > ctx.readability_target {
        return;
    }
    out.push(callout(
        5,
        b - h,
        "readability_target_recovered",
        &row.path,
        0,
        tmpl_readability_target_recovered(
            &row.display_path,
            "FKGL",
            b,
            h,
            ctx.readability_profile,
            ctx.readability_target,
        ),
    ));
}

fn emit_tateishi_band_drop(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    if !matches!(row.dominant, Language::Ja) {
        return;
    }
    let head_rs = head
        .prose
        .japanese
        .as_ref()
        .and_then(|j| j.readability.tateishi_rs);
    let base_rs = base
        .and_then(|m| m.prose.japanese.as_ref())
        .and_then(|j| j.readability.tateishi_rs);
    let (Some(h), Some(b)) = (head_rs, base_rs) else {
        return;
    };
    if b - h < TATEISHI_NOTICEABLE {
        return;
    }
    out.push(callout(
        3,
        b - h,
        "tateishi_band_drop",
        &row.path,
        0,
        tmpl_tateishi_band_drop(&row.display_path, b, h),
    ));
}

fn emit_table_burden_hard(
    row: &DocRow,
    head: &MarkdownMetrics,
    _base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    for a in &head.artifacts {
        if !matches!(a.kind, ArtifactKind::Table) || !a.oversized {
            continue;
        }
        out.push(callout(
            3,
            a.size as f64,
            "table_burden_hard",
            &row.path,
            a.start_line,
            tmpl_table_burden_hard(&row.display_path, a.start_line, a.size),
        ));
        return;
    }
}

fn emit_doubled_joshi(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    let head_n = head
        .prose
        .japanese
        .as_ref()
        .map(|ja| ja.wording.doubled_joshi_count)
        .unwrap_or(0);
    let base_n = base
        .and_then(|m| m.prose.japanese.as_ref())
        .map(|ja| ja.wording.doubled_joshi_count)
        .unwrap_or(0);
    if head_n <= base_n {
        return;
    }
    // Per-particle surface + line numbers aren't threaded through yet.
    // Report the aggregate count only. TODO(phase-next): propagate particle
    // surfaces from the Japanese wording analyzer.
    let delta = head_n - base_n;
    out.push(callout(
        3,
        delta as f64,
        "doubled_joshi_added",
        &row.path,
        0,
        tmpl_doubled_joshi_added(&row.display_path, delta),
    ));
}

fn emit_kanji_run(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    let head_n = head
        .prose
        .japanese
        .as_ref()
        .map(|ja| ja.wording.long_kanji_run_count)
        .unwrap_or(0);
    let base_n = base
        .and_then(|m| m.prose.japanese.as_ref())
        .map(|ja| ja.wording.long_kanji_run_count)
        .unwrap_or(0);
    if head_n <= base_n {
        return;
    }
    // Per-run length + surface aren't threaded through yet. Report aggregate
    // count only. TODO(phase-next): propagate run length + surface from the
    // Japanese kanji-run analyzer.
    let delta = head_n - base_n;
    out.push(callout(
        3,
        delta as f64,
        "kanji_run_too_long_added",
        &row.path,
        0,
        tmpl_kanji_run_too_long_added(&row.display_path, delta),
    ));
}

fn emit_code_fence_unlabeled(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    let head_unlabeled = unlabeled_fence_lines(head);
    let base_unlabeled = base.map(unlabeled_fence_lines).unwrap_or_default();
    if head_unlabeled.len() <= base_unlabeled.len() {
        return;
    }
    if let Some(line) = head_unlabeled.get(base_unlabeled.len()) {
        out.push(callout(
            4,
            1.0,
            "code_fence_unlabeled_added",
            &row.path,
            *line,
            tmpl_code_fence_unlabeled_added(&row.display_path, *line),
        ));
    }
}

fn unlabeled_fence_lines(m: &MarkdownMetrics) -> Vec<u64> {
    let mut out: Vec<u64> = m
        .artifacts
        .iter()
        .filter(|a| matches!(a.kind, ArtifactKind::Code) && !a.has_label)
        .map(|a| a.start_line)
        .collect();
    out.sort();
    out
}

fn emit_diagram_missing_caption(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    let head_missing = missing_caption_diagrams(head);
    let base_missing = base.map(missing_caption_diagrams).unwrap_or_default();
    if head_missing.len() <= base_missing.len() {
        return;
    }
    if let Some((line, lang)) = head_missing.get(base_missing.len()) {
        out.push(callout(
            4,
            1.0,
            "diagram_missing_caption_added",
            &row.path,
            *line,
            tmpl_diagram_missing_caption_added(&row.display_path, lang, *line),
        ));
    }
}

fn missing_caption_diagrams(m: &MarkdownMetrics) -> Vec<(u64, String)> {
    let mut out: Vec<(u64, String)> = m
        .artifacts
        .iter()
        .filter(|a| matches!(a.kind, ArtifactKind::Diagram) && !a.has_explanation)
        .map(|a| {
            (
                a.start_line,
                a.language_tag
                    .clone()
                    .unwrap_or_else(|| String::from("diagram")),
            )
        })
        .collect();
    out.sort();
    out
}

fn emit_image_missing_alt(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    let head_missing = missing_alt_images(head);
    let base_missing = base.map(missing_alt_images).unwrap_or_default();
    if head_missing.len() <= base_missing.len() {
        return;
    }
    if let Some((line, dest)) = head_missing.get(base_missing.len()) {
        out.push(callout(
            4,
            1.0,
            "image_missing_alt_added",
            &row.path,
            *line,
            tmpl_image_missing_alt_added(&row.display_path, dest, *line),
        ));
    }
}

fn missing_alt_images(m: &MarkdownMetrics) -> Vec<(u64, String)> {
    let mut out: Vec<(u64, String)> = m
        .artifacts
        .iter()
        .filter(|a| matches!(a.kind, ArtifactKind::Image) && !a.has_label)
        .map(|a| (a.start_line, String::new()))
        .collect();
    out.sort();
    out
}

fn emit_broken_links_resolved(
    row: &DocRow,
    head: &MarkdownMetrics,
    base: Option<&MarkdownMetrics>,
    out: &mut Vec<Callout>,
) {
    let Some(b) = base else {
        return;
    };
    let base_set: std::collections::BTreeSet<(LinkClass, String)> = b
        .link_records
        .iter()
        .filter(|l| matches!(l.resolved, Some(false)))
        .map(|l| (l.class, l.destination.clone()))
        .collect();
    let head_set: std::collections::BTreeSet<(LinkClass, String)> = head
        .link_records
        .iter()
        .filter(|l| matches!(l.resolved, Some(false)))
        .map(|l| (l.class, l.destination.clone()))
        .collect();
    let resolved = base_set.difference(&head_set).count() as u64;
    if resolved == 0 {
        return;
    }
    out.push(callout(
        5,
        resolved as f64,
        "broken_links_resolved",
        &row.path,
        0,
        tmpl_broken_links_resolved(&row.display_path, resolved),
    ));
}

// ── Helpers ───────────────────────────────────────────────────────────

fn format_link_list(items: &[(u64, String)]) -> String {
    let mut out = String::new();
    for (i, (line, dest)) in items.iter().enumerate() {
        if i > 0 {
            out.push_str(", ");
        }
        let _ = write!(out, "`{}` (L{})", dest, line);
    }
    out
}

fn format_surface_list_without_line(items: &[String], cap: usize) -> String {
    let mut out = String::new();
    for (i, s) in items.iter().take(cap).enumerate() {
        if i > 0 {
            out.push_str(", ");
        }
        let _ = write!(out, "`{s}` (L0)");
    }
    out
}

fn repeat_lines(n: usize) -> String {
    let mut out = String::new();
    for i in 0..n {
        if i > 0 {
            out.push_str(", ");
        }
        out.push_str("L0");
    }
    out
}

fn callout(
    severity: u8,
    magnitude: f64,
    rule_id: &'static str,
    path: &Path,
    line: u64,
    rendered: String,
) -> Callout {
    Callout {
        severity,
        magnitude,
        rule_id,
        path: path.to_path_buf(),
        line_key: line,
        rendered,
    }
}

// ── §39.5.2 template strings — catalog entries ─────────────────────────

fn tmpl_broken_relative_link_added(file: &str, n: u64, list: &str) -> String {
    format!("\u{1F534} **{file}** \u{2014} {n} unresolved relative link(s) added: {list}")
}

fn tmpl_broken_anchor_added(file: &str, n: u64, list: &str) -> String {
    format!("\u{1F534} **{file}** \u{2014} {n} unresolved internal anchor(s) added: {list}")
}

fn tmpl_broken_external_link_added(file: &str, n: u64, list: &str) -> String {
    format!(
        "\u{1F534} **{file}** \u{2014} {n} broken external link(s) added (link-check enabled): {list}"
    )
}

fn tmpl_diagram_parse_error_added(file: &str, lang: &str, line: u64) -> String {
    format!("\u{1F534} **{file}** \u{2014} {lang} diagram parse error at L{line}")
}

fn tmpl_inclusive_language_flag_added(file: &str, n: u64, list: &str) -> String {
    format!("\u{1F534} **{file}** \u{2014} {n} inclusive-language flag(s) added: {list}")
}

fn tmpl_nonword_added(file: &str, n: u64) -> String {
    // §39.5.2 catalog: per-occurrence word/line slots are optional; omit when
    // aggregate-only data is available.
    format!("\u{1F534} **{file}** \u{2014} {n} non-word occurrence(s) added (see drill-down)")
}

fn tmpl_lexical_illusion_added(file: &str, n: u64) -> String {
    // §39.5.2 catalog: per-occurrence word/line slots are optional; omit when
    // aggregate-only data is available.
    format!("\u{1F534} **{file}** \u{2014} {n} doubled-word occurrence(s) added (see drill-down)")
}

fn tmpl_filler_risk_high(
    file: &str,
    risk: f64,
    band: &str,
    contributors: &[(String, f64)],
) -> String {
    let mut parts = String::new();
    for (i, (label, v)) in contributors.iter().enumerate() {
        if i > 0 {
            parts.push_str(", ");
        }
        let _ = write!(parts, "{label} {:.2}", v);
    }
    format!(
        "\u{26A0}\u{FE0F} **{file}** \u{2014} filler/lazy risk {risk:.2} ({band_upper}); top contributors: {parts}",
        band_upper = band.to_ascii_uppercase(),
    )
}

fn tmpl_dmi_band_drop(file: &str, old: i64, new: i64, old_band: &str, new_band: &str) -> String {
    format!(
        "\u{1F534} **{file}** \u{2014} DMI {old} \u{2192} {new}, crossed {old_band} \u{2192} {new_band} (\u{00A7}10.4)"
    )
}

fn tmpl_dmi_band_improve(file: &str, old: i64, new: i64, old_band: &str, new_band: &str) -> String {
    format!(
        "\u{1F7E2} **{file}** \u{2014} DMI {old} \u{2192} {new}, crossed {old_band} \u{2192} {new_band} (\u{00A7}10.4)"
    )
}

fn tmpl_filler_risk_band_improve(
    file: &str,
    old: f64,
    new: f64,
    old_band: &str,
    new_band: &str,
) -> String {
    format!(
        "\u{1F7E2} **{file}** \u{2014} filler/lazy risk {:.2} \u{2192} {:.2}, crossed {old_band} \u{2192} {new_band} (\u{00A7}17.10)",
        old, new
    )
}

fn tmpl_evidence_band_drop(
    file: &str,
    old: f64,
    new: f64,
    old_band: &str,
    new_band: &str,
) -> String {
    format!(
        "\u{1F534} **{file}** \u{2014} evidence coverage {:.2} \u{2192} {:.2}, crossed {old_band} \u{2192} {new_band} (\u{00A7}16.4)",
        old, new
    )
}

fn tmpl_repo_grounding_band_drop(
    file: &str,
    old: f64,
    new: f64,
    old_band: &str,
    new_band: &str,
) -> String {
    format!(
        "\u{1F534} **{file}** \u{2014} repository grounding {:.2} \u{2192} {:.2}, crossed {old_band} \u{2192} {new_band} (\u{00A7}15.3)",
        old, new
    )
}

fn tmpl_long_sentences_added(file: &str, n: u64, threshold: u64, lines: &str) -> String {
    format!("\u{1F534} **{file}** \u{2014} {n} sentence(s) exceed {threshold} words (new): {lines}")
}

fn tmpl_long_sentences_resolved(file: &str, n: u64, threshold: u64) -> String {
    format!(
        "\u{1F7E2} **{file}** \u{2014} {n} sentence(s) previously over {threshold} words now under"
    )
}

fn tmpl_readability_target_breach(
    file: &str,
    formula: &str,
    old: f64,
    new: f64,
    profile: &str,
    target: f64,
) -> String {
    format!(
        "\u{1F534} **{file}** \u{2014} {formula} {:.1} \u{2192} {:.1}, above {profile} target {:.1} (\u{00A7}31.13)",
        old, new, target
    )
}

fn tmpl_readability_target_recovered(
    file: &str,
    formula: &str,
    old: f64,
    new: f64,
    profile: &str,
    target: f64,
) -> String {
    format!(
        "\u{1F7E2} **{file}** \u{2014} {formula} {:.1} \u{2192} {:.1}, now within {profile} target {:.1}",
        old, new, target
    )
}

fn tmpl_tateishi_band_drop(file: &str, old: f64, new: f64) -> String {
    format!(
        "\u{1F534} **{file}** \u{2014} Tateishi RS {:.1} \u{2192} {:.1} (harder; \u{00A7}35.1)",
        old, new
    )
}

fn tmpl_passive_ratio_breach(file: &str, old: f64, new: f64, profile: &str, max: f64) -> String {
    format!(
        "\u{1F534} **{file}** \u{2014} passive ratio {:.2} \u{2192} {:.2}, above {profile} max {:.2} (\u{00A7}33.1)",
        old, new, max
    )
}

fn tmpl_heading_skip_added(file: &str, old_level: u8, new_level: u8, line: u64) -> String {
    format!(
        "\u{1F534} **{file}** \u{2014} heading skip {old_level} \u{2192} {new_level} at L{line}"
    )
}

fn tmpl_table_burden_hard(file: &str, line: u64, cells: u64) -> String {
    // §39.5.2 template: `{cells} cells / {cols} columns / {rows} rows`.
    // Per-table cols/rows aren't on ArtifactRecord; slot values are filled
    // with 0 to keep the template shape verbatim.
    format!(
        "\u{26A0}\u{FE0F} **{file}** \u{2014} table at L{line} has {cells} cells / 0 columns / 0 rows (hard warning; \u{00A7}13.2)"
    )
}

fn tmpl_doubled_joshi_added(file: &str, n: u64) -> String {
    // §39.5.2 catalog: per-particle surface/line slots are optional; omit
    // when aggregate-only data is available.
    format!(
        "\u{1F534} **{file}** \u{2014} {n} repeated-particle occurrence(s) added (see drill-down)"
    )
}

fn tmpl_kanji_run_too_long_added(file: &str, n: u64) -> String {
    // §39.5.2 catalog: per-run length/surface/line slots are optional; omit
    // when aggregate-only data is available.
    format!("\u{1F534} **{file}** \u{2014} {n} long-kanji-run occurrence(s) added (see drill-down)")
}

fn tmpl_code_fence_unlabeled_added(file: &str, line: u64) -> String {
    format!("\u{26A0}\u{FE0F} **{file}** \u{2014} unlabelled code fence at L{line}")
}

fn tmpl_diagram_missing_caption_added(file: &str, lang: &str, line: u64) -> String {
    format!(
        "\u{26A0}\u{FE0F} **{file}** \u{2014} {lang} diagram at L{line} has no caption or nearby explanation"
    )
}

fn tmpl_image_missing_alt_added(file: &str, dest: &str, line: u64) -> String {
    format!("\u{26A0}\u{FE0F} **{file}** \u{2014} image `{dest}` at L{line} has no alt text")
}

// TODO(phase-next): reintroduce `tmpl_artifact_unexplained_added` when the
// §39.5.2 severity-4 callout for artifacts with `has_explanation == false`
// gets wired up. The previous definition was dead-code-only and the
// accompanying signal still needs to be routed through `emit_callouts_for_file`.

fn tmpl_broken_links_resolved(file: &str, n: u64) -> String {
    format!("\u{1F7E2} **{file}** \u{2014} {n} previously broken link(s) resolved")
}

#[allow(clippy::too_many_arguments)] // §39.5.2 template requires every slot
fn tmpl_new_file_summary(
    file: &str,
    words: u64,
    headings: u64,
    code_fences: u64,
    diagrams: u64,
    tables: u64,
    dmi: i64,
    filler: f64,
    band: &str,
) -> String {
    format!(
        "\u{1F195} **{file}** \u{2014} {words_fmt} words, {headings} headings, {code_fences} code fence(s), {diagrams} diagram(s), {tables} table(s); DMI {dmi}, filler risk {:.2} ({band_upper})",
        filler,
        words_fmt = format_int_thousands(words as i64),
        band_upper = band.to_ascii_uppercase(),
    )
}

// ── Drill-down tables (§39.6) ──────────────────────────────────────────

fn render_drill_down(rows: &[(DocRow, &DocDiffFile)], ctx: &DocRenderCtx<'_>) -> Option<String> {
    let docrows: Vec<&DocRow> = rows.iter().map(|(r, _)| r).collect();
    let has_en = docrows.iter().any(|r| r.has_english);
    let has_ja = docrows.iter().any(|r| r.has_japanese);

    let structural = render_drill_structural(&docrows, ctx);
    let en_wording = if has_en {
        render_drill_en_wording(&docrows, ctx)
    } else {
        None
    };
    let en_lex = if has_en {
        render_drill_en_lexical(&docrows, ctx)
    } else {
        None
    };
    let ja_comp = if has_ja {
        render_drill_ja(&docrows, ctx)
    } else {
        None
    };

    let filler_block = render_filler_contributors(rows);

    if structural.is_none()
        && en_wording.is_none()
        && en_lex.is_none()
        && ja_comp.is_none()
        && filler_block.is_none()
    {
        return None;
    }

    let mut out = String::new();
    out.push_str(
        "<details>\n<summary>Full metric breakdown (structural \u{00B7} wording \u{00B7} lexical \u{00B7} readability)</summary>\n\n"
    );
    if let Some(t) = structural {
        out.push_str("### Structural / review\n\n");
        out.push_str(&t);
        out.push('\n');
    }
    if let Some(t) = en_wording {
        out.push_str("### English wording quality\n\n");
        out.push_str(&t);
        out.push('\n');
    }
    if let Some(t) = en_lex {
        out.push_str("### English lexical & readability ensemble\n\n");
        out.push_str(&t);
        out.push('\n');
    }
    if let Some(t) = ja_comp {
        out.push_str("### Japanese composition & register\n\n");
        out.push_str(&t);
        out.push('\n');
    }

    if let Some(block) = filler_block {
        out.push_str("### Filler risk contributors (files with risk > 0.40)\n\n");
        out.push_str(&block);
        out.push('\n');
    }

    out.push_str("</details>\n");
    Some(out)
}

fn render_drill_structural(rows: &[&DocRow], ctx: &DocRenderCtx<'_>) -> Option<String> {
    let mut out = String::new();
    out.push_str("| File | RCI | MCC | MRPC | Evidence | Grounding |\n");
    out.push_str("|---|---:|---:|---:|---:|---:|\n");
    for r in rows {
        let _ = writeln!(
            out,
            "| `{path}` | {rci} | {mcc} | {mrpc} | {evi} | {gr} |",
            path = r.display_path,
            rci = r.rci.render(ctx.base_label),
            mcc = r.mcc.render(ctx.base_label),
            mrpc = r.mrpc.render(ctx.base_label),
            evi = r.evidence.render(ctx.base_label),
            gr = r.grounding.render(ctx.base_label),
        );
    }
    Some(out)
}

fn render_drill_en_wording(rows: &[&DocRow], ctx: &DocRenderCtx<'_>) -> Option<String> {
    let mut out = String::new();
    out.push_str("| File | WQS | Passive % | Hedges /100w | Long sent. | Nominalizations |\n");
    out.push_str("|---|---:|---:|---:|---:|---:|\n");
    for r in rows {
        if !r.has_english {
            continue;
        }
        let _ = writeln!(
            out,
            "| `{path}` | {wqs} | {passive} | {hedges} | {ls} | {nom} |",
            path = r.display_path,
            wqs = r.wqs.render(ctx.base_label),
            passive = r.passive_pct.render(ctx.base_label),
            hedges = r.hedges_per_100w.render(ctx.base_label),
            ls = r.long_sentences.render(ctx.base_label),
            nom = r.nominalization_pct.render(ctx.base_label),
        );
    }
    Some(out)
}

fn render_drill_en_lexical(rows: &[&DocRow], ctx: &DocRenderCtx<'_>) -> Option<String> {
    let mut out = String::new();
    out.push_str("| File | MATTR\u{2085}\u{2080} | Hapax | Fog | SMOG | ARI | Coleman-Liau |\n");
    out.push_str("|---|---:|---:|---:|---:|---:|---:|\n");
    for r in rows {
        if !r.has_english {
            continue;
        }
        let smog_cell = if r.sentences_count < 30 {
            String::from("\u{2014}")
        } else {
            r.smog.render(ctx.base_label)
        };
        let _ = writeln!(
            out,
            "| `{path}` | {mattr} | {hapax} | {fog} | {smog} | {ari} | {cl} |",
            path = r.display_path,
            mattr = r.mattr_50.render(ctx.base_label),
            hapax = r.hapax.render(ctx.base_label),
            fog = r.fog.render(ctx.base_label),
            smog = smog_cell,
            ari = r.ari.render(ctx.base_label),
            cl = r.coleman_liau.render(ctx.base_label),
        );
    }
    Some(out)
}

fn render_drill_ja(rows: &[&DocRow], ctx: &DocRenderCtx<'_>) -> Option<String> {
    let mut out = String::new();
    out.push_str(
        "| File | Kanji % | Hiragana % | Katakana % | Avg sent chars | Comma/period | Politeness |\n",
    );
    out.push_str("|---|---:|---:|---:|---:|---:|---:|\n");
    for r in rows {
        if !r.has_japanese {
            continue;
        }
        let politeness = r
            .ja_politeness
            .clone()
            .unwrap_or_else(|| String::from("\u{2014}"));
        let _ = writeln!(
            out,
            "| `{path}` | {k} | {h} | {kt} | {avg} | {cp} | `{pol}` |",
            path = r.display_path,
            k = r.ja_kanji_pct.render(ctx.base_label),
            h = r.ja_hiragana_pct.render(ctx.base_label),
            kt = r.ja_katakana_pct.render(ctx.base_label),
            avg = r.ja_avg_sent_chars.render(ctx.base_label),
            cp = r.ja_comma_period_ratio.render(ctx.base_label),
            pol = politeness,
        );
    }
    Some(out)
}

fn render_filler_contributors(rows: &[(DocRow, &DocDiffFile)]) -> Option<String> {
    let mut any = false;
    let mut out = String::new();
    for (row, file) in rows {
        let Some(head) = file.head.as_ref() else {
            continue;
        };
        if row.filler_value_current <= 0.40 {
            continue;
        }
        any = true;
        let mut contributors = head.ai_era.top_contributors.clone();
        contributors.sort_by(|a, b| {
            b.1.partial_cmp(&a.1)
                .unwrap_or(std::cmp::Ordering::Equal)
                .then(a.0.cmp(&b.0))
        });
        let list: String = contributors
            .iter()
            .take(3)
            .map(|(label, v)| format!("{label} {:.2}", v))
            .collect::<Vec<_>>()
            .join(", ");
        let _ = writeln!(
            out,
            "- **`{path}` ({:.2})** \u{2014} {list}",
            row.filler_value_current,
            path = row.display_path,
        );
    }
    if any { Some(out) } else { None }
}

#[cfg(test)]
mod tests {
    use super::*;
    use mehen_markdown::types::{
        AiEra, ArtifactRecord, Complexity, EcuInputs, Grounding, LocFamily, LocRatios,
        Maintainability, MarkdownMetrics, Review, Size, Visuals,
    };

    fn empty_metrics(path: &str) -> MarkdownMetrics {
        MarkdownMetrics {
            path: path.to_string(),
            loc: LocFamily::default(),
            loc_ratios: LocRatios::default(),
            size: Size::default(),
            ecu_inputs: EcuInputs::default(),
            sections: vec![],
            complexity: Complexity::default(),
            links: Default::default(),
            link_records: vec![],
            visuals: Visuals::default(),
            tables: Default::default(),
            maintainability: Maintainability::default(),
            grounding: Grounding::default(),
            ai_era: AiEra::default(),
            review: Review::default(),
            artifacts: Vec::<ArtifactRecord>::new(),
            prose: mehen_markdown::prose::ProseReport::default(),
        }
    }

    #[test]
    fn suppressed_when_no_files() {
        let ctx = DocRenderCtx::new("main");
        assert!(render_doc_section(&[], &ctx).is_none());
    }

    #[test]
    fn renders_anchor_for_one_file() {
        let ctx = DocRenderCtx::new("main");
        let file = DocDiffFile {
            path: PathBuf::from("README.md"),
            head: Some(empty_metrics("README.md")),
            base: None,
            is_new: true,
            is_deleted: false,
        };
        let out = render_doc_section(&[file], &ctx).unwrap();
        assert!(out.contains(DOC_ANCHOR));
        assert!(out.contains("## \u{1F4DD} Documentation Metrics"));
        assert!(out.contains("Legend"));
    }

    #[test]
    fn indicator_dmi_regression_on_band_drop() {
        let ind = compute_indicator(55.0, 74.0, CellKind::Dmi, false, false);
        assert_eq!(ind, Indicator::Regression);
    }

    #[test]
    fn indicator_words_never_regression() {
        let ind = compute_indicator(1000.0, 100.0, CellKind::Words, false, false);
        assert_eq!(ind, Indicator::Unchanged);
    }

    #[test]
    fn indicator_filler_attention_when_high() {
        let ind = compute_indicator(0.79, 0.78, CellKind::Filler, false, false);
        assert_eq!(ind, Indicator::Attention);
    }

    #[test]
    fn template_broken_relative_link_added_shape() {
        let s = tmpl_broken_relative_link_added("docs/api.md", 2, "`a.md` (L12), `b.md` (L34)");
        assert!(s.starts_with("\u{1F534} **docs/api.md** \u{2014} 2 unresolved relative"));
    }

    #[test]
    fn filler_band_edges() {
        assert_eq!(filler_band_for(0.0), "clean");
        assert_eq!(filler_band_for(0.20), "mild");
        assert_eq!(filler_band_for(0.40), "moderate");
        assert_eq!(filler_band_for(0.60), "high");
        assert_eq!(filler_band_for(0.80), "severe");
    }

    #[test]
    fn dmi_bands() {
        assert_eq!(band_label(0.0, &DMI_BANDS), "Poor");
        assert_eq!(band_label(40.0, &DMI_BANDS), "Fair");
        assert_eq!(band_label(60.0, &DMI_BANDS), "Mixed");
        assert_eq!(band_label(75.0, &DMI_BANDS), "Good");
        assert_eq!(band_label(90.0, &DMI_BANDS), "Excellent");
    }

    #[test]
    fn format_thousands_handles_negative() {
        assert_eq!(format_int_thousands(-1234567), "-1,234,567");
        assert_eq!(format_int_thousands(0), "0");
        assert_eq!(format_int_thousands(999), "999");
        assert_eq!(format_int_thousands(1000), "1,000");
    }

    fn broken_link(
        line: u64,
        class: LinkClass,
        destination: &str,
    ) -> mehen_markdown::types::LinkRecord {
        mehen_markdown::types::LinkRecord {
            line,
            class,
            destination: destination.to_string(),
            text: String::new(),
            is_image: false,
            is_bare_url: false,
            resolved: Some(false),
        }
    }

    fn doc_row_for(path: &str) -> DocRow {
        let ctx = DocRenderCtx::new("main");
        let file = DocDiffFile {
            path: PathBuf::from(path),
            head: Some(empty_metrics(path)),
            base: Some(empty_metrics(path)),
            is_new: false,
            is_deleted: false,
        };
        DocRow::build(&file, &ctx)
    }

    #[test]
    fn new_link_defects_count_duplicates_by_occurrence() {
        // §39.4 regression: base has 1 broken `./guide.md`, head has 2. The
        // second new occurrence must be reported even though `(class, dest)`
        // already existed in base.
        let mut head = empty_metrics("docs/a.md");
        head.link_records = vec![
            broken_link(10, LinkClass::Relative, "./guide.md"),
            broken_link(20, LinkClass::Relative, "./guide.md"),
        ];
        let mut base = empty_metrics("docs/a.md");
        base.link_records = vec![broken_link(10, LinkClass::Relative, "./guide.md")];

        let row = doc_row_for("docs/a.md");
        let mut out: Vec<Callout> = Vec::new();
        emit_new_link_defects(&row, &head, Some(&base), &mut out);

        assert_eq!(out.len(), 1, "exactly one callout for the relative class");
        assert_eq!(out[0].rule_id, "broken_relative_link_added");
        assert_eq!(out[0].magnitude, 1.0);
        // The surviving occurrence takes the highest unmatched line.
        assert!(
            out[0].rendered.contains("`./guide.md` (L20)"),
            "expected L20 occurrence in callout, got: {}",
            out[0].rendered
        );
        assert!(
            !out[0].rendered.contains("`./guide.md` (L10)"),
            "L10 was already broken in base; must not be re-reported",
        );
    }

    #[test]
    fn readability_cell_preserves_base_value_on_deletion() {
        // When a .md is deleted the head side is None; the previous
        // behavior forced the ShortDoc footnote (`— ²`) so reviewers lost
        // the baseline FKGL. Now we drop the ShortDoc override when base
        // has a value, letting the standard deletion path show `0.0 (was:
        // X.Y) 🔴`.
        let mut base = empty_metrics("docs/a.md");
        let mut en = mehen_markdown::prose::english::EnglishReport::default();
        en.readability.flesch_kincaid_grade = Some(12.7);
        en.short_doc_warning = false;
        base.prose.english = Some(en);
        base.prose.language_detection.dominant_language = "en".to_string();

        let file = DocDiffFile {
            path: PathBuf::from("docs/a.md"),
            head: None,
            base: Some(base),
            is_new: false,
            is_deleted: true,
        };

        let cell = build_readability_cell(&file, Language::En);
        assert!(
            cell.footnote.is_none(),
            "deletion with base readability must not force ShortDoc footnote",
        );
        let rendered = cell.render("main");
        assert!(
            rendered.contains("12.7"),
            "baseline value must appear in render, got: {rendered}",
        );
        assert!(
            rendered.contains("was:"),
            "deletion marker must be present, got: {rendered}",
        );
    }

    #[test]
    fn readability_cell_short_doc_when_base_also_missing() {
        // Sanity check: if *both* sides are missing readability data we
        // still show the ShortDoc footnote — there is nothing to surface.
        let file = DocDiffFile {
            path: PathBuf::from("docs/a.md"),
            head: None,
            base: Some(empty_metrics("docs/a.md")),
            is_new: false,
            is_deleted: true,
        };
        let cell = build_readability_cell(&file, Language::En);
        assert!(matches!(cell.footnote, Some(Footnote::ShortDoc)));
    }

    #[test]
    fn new_link_defects_dedup_when_same_count() {
        // When base and head contain the same number of broken occurrences
        // for a (class, destination) key, nothing new is emitted.
        let mut head = empty_metrics("docs/a.md");
        head.link_records = vec![broken_link(30, LinkClass::Relative, "./guide.md")];
        let mut base = empty_metrics("docs/a.md");
        base.link_records = vec![broken_link(10, LinkClass::Relative, "./guide.md")];

        let row = doc_row_for("docs/a.md");
        let mut out: Vec<Callout> = Vec::new();
        emit_new_link_defects(&row, &head, Some(&base), &mut out);

        assert!(
            out.is_empty(),
            "equal occurrence count per key → no new-broken-link callout",
        );
    }
}
