"""Tests for pdf_font_embed.fix_degenerate_widths (veraPDF PDF/UA-1 7.21.5:
embedded Type1 glyph-program widths inconsistent with /Widths).

Fixtures are minimal, synthetic Type1 font programs built from scratch via
fontTools.t1Lib's own writer (no commercial font bytes committed to the
repo) — degenerate glyphs (hsbw + endchar, no visible marks) are enough
since the fix only ever touches the wx operand, never the outline.

CI-light: pure pikepdf/t1Lib fixtures, no vendored ODL/java/verapdf.
"""

import os
import sys

import pikepdf
import pytest
from fontTools import t1Lib
from fontTools.misc.psCharStrings import T1CharString

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

import pdf_font_embed as pfe  # noqa: E402

_RD = ("string", "currentfile", "exch", "readstring", "pop")
_ND = ("def",)
_NP = ("put",)


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

def _build_type1_bytes(glyph_widths, matrix=(0.001, 0, 0, 0.001, 0, 0),
                        skip_glyph_in_charstrings=None):
    """A minimal, from-scratch Type1 font program (fontTools.t1Lib's own
    round-trip writer). glyph_widths: {glyph_name: wx} in built-in-encoding
    order (code 1, 2, 3, ... — code 0 is .notdef). Every glyph is a
    degenerate no-op charstring (hsbw + endchar) — only wx is meaningful
    here. `skip_glyph_in_charstrings` names one glyph the /Encoding array
    references but that is absent from CharStrings (uncertain-mapping
    case)."""
    charstrings = {".notdef": T1CharString(program=[0, 0, "hsbw", "endchar"])}
    encoding = [".notdef"] * 256
    for i, (name, w) in enumerate(glyph_widths.items()):
        encoding[i + 1] = name
        if name == skip_glyph_in_charstrings:
            continue
        charstrings[name] = T1CharString(program=[0, w, "hsbw", "endchar"])

    font_dict = {
        "FontType": 1,
        "FontMatrix": list(matrix),
        "FontName": "TestSynthetic",
        "Encoding": encoding,
        "FontInfo": {"FullName": "TestSynthetic", "FamilyName": "TestSynthetic"},
        "Private": {"RD": _RD, "ND": _ND, "NP": _NP, "lenIV": 4,
                     "OtherSubrs": [], "Subrs": []},
        "CharStrings": charstrings,
    }
    t1 = t1Lib.T1Font.__new__(t1Lib.T1Font)
    t1.font = font_dict
    t1.encoding = "ascii"
    return t1.getData()


def _programmed_widths(data: bytes) -> dict:
    """Re-parse a Type1 FontFile's own program-declared wx per glyph name,
    independent of pdf_font_embed internals — an oracle for assertions."""
    import tempfile
    fh, path = tempfile.mkstemp(suffix=".pfa")
    try:
        with os.fdopen(fh, "wb") as f:
            f.write(data)
        t1 = t1Lib.T1Font(path)
        t1.parse()
    finally:
        os.unlink(path)
    out = {}
    for name, cs in t1.font["CharStrings"].items():
        cs.decompile()
        idx = pfe._charstring_width_index(cs.program)
        if idx is not None:
            out[name] = cs.program[idx]
    return out


def _type1_font(pdf, glyph_widths, pdf_widths, base_font="/ABCDEF+TestSynthetic",
                 matrix=(0.001, 0, 0, 0.001, 0, 0), skip_glyph_in_charstrings=None,
                 first_char=1):
    """glyph_widths: program-declared {glyph_name: wx} in built-in-encoding
    order. pdf_widths: the /Widths array (list, one entry per code starting
    at first_char) — the two are independent so tests can make them agree
    or disagree."""
    data = _build_type1_bytes(glyph_widths, matrix=matrix,
                               skip_glyph_in_charstrings=skip_glyph_in_charstrings)
    fd = pdf.make_indirect(pikepdf.Dictionary({"/Type": pikepdf.Name("/FontDescriptor")}))
    fd["/FontFile"] = pdf.make_stream(data)
    font = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name("/Type1"),
        "/BaseFont": pikepdf.Name(base_font), "/FontDescriptor": fd,
        "/FirstChar": first_char, "/LastChar": first_char + len(pdf_widths) - 1,
        "/Widths": pikepdf.Array(pdf_widths),
    }))
    return font, fd


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


# ── the fix itself ───────────────────────────────────────────────────────

