"""Tests for scripts/figure_alt.py + the figure-context additions in
pdf_tree_edit.py (docs/plan-figure-alt.md, AC1-AC7).

Doctrine under test: the SCRIPT never concludes decorative/content — every
context field (including width/height) is evidence for the AI, and every
manifest entry's "disposition" is null until an agent annotates it (AC6:
even a 14x14 figure gets disposition: null — no size-based auto-classify).

CI-light: hand-built pikepdf/fitz fixtures with real (uncompressed) Image
XObjects placed via `Do`, so figure_alt.py's PNG cropping (page.get_image_rects)
and pdf_tree_edit's image-XObject resolution both exercise real code paths.
verapdf_gate.run_verapdf is monkeypatched (no vendored veraPDF install
required) — matching test_remediate_pdf_fix_contrast.py's convention.
"""
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 figure_alt  # noqa: E402
import pdf_tree_edit as pte  # noqa: E402


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

def _make_image(pdf, width, height, color=(255, 0, 0)):
    """Uncompressed (no /Filter) RGB Image XObject — valid per PDF spec,
    renders fine in fitz, and needs no external codec in the test."""
    data = bytes(color) * (width * height)
    return pdf.make_indirect(pdf.make_stream(
        data, Type=pikepdf.Name.XObject, Subtype=pikepdf.Name.Image,
        Width=width, Height=height, ColorSpace=pikepdf.Name.DeviceRGB,
        BitsPerComponent=8))


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


def _build_two_figure_pdf(path):
    """1-page PDF, 2 figures: a 300x200 "content" figure (Im0, unique) and a
    14x14 "decorative-candidate" figure (ImTiny) — the minimal fixture for
    apply-path tests (AC4/AC5/AC7)."""
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(400, 600))
    im_content = _make_image(pdf, 300, 200)
    im_tiny = _make_image(pdf, 14, 14, (0, 255, 0))
    page.Resources = pikepdf.Dictionary(
        XObject=pikepdf.Dictionary({"/Im0": im_content, "/ImTiny": im_tiny}))
    page.Contents = pdf.make_stream(
        b"/P <</MCID 0>> BDC BT (Intro paragraph before the chart.) Tj ET EMC\n"
        b"/Figure <</MCID 1>> BDC q 300 0 0 200 50 300 cm /Im0 Do Q EMC\n"
        b"/P <</MCID 2>> BDC BT (Caption text after the chart.) Tj ET EMC\n"
        b"/Figure <</MCID 3>> BDC q 14 0 0 14 10 10 cm /ImTiny Do Q EMC\n"
    )
    p0 = _struct_elem(pdf, S=pikepdf.Name("/P"), Pg=page.obj, K=0)
    fig_content = _struct_elem(pdf, S=pikepdf.Name("/Figure"), Pg=page.obj, K=1)
    p1 = _struct_elem(pdf, S=pikepdf.Name("/P"), Pg=page.obj, K=2)
    fig_deco = _struct_elem(pdf, S=pikepdf.Name("/Figure"), Pg=page.obj, K=3)
    doc_elem = _struct_elem(pdf, S=pikepdf.Name("/Document"),
                             K=pikepdf.Array([p0, fig_content, p1, fig_deco]))
    for e in (p0, fig_content, p1, fig_deco):
        e["/P"] = doc_elem
    pdf.Root.StructTreeRoot = pdf.make_indirect(
        pikepdf.Dictionary(Type=pikepdf.Name("/StructTreeRoot"), K=doc_elem))
    pdf.save(path)
    pdf.close()
    return path


