"""Tests for scripts/heading_promote.py + the heading-cluster/promote
additions in pdf_tree_edit.py (docs/plan-heading-promote.md, AC1-AC5).

Doctrine under test: the SCRIPT clusters non-body text by font signature and
surfaces evidence, but NEVER decides a cluster is a heading or its level --
`level` is always null in a built manifest, and an occurrence left at
`level: null` is never promoted. AI (or a human) assigns levels; the script
then applies + verifies (veraPDF compliant AND heading count > 0), restoring
the pre-apply backup on any failure.

CI-light: hand-built pikepdf fixtures with real content streams (Tf/rg/BDC-
MCID text runs), matching test_figure_alt.py's convention. verapdf_gate.run_
verapdf is monkeypatched (no vendored veraPDF install required).
"""
import json
import os
import sys

import fitz  # PyMuPDF
import pikepdf
import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))

import fidelity_gate  # noqa: E402
import heading_promote  # noqa: E402
import pdf_tree_edit as pte  # noqa: E402


# ── Fixture builders ─────────────────────────────────────────────────────

def _font(pdf, base_font):
    return pdf.make_indirect(pikepdf.Dictionary(
        Type=pikepdf.Name.Font, Subtype=pikepdf.Name.Type1,
        BaseFont=pikepdf.Name(base_font)))


def _struct_elem(pdf, **kw):
    return pdf.make_indirect(pikepdf.Dictionary(Type=pikepdf.Name.StructElem, **kw))


def _build_chapter_pdf(path):
    """1-page PDF: a bold 24pt title, 3 bold 14pt "section" headings each
    followed by a short non-bold 10pt body paragraph, and one bold-but-small
    (10pt) callout label sharing the body's size but not its boldness --
    the shape a real remediated chapter looks like (many /P, zero /H)."""
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(400, 800))
    page.Resources = pikepdf.Dictionary(Font=pikepdf.Dictionary({
        "/F1": _font(pdf, "/Helvetica"),
        "/F2": _font(pdf, "/Helvetica-Bold"),
    }))
    page.Contents = pdf.make_stream(
        b"/P <</MCID 0>> BDC BT /F2 24 Tf 0 0 0 rg (Chapter 10: Risk Management) Tj ET EMC\n"
        b"/P <</MCID 1>> BDC BT /F2 14 Tf 0 0 0 rg (10.1 Identifying Risk) Tj ET EMC\n"
        b"/P <</MCID 2>> BDC BT /F1 10 Tf 0 0 0 rg (Body paragraph one, ordinary text.) Tj ET EMC\n"
        b"/P <</MCID 3>> BDC BT /F2 14 Tf 0 0 0 rg (10.2 Mitigating Risk) Tj ET EMC\n"
        b"/P <</MCID 4>> BDC BT /F1 10 Tf 0 0 0 rg (Body paragraph two, more text here.) Tj ET EMC\n"
        b"/P <</MCID 5>> BDC BT /F2 10 Tf 0 0 0.8 rg (Explore) Tj ET EMC\n"
        b"/P <</MCID 6>> BDC BT /F1 10 Tf 0 0 0 rg (Body paragraph three, closing text.) Tj ET EMC\n"
    )
    elems = [
        _struct_elem(pdf, S=pikepdf.Name("/P"), Pg=page.obj, K=i) for i in range(7)
    ]
    doc = _struct_elem(pdf, S=pikepdf.Name("/Document"), K=pikepdf.Array(elems))
    for e in elems:
        e["/P"] = doc
    pdf.Root.StructTreeRoot = pdf.make_indirect(
        pikepdf.Dictionary(Type=pikepdf.Name("/StructTreeRoot"), K=doc))
    pdf.save(path)
    pdf.close()
    return path


def _mock_verapdf(monkeypatch, compliant):
    monkeypatch.setattr(
        heading_promote.verapdf_gate, "run_verapdf",
        lambda path: {"compliant": compliant, "passed_rules": 1, "failed_rules": 0,
                       "failed_summaries": []})


# ── AC1: heading_candidates clusters, excludes body, emits level: null ──

