#!/usr/bin/env python3
"""Engine E2/E3 orchestrator (file-remediation Stage 2+3).

Routes a PDF by Assess class to E3 (metadata-only patch, tagged-but-sloppy
PDFs) or E2 (opendataloader-pdf in-situ tagging + judgment repairs,
untagged/scanned/hybrid PDFs), then runs the veraPDF + fidelity hard gates.
Gate failure aborts with the output preserved at "{output}.rejected.pdf" —
never silently ship a non-compliant or visually-altered file.

Both branches also run `pdf_font_embed.repair_missing_fonts` (P1+P2 of the
font-embed engine, docs/plan-font-embed-engine.md) immediately before
`pdf_font_repair.repair_fonts`, so veraPDF 7.21.4.1 ("font program not
embedded") is now *partially* auto-fixed: substitutable TrueType **and
Type1 base-14** fonts (Times/Arial/Courier-family) get a bundled Liberation
FontFile2 (Type1 is flipped to `/TrueType` on embed). Residuals (no free
clone, e.g. Symbol/Wingdings, or CFF pending P3) still trip `needs_manual`.
Any run that actually embeds a font gets a structural-diff fidelity gate
(not raw pixel_diff, not exact-zero) — see FONT_EMBED_DELTA /
FONT_EMBED_STRUCT_PCT below.

Both branches also run three structural fixes from pdf_tree_edit.py before
their veraPDF gate: remove_circular_rolemap (7.1 circular-mapping —
self-mapped RoleMap entries a source's own authoring tool left behind),
wrap_link_annotations (7.18.5 — /Link struct elem + /OBJR for any Link
annotation the struct tree doesn't already reach), and
resolve_unreachable_content (7.1 unmarked/orphaned content — attaches
genuine text to the struct tree, artifacts pure decoration). These target
defects that live in a *preserved* source struct tree (E3) as much as an
ODL-generated one (E2) — the E3 branch reuses the doc's own tree as-is, so
a source document's own tagging gaps carry straight through to the output
otherwise. See edits.rolemap_cycles_removed / edits.links_wrapped /
edits.unreachable_content in the result JSON.

Both branches also run an automatic artifact-status-preservation pass
(_preserve_artifact_status, ARTIFACT_CHECK_* constants below) right before
save: opendataloader-pdf's re-tag can pull source-/Artifact content
(running headers/footers correctly excluded from reading order) into the
new struct tree as real tagged content, and resolve_unreachable_content's
"prefer attaching real text" bias can do the same for a preserved tree —
veraPDF and the fidelity gate both stay green on that regression
(well-formedness + pixels only, neither checks reading order), so it needs
its own check. See reading_order.py's module docstring for the detection
approach and edits.artifacts_preserved in the result JSON for what ran.

judgment.json schema (Phase-1-prototype shape, extended for Phase 2):
{
  "doc_title": "string, optional — derived from the first H1 (ODL) or PDF
                 docinfo/filename (E3) if absent",
  "language": "en-US (default)",
  "alt_text": {"<figure_index>": "alt text"},
      # figure_index = 0-based depth-first index among /Figure struct
      # elements (pdf_tree_edit.walk_figures order) — NOT a filename, this
      # doc has no per-image filenames to key against.
  "heading_overrides": [
    {"index": <int>, "level": <int>},
        # index into pdf_tree_edit.walk_elems(pdf, None) pre-order (all
        # struct elements) — "promote this element to H<level>".
    {"bbox": [x0, y0, x1, y1], "page": <int>, "level": <int>}
        # matched to the nearest ODL JSON node on that page by bbox-center
        # distance, then applied at the same tree-walk index (see
        # pdf_tree_edit.py module docstring for the alignment assumption).
  ],
  "tables": [{"page": <int>, "bbox": [x0, y0, x1, y1]}],
      # passed through to pdf_table_weave.weave_tables (fallback-only, AC6).
  "table_headers": [{"index": <int>, "header_row": <bool>, "header_col": <bool>,
                      "header_row_index": <int, optional>, "force": <bool, optional>}],
      # index = 0-based position in pdf_table_weave.list_tables(pdf) document
      # order. Opt-in ONLY — a table absent from this list is never touched.
      # Promotes /TD -> /TH (+ /Scope) for the header row and/or first column
      # of that table. The header row defaults to the first cell-bearing /TR
      # (list_tables()'s "header_row_index") — NOT raw /TR index 0, which is
      # frequently a cell-less junk row (e.g. a swept-in /Link) on real
      # tagged PDFs. Pass "header_row_index" explicitly to override when the
      # genuine header is a different row. Never auto-promote: inventing
      # headers on a table that has none makes a screen reader announce
      # ordinary data as column/row headers, which actively misleads.
      # Inspect list_tables()'s first_row_text (or render the page) first.
      # RAGGED TABLES (list_tables()'s "ragged": true — cell-bearing rows
      # have differing cell counts, i.e. merged cells with no /ColSpan) are
      # refused by default: attributing (/A /Scope) makes veraPDF actually
      # check clause 7.2 (equal columns per row), which can turn a
      # compliant document non-compliant. "force": true overrides.
  "table_to_list": [{"index": <int>, "order": "row"|"column" (default "row")}],
      # index = 0-based position in pdf_table_weave.list_tables(pdf)
      # document order (same indexing as table_headers). Opt-in ONLY — a
      # table absent from this list is never touched. Converts a LAYOUT
      # table (a visual grid with no real row/column relationship, e.g. a
      # multi-column word list) to /L -> /LI -> /LBody. "column" order
      # emits items column-major — the authored reading order for a list
      # that flows down columns, which a row-major /Table tag scrambles.
      # NEVER apply to a genuine data table (real headers, real
      # row/column meaning) — that destroys real semantics; inspect
      # list_tables()'s first_row_text or a page render first.
      # ORDERING CONSTRAINT: run table_to_list BEFORE table_headers.
      # Converting a table removes it from the /Table population, which
      # shifts list_tables() document-order indices for every table after
      # it — so a table_headers index computed against the ORIGINAL
      # list_tables() order is not safe to combine with table_to_list in
      # the same judgment.json. This applies to table_to_figure too (see
      # its own entry below) — table_to_list, table_headers, and
      # table_to_figure indices must ALL be computed against the ORIGINAL
      # list_tables() order; mixing any two of these keys in one run is NOT
      # index-safe; do it in two passes instead — convert first, re-run
      # list_tables() (or remediate_assess) to get fresh indices, THEN
      # promote headers on the remaining tables in a second judgment run.
  "table_to_figure": [{"index": <int>, "alt": "<non-empty string, required>"}],
      # index = 0-based position in pdf_table_weave.list_tables(pdf)
      # document order (same indexing as table_headers / table_to_list).
      # Opt-in ONLY — a table absent from this list is never touched.
      # Converts a MIS-DETECTED table (a visual grid that is actually a
      # chart/plot/diagram, e.g. opendataloader-pdf tagging a 2x2 panel of
      # regression residual plots as /Table) to /Figure. "alt" is REQUIRED
      # and non-empty — a /Figure with no /Alt fails veraPDF 7.3; a spec
      # missing/blank alt is skipped. NEVER apply to a genuine data table —
      # inspect list_tables()'s first_row_text or a page render first.
      # SAME two-pass ordering constraint as table_to_list: converting a
      # table shifts list_tables() document-order indices for tables after
      # it, so table_to_figure, table_to_list, and table_headers indices
      # must ALL be computed against the ORIGINAL list_tables() order —
      # mixing any two of these keys in one judgment.json run is not
      # index-safe; convert in one pass, re-run list_tables() /
      # remediate_assess for fresh indices, then act on the remaining
      # tables in a second run.
      # ENFORCED, not just documented: _validate_judgment raises ValueError
      # if more than one of table_to_list/table_headers/table_to_figure is
      # present and non-empty in the same judgment.json — silently
      # converting the wrong element (e.g. a real data table landing on
      # /Figure because its index shifted) still passes veraPDF, so prose
      # alone was not a safe enough guard.
  "vector_figures": {"<1-based page>": "alt text"},
      # pages whose unmarked vector-drawn charts should be wrapped as ONE
      # tagged /Figure with this alt. Candidates are reported in
      # result.edits.unmarked_vector_pages after a first run.
  "vector_artifacts": [<1-based pages>]
      # pages whose unmarked content is DECORATIVE (slide backgrounds, chrome,
      # nav symbols) — wrapped as /Artifact, hidden from AT. The decorative
      # counterpart of vector_figures; tagged real content is never touched.
}

Usage:
    python remediate_pdf.py --input FILE [--output FILE] [--judgment judgment.json]
                             [--class CLASS] [--skip-ocr] [--fix-contrast] [--json]
Exit: 0 gates passed, 1 gate failure (output at {output}.rejected.pdf), 2 error.

--fix-contrast (opt-in, default off): after the veraPDF + fidelity gates
already passed, runs pdf_contrast.remediate_contrast (WCAG 1.4.3 text-color
recolor) as a final, deliberately appearance-changing step -- see
_run_contrast_step below for why this must run AFTER, not folded into, the
gates above.
"""