def _build_full_context_pdf(path):
    """2-page PDF, 5 figures exercising every list_figures context field:
      page1: figA (300x200, unique) -- content candidate, surrounded by text
             figB (14x14, ImTiny)   -- repeated with figC
      page2: figC (14x14, ImTiny)   -- repeat_count(figB)==repeat_count(figC)==2
             figD (100x100, ImLink) -- nested inside a /Link ancestor
             figE (50x50, ImHead)   -- nested inside an /H2 ancestor
    """
    pdf = pikepdf.new()
    page1 = pdf.add_blank_page(page_size=(400, 600))
    page2 = pdf.add_blank_page(page_size=(400, 600))

    im_content = _make_image(pdf, 300, 200)
    im_tiny = _make_image(pdf, 14, 14, (0, 255, 0))
    im_link = _make_image(pdf, 100, 100, (0, 0, 255))
    im_head = _make_image(pdf, 50, 50, (255, 255, 0))

    page1.Resources = pikepdf.Dictionary(
        XObject=pikepdf.Dictionary({"/Im0": im_content, "/ImTiny": im_tiny}))
    page1.Contents = pdf.make_stream(
        b"/P <</MCID 0>> BDC BT (Intro paragraph before the chart.) Tj ET EMC\n"
        b"/Figure <</MCID 1>> BDC q 300 0 0 200 50 300 cm /Im0 Do Q EMC\n"
        b"/P <</MCID 2>> BDC BT (Caption text after the chart.) Tj ET EMC\n"
        b"/Figure <</MCID 3>> BDC q 14 0 0 14 10 10 cm /ImTiny Do Q EMC\n"
    )
    page2.Resources = pikepdf.Dictionary(
        XObject=pikepdf.Dictionary({
            "/ImTiny": im_tiny, "/ImLink": im_link, "/ImHead": im_head,
        }))
    page2.Contents = pdf.make_stream(
        b"/Figure <</MCID 0>> BDC q 14 0 0 14 10 10 cm /ImTiny Do Q EMC\n"
        b"/Figure <</MCID 1>> BDC q 100 0 0 100 200 400 cm /ImLink Do Q EMC\n"
        b"/Figure <</MCID 2>> BDC q 50 0 0 50 250 100 cm /ImHead Do Q EMC\n"
    )

    p0 = _struct_elem(pdf, S=pikepdf.Name("/P"), Pg=page1.obj, K=0)
    fig_a = _struct_elem(pdf, S=pikepdf.Name("/Figure"), Pg=page1.obj, K=1)
    p1 = _struct_elem(pdf, S=pikepdf.Name("/P"), Pg=page1.obj, K=2)
    fig_b = _struct_elem(pdf, S=pikepdf.Name("/Figure"), Pg=page1.obj, K=3)
    fig_c = _struct_elem(pdf, S=pikepdf.Name("/Figure"), Pg=page2.obj, K=0)
    fig_d = _struct_elem(pdf, S=pikepdf.Name("/Figure"), Pg=page2.obj, K=1)
    fig_e = _struct_elem(pdf, S=pikepdf.Name("/Figure"), Pg=page2.obj, K=2)

    link_elem = _struct_elem(pdf, S=pikepdf.Name("/Link"), K=pikepdf.Array([fig_d]))
    fig_d["/P"] = link_elem
    heading_elem = _struct_elem(pdf, S=pikepdf.Name("/H2"), K=pikepdf.Array([fig_e]))
    fig_e["/P"] = heading_elem

    doc_elem = _struct_elem(
        pdf, S=pikepdf.Name("/Document"),
        K=pikepdf.Array([p0, fig_a, p1, fig_b, fig_c, link_elem, heading_elem]))
    for e in (p0, fig_a, p1, fig_b, fig_c, link_elem, heading_elem):
        e["/P"] = doc_elem

    pdf.Root.StructTreeRoot = pdf.make_indirect(
        pikepdf.Dictionary(Type=pikepdf.Name("/StructTreeRoot"), K=doc_elem))
    pdf.save(path)
    pdf.close()
    return path


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


# ── figures_image_info / list_figures context fields ────────────────────

class TestFiguresImageInfo:
    def test_resolves_width_height_and_objid(self, tmp_path):
        p = _build_two_figure_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        info = pte.figures_image_info(pdf)
        assert info[0]["width"] == 300 and info[0]["height"] == 200
        assert info[1]["width"] == 14 and info[1]["height"] == 14
        assert info[0]["objid"] is not None and info[1]["objid"] is not None
        assert info[0]["objid"] != info[1]["objid"]

    def test_no_image_yields_none_info(self, tmp_path):
        pdf = pikepdf.new()
        page = pdf.add_blank_page(page_size=(200, 200))
        fig = pdf.make_indirect(pikepdf.Dictionary(
            Type=pikepdf.Name.StructElem, S=pikepdf.Name("/Figure"), Pg=page.obj))
        pdf.Root.StructTreeRoot = pdf.make_indirect(
            pikepdf.Dictionary(Type=pikepdf.Name("/StructTreeRoot"), K=fig))
        info = pte.figures_image_info(pdf)
        assert info == [None]


