"""Tests for scripts/pdf_font_embed.py (font-embed engine P1/P2: TrueType and
Type1 base-14 -> FontFile2, docs/plan-font-embed-engine.md).

CI-light: pure pikepdf fixtures built in-memory (mirrors test_font_repair.py),
no network, no vendored ODL. The optional real-veraPDF clause-drop check
uses the existing `remediation_tools` marker/skipif pattern from
test_phase2_gates.py — skipped when the vendored java/verapdf aren't
installed, never faked.
"""

import os
import sys

import pikepdf
import pytest

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

import pdf_font_embed as pfe  # noqa: E402
import setup_remediation  # noqa: E402
import verapdf_gate  # noqa: E402

REMEDIATION_TOOLS_READY = bool(setup_remediation.get_java() and setup_remediation.get_verapdf())


# ── fixtures ─────────────────────────────────────────────────────────────

def _tt_font(pdf, base_font="/ArialMT", embedded=False, subtype="/TrueType",
             first=32, last=126, encoding="/WinAnsiEncoding", flags=32,
             with_descriptor=True):
    fd = None
    if with_descriptor:
        fd_dict = {"/Type": pikepdf.Name("/FontDescriptor"), "/Flags": flags}
        if embedded:
            fd_dict["/FontFile2"] = pdf.make_stream(b"already-embedded-bytes")
        fd = pdf.make_indirect(pikepdf.Dictionary(fd_dict))
    font_dict = {
        "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name(subtype),
        "/BaseFont": pikepdf.Name(base_font), "/Encoding": pikepdf.Name(encoding),
        "/FirstChar": first, "/LastChar": last,
    }
    if fd is not None:
        font_dict["/FontDescriptor"] = fd
    return pdf.make_indirect(pikepdf.Dictionary(font_dict))


def _type0_font(pdf, embedded=False):
    fd_dict = {"/Type": pikepdf.Name("/FontDescriptor")}
    if embedded:
        fd_dict["/FontFile2"] = pdf.make_stream(b"cid-embedded-bytes")
    fd = pdf.make_indirect(pikepdf.Dictionary(fd_dict))
    cid_font = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name("/CIDFontType2"),
        "/BaseFont": pikepdf.Name("/ABCDEF+SomeCID"), "/FontDescriptor": fd,
    }))
    return pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name("/Type0"),
        "/BaseFont": pikepdf.Name("/ABCDEF+SomeCID"),
        "/DescendantFonts": pikepdf.Array([cid_font]),
    }))


def _page_with_font(pdf, font):
    page = pdf.add_blank_page()
    page.Resources = pikepdf.Dictionary({"/Font": pikepdf.Dictionary({"/F1": font})})
    return page


def _type1_font(pdf, base_font="/Times-Roman", encoding="/WinAnsiEncoding",
                 with_descriptor=True, with_widths=True, first=32, last=126):
    """Type1 base-14 fixture (P2). `with_descriptor`/`with_widths` toggle
    the two real-world shapes: census2000manual.pdf HAS both, Learning
    Community's /Times-Roman has NEITHER."""
    fd = None
    if with_descriptor:
        fd = pdf.make_indirect(pikepdf.Dictionary({
            "/Type": pikepdf.Name("/FontDescriptor"), "/Flags": 34,
            "/FontName": pikepdf.Name(base_font), "/ItalicAngle": 0,
            "/Ascent": 905, "/Descent": -212, "/CapHeight": 716, "/StemV": 80,
        }))
    font_dict = {
        "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name("/Type1"),
        "/BaseFont": pikepdf.Name(base_font), "/Encoding": pikepdf.Name(encoding),
    }
    if fd is not None:
        font_dict["/FontDescriptor"] = fd
    if with_widths:
        font_dict["/FirstChar"] = first
        font_dict["/LastChar"] = last
        font_dict["/Widths"] = pikepdf.Array([500] * (last - first + 1))
    return pdf.make_indirect(pikepdf.Dictionary(font_dict))


# ── mechanism ────────────────────────────────────────────────────────────