from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
import tempfile
from collections import Counter
from pathlib import Path

SCRIPTS_DIR = Path(__file__).resolve().parent
if str(SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPTS_DIR))

import pikepdf

import fidelity_gate
import ocr_text_layer
import pdf_contrast
import pdf_font_embed
import pdf_font_repair
import pdf_table_weave
import pdf_tree_edit
import reading_order
import remediate_assess
import setup_remediation
import verapdf_gate

MAX_ODL_INPUT_MB = 150
ODL_TIMEOUT_S = 300
FLAG_ONLY_CLASSES = {"encrypted", "corrupt", "unsupported", "missing"}

# veraPDF clause prefix for embedded-font-program defects (7.21.x: missing
# character maps / CIDToGIDMap, and 7.21.4.1 missing font program). Verified
# live on census2000manual.pdf: most survive both the E3 metadata patch AND a
# forced E2 re-tag — not fixable by tag-tree edits, only by source-file or
# Acrobat font remediation. Exception: pdf_font_embed.repair_missing_fonts
# (P1+P2) now auto-fixes 7.21.4.1 for substitutable TrueType and Type1
# base-14 fonts (Times/Arial/Courier-family -> bundled Liberation); a
# surviving 7.21.4.1 after embed means no free substitute existed (CFF
# pending P3, or a symbol/dingbat font with no clone).
FONT_DEFECT_PREFIX = "7.21"