class TestListFiguresContext:
    def test_surrounding_text_before_and_after(self, tmp_path):
        p = _build_two_figure_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        recs = pte.list_figures(pdf)
        assert recs[0]["surrounding_text"]["before"] == "Intro paragraph before the chart."
        assert recs[0]["surrounding_text"]["after"] == "Caption text after the chart."

    def test_reading_order_index_strictly_increases_in_document_order(self, tmp_path):
        p = _build_full_context_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        recs = pte.list_figures(pdf)
        indices = [r["reading_order_index"] for r in recs]
        assert all(i is not None for i in indices)
        assert indices == sorted(indices)
        assert len(set(indices)) == len(indices)

    def test_repeat_count_for_shared_and_unique_images(self, tmp_path):
        p = _build_full_context_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        recs = pte.list_figures(pdf)
        by_page_mcid = {(r["page"], r["mcids"][0]): r for r in recs}
        fig_a = by_page_mcid[(1, 1)]   # unique 300x200
        fig_b = by_page_mcid[(1, 3)]   # tiny, repeated
        fig_c = by_page_mcid[(2, 0)]   # same tiny image as fig_b
        fig_d = by_page_mcid[(2, 1)]   # unique (in link)
        assert fig_a["repeat_count"] == 1
        assert fig_b["repeat_count"] == 2
        assert fig_c["repeat_count"] == 2
        assert fig_d["repeat_count"] == 1

    def test_in_link_true_only_for_link_nested_figure(self, tmp_path):
        p = _build_full_context_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        recs = pte.list_figures(pdf)
        by_page_mcid = {(r["page"], r["mcids"][0]): r for r in recs}
        assert by_page_mcid[(2, 1)]["in_link"] is True
        assert by_page_mcid[(1, 1)]["in_link"] is False
        assert by_page_mcid[(2, 2)]["in_link"] is False

    def test_in_heading_true_only_for_heading_nested_figure(self, tmp_path):
        p = _build_full_context_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        recs = pte.list_figures(pdf)
        by_page_mcid = {(r["page"], r["mcids"][0]): r for r in recs}
        assert by_page_mcid[(2, 2)]["in_heading"] is True
        assert by_page_mcid[(1, 1)]["in_heading"] is False
        assert by_page_mcid[(2, 1)]["in_heading"] is False

    def test_mcids_and_page_fields(self, tmp_path):
        p = _build_two_figure_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        recs = pte.list_figures(pdf)
        assert recs[0]["mcids"] == [1] and recs[0]["page"] == 1
        assert recs[1]["mcids"] == [3] and recs[1]["page"] == 1

    def test_existing_fields_still_present_no_regression(self, tmp_path):
        """Guard: extending list_figures must not drop the fields
        remediate_pdf._check_figure_alt_placeholders relies on."""
        p = _build_two_figure_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        recs = pte.list_figures(pdf, path=str(p))
        for r in recs:
            assert set(["index", "page", "alt", "is_placeholder",
                        "caption_candidate"]).issubset(r.keys())

        import remediate_pdf as rp  # noqa: E402 (import here — heavy module)
        review, reason = rp._check_figure_alt_placeholders(p)
        assert review["total"] == 2
        assert review["placeholder"] == 2  # both figures start with empty /Alt
        assert reason is not None


# ── demote_figures_to_artifact ───────────────────────────────────────────

class TestDemoteFiguresToArtifact:
    def test_demotes_named_figure_drops_from_walk_figures(self, tmp_path):
        p = _build_two_figure_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        assert len(pte.walk_figures(pdf)) == 2
        demoted = pte.demote_figures_to_artifact(pdf, [1])  # the 14x14 figure
        assert demoted == 1
        remaining = pte.walk_figures(pdf)
        assert len(remaining) == 1
        assert int(remaining[0].K) == 1  # the content figure (MCID 1) is untouched

    def test_out_of_range_index_skipped_not_raised(self, tmp_path):
        p = _build_two_figure_pdf(tmp_path / "t.pdf")
        pdf = pikepdf.open(p)
        demoted = pte.demote_figures_to_artifact(pdf, [99, -1, "not-an-int-ish"[:0]])
        assert demoted == 0
        assert len(pte.walk_figures(pdf)) == 2


# ── figure_alt.py: build-manifest (AC1, AC6) ────────────────────────────

class TestBuildManifest:
    def test_one_record_per_figure_with_png_and_every_context_field(self, tmp_path):
        p = _build_full_context_pdf(tmp_path / "t.pdf")
        manifest_path, manifest = figure_alt.build_manifest(str(p), outdir=tmp_path / "out")
        assert len(manifest) == 5
        expected_keys = {
            "index", "page", "alt", "is_placeholder", "caption_candidate",
            "width", "height", "surrounding_text", "reading_order_index",
            "repeat_count", "in_link", "in_heading", "mcids",
            "png", "disposition", "reason",
        }
        for entry in manifest:
            assert expected_keys.issubset(entry.keys())
            assert os.path.exists(entry["png"])
        assert json.loads(manifest_path.read_text()) == manifest

    def test_ac6_disposition_null_for_every_figure_including_14x14(self, tmp_path):
        """AC6 — the script never auto-classifies decorative by size: a
        14x14 figure gets disposition: null exactly like a 300x200 one."""
        p = _build_two_figure_pdf(tmp_path / "t.pdf")
        _, manifest = figure_alt.build_manifest(str(p), outdir=tmp_path / "out")
        tiny = next(e for e in manifest if e["width"] == 14 and e["height"] == 14)
        assert tiny["disposition"] is None
        assert all(e["disposition"] is None for e in manifest)