class TestMechanism:
    def test_embeds_fontfile2_with_length1(self):
        pdf = pikepdf.new()
        font = _tt_font(pdf)
        _page_with_font(pdf, font)

        counts = pfe.repair_missing_fonts(pdf)

        assert counts == {"embedded": 1, "skipped_no_substitute": 0,
                           "skipped_type3": 0, "skipped_embedded": 0}
        fd = font.FontDescriptor
        assert "/FontFile2" in fd
        stream = fd.FontFile2
        data = stream.read_bytes()
        assert stream.get("/Length1") == len(data)
        assert len(data) > 0

    def test_family_and_style_resolution(self):
        cases = [
            ("/Times-Bold", "LiberationSerif", "Bold"),
            ("/Times New Roman,Italic", "LiberationSerif", "Italic"),
            ("/ABCDEF+Courier-BoldOblique", "LiberationMono", "BoldItalic"),
            ("/Helvetica", "LiberationSans", "Regular"),
        ]
        for base_font, family, variant in cases:
            pdf = pikepdf.new()
            font = _tt_font(pdf, base_font=base_font)
            _page_with_font(pdf, font)
            counts = pfe.repair_missing_fonts(pdf)
            assert counts["embedded"] == 1, base_font
            expected_path = pfe.get_fonts_dir() / f"{family}-{variant}.ttf"
            assert expected_path.exists(), (base_font, expected_path)

    def test_subset_failure_falls_back_to_full_font(self, monkeypatch):
        monkeypatch.setattr(pfe, "_subset_font",
                             lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("boom")))
        pdf = pikepdf.new()
        font = _tt_font(pdf)
        _page_with_font(pdf, font)

        counts = pfe.repair_missing_fonts(pdf)

        assert counts["embedded"] == 1
        full_bytes = (pfe.get_fonts_dir() / "LiberationSans-Regular.ttf").read_bytes()
        data = font.FontDescriptor.FontFile2.read_bytes()
        assert data == full_bytes
        assert font.FontDescriptor.FontFile2.get("/Length1") == len(full_bytes)


# ── negatives ────────────────────────────────────────────────────────────

class TestNoSubstitute:
    @pytest.mark.parametrize("base_font", ["/Wingdings", "/Symbol", "/ZapfDingbats"])
    def test_no_free_clone_is_skipped(self, base_font):
        pdf = pikepdf.new()
        font = _tt_font(pdf, base_font=base_font)
        _page_with_font(pdf, font)

        counts = pfe.repair_missing_fonts(pdf)

        assert counts == {"embedded": 0, "skipped_no_substitute": 1,
                           "skipped_type3": 0, "skipped_embedded": 0}
        assert "/FontFile2" not in font.FontDescriptor

    def test_unhandled_subtype_is_skipped(self):
        """P2 handles /TrueType and /Type1; other subtypes (/MMType1, etc.)
        are still P3+ and stay skipped_no_substitute."""
        pdf = pikepdf.new()
        font = _tt_font(pdf, base_font="/ArialMT", subtype="/MMType1")
        _page_with_font(pdf, font)

        counts = pfe.repair_missing_fonts(pdf)

        assert counts["skipped_no_substitute"] == 1
        assert counts["embedded"] == 0

    def test_no_descriptor_is_skipped_not_crashed(self):
        pdf = pikepdf.new()
        font = _tt_font(pdf, base_font="/ArialMT", with_descriptor=False)
        _page_with_font(pdf, font)

        counts = pfe.repair_missing_fonts(pdf)

        assert counts == {"embedded": 0, "skipped_no_substitute": 1,
                           "skipped_type3": 0, "skipped_embedded": 0}


class TestType3Untouched:
    def test_type3_is_skipped(self):
        pdf = pikepdf.new()
        font = pdf.make_indirect(pikepdf.Dictionary({
            "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name("/Type3"),
        }))
        _page_with_font(pdf, font)

        counts = pfe.repair_missing_fonts(pdf)

        assert counts == {"embedded": 0, "skipped_no_substitute": 0,
                           "skipped_type3": 1, "skipped_embedded": 0}


class TestType0:
    def test_embedded_descendant_is_skipped_embedded(self):
        pdf = pikepdf.new()
        font = _type0_font(pdf, embedded=True)
        _page_with_font(pdf, font)

        counts = pfe.repair_missing_fonts(pdf)

        assert counts["skipped_embedded"] == 1
        assert counts["embedded"] == 0

    def test_non_embedded_descendant_is_no_substitute(self):
        """P1 does not embed Type0/CID fonts (deferred)."""
        pdf = pikepdf.new()
        font = _type0_font(pdf, embedded=False)
        _page_with_font(pdf, font)

        counts = pfe.repair_missing_fonts(pdf)

        assert counts["skipped_no_substitute"] == 1
        assert counts["embedded"] == 0


# ── Type1 base-14 (P2) ───────────────────────────────────────────────────