# Fidelity gate for the font-embed path ONLY (every other E2/E3 edit stays
# exact-zero via pixel_diff). PURPOSE: un-block clean substitutions that raw
# pixel_diff FALSE-REJECTS — a metric-compatible clone outline shifts every
# glyph edge by a hair of anti-aliasing, ~10-15% raw diff on a dense page,
# while being visually identical. structural_diff counts only pixels whose
# grayscale intensity changes by MORE than FONT_EMBED_DELTA, so it ignores
# that AA noise and flags GROSS page-spanning breakage (missing-glyph .notdef
# boxes, reflow). Clean real substitutions measured <=1.8% worst-page.
# HONEST SCOPE (adversarial review 2026-07-23): structural_diff is
# density-DEPENDENT — on a sparse/heading page a wrong-weight/family
# substitution can read <1% and pass this bound. It is NOT the substitution-
# correctness discriminator. The real correctness backstop is veraPDF, which
# gates_ok ANDs in: 7.21.4.1 (embedded font must define all referenced glyphs
# -> catches missing/wrong glyphs) and 7.21.5 (glyph widths must match
# /Widths -> catches wrong metrics; Liberation is metric-compatible so clean
# substitutions pass, a wrong family/weight fails). Advances come from
# /Widths, so layout is pinned regardless of the substitute outline.
FONT_EMBED_DELTA = 64
FONT_EMBED_STRUCT_PCT = 3.5

# Artifact-status preservation (E2 re-tag path only — see reading_order.py
# module docstring). The re-tag can pull source-/Artifact content (running
# headers/footers) into the new struct tree as real reading-order content;
# neither veraPDF nor the fidelity gate notices (well-formedness + pixels
# only). Detection is a leaked-artifact heuristic, not a general diff:
# GUARD (load-bearing — do not remove): skip the whole pass when the
# source has fewer than this many struct-order runs. An essentially-
# untagged source legitimately gains hundreds of NEW runs from the re-tag
# (real body text that was never in a struct tree before) — every one of
# those looks "leaked" by the page-distribution test below, and without
# this floor the pass would demote the entire document body to /Artifact.
ARTIFACT_CHECK_MIN_SOURCE_RUNS = 50
# A leaked run must be short (running headers are short; a repeated body
# paragraph is not) ...
ARTIFACT_LEAK_MAX_LEN = 80
# ... absent from the source's own struct-order text entirely (a
# heading/label the source legitimately tagged and repeats is not a leak)
# ... and distributed like a running header, not like repeated body text.
#
# NOT a raw occurrence count (deliberately — do not "simplify" this back to
# one). A real regression on taczak.pdf/cohn.pdf: a raw count of >=3 total
# occurrences also flagged bold lead-in words from bulleted lists ("What" in
# "• What is the purpose", "• What is the genre", "• What is the audience";
# "a" in "• a job wanted ad", "• a personal ..."). opendataloader-pdf splits
# a bold lead-in word into its own MCID, and several parallel list items in
# ONE section legitimately reuse the same lead-in word — that clears any
# occurrence floor, and demoting it makes a screen reader read "is the
# purpose" instead of "What is the purpose": worse than the header noise
# this pass exists to remove. The discriminator that separates the two
# shapes: a running header appears on MANY pages, at most ONCE per page. A
# repeated body phrase clusters on ONE page, multiple times. So a leak
# candidate must appear on at least this many DISTINCT pages ...
ARTIFACT_LEAK_MIN_PAGES = 3
# ... and never more than once on any single page (enforced inline in
# _preserve_artifact_status, not a separate constant — this is the actual
# discriminator; MIN_PAGES alone would still pass "What" if it happened to
# also appear on 3 separate pages, so both conditions are required).

_FONT_DEFECT_REASON = (
    "embedded font-program defects (veraPDF 7.21.x: missing character "
    "maps / CIDToGIDMap) cannot be fixed by re-tagging; needs source-file "
    "or Acrobat font remediation"
)


def _check_figure_alt_placeholders(output_path):
    """Post-remediation gate veraPDF cannot express: clause 7.3 only checks
    that /Alt EXISTS, never whether it's useful. A file shipped live
    (2026-07-28) with an auto-tagger's default alt on every figure --
    "image 1", "image 2", ... -- passed veraPDF 106/106 while conveying
    nothing to a screen reader, and OVERWROTE the document's own printed
    captions in the process. Opens the just-saved output_path fresh (the
    E2/E3 branches close their own pdf handle before this point), runs
    pdf_tree_edit.list_figures for the manifest, and returns
    (figure_alt_review, reason) -- reason is None when every alt is real.
    "figures" in the review lists only the placeholder entries (index,
    page, alt, caption_candidate) -- the evidence a judgment.alt_text pass
    needs, never auto-applied here (manifest-for-agent, see
    pdf_tree_edit.py's module docstring)."""
    pdf = pikepdf.open(str(output_path))
    try:
        figures = pdf_tree_edit.list_figures(pdf, path=str(output_path))
    finally:
        pdf.close()
    placeholders = [f for f in figures if f["is_placeholder"]]
    review = {"total": len(figures), "placeholder": len(placeholders), "figures": placeholders}
    if not placeholders:
        return review, None
    reason = (
        f"{len(placeholders)} of {len(figures)} figure(s) have placeholder alt text "
        "(e.g. \"image N\") that a screen reader would read verbatim instead of real "
        "content -- supply real descriptions via judgment.alt_text (see "
        "edits.figure_alt_review for caption candidates extracted from the document)"
    )
    return review, reason