# ── figure_alt.py: apply (AC4, AC5, AC7) ─────────────────────────────────

class TestApplyManifest:
    def _annotated_manifest(self, tmp_path, describe_alt="A red bar chart of quarterly results."):
        p = _build_two_figure_pdf(tmp_path / "t.pdf")
        manifest_path, manifest = figure_alt.build_manifest(str(p), outdir=tmp_path / "out")
        manifest[0]["disposition"] = "describe"
        manifest[0]["alt"] = describe_alt
        manifest[0]["reason"] = "Content chart discussed in the surrounding text."
        manifest[1]["disposition"] = "decorative"
        manifest[1]["reason"] = "Tiny accent graphic, not referenced by the text."
        manifest_path.write_text(json.dumps(manifest, indent=2))
        return p, manifest_path

    def test_ac4_describe_and_decorative_applied_and_verified(self, tmp_path, monkeypatch):
        p, manifest_path = self._annotated_manifest(tmp_path)
        _mock_verapdf(monkeypatch, compliant=True)

        result = figure_alt.apply_manifest(str(p), str(manifest_path))
        assert result["ok"] is True
        assert result["placeholders_remaining"] == 0
        assert result["applied"]["demoted_count"] == 1

        pdf = pikepdf.open(p)
        figs = pte.walk_figures(pdf)
        assert len(figs) == 1  # decorative figure demoted out of the tree
        assert str(figs[0].Alt) == "A red bar chart of quarterly results."

    def test_ac5_unannotated_figure_refuses_and_restores(self, tmp_path, monkeypatch):
        p, manifest_path = self._annotated_manifest(tmp_path)
        manifest = json.loads(manifest_path.read_text())
        manifest[1]["disposition"] = None  # leave the second figure unannotated
        manifest[1]["reason"] = None
        manifest_path.write_text(json.dumps(manifest, indent=2))
        _mock_verapdf(monkeypatch, compliant=True)

        result = figure_alt.apply_manifest(str(p), str(manifest_path))
        assert result["ok"] is False
        assert result["restored"] is True
        assert any(m["reason"] == "disposition not annotated" for m in result["needs_manual"])
        # nothing was kept — the file is back to its pre-apply state
        pdf = pikepdf.open(p)
        assert len(pte.walk_figures(pdf)) == 2
        assert all(f["is_placeholder"] for f in pte.list_figures(pdf))

    def test_ac5_describe_with_placeholder_alt_rejected(self, tmp_path, monkeypatch):
        p, manifest_path = self._annotated_manifest(tmp_path, describe_alt="image 1")
        _mock_verapdf(monkeypatch, compliant=True)

        result = figure_alt.apply_manifest(str(p), str(manifest_path))
        assert result["ok"] is False
        assert result["restored"] is True
        assert any("placeholder alt" in m["reason"] for m in result["needs_manual"])

    def test_ac5_describe_with_empty_alt_rejected(self, tmp_path, monkeypatch):
        p, manifest_path = self._annotated_manifest(tmp_path, describe_alt="   ")
        _mock_verapdf(monkeypatch, compliant=True)

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

    def test_verapdf_regression_restores_backup(self, tmp_path, monkeypatch):
        p, manifest_path = self._annotated_manifest(tmp_path)
        before_bytes = p.read_bytes()
        _mock_verapdf(monkeypatch, compliant=False)

        result = figure_alt.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_two_figure_pdf(tmp_path / "t.pdf")
        before_bytes = p.read_bytes()
        result = figure_alt.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

    def test_ac7_fidelity_text_and_pixels_unchanged(self, tmp_path, monkeypatch):
        """AC7 — apply changes only /Alt + marked-content operators: page
        text extraction is byte-identical and rendered pixels are identical
        (artifact/alt text are non-visual)."""
        p, manifest_path = self._annotated_manifest(tmp_path)
        before_path = tmp_path / "before.pdf"
        before_path.write_bytes(p.read_bytes())
        before_text = fitz.open(str(before_path))[0].get_text("text")

        _mock_verapdf(monkeypatch, compliant=True)
        result = figure_alt.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