class TestPatchesDegenerateWidths:
    def test_uniform_program_width_patched_to_widths(self):
        """The documented real-world shape: every glyph's program wx is a
        uniform placeholder (1000) that disagrees with /Widths."""
        pdf = pikepdf.new()
        font, fd = _type1_font(
            pdf, glyph_widths={"A": 1000, "B": 1000, "space": 1000},
            pdf_widths=[494, 463, 269])
        _page_with_font(pdf, font)
        original_widths = list(font.Widths)

        counts = pfe.fix_degenerate_widths(pdf)

        assert counts == {"fixed": 1, "skipped_uncertain_mapping": 0}
        new_data = fd["/FontFile"].read_bytes()
        programmed = _programmed_widths(new_data)
        assert programmed["A"] == 494
        assert programmed["B"] == 463
        assert programmed["space"] == 269
        # /Widths itself is the authority and must never be touched.
        assert list(font.Widths) == original_widths == [494, 463, 269]

    def test_length_fields_match_actual_segment_sizes(self):
        pdf = pikepdf.new()
        font, fd = _type1_font(
            pdf, glyph_widths={"A": 1000}, pdf_widths=[500])
        _page_with_font(pdf, font)

        pfe.fix_degenerate_widths(pdf)

        stream = fd["/FontFile"]
        data = stream.read_bytes()
        l1, l2, l3 = stream.get("/Length1"), stream.get("/Length2"), stream.get("/Length3")
        assert l1 is not None and l2 is not None and l3 is not None
        assert int(l1) + int(l2) + int(l3) == len(data)

    def test_already_consistent_program_is_left_untouched(self):
        """Only touch fonts that actually fail the check — nothing to fix
        here, so no patch, no re-embed."""
        pdf = pikepdf.new()
        font, fd = _type1_font(
            pdf, glyph_widths={"A": 500, "B": 463}, pdf_widths=[500, 463])
        _page_with_font(pdf, font)
        original_bytes = fd["/FontFile"].read_bytes()

        counts = pfe.fix_degenerate_widths(pdf)

        assert counts == {"fixed": 0, "skipped_uncertain_mapping": 0}
        assert fd["/FontFile"].read_bytes() == original_bytes

    def test_partial_disagreement_still_patches_the_whole_font(self):
        """Only some glyphs disagree; the ones that already agree are
        rewritten to the same value (harmless) and the font is still
        counted as fixed once, matching pdf_font_repair's whole-object
        (not per-glyph) counting convention."""
        pdf = pikepdf.new()
        font, fd = _type1_font(
            pdf, glyph_widths={"A": 500, "B": 1000}, pdf_widths=[500, 463])
        _page_with_font(pdf, font)

        counts = pfe.fix_degenerate_widths(pdf)

        assert counts == {"fixed": 1, "skipped_uncertain_mapping": 0}
        programmed = _programmed_widths(fd["/FontFile"].read_bytes())
        assert programmed["A"] == 500
        assert programmed["B"] == 463

    def test_idempotent(self):
        pdf = pikepdf.new()
        font, fd = _type1_font(
            pdf, glyph_widths={"A": 1000, "B": 1000}, pdf_widths=[494, 463])
        _page_with_font(pdf, font)

        first = pfe.fix_degenerate_widths(pdf)
        patched_once = fd["/FontFile"].read_bytes()
        second = pfe.fix_degenerate_widths(pdf)

        assert first["fixed"] == 1
        assert second["fixed"] == 0
        assert fd["/FontFile"].read_bytes() == patched_once


# ── collision guard ──────────────────────────────────────────────────────