def _validate_judgment(judgment):
    if not isinstance(judgment, dict):
        raise ValueError("judgment must be a JSON object")
    if "alt_text" in judgment and not isinstance(judgment["alt_text"], dict):
        raise ValueError("judgment.alt_text must be an object {index: alt text}")
    if "heading_overrides" in judgment and not isinstance(judgment["heading_overrides"], list):
        raise ValueError("judgment.heading_overrides must be a list")
    if "tables" in judgment and not isinstance(judgment["tables"], list):
        raise ValueError("judgment.tables must be a list")
    if "table_headers" in judgment:
        th = judgment["table_headers"]

        def _valid_hier_list(v):
            # header_rows/header_cols/band_rows: optional, must be a list
            # of int when present (pdf_table_weave.promote_headers's
            # hierarchical-headers path).
            return v is None or (isinstance(v, list) and all(isinstance(i, int) for i in v))

        if not isinstance(th, list) or not all(
                isinstance(s, dict) and isinstance(s.get("index"), int)
                and _valid_hier_list(s.get("header_rows"))
                and _valid_hier_list(s.get("header_cols"))
                and _valid_hier_list(s.get("band_rows"))
                for s in th):
            raise ValueError(
                "judgment.table_headers must be a list of {index: int, "
                "header_row?: bool, header_col?: bool, header_rows?: [int], "
                "header_cols?: [int], band_rows?: [int]}")
    if "table_to_list" in judgment:
        tl = judgment["table_to_list"]
        if not isinstance(tl, list) or not all(
                isinstance(s, dict) and isinstance(s.get("index"), int)
                and s.get("order", "row") in ("row", "column") for s in tl):
            raise ValueError(
                "judgment.table_to_list must be a list of {index: int, "
                "order?: 'row'|'column'}")
    if "table_to_figure" in judgment:
        tf = judgment["table_to_figure"]
        if not isinstance(tf, list) or not all(
                isinstance(s, dict) and isinstance(s.get("index"), int)
                and isinstance(s.get("alt"), str) and s.get("alt", "").strip()
                for s in tf):
            raise ValueError(
                "judgment.table_to_figure must be a list of {index: int, "
                "alt: non-empty str}")
    if "table_merge" in judgment:
        tm = judgment["table_merge"]
        if not isinstance(tm, list) or not all(
                isinstance(s, dict) and isinstance(s.get("indices"), list)
                and len(s["indices"]) >= 2
                and all(isinstance(i, int) for i in s["indices"])
                for s in tm):
            raise ValueError(
                "judgment.table_merge must be a list of {indices: [int, int, "
                "...] (>=2), force?: bool}")
    _table_convert_keys = ("table_to_list", "table_headers", "table_to_figure", "table_merge")
    _present = [k for k in _table_convert_keys if judgment.get(k)]
    if len(_present) > 1:
        raise ValueError(
            f"judgment keys {_present} all index against list_tables() document "
            "order, and table_to_list/table_to_figure/table_merge REMOVE a "
            "/Table (shifting every later index) — mixing more than one of "
            "table_to_list, table_headers, table_to_figure, table_merge in "
            "one judgment.json is not index-safe. Run table_merge first (if "
            "used), re-run list_tables() (or remediate_assess) for fresh "
            "indices, then promote headers or convert the remaining tables "
            "in a second judgment run — merge, re-list, then act, in two "
            "passes.")
    if "vector_figures" in judgment and not isinstance(judgment["vector_figures"], dict):
        raise ValueError("judgment.vector_figures must be an object {page: alt text}")
    if "vector_artifacts" in judgment and not isinstance(judgment["vector_artifacts"], list):
        raise ValueError("judgment.vector_artifacts must be a list of 1-based pages")
    overlap = ({int(k) for k in (judgment.get("vector_figures") or {})}
               & {int(x) for x in (judgment.get("vector_artifacts") or [])})
    if overlap:
        raise ValueError(
            f"pages {sorted(overlap)} appear in BOTH vector_figures and vector_artifacts — "
            "a page's unmarked content is either described or decorative, not both")