class TestType1Embed:
    def test_with_descriptor_and_widths_is_embedded(self):
        """census2000manual.pdf shape: descriptor + Widths already present —
        only inject FontFile2 and flip the subtype; leave the rest as-is."""
        pdf = pikepdf.new()
        font = _type1_font(pdf, with_descriptor=True, with_widths=True)
        original_widths = list(font.Widths)
        _page_with_font(pdf, font)

        counts = pfe.repair_missing_fonts(pdf)

        assert counts == {"embedded": 1, "skipped_no_substitute": 0,
                           "skipped_type3": 0, "skipped_embedded": 0}
        assert str(font.Subtype) == "/TrueType"
        fd = font.FontDescriptor
        assert "/FontFile2" in fd
        stream = fd.FontFile2
        data = stream.read_bytes()
        assert stream.get("/Length1") == len(data)
        assert len(data) > 0
        assert list(font.Widths) == original_widths

    def test_without_descriptor_or_widths_synthesizes_both(self):
        """Learning Community shape: /Times-Roman with NEITHER a descriptor
        nor /Widths. Both must be synthesized from the subset font."""
        pdf = pikepdf.new()
        font = _type1_font(pdf, with_descriptor=False, with_widths=False)
        _page_with_font(pdf, font)

        counts = pfe.repair_missing_fonts(pdf)

        assert counts == {"embedded": 1, "skipped_no_substitute": 0,
                           "skipped_type3": 0, "skipped_embedded": 0}
        assert str(font.Subtype) == "/TrueType"
        fd = font.FontDescriptor
        assert fd is not None
        assert "/FontFile2" in fd
        data = fd.FontFile2.read_bytes()
        assert fd.FontFile2.get("/Length1") == len(data)
        first, last = int(font.FirstChar), int(font.LastChar)
        assert len(font.Widths) == last - first + 1
        # A (0x41) is within the default 0x20-0xFF synthesis range and maps
        # to a real glyph in LiberationSerif-Regular -> nonzero advance.
        assert font.Widths[ord("A") - first] > 0

    def test_symbol_type1_still_no_substitute(self):
        """/Symbol has no bundleable free clone (P3/opt-in) — unchanged from
        P1 even though the general Type1 path is now handled."""
        pdf = pikepdf.new()
        font = _type1_font(pdf, base_font="/Symbol", with_descriptor=False,
                            with_widths=False)
        _page_with_font(pdf, font)

        counts = pfe.repair_missing_fonts(pdf)

        assert counts == {"embedded": 0, "skipped_no_substitute": 1,
                           "skipped_type3": 0, "skipped_embedded": 0}
        assert font.get("/FontDescriptor") is None
        assert str(font.Subtype) == "/Type1"


# ── idempotence ──────────────────────────────────────────────────────────

class TestIdempotent:
    def test_already_embedded_is_noop_on_rerun(self):
        pdf = pikepdf.new()
        font = _tt_font(pdf, embedded=True)
        _page_with_font(pdf, font)

        first = pfe.repair_missing_fonts(pdf)
        second = pfe.repair_missing_fonts(pdf)

        assert first == {"embedded": 0, "skipped_no_substitute": 0,
                          "skipped_type3": 0, "skipped_embedded": 1}
        assert second == first
        assert font.FontDescriptor.FontFile2.read_bytes() == b"already-embedded-bytes"

    def test_embed_then_rerun_is_skipped_embedded(self):
        pdf = pikepdf.new()
        font = _tt_font(pdf)
        _page_with_font(pdf, font)

        first = pfe.repair_missing_fonts(pdf)
        second = pfe.repair_missing_fonts(pdf)

        assert first["embedded"] == 1
        assert second == {"embedded": 0, "skipped_no_substitute": 0,
                           "skipped_type3": 0, "skipped_embedded": 1}


# ── cycle guard (mirrors test_font_repair.py) ───────────────────────────

class TestCycleGuard:
    def test_recursive_form_resources_do_not_infinite_loop(self):
        pdf = pikepdf.new()
        page = pdf.add_blank_page()
        form_res = pikepdf.Dictionary({})
        form = pdf.make_indirect(pikepdf.Stream(
            pdf, b"", {"/Subtype": pikepdf.Name("/Form"), "/Resources": form_res}))
        font = _tt_font(pdf)
        form.Resources["/Font"] = pikepdf.Dictionary({"/F1": font})
        form.Resources["/XObject"] = pikepdf.Dictionary({"/Fx": form})  # self-cycle
        page.Resources = pikepdf.Dictionary({"/XObject": pikepdf.Dictionary({"/Fx": form})})

        counts = pfe.repair_missing_fonts(pdf)  # would RecursionError without the guard

        assert counts["embedded"] == 1