class TestHeadingCandidates:
    def test_clusters_non_body_excludes_body_emits_null_level(self, tmp_path):
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        cands = pte.heading_candidates(pdf, str(p))

        texts = {c["text"] for c in cands}
        assert "Chapter 10: Risk Management" in texts
        assert "10.1 Identifying Risk" in texts
        assert "10.2 Mitigating Risk" in texts
        assert "Explore" in texts
        # body paragraphs (10pt, non-bold, black) never surface as candidates
        assert not any("Body paragraph" in t for t in texts)

        for c in cands:
            assert c["level"] is None
            assert c["mcids"]
            assert c["current_tag"] == "/P"

    def test_cluster_ids_group_same_signature_largest_first(self, tmp_path):
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        cands = pte.heading_candidates(pdf, str(p))
        by_text = {c["text"]: c for c in cands}

        title, sec1, sec2 = by_text["Chapter 10: Risk Management"], \
            by_text["10.1 Identifying Risk"], by_text["10.2 Mitigating Risk"]
        assert sec1["cluster_id"] == sec2["cluster_id"]
        assert sec1["cluster_count"] == 2
        # 24pt title cluster is distinct from and ranked before the 14pt cluster
        assert title["cluster_id"] != sec1["cluster_id"]
        assert title["size"] > sec1["size"]
        assert title["cluster_id"] < sec1["cluster_id"]

    def test_bold_evidence_and_color_evidence_never_a_verdict(self, tmp_path):
        """AC-analog to figure-alt's AC6: a same-size, differently-styled
        occurrence (bold blue "Explore" at the body's own 10pt size) still
        surfaces as its own candidate/cluster -- boldness and color are
        evidence, not folded into a size-only heuristic."""
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        cands = pte.heading_candidates(pdf, str(p))
        explore = next(c for c in cands if c["text"] == "Explore")
        assert explore["bold"] is True
        assert explore["rgb"] != [0.0, 0.0, 0.0]
        assert explore["level"] is None

    def test_no_struct_tree_yields_empty_list(self, tmp_path):
        pdf = pikepdf.new()
        pdf.add_blank_page(page_size=(200, 200))
        pdf.save(tmp_path / "empty.pdf")
        pdf.close()
        pdf = pikepdf.open(tmp_path / "empty.pdf")
        assert pte.heading_candidates(pdf) == []


# ── AC2: promote_headings retags + normalizes ────────────────────────────

class TestPromoteHeadings:
    def test_retags_named_elements_to_hn(self, tmp_path):
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        cands = pte.heading_candidates(pdf, str(p))
        by_text = {c["text"]: c for c in cands}
        assignments = [
            {"page": by_text["Chapter 10: Risk Management"]["page"],
             "mcids": by_text["Chapter 10: Risk Management"]["mcids"], "level": 1},
            {"page": by_text["10.1 Identifying Risk"]["page"],
             "mcids": by_text["10.1 Identifying Risk"]["mcids"], "level": 2},
            {"page": by_text["10.2 Mitigating Risk"]["page"],
             "mcids": by_text["10.2 Mitigating Risk"]["mcids"], "level": 2},
        ]
        result = pte.promote_headings(pdf, assignments)
        assert len(result["promoted"]) == 3
        assert result["unresolved"] == []

        tags = [str(e.get("/S")) for e in pte.walk_elems(pdf, None)]
        assert tags.count("/H1") == 1
        assert tags.count("/H2") == 2

    def test_normalize_clamps_level_jump_from_new_headings(self, tmp_path):
        """H1 -> H3 (skipping H2) gets clamped to H1 -> H2 by the mandatory
        normalize_heading_levels() call inside promote_headings()."""
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        cands = pte.heading_candidates(pdf, str(p))
        by_text = {c["text"]: c for c in cands}
        assignments = [
            {"page": by_text["Chapter 10: Risk Management"]["page"],
             "mcids": by_text["Chapter 10: Risk Management"]["mcids"], "level": 1},
            {"page": by_text["10.1 Identifying Risk"]["page"],
             "mcids": by_text["10.1 Identifying Risk"]["mcids"], "level": 3},
        ]
        pte.promote_headings(pdf, assignments)
        tags = [str(e.get("/S")) for e in pte.walk_elems(pdf, None) if str(e.get("/S", "")).startswith("/H")]
        assert tags == ["/H1", "/H2"]

    def test_unresolved_mcids_reported_not_raised(self, tmp_path):
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        result = pte.promote_headings(pdf, [{"page": 1, "mcids": [999], "level": 1}])
        assert result["promoted"] == []
        assert len(result["unresolved"]) == 1
        assert "could not resolve" in result["unresolved"][0]["reason"]

    def test_out_of_range_and_invalid_levels_reported_not_raised(self, tmp_path):
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        result = pte.promote_headings(pdf, [
            {"page": 1, "mcids": [0], "level": 9},
            {"page": 1, "mcids": [1], "level": "not-a-number"},
        ])
        assert result["promoted"] == []
        assert len(result["unresolved"]) == 2


# ── AC3-honesty: null-level never promoted; all-null apply restores ─────