def _run_odl(pdf_path, outdir, timeout=ODL_TIMEOUT_S):
    """Sandboxed opendataloader-pdf run: vendored JRE, bounded heap, isolated
    tmp outdir (caller-provided, cleaned up by caller), wall-clock timeout."""
    java = setup_remediation.get_java()
    if not java:
        raise RuntimeError(
            "vendored JRE not installed — run `python scripts/setup_remediation.py --install`"
        )
    env = dict(os.environ)
    env["JAVA_HOME"] = str(java.parent.parent)
    env["PATH"] = f"{java.parent}{os.pathsep}{env.get('PATH', '')}"
    env["JAVA_TOOL_OPTIONS"] = "-Xmx1g"

    # opendataloader-pdf CLI script lives alongside the interpreter in the
    # plugin venv (scripts/requirements.txt installs it there); fall back to
    # PATH lookup for non-venv setups.
    odl_bin = Path(sys.executable).parent / "opendataloader-pdf"
    cmd = [str(odl_bin) if odl_bin.exists() else "opendataloader-pdf",
           "-o", str(outdir), "-f", "json,tagged-pdf", "-q", str(pdf_path)]
    try:
        proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env)
    except subprocess.TimeoutExpired as exc:
        raise RuntimeError(f"opendataloader-pdf timed out after {timeout}s") from exc
    if proc.returncode != 0:
        raise RuntimeError(f"opendataloader-pdf failed (rc={proc.returncode}): {proc.stderr[:500]}")

    stem = Path(pdf_path).stem
    json_path = Path(outdir) / f"{stem}.json"
    tagged_path = Path(outdir) / f"{stem}_tagged.pdf"
    if not json_path.exists() or not tagged_path.exists():
        raise RuntimeError(f"opendataloader-pdf did not produce expected output in {outdir}")
    return json_path, tagged_path


def _derive_title_from_odl(flat_nodes):
    for n in flat_nodes:
        if n.get("pdfua_tag") == "H1" and n.get("content"):
            return n["content"].strip()
    return None


def _derive_title_fallback(pdf, input_path):
    try:
        title = pdf.docinfo.get("/Title")
        if title:
            return str(title)
    except Exception:
        pass
    return Path(input_path).stem.replace("_", " ").replace("-", " ").strip() or "Untitled"


def _resolve_override_index(spec, flat_nodes):
    """Resolve a heading_overrides entry to a walk_elems(pdf, None) index."""
    if "index" in spec:
        return int(spec["index"])
    if "bbox" in spec:
        page = spec.get("page")
        bx0, by0, bx1, by1 = spec["bbox"]
        bc = ((bx0 + bx1) / 2, (by0 + by1) / 2)
        best_i, best_d = None, None
        for i, n in enumerate(flat_nodes):
            if n.get("page") != page or not n.get("bbox"):
                continue
            x0, y0, x1, y1 = n["bbox"]
            c = ((x0 + x1) / 2, (y0 + y1) / 2)
            d = (c[0] - bc[0]) ** 2 + (c[1] - bc[1]) ** 2
            if best_d is None or d < best_d:
                best_d, best_i = d, i
        return best_i
    return None


def _has_font_defects(verapdf_result):
    """True if any failed veraPDF rule is a 7.21.x embedded-font-program
    defect — those are unfixable by re-tagging (see FONT_DEFECT_PREFIX)."""
    summaries = (verapdf_result or {}).get("failed_summaries") or []
    return any(str(s.get("clause", "")).startswith(FONT_DEFECT_PREFIX) for s in summaries)