# ── real-veraPDF clause-drop check (optional AC, heavy) ──────────────────

def _standalone_pdf_with_arial(path, embed):
    """A minimal but content-bearing PDF referencing non-embedded ArialMT,
    parseable by veraPDF (not just pikepdf). Not tagged/PDF-UA-structured —
    only 7.21.4.1 presence/absence is asserted, not overall compliance."""
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    fd = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/FontDescriptor"), "/Flags": 32,
        "/FontName": pikepdf.Name("/ArialMT"), "/ItalicAngle": 0,
        "/Ascent": 905, "/Descent": -212, "/CapHeight": 716, "/StemV": 80,
    }))
    font = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name("/TrueType"),
        "/BaseFont": pikepdf.Name("/ArialMT"), "/Encoding": pikepdf.Name("/WinAnsiEncoding"),
        "/FirstChar": 32, "/LastChar": 126, "/Widths": [500] * (126 - 32 + 1),
        "/FontDescriptor": fd,
    }))
    page.Resources = pikepdf.Dictionary({"/Font": pikepdf.Dictionary({"/F1": font})})
    page.Contents = pdf.make_stream(b"BT /F1 12 Tf 10 100 Td (Hello) Tj ET")
    if embed:
        pfe.repair_missing_fonts(pdf)
    pdf.save(str(path))
    pdf.close()


def _standalone_pdf_with_times_roman(path, embed):
    """A minimal but content-bearing PDF referencing a non-embedded /Type1
    /Times-Roman WITH a descriptor + Widths (census2000manual.pdf shape),
    parseable by veraPDF. Only 7.21.4.1 presence/absence is asserted."""
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    fd = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/FontDescriptor"), "/Flags": 34,
        "/FontName": pikepdf.Name("/Times-Roman"), "/ItalicAngle": 0,
        "/Ascent": 905, "/Descent": -212, "/CapHeight": 716, "/StemV": 80,
    }))
    font = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name("/Type1"),
        "/BaseFont": pikepdf.Name("/Times-Roman"), "/Encoding": pikepdf.Name("/WinAnsiEncoding"),
        "/FirstChar": 32, "/LastChar": 126, "/Widths": [500] * (126 - 32 + 1),
        "/FontDescriptor": fd,
    }))
    page.Resources = pikepdf.Dictionary({"/Font": pikepdf.Dictionary({"/F1": font})})
    page.Contents = pdf.make_stream(b"BT /F1 12 Tf 10 100 Td (Hello) Tj ET")
    if embed:
        pfe.repair_missing_fonts(pdf)
    pdf.save(str(path))
    pdf.close()


@pytest.mark.remediation_tools
@pytest.mark.skipif(not REMEDIATION_TOOLS_READY, reason="vendored java/verapdf not installed")
class TestVeraPdfClauseDrop:
    def test_7_21_4_1_present_before_absent_after(self, tmp_path):
        before_path = tmp_path / "before.pdf"
        after_path = tmp_path / "after.pdf"
        _standalone_pdf_with_arial(before_path, embed=False)
        _standalone_pdf_with_arial(after_path, embed=True)

        before = verapdf_gate.run_verapdf(str(before_path))
        after = verapdf_gate.run_verapdf(str(after_path))

        before_clauses = {s.get("clause") for s in before.get("failed_summaries", [])}
        after_clauses = {s.get("clause") for s in after.get("failed_summaries", [])}
        assert "7.21.4.1" in before_clauses
        assert "7.21.4.1" not in after_clauses

    def test_type1_7_21_4_1_present_before_absent_after(self, tmp_path):
        """P2: the Type1 base-14 path clears the same clause as TrueType."""
        before_path = tmp_path / "before_t1.pdf"
        after_path = tmp_path / "after_t1.pdf"
        _standalone_pdf_with_times_roman(before_path, embed=False)
        _standalone_pdf_with_times_roman(after_path, embed=True)

        before = verapdf_gate.run_verapdf(str(before_path))
        after = verapdf_gate.run_verapdf(str(after_path))

        before_clauses = {s.get("clause") for s in before.get("failed_summaries", [])}
        after_clauses = {s.get("clause") for s in after.get("failed_summaries", [])}
        assert "7.21.4.1" in before_clauses
        assert "7.21.4.1" not in after_clauses