class TestCollisionGuard:
    def test_two_codes_same_glyph_conflicting_widths_is_skipped(self):
        """A single charstring carries one wx. If two PDF codes map (via
        the built-in /Encoding) to the SAME glyph but /Widths wants
        DIFFERENT advances for them, neither can be satisfied — skip
        rather than silently pick a winner."""
        pdf = pikepdf.new()
        # Both code 1 and code 2 point at glyph "A" (duplicate in the
        # built-in encoding array), but /Widths disagrees between them.
        data = _build_type1_bytes({"A": 1000})
        fh_path = None
        fd = pdf.make_indirect(pikepdf.Dictionary({"/Type": pikepdf.Name("/FontDescriptor")}))
        stream = pdf.make_stream(data)
        fd["/FontFile"] = stream

        # Patch the Encoding array so code 2 also names "A" (collision),
        # by round-tripping through t1Lib once more.
        import tempfile
        fh, path = tempfile.mkstemp(suffix=".pfa")
        with os.fdopen(fh, "wb") as f:
            f.write(data)
        t1 = t1Lib.T1Font(path)
        t1.parse()
        os.unlink(path)
        t1.font["Encoding"][2] = "A"
        collided_data = t1.getData()
        fd["/FontFile"] = pdf.make_stream(collided_data)

        font = pdf.make_indirect(pikepdf.Dictionary({
            "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name("/Type1"),
            "/BaseFont": pikepdf.Name("/ABCDEF+TestSynthetic"), "/FontDescriptor": fd,
            "/FirstChar": 1, "/LastChar": 2,
            "/Widths": pikepdf.Array([494, 463]),  # code 1 wants 494, code 2 wants 463 -- conflict
        }))
        _page_with_font(pdf, font)
        original_bytes = fd["/FontFile"].read_bytes()

        counts = pfe.fix_degenerate_widths(pdf)

        assert counts == {"fixed": 0, "skipped_uncertain_mapping": 1}
        assert fd["/FontFile"].read_bytes() == original_bytes

    def test_two_codes_same_glyph_agreeing_widths_is_fine(self):
        pdf = pikepdf.new()
        data = _build_type1_bytes({"A": 1000})
        import tempfile
        fh, path = tempfile.mkstemp(suffix=".pfa")
        with os.fdopen(fh, "wb") as f:
            f.write(data)
        t1 = t1Lib.T1Font(path)
        t1.parse()
        os.unlink(path)
        t1.font["Encoding"][2] = "A"  # code 2 also names "A"
        collided_data = t1.getData()

        fd = pdf.make_indirect(pikepdf.Dictionary({"/Type": pikepdf.Name("/FontDescriptor")}))
        fd["/FontFile"] = pdf.make_stream(collided_data)
        font = pdf.make_indirect(pikepdf.Dictionary({
            "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name("/Type1"),
            "/BaseFont": pikepdf.Name("/ABCDEF+TestSynthetic"), "/FontDescriptor": fd,
            "/FirstChar": 1, "/LastChar": 2,
            "/Widths": pikepdf.Array([494, 494]),  # same target for both codes -- no conflict
        }))
        _page_with_font(pdf, font)

        counts = pfe.fix_degenerate_widths(pdf)

        assert counts == {"fixed": 1, "skipped_uncertain_mapping": 0}
        programmed = _programmed_widths(fd["/FontFile"].read_bytes())
        assert programmed["A"] == 494


# ── skip / uncertain-mapping paths — never guess ─────────────────────────

class TestSkipsUncertainMapping:
    def test_glyph_named_by_encoding_but_missing_from_charstrings(self):
        pdf = pikepdf.new()
        font, fd = _type1_font(
            pdf, glyph_widths={"A": 1000, "B": 1000}, pdf_widths=[494, 463],
            skip_glyph_in_charstrings="B")
        _page_with_font(pdf, font)
        original_bytes = fd["/FontFile"].read_bytes()

        counts = pfe.fix_degenerate_widths(pdf)

        assert counts == {"fixed": 0, "skipped_uncertain_mapping": 1}
        assert fd["/FontFile"].read_bytes() == original_bytes

    def test_non_default_font_matrix_is_skipped(self):
        pdf = pikepdf.new()
        font, fd = _type1_font(
            pdf, glyph_widths={"A": 1000}, pdf_widths=[494],
            matrix=(0.0005, 0, 0, 0.0005, 0, 0))
        _page_with_font(pdf, font)
        original_bytes = fd["/FontFile"].read_bytes()

        counts = pfe.fix_degenerate_widths(pdf)

        assert counts == {"fixed": 0, "skipped_uncertain_mapping": 1}
        assert fd["/FontFile"].read_bytes() == original_bytes

    def test_corrupt_fontfile_is_skipped_not_crashed(self):
        pdf = pikepdf.new()
        fd = pdf.make_indirect(pikepdf.Dictionary({"/Type": pikepdf.Name("/FontDescriptor")}))
        fd["/FontFile"] = pdf.make_stream(b"not a real Type1 program")
        font = pdf.make_indirect(pikepdf.Dictionary({
            "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name("/Type1"),
            "/BaseFont": pikepdf.Name("/ABCDEF+Broken"), "/FontDescriptor": fd,
            "/FirstChar": 1, "/LastChar": 1, "/Widths": pikepdf.Array([500]),
        }))
        _page_with_font(pdf, font)

        counts = pfe.fix_degenerate_widths(pdf)  # would raise without the try/except guard

        assert counts == {"fixed": 0, "skipped_uncertain_mapping": 1}


# ── structural non-matches — silently untouched, not counted ────────────