def _run_e2(work_input, input_path, output_path, judgment, title, lang, notes):
    """Engine E2: opendataloader-pdf in-situ tagging + judgment repairs,
    saved to output_path. Returns (edits, title) — title is resolved here if
    not already supplied by judgment. Shared by the untagged/scanned/hybrid
    direct route and the digital-tagged E3->E2 fallthrough."""
    size_mb = work_input.stat().st_size / (1024 * 1024)
    if size_mb > MAX_ODL_INPUT_MB:
        raise RuntimeError(f"input {size_mb:.1f}MB exceeds {MAX_ODL_INPUT_MB}MB ODL cap")

    edits = {"promotions": [], "alts_set": {}, "tables_flagged": []}
    with tempfile.TemporaryDirectory(prefix="asucb-odl-") as tmp:
        json_path, tagged_path = _run_odl(work_input, tmp)
        flat_nodes = pdf_tree_edit.load_odl_json(json_path)
        pdf = pikepdf.open(str(tagged_path))
        if not title:
            title = _derive_title_from_odl(flat_nodes) or \
                _derive_title_fallback(pdf, input_path)

        edits["alts_set"] = pdf_tree_edit.set_alts(pdf, judgment.get("alt_text") or {})

        all_nodes = pdf_tree_edit.walk_elems(pdf, None)
        for spec in judgment.get("heading_overrides") or []:
            idx = _resolve_override_index(spec, flat_nodes)
            if idx is None or not (0 <= idx < len(all_nodes)):
                notes.append(f"heading_override unresolved: {spec}")
                continue
            pdf_tree_edit.retag(pdf, all_nodes[idx], f"/H{spec['level']}")
        edits["promotions"] = pdf_tree_edit.normalize_heading_levels(pdf)

        edits["tables_flagged"] = pdf_table_weave.weave_tables(
            pdf, judgment.get("tables") or [])["flagged"]
        # table_to_list runs BEFORE promote_headers: converting a table
        # removes it from list_tables() document order, shifting indices
        # for any table after it. See judgment.json schema comment above —
        # mixing both keys against one list_tables() snapshot is not
        # index-safe; callers do it in two passes.
        edits["table_to_list"] = pdf_table_weave.convert_tables_to_lists(
            pdf, judgment.get("table_to_list") or [])
        edits["table_headers"] = pdf_table_weave.promote_headers(
            pdf, judgment.get("table_headers") or [])
        try:
            edits["table_to_figure"] = pdf_table_weave.convert_tables_to_figures(
                pdf, judgment.get("table_to_figure") or [])
        except Exception as exc:  # never let one converter abort the whole run
            notes.append(f"table_to_figure failed: {exc}")
            edits["table_to_figure"] = {"converted": 0, "figures_created": 0, "skipped": []}

        # Unmarked vector-drawn charts (ODL misses them; veraPDF 7.1 fails).
        # Judgment supplies alt per page; remaining pages are reported so the
        # judgment loop can view renders and rerun. Verified live: took a
        # 26-page R-demo from 105/106 to COMPLIANT, pixel-identical.
        edits["vector_figures_wrapped"] = {}
        for page_str, alt in (judgment.get("vector_figures") or {}).items():
            try:
                pdf_tree_edit.wrap_page_vectors_as_figure(pdf, int(page_str), alt)
                edits["vector_figures_wrapped"][page_str] = alt
            except RuntimeError as exc:
                notes.append(f"vector_figures page {page_str}: {exc}")
        edits["vector_artifacts_wrapped"] = {}
        for page_no in (judgment.get("vector_artifacts") or []):
            try:
                n = pdf_tree_edit.artifact_page_unmarked(pdf, int(page_no))
                if n:
                    edits["vector_artifacts_wrapped"][str(page_no)] = n
            except RuntimeError as exc:
                notes.append(f"vector_artifacts page {page_no}: {exc}")
        edits["unmarked_vector_pages"] = pdf_tree_edit.detect_unmarked_paint_pages(pdf)

        # Automatic (non-judgment) counterpart to the two judgment-driven
        # passes above: every remaining depth-0 unmarked run and every
        # orphaned MCID (tagged in the content stream but unreachable from
        # the struct tree) on the page, wherever it wasn't already
        # resolved by a vector_figures/vector_artifacts judgment call.
        edits["unreachable_content"] = pdf_tree_edit.resolve_unreachable_content(pdf)

        edits["font_embed"] = pdf_font_embed.repair_missing_fonts(pdf)
        edits["font_width_fix"] = pdf_font_embed.fix_degenerate_widths(pdf)
        font_repair = pdf_font_repair.repair_fonts(pdf)
        if any(font_repair.values()):
            edits["font_repair"] = font_repair

        pdf_tree_edit.set_metadata(pdf, title, lang)
        edits["links_wrapped"] = pdf_tree_edit.wrap_link_annotations(pdf, flat_nodes)
        pdf_tree_edit.tag_link_annotations(pdf)
        mapped = pdf_tree_edit.ensure_role_map(pdf)
        if mapped:
            notes.append(f"role-mapped non-standard structure types: {mapped}")
        edits["rolemap_cycles_removed"] = pdf_tree_edit.remove_circular_rolemap(pdf)

        edits["artifacts_preserved"] = _preserve_artifact_status(pdf, input_path, notes)

        pdf.save(str(output_path))
        pdf.close()
    return edits, title


def _preserve_artifact_status(pdf, input_path, notes):
    """Detect and demote source-/Artifact content (running headers/footers)
    that the E2 re-tag pulled into the new struct tree as real reading-
    order content — see reading_order.py module docstring and the
    ARTIFACT_CHECK_* constants above for the guard rationale. Returns the
    edits.artifacts_preserved payload (never silently empty when something
    was demoted)."""
    try:
        before_runs = reading_order.struct_order_text(str(input_path))
    except Exception as exc:  # noqa: BLE001 - never abort a run over this check
        notes.append(f"artifact-preservation check skipped: could not read source: {exc}")
        return {"checked": False}

    if len(before_runs) < ARTIFACT_CHECK_MIN_SOURCE_RUNS:
        return {"checked": False, "reason": "source has too few struct-order runs "
                f"({len(before_runs)} < {ARTIFACT_CHECK_MIN_SOURCE_RUNS}) — source is "
                "essentially untagged, every new run is legitimate body text"}

    before_set = set(before_runs)
    output_runs = reading_order.struct_order_runs(pdf)

    # Page-distribution test (see the ARTIFACT_LEAK_MIN_PAGES comment above
    # for why this replaced a raw occurrence count): per-text {page: count}.
    per_text_pages: dict = {}
    for page, _mcid, text in output_runs:
        if page is None:
            continue
        per_text_pages.setdefault(text, Counter())[page] += 1

    leaked_texts = {
        text for text, page_counts in per_text_pages.items()
        if len(text) <= ARTIFACT_LEAK_MAX_LEN
        and text not in before_set
        and len(page_counts) >= ARTIFACT_LEAK_MIN_PAGES  # on >=N distinct pages ...
        and max(page_counts.values()) <= 1               # ... at most once per page
    }
    if not leaked_texts:
        return {"checked": True, "demoted_sequences": 0}

    targets: dict = {}
    for page, mcid, text in output_runs:
        if text in leaked_texts and page is not None:
            targets.setdefault(page, []).append(mcid)
    demoted = pdf_tree_edit.demote_mcids_to_artifact(pdf, targets)
    result = {
        "checked": True, "demoted_sequences": demoted,
        "texts": sorted(leaked_texts), "pages": {str(k): v for k, v in targets.items()},
    }
    if demoted:
        notes.append(
            f"artifact status preserved: demoted {demoted} leaked struct-tree "
            f"sequence(s) back to /Artifact — {sorted(leaked_texts)}")
    return result