class TestHonesty:
    def test_null_level_candidate_not_promoted(self, tmp_path):
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        result = pte.promote_headings(pdf, [{"page": 1, "mcids": [0], "level": None}])
        assert result["promoted"] == []
        assert result["unresolved"][0]["reason"] == "no level assigned"
        assert str(pte.walk_elems(pdf, None)[0].get("/S")) == "/P"

    def test_apply_all_null_manifest_restores_not_false_success(self, tmp_path, monkeypatch):
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        before_bytes = p.read_bytes()
        manifest_path, manifest = heading_promote.build_manifest(str(p), outdir=tmp_path / "out")
        assert all(c["level"] is None for c in manifest["candidates"])
        _mock_verapdf(monkeypatch, compliant=True)

        result = heading_promote.apply_manifest(str(p), str(manifest_path))
        assert result["ok"] is False
        assert result["restored"] is True
        assert result["heading_count"] == 0
        assert result["skipped_null_level"] == len(manifest["candidates"])
        assert p.read_bytes() == before_bytes

    def test_apply_verapdf_regression_restores(self, tmp_path, monkeypatch):
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        before_bytes = p.read_bytes()
        manifest_path, manifest = heading_promote.build_manifest(str(p), outdir=tmp_path / "out")
        manifest["candidates"][0]["level"] = 1
        manifest_path.write_text(json.dumps(manifest, indent=2))
        _mock_verapdf(monkeypatch, compliant=False)

        result = heading_promote.apply_manifest(str(p), str(manifest_path))
        assert result["ok"] is False
        assert result["restored"] is True
        assert p.read_bytes() == before_bytes

    def test_missing_manifest_errors_without_touching_pdf(self, tmp_path):
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        before_bytes = p.read_bytes()
        result = heading_promote.apply_manifest(str(p), str(tmp_path / "nope.json"))
        assert result["ok"] is False
        assert "manifest not found" in result["error"]
        assert p.read_bytes() == before_bytes


# ── build_manifest / apply happy path (AC4) ──────────────────────────────

class TestBuildAndApplyHappyPath:
    def test_build_manifest_shape_and_font_size_rank(self, tmp_path):
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        manifest_path, manifest = heading_promote.build_manifest(str(p), outdir=tmp_path / "out")
        assert json.loads(manifest_path.read_text()) == manifest
        assert len(manifest["candidates"]) == 4  # title, 2x section, Explore
        ranks = manifest["font_size_rank"]
        sizes = [r["size"] for r in ranks]
        assert sizes == sorted(sizes, reverse=True)
        assert all(r["count"] >= 1 for r in ranks)

    def test_apply_promotes_annotated_levels_and_verifies(self, tmp_path, monkeypatch):
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        manifest_path, manifest = heading_promote.build_manifest(str(p), outdir=tmp_path / "out")
        for c in manifest["candidates"]:
            if c["text"].startswith("Chapter 10"):
                c["level"] = 1
            elif c["text"].startswith("10."):
                c["level"] = 2
            # "Explore" callout left at level: null on purpose
        manifest_path.write_text(json.dumps(manifest, indent=2))
        _mock_verapdf(monkeypatch, compliant=True)

        result = heading_promote.apply_manifest(str(p), str(manifest_path))
        assert result["ok"] is True
        assert result["heading_count"] == 3
        assert result["skipped_null_level"] == 1
        assert len(result["promoted"]) == 3

        pdf = pikepdf.open(p)
        tags = [str(e.get("/S")) for e in pte.walk_elems(pdf, None)]
        assert tags.count("/H1") == 1
        assert tags.count("/H2") == 2
        assert tags.count("/P") == 4  # 3 body paragraphs + the un-promoted "Explore"


# ── AC5-fidelity: retag is non-visual, text byte-identical ──────────────

class TestFidelity:
    def test_get_text_byte_identical_and_pixels_unchanged(self, tmp_path, monkeypatch):
        p = _build_chapter_pdf(tmp_path / "t.pdf")
        before_path = tmp_path / "before.pdf"
        before_path.write_bytes(p.read_bytes())
        before_text = fitz.open(str(before_path))[0].get_text("text")

        manifest_path, manifest = heading_promote.build_manifest(str(p), outdir=tmp_path / "out")
        for c in manifest["candidates"]:
            c["level"] = 1 if c["text"].startswith("Chapter") else 2
        manifest_path.write_text(json.dumps(manifest, indent=2))
        _mock_verapdf(monkeypatch, compliant=True)

        result = heading_promote.apply_manifest(str(p), str(manifest_path))
        assert result["ok"] is True

        after_text = fitz.open(str(p))[0].get_text("text")
        assert after_text == before_text

        diff = fidelity_gate.pixel_diff(str(before_path), str(p))
        assert diff["identical"] is True
        assert diff["worst_pct"] == 0.0