class TestStructuralNonMatches:
    def test_non_subset_name_untouched(self):
        pdf = pikepdf.new()
        font, fd = _type1_font(
            pdf, glyph_widths={"A": 1000}, pdf_widths=[494],
            base_font="/TestSynthetic")  # no subset prefix
        _page_with_font(pdf, font)
        original_bytes = fd["/FontFile"].read_bytes()

        counts = pfe.fix_degenerate_widths(pdf)

        assert counts == {"fixed": 0, "skipped_uncertain_mapping": 0}
        assert fd["/FontFile"].read_bytes() == original_bytes

    def test_truetype_subtype_untouched(self):
        pdf = pikepdf.new()
        font = pdf.make_indirect(pikepdf.Dictionary({
            "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name("/TrueType"),
            "/BaseFont": pikepdf.Name("/ABCDEF+Foo"),
        }))
        _page_with_font(pdf, font)

        counts = pfe.fix_degenerate_widths(pdf)

        assert counts == {"fixed": 0, "skipped_uncertain_mapping": 0}

    def test_fontfile3_cff_untouched(self):
        """CFF (/FontFile3) is out of scope — this fix is Type1/FontFile
        (hsbw charstrings) only."""
        pdf = pikepdf.new()
        fd = pdf.make_indirect(pikepdf.Dictionary({"/Type": pikepdf.Name("/FontDescriptor")}))
        fd["/FontFile3"] = pdf.make_stream(b"cff bytes")
        font = pdf.make_indirect(pikepdf.Dictionary({
            "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name("/Type1"),
            "/BaseFont": pikepdf.Name("/ABCDEF+Foo"), "/FontDescriptor": fd,
        }))
        _page_with_font(pdf, font)

        counts = pfe.fix_degenerate_widths(pdf)

        assert counts == {"fixed": 0, "skipped_uncertain_mapping": 0}
        assert "/FontFile" not in fd

    def test_missing_widths_or_firstchar_untouched(self):
        pdf = pikepdf.new()
        data = _build_type1_bytes({"A": 1000})
        fd = pdf.make_indirect(pikepdf.Dictionary({"/Type": pikepdf.Name("/FontDescriptor")}))
        fd["/FontFile"] = pdf.make_stream(data)
        font = pdf.make_indirect(pikepdf.Dictionary({
            "/Type": pikepdf.Name("/Font"), "/Subtype": pikepdf.Name("/Type1"),
            "/BaseFont": pikepdf.Name("/ABCDEF+TestSynthetic"), "/FontDescriptor": fd,
            # no /Widths, no /FirstChar
        }))
        _page_with_font(pdf, font)

        counts = pfe.fix_degenerate_widths(pdf)

        assert counts == {"fixed": 0, "skipped_uncertain_mapping": 0}


# ── cycle guard (mirrors test_pdf_font_embed.py / 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, _fd = _type1_font(pdf, glyph_widths={"A": 1000}, pdf_widths=[494])
        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.fix_degenerate_widths(pdf)  # would RecursionError without the guard

        assert counts["fixed"] == 1


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

def _standalone_pdf_with_degenerate_widths(path, fix, glyph_widths, pdf_widths):
    """A minimal but content-bearing PDF whose Type1 font is embedded,
    subset-named, and (before `fix`) width-degenerate, parseable by
    veraPDF. Only 7.21.5 presence/absence is asserted, not overall
    compliance."""
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    font, fd = _type1_font(pdf, glyph_widths=glyph_widths, pdf_widths=pdf_widths)
    page.Resources = pikepdf.Dictionary({"/Font": pikepdf.Dictionary({"/F1": font})})
    page.Contents = pdf.make_stream(b"BT /F1 12 Tf 10 100 Td (\x01\x02) Tj ET")
    if fix:
        pfe.fix_degenerate_widths(pdf)
    pdf.save(str(path))
    pdf.close()


try:
    import setup_remediation  # noqa: E402
    import verapdf_gate  # noqa: E402
    REMEDIATION_TOOLS_READY = bool(setup_remediation.get_java() and setup_remediation.get_verapdf())
except ImportError:
    REMEDIATION_TOOLS_READY = False


@pytest.mark.remediation_tools
@pytest.mark.skipif(not REMEDIATION_TOOLS_READY, reason="vendored java/verapdf not installed")
class TestVeraPdfClauseDrop:
    def test_7_21_5_present_before_absent_after(self, tmp_path):
        before_path = tmp_path / "before.pdf"
        after_path = tmp_path / "after.pdf"
        glyph_widths = {"A": 1000, "B": 1000}
        pdf_widths = [494, 463]
        _standalone_pdf_with_degenerate_widths(before_path, fix=False,
                                                glyph_widths=glyph_widths, pdf_widths=pdf_widths)
        _standalone_pdf_with_degenerate_widths(after_path, fix=True,
                                                glyph_widths=glyph_widths, pdf_widths=pdf_widths)

        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.5" in before_clauses
        assert "7.21.5" not in after_clauses