def _run_contrast_step(output_path, notes):
    """Opt-in post-gate contrast recolor (WCAG 1.4.3, --fix-contrast). Runs
    ONLY after the veraPDF + fidelity gates already passed at output_path --
    pdf_contrast.remediate_contrast rewrites text fill-color operators by
    design, which the exact-zero pixel_diff fidelity gate above would wrongly
    reject as a regression. So this is a deliberate final appearance change,
    never re-checked by pixel_diff. It re-runs veraPDF on the recolored file
    as its own safety net -- the recolor only rewrites nonstroking-color
    operand bytes ahead of an existing text-show op, so it shouldn't touch
    structure, but verify rather than assume. Returns (contrast_result,
    new_vera_or_None); the caller replaces result["verapdf"] with new_vera
    only when the recolor is actually kept."""
    tmp_path = output_path.with_name(output_path.name + ".contrast-tmp.pdf")
    try:
        contrast_result = pdf_contrast.remediate_contrast(str(output_path), str(tmp_path))
        vera_after = verapdf_gate.run_verapdf(str(tmp_path))
        if vera_after.get("compliant"):
            os.replace(str(tmp_path), str(output_path))
            notes.append(
                f"contrast: recolored {len(contrast_result.get('fixed') or [])} low-contrast "
                "text span(s) (appearance changed by design; not subject to the pixel-identity gate)")
            return contrast_result, vera_after
        notes.append(
            "contrast: recolor regressed veraPDF compliance -- discarded, kept tagged-only output")
        contrast_result = dict(contrast_result)
        contrast_result["skipped"] = "veraPDF regressed after recolor"
        return contrast_result, None
    finally:
        tmp_path.unlink(missing_ok=True)


def remediate_pdf(input_path, output_path=None, judgment=None, cls=None, skip_ocr=False,
                   fix_contrast=False):
    input_path = Path(input_path)
    if not input_path.exists():
        raise RuntimeError(f"input not found: {input_path}")
    output_path = Path(output_path) if output_path else \
        input_path.with_name(f"{input_path.stem}.remediated.pdf")
    judgment = judgment or {}
    _validate_judgment(judgment)

    if cls is None:
        cls = remediate_assess.classify_file(input_path)["class"]
    if cls in FLAG_ONLY_CLASSES:
        raise RuntimeError(f"refusing class={cls!r} — flag-only Assess route, never remediated")

    notes = []
    work_input = input_path
    tmp_ocr_path = None
    if cls in ("scanned", "hybrid") and not skip_ocr:
        tmp_ocr_path = output_path.with_name(f".{input_path.stem}.ocr-tmp.pdf")
        ocr_text_layer.add_text_layer(str(input_path), str(tmp_ocr_path))
        work_input = tmp_ocr_path
        notes.append("OCR text layer applied before tagging (scanned/hybrid route)")

    title = judgment.get("doc_title")
    lang = judgment.get("language", "en-US")
    edits = {"promotions": [], "alts_set": {}, "tables_flagged": []}
    needs_manual = False
    manual_reason = None

    try:
        if cls == "digital-tagged":
            engine = "E3-metadata-patch"
            pdf = pikepdf.open(str(work_input))
            if not title:
                title = _derive_title_fallback(pdf, input_path)
            edits["font_embed"] = pdf_font_embed.repair_missing_fonts(pdf)
            edits["font_width_fix"] = pdf_font_embed.fix_degenerate_widths(pdf)
            font_repair = pdf_font_repair.repair_fonts(pdf)
            if any(font_repair.values()):
                edits["font_repair"] = font_repair
            edits["unreachable_content"] = pdf_tree_edit.resolve_unreachable_content(pdf)
            pdf_tree_edit.set_metadata(pdf, title, lang)
            edits["links_wrapped"] = pdf_tree_edit.wrap_link_annotations(pdf)
            pdf_tree_edit.tag_link_annotations(pdf)
            edits["rolemap_cycles_removed"] = pdf_tree_edit.remove_circular_rolemap(pdf)
            edits["artifacts_preserved"] = _preserve_artifact_status(pdf, input_path, notes)
            pdf.save(str(output_path))
            pdf.close()

            vera = verapdf_gate.run_verapdf(str(output_path))
            if not vera.get("compliant"):
                if _has_font_defects(vera):
                    needs_manual = True
                    manual_reason = _FONT_DEFECT_REASON
                else:
                    notes.append("E3 metadata patch insufficient — falling through to E2 re-tag")
                    engine = "E3-metadata-patch->E2-insitu"
                    edits, title = _run_e2(work_input, input_path, output_path, judgment,
                                            title, lang, notes)
                    vera = verapdf_gate.run_verapdf(str(output_path))
                    if not vera.get("compliant"):
                        needs_manual = True
                        manual_reason = "structural remediation incomplete after re-tag; see failed_summaries"
        else:
            engine = "E2-insitu"
            edits, title = _run_e2(work_input, input_path, output_path, judgment,
                                    title, lang, notes)
            vera = verapdf_gate.run_verapdf(str(output_path))
    finally:
        if tmp_ocr_path:
            Path(tmp_ocr_path).unlink(missing_ok=True)

    # Shared gate: covers the E2-insitu direct route (untagged/scanned/hybrid
    # class), which never went through the digital-tagged branch's own
    # font-defect check above. Guarded by "not needs_manual" so it never
    # overrides a reason already set on the E3/fallthrough branches.
    if not needs_manual and not vera.get("compliant") and _has_font_defects(vera):
        needs_manual = True
        manual_reason = _FONT_DEFECT_REASON

    # Placeholder-alt gate (independent of veraPDF/font-defect state above —
    # this is a NEW gate that will reject files veraPDF passes; see
    # _check_figure_alt_placeholders' docstring). Runs on whatever landed at
    # output_path regardless of which branch produced it.
    edits["figure_alt_review"], alt_reason = _check_figure_alt_placeholders(output_path)
    if alt_reason:
        manual_reason = f"{manual_reason}; {alt_reason}" if needs_manual else alt_reason
        needs_manual = True

    # Bounded fidelity gate (plan §3): only the font-embed path gets slack —
    # a metric-compatible substitute outline is sub-perceptually different,
    # not pixel-zero. Every other edit stays exact-zero (pixel_diff default
    # threshold_pct=0.0). The font-embed path uses structural_diff instead of
    # raw pixel_diff: pixel_diff can't discriminate correct vs wrong font
    # substitution on text-dense pages (see FONT_EMBED_DELTA/STRUCT_PCT
    # comment above). Never claim "pixel-identical" on this path; always
    # report the actual worst_pct so a real layout break (>bound) still fails.
    # fix_degenerate_widths is NOT included here: it patches the wx operand
    # inside the original Type1 charstrings in place (outlines untouched),
    # so it stays on the exact-zero pixel_diff gate below like every other
    # non-font-substituting edit — see its module docstring.
    font_embed_count = edits.get("font_embed", {}).get("embedded", 0)
    if font_embed_count > 0:
        fidelity = fidelity_gate.structural_diff(
            str(input_path), str(output_path),
            delta=FONT_EMBED_DELTA, threshold_pct=FONT_EMBED_STRUCT_PCT)
        notes.append(
            f"fonts embedded; structural diff {fidelity.get('worst_pct')}% "
            f"(|Δ|>{FONT_EMBED_DELTA}, below {FONT_EMBED_STRUCT_PCT}% structural bound)")
    else:
        fidelity = fidelity_gate.pixel_diff(str(input_path), str(output_path))
    gates_ok = bool(vera.get("compliant")) and bool(fidelity.get("identical"))

    # --fix-contrast (opt-in, default off): runs ONLY after gates_ok is
    # already True -- see _run_contrast_step's docstring for why contrast
    # recolor must never be subject to the pixel_diff fidelity gate above.
    # gates_ok itself is never affected by the contrast outcome (a
    # veraPDF regression here is discarded, not fatal to the whole run).
    contrast_result = None
    if fix_contrast and gates_ok:
        contrast_result, vera_after_contrast = _run_contrast_step(output_path, notes)
        if vera_after_contrast is not None:
            vera = vera_after_contrast

    result = {
        "ok": gates_ok, "engine": engine, "edits": edits, "notes": notes,
        "verapdf": vera, "fidelity": fidelity,
        "needs_manual": needs_manual, "manual_reason": manual_reason,
    }
    if contrast_result is not None:
        result["contrast"] = contrast_result
    if font_embed_count > 0:
        result["font_embed_fidelity"] = {
            "metric": "structural", "delta": FONT_EMBED_DELTA,
            "threshold_pct": FONT_EMBED_STRUCT_PCT, "worst_pct": fidelity.get("worst_pct")}
    if gates_ok:
        result["output"] = str(output_path)
    else:
        rejected = output_path.with_name(output_path.name + ".rejected.pdf")
        output_path.rename(rejected)
        result["output"] = None
        result["rejected_output"] = str(rejected)
    return result


def main():
    parser = argparse.ArgumentParser(description="Engine E2/E3 PDF remediation orchestrator")
    parser.add_argument("--input", required=True)
    parser.add_argument("--output")
    parser.add_argument("--judgment", help="Path to judgment.json")
    parser.add_argument("--class", dest="cls", help="Override Assess class")
    parser.add_argument("--skip-ocr", action="store_true")
    parser.add_argument("--fix-contrast", action="store_true",
                         help="Opt-in WCAG 1.4.3 text-contrast recolor, run after the "
                              "veraPDF + fidelity gates pass (appearance-changing; default off)")
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()

    judgment = {}
    if args.judgment:
        try:
            judgment = json.loads(Path(args.judgment).read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as exc:
            print(f"ERROR: could not read --judgment {args.judgment}: {exc}", file=sys.stderr)
            sys.exit(2)

    try:
        result = remediate_pdf(args.input, args.output, judgment, args.cls, args.skip_ocr,
                                fix_contrast=args.fix_contrast)
    except (RuntimeError, ValueError) as exc:
        if args.json:
            print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
        else:
            print(f"ERROR: {exc}", file=sys.stderr)
        sys.exit(2)

    if args.json:
        print(json.dumps(result, indent=2))
    else:
        status = "OK" if result["ok"] else "GATE FAILURE"
        print(f"{status}: engine={result['engine']} output={result.get('output') or result.get('rejected_output')}")
    sys.exit(0 if result["ok"] else 1)


if __name__ == "__main__":
    main()
