"""Tests for scripts/pdf_contrast.py (WCAG 1.4.3 text-contrast remediation)."""
import colorsys
import os
import re
import sys

import fitz  # PyMuPDF

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
from wcag_checks import contrast_ratio
from pdf_contrast import darken_to_ratio, _classify_occurrence, remediate_contrast, _darken_multi


def test_darken_math_reaches_target_and_preserves_hue():
    green = (61, 179, 112)
    assert contrast_ratio(green, (255, 255, 255)) < 4.5

    new_rgb = darken_to_ratio(green, target_ratio=4.5)
    assert contrast_ratio(new_rgb, (255, 255, 255)) >= 4.5

    h_before, _, _ = colorsys.rgb_to_hls(*(c / 255.0 for c in green))
    h_after, _, _ = colorsys.rgb_to_hls(*(c / 255.0 for c in new_rgb))
    assert abs(h_before - h_after) < 0.02  # hue preserved within tolerance


def test_darken_math_leaves_passing_color_unchanged():
    black = (0, 0, 0)
    assert contrast_ratio(black, (255, 255, 255)) >= 4.5
    assert darken_to_ratio(black, target_ratio=4.5) == black


def test_classifier_text_vs_path():
    text_stream = b"0.2 0.7 0.4 rg BT /F1 12 Tf (Hello) Tj ET"
    m_end = text_stream.index(b"rg") + len(b"rg")
    assert _classify_occurrence(text_stream, m_end) == "text"

    path_stream = b"0.2 0.7 0.4 rg 10 10 100 100 re f"
    m_end = path_stream.index(b"rg") + len(b"rg")
    assert _classify_occurrence(path_stream, m_end) == "path"


def test_classifier_ignores_path_letters_inside_kerned_tj_fragment():
    """A kerned TJ array often splits a word across fragments, e.g.
    ``[ (Incorporate f) 12.1 (eedback) ] TJ`` -- the lone ``f`` sitting inside
    the paren-delimited string must never be mistaken for the path-paint
    operator ``f``. Regression for a real chapter10.pdf false-unfixable."""
    stream = (
        b"0.55 0 0.33 0.4 k\nBT\n/GS1 gs\n/T1_0 1 Tf\n12 0 0 12 48 579 Tm\n"
        b"[ (Incorporate f) 12.1 (eedback fr) 20.1 (om exper) -33.9 (t coaches) ] TJ\nET"
    )
    m_end = stream.index(b"k") + 1
    assert _classify_occurrence(stream, m_end) == "text"


def _make_test_pdf(path):
    doc = fitz.open()
    page = doc.new_page()
    # Low-contrast green heading (fails 4.5:1 on white).
    page.insert_text((50, 100), "Low Contrast Heading", color=(61 / 255, 179 / 255, 112 / 255), fontsize=18)
    # High-contrast black body text.
    page.insert_text((50, 200), "Normal body text.", color=(0, 0, 0), fontsize=12)
    doc.save(path)
    doc.close()


def test_integration_fixes_heading_leaves_body_untouched(tmp_path):
    input_path = str(tmp_path / "sample.pdf")
    output_path = str(tmp_path / "sample_fixed.pdf")
    _make_test_pdf(input_path)

    before_doc = fitz.open(input_path)
    before_text = before_doc[0].get_text("text")
    before_doc.close()

    result = remediate_contrast(input_path, output_path, target_ratio=4.5)

    assert result["ok"] is True
    assert result["unfixable"] == []
    assert len(result["fixed"]) == 1
    fixed = result["fixed"][0]
    assert fixed["ratio_after"] >= 4.5

    after_doc = fitz.open(output_path)
    after_text = after_doc[0].get_text("text")
    span_colors = []
    for block in after_doc[0].get_text("dict")["blocks"]:
        for line in block.get("lines", []):
            for span in line.get("spans", []):
                color_int = span["color"]
                rgb = ((color_int >> 16) & 255, (color_int >> 8) & 255, color_int & 255)
                span_colors.append((span["text"], rgb))
    after_doc.close()

    # Text content is byte-identical; only color changed.
    assert after_text == before_text

    black_spans = [c for t, c in span_colors if "body" in t.lower()]
    assert all(c == (0, 0, 0) for c in black_spans)

    heading_spans = [c for t, c in span_colors if "Heading" in t]
    assert all(contrast_ratio(c, (255, 255, 255)) >= 4.5 for c in heading_spans)


def test_whitespace_only_span_not_counted(tmp_path):
    input_path = str(tmp_path / "spacer.pdf")
    output_path = str(tmp_path / "spacer_fixed.pdf")
    doc = fitz.open()
    page = doc.new_page()
    # Whitespace-only low-contrast "spacer" span.
    page.insert_text((50, 100), "   ", color=(1, 1, 0.9), fontsize=12)
    # One real high-contrast span so the page isn't empty.
    page.insert_text((50, 200), "Real text.", color=(0, 0, 0), fontsize=12)
    doc.save(input_path)
    doc.close()

    result = remediate_contrast(input_path, output_path, target_ratio=4.5)
    assert result["fixed"] == []
    assert result["unfixable"] == []
    assert result["ok"] is True


def test_unfixable_honesty_when_color_op_cannot_be_matched(tmp_path, monkeypatch):
    input_path = str(tmp_path / "unfixable.pdf")
    output_path = str(tmp_path / "unfixable_fixed.pdf")
    _make_test_pdf(input_path)

    import pdf_contrast

    # Force the rewriter to never find a text-show occurrence, simulating a
    # color set via scn/pattern/ICC separation that the rewriter can't locate.
    monkeypatch.setattr(pdf_contrast, "_classify_occurrence", lambda content, end: "unknown")

    result = pdf_contrast.remediate_contrast(input_path, output_path, target_ratio=4.5)

    assert result["ok"] is False
    assert len(result["unfixable"]) == 1
    assert result["unfixable"][0]["reason"]
    assert result["fixed"] == []


def test_cmyk_k_operator_is_fixed(tmp_path):
    """A CMYK `k` fill op whose naive-formula RGB diverges from PyMuPDF's own
    CMYK->RGB conversion must still be detected, matched, and rewritten --
    regression for the rewrite loop using ``255*(1-c)*(1-k)`` while detection
    used fitz's conversion, causing genuinely-fixable CMYK headings to be
    misclassified as unfixable (see PR fix/pdf-contrast-cmyk)."""
    input_path = str(tmp_path / "cmyk_kop.pdf")
    output_path = str(tmp_path / "cmyk_kop_fixed.pdf")

    doc = fitz.open()
    page = doc.new_page()
    # Placeholder RGB text; will be forced to a real k-op below.
    page.insert_text((72, 72), "CMYK heading", fontsize=20, color=(0, 0, 0))
    xref = page.get_contents()[0]
    raw = doc.xref_stream(xref)
    # 0.16 0.74 0 0 k -> fitz reports (206, 100, 166), which fails 4.5:1 on white.
    raw = re.sub(rb"[-0-9.]+ [-0-9.]+ [-0-9.]+ rg", b"0.16 0.74 0 0 k", raw)
    assert b" k" in raw and b" rg" not in raw  # sanity: forced a real k-op
    doc.update_stream(xref, raw)
    doc.save(input_path)
    doc.close()

    before_doc = fitz.open(input_path)
    before_rgb = None
    for block in before_doc[0].get_text("dict")["blocks"]:
        for line in block.get("lines", []):
            for span in line.get("spans", []):
                color_int = span["color"]
                before_rgb = ((color_int >> 16) & 255, (color_int >> 8) & 255, color_int & 255)
    before_doc.close()
    assert before_rgb == (206, 100, 166)
    assert contrast_ratio(before_rgb, (255, 255, 255)) < 4.5

    result = remediate_contrast(input_path, output_path, target_ratio=4.5)

    assert result["ok"] is True
    assert result["unfixable"] == []
    assert len(result["fixed"]) == 1
    fixed = result["fixed"][0]
    assert fixed["from_rgb"] == [206, 100, 166]
    assert fixed["ratio_after"] >= 4.5

    after_doc = fitz.open(output_path)
    span_colors = []
    for block in after_doc[0].get_text("dict")["blocks"]:
        for line in block.get("lines", []):
            for span in line.get("spans", []):
                color_int = span["color"]
                rgb = ((color_int >> 16) & 255, (color_int >> 8) & 255, color_int & 255)
                span_colors.append(rgb)
    after_doc.close()
    assert all(contrast_ratio(c, (255, 255, 255)) >= 4.5 for c in span_colors)


def test_gray_g_operator_is_fixed(tmp_path):
    """A single-number gray `g` fill op (not `rg`) must still be detected,
    matched, and rewritten -- regression for a regex that required >=2
    leading numbers and so silently missed true `g` operators."""
    input_path = str(tmp_path / "gray_gop.pdf")
    output_path = str(tmp_path / "gray_gop_fixed.pdf")

    doc = fitz.open()
    page = doc.new_page()
    page.insert_text((72, 72), "Gray heading", fontsize=20, color=(0.59, 0.59, 0.59))
    xref = page.get_contents()[0]
    raw = doc.xref_stream(xref)
    raw = re.sub(rb"[-0-9.]+ [-0-9.]+ [-0-9.]+ rg", b"0.59 g", raw)
    assert b" g" in raw and b" rg" not in raw  # sanity: forced a real g-op
    doc.update_stream(xref, raw)
    doc.save(input_path)
    doc.close()

    result = remediate_contrast(input_path, output_path, target_ratio=4.5)

    assert result["ok"] is True
    assert result["unfixable"] == []
    assert len(result["fixed"]) == 1
    fixed = result["fixed"][0]
    assert fixed["from_rgb"] == [150, 150, 150]
    assert fixed["ratio_after"] >= 4.5

    after_doc = fitz.open(output_path)
    span_colors = []
    for block in after_doc[0].get_text("dict")["blocks"]:
        for line in block.get("lines", []):
            for span in line.get("spans", []):
                color_int = span["color"]
                rgb = ((color_int >> 16) & 255, (color_int >> 8) & 255, color_int & 255)
                span_colors.append(rgb)
    after_doc.close()
    assert all(contrast_ratio(c, (255, 255, 255)) >= 4.5 for c in span_colors)


# --- Background-aware v2 tests -------------------------------------------------

PINK_FAIL_FILL = (206, 100, 166)  # real chapter10.pdf callout-bar pink; white-on-this = 3.50
DARK_PASS_FILL = (40, 10, 30)     # dark enough that white-on-this already clears 5.0


def _page_fill_colors(page):
    return [tuple(round(c * 255) for c in d["fill"]) for d in page.get_drawings() if d.get("fill")]


def _span_colors(page):
    out = []
    for block in page.get_text("dict")["blocks"]:
        for line in block.get("lines", []):
            for span in line.get("spans", []):
                color_int = span["color"]
                rgb = ((color_int >> 16) & 255, (color_int >> 8) & 255, color_int & 255)
                out.append((span["text"], rgb))
    return out


def _make_callout_bar_pdf(path, fill_rgb01, text="Explore 10.1", text_rgb01=(1, 1, 1)):
    doc = fitz.open()
    page = doc.new_page()
    page.draw_rect(fitz.Rect(50, 50, 250, 90), color=None, fill=fill_rgb01, fill_opacity=1)
    page.insert_text((60, 75), text, color=text_rgb01, fontsize=14)
    doc.save(path)
    doc.close()


def test_darken_multi_reaches_target_against_all_backgrounds():
    text = (200, 200, 200)
    backgrounds = [(255, 255, 255), (150, 150, 150)]
    assert any(contrast_ratio(text, bg) < 5.0 for bg in backgrounds)
    new_rgb = _darken_multi(text, backgrounds, 5.0)
    assert new_rgb is not None
    assert all(contrast_ratio(new_rgb, bg) >= 5.0 for bg in backgrounds)


def test_darken_multi_returns_none_when_unreachable():
    # A target above the theoretical max (21:1) can never be satisfied.
    assert _darken_multi((200, 200, 200), [(255, 255, 255)], 25.0) is None


def test_bg_regression_white_on_failing_fill_darkens_fill_not_text(tmp_path):
    """The exact defect-1/defect-2 bug: white text on a colored fill that
    FAILS against the real (pink) background must never be 'fixed' by
    darkening the text -- that degrades contrast against the fill from
    3.50 to as low as 1.30. The fill must be darkened instead; text color
    is untouched."""
    input_path = str(tmp_path / "bar.pdf")
    output_path = str(tmp_path / "bar_fixed.pdf")
    _make_callout_bar_pdf(input_path, tuple(c / 255 for c in PINK_FAIL_FILL))

    before_doc = fitz.open(input_path)
    assert contrast_ratio((255, 255, 255), PINK_FAIL_FILL) < 4.5
    before_doc.close()

    result = remediate_contrast(input_path, output_path)

    assert result["fixed"] == []  # text was NEVER touched
    assert len(result["fills_darkened"]) == 1
    fill_fix = result["fills_darkened"][0]
    assert fill_fix["from_rgb"] == list(PINK_FAIL_FILL)
    assert fill_fix["ratio_after"] >= result["effective_target_ratio"]

    after_doc = fitz.open(output_path)
    after_page = after_doc[0]
    span_colors = _span_colors(after_page)
    assert all(rgb == (255, 255, 255) for _t, rgb in span_colors)  # text still pure white
    new_fills = _page_fill_colors(after_page)
    assert len(new_fills) == 1
    assert new_fills[0] == tuple(fill_fix["to_rgb"])
    assert new_fills[0] != PINK_FAIL_FILL  # fill actually got darker
    # Never turned into the old tool's degraded grey-on-pink outcome.
    assert contrast_ratio((118, 118, 118), new_fills[0]) != contrast_ratio((255, 255, 255), new_fills[0])
    assert contrast_ratio((255, 255, 255), new_fills[0]) >= result["effective_target_ratio"]
    after_doc.close()


def test_bg_already_passing_white_on_fill_left_untouched(tmp_path):
    """White text on a fill that ALREADY clears the effective target must
    never be touched -- neither the text nor the fill changes."""
    input_path = str(tmp_path / "bar_ok.pdf")
    output_path = str(tmp_path / "bar_ok_fixed.pdf")
    _make_callout_bar_pdf(input_path, tuple(c / 255 for c in DARK_PASS_FILL))
    assert contrast_ratio((255, 255, 255), DARK_PASS_FILL) >= 5.0

    result = remediate_contrast(input_path, output_path)

    assert result["fixed"] == []
    assert result["fills_darkened"] == []
    assert result["unfixable"] == []
    assert result["already_passing"] >= 1
    assert result["ok"] is True

    after_doc = fitz.open(output_path)
    after_page = after_doc[0]
    assert _page_fill_colors(after_page)[0] == DARK_PASS_FILL  # untouched
    assert all(rgb == (255, 255, 255) for _t, rgb in _span_colors(after_page))
    after_doc.close()


def test_bg_detection_text_over_image_is_unfixable(tmp_path):
    """Text sitting over an image background can't be safely recolored --
    the tool must never guess; it must report unfixable with a reason."""
    input_path = str(tmp_path / "img_bg.pdf")
    output_path = str(tmp_path / "img_bg_fixed.pdf")

    doc = fitz.open()
    page = doc.new_page()
    pix = fitz.Pixmap(fitz.csRGB, 8, 8, bytes([40, 10, 30] * 64), 0)
    page.insert_image(fitz.Rect(50, 50, 250, 90), pixmap=pix)
    page.insert_text((60, 75), "Over image", color=(0.6, 0.6, 0.6), fontsize=14)
    doc.save(input_path)
    doc.close()

    result = remediate_contrast(input_path, output_path)

    assert result["fixed"] == []
    assert result["fills_darkened"] == []
    assert result["ok"] is False
    assert len(result["unfixable"]) == 1
    assert "image" in result["unfixable"][0]["reason"]


def test_margin_default_effective_target_is_5_not_4_5(tmp_path):
    """Even a caller-supplied target_ratio of 4.5 gets a 5.0 floor by
    default (safety_margin) -- fixed spans land with headroom, not on the
    4.5 boundary a third-party checker might still flag."""
    input_path = str(tmp_path / "margin.pdf")
    output_path = str(tmp_path / "margin_fixed.pdf")
    doc = fitz.open()
    page = doc.new_page()
    page.insert_text((50, 100), "Boundary-ish heading", color=(61 / 255, 179 / 255, 112 / 255), fontsize=18)
    doc.save(input_path)
    doc.close()

    result = remediate_contrast(input_path, output_path, target_ratio=4.5)
    assert result["effective_target_ratio"] == 5.0
    assert len(result["fixed"]) == 1
    assert result["fixed"][0]["ratio_after"] >= 5.0


def test_fill_share_two_bars_same_color_both_darken_uniformly(tmp_path):
    """AC-fill-share: two bars using the exact same design-system fill color
    both get recolored from a single computed fix."""
    input_path = str(tmp_path / "two_bars.pdf")
    output_path = str(tmp_path / "two_bars_fixed.pdf")
    doc = fitz.open()
    page = doc.new_page()
    fill01 = tuple(c / 255 for c in PINK_FAIL_FILL)
    page.draw_rect(fitz.Rect(50, 50, 250, 90), color=None, fill=fill01, fill_opacity=1)
    page.insert_text((60, 75), "Explore 10.1", color=(1, 1, 1), fontsize=14)
    page.draw_rect(fitz.Rect(50, 150, 250, 190), color=None, fill=fill01, fill_opacity=1)
    page.insert_text((60, 175), "Explore 10.2", color=(1, 1, 1), fontsize=14)
    doc.save(input_path)
    doc.close()

    result = remediate_contrast(input_path, output_path)

    assert result["fixed"] == []
    assert len(result["fills_darkened"]) == 1  # one fix, shared by both bars
    to_rgb = tuple(result["fills_darkened"][0]["to_rgb"])

    after_doc = fitz.open(output_path)
    fills = _page_fill_colors(after_doc[0])
    assert len(fills) == 2
    assert all(f == to_rgb for f in fills)
    after_doc.close()


def test_honesty_unreachable_target_marks_unfixable_not_fake_pass(tmp_path):
    """A target ratio beyond the theoretical maximum (21:1) can never be
    reached by darkening alone -- must be reported unfixable, never a fake
    'fixed' entry with a ratio_after that doesn't actually clear it."""
    input_path = str(tmp_path / "impossible.pdf")
    output_path = str(tmp_path / "impossible_fixed.pdf")
    doc = fitz.open()
    page = doc.new_page()
    page.insert_text((50, 100), "Impossible target", color=(61 / 255, 179 / 255, 112 / 255), fontsize=18)
    doc.save(input_path)
    doc.close()

    result = remediate_contrast(input_path, output_path, target_ratio=25.0, safety_margin=25.0)

    assert result["fixed"] == []
    assert result["ok"] is False
    assert len(result["unfixable"]) == 1
    assert "cannot reach target" in result["unfixable"][0]["reason"]


def test_verify_zero_residual_and_text_byte_identical(tmp_path):
    """AC-verify: after a mixed fix (fill-bar + plain heading on white),
    re-detecting real backgrounds finds zero residual failures, and the
    extracted text content is unchanged."""
    input_path = str(tmp_path / "mixed.pdf")
    output_path = str(tmp_path / "mixed_fixed.pdf")
    doc = fitz.open()
    page = doc.new_page()
    page.draw_rect(fitz.Rect(50, 50, 250, 90), color=None, fill=tuple(c / 255 for c in PINK_FAIL_FILL), fill_opacity=1)
    page.insert_text((60, 75), "Explore 10.1", color=(1, 1, 1), fontsize=14)
    page.insert_text((50, 200), "Low Contrast Heading", color=(61 / 255, 179 / 255, 112 / 255), fontsize=18)
    page.insert_text((50, 300), "Normal body text.", color=(0, 0, 0), fontsize=12)
    doc.save(input_path)
    doc.close()

    before_doc = fitz.open(input_path)
    before_text = before_doc[0].get_text("text")
    before_doc.close()

    result = remediate_contrast(input_path, output_path)
    assert result["ok"] is True
    assert len(result["fills_darkened"]) == 1
    assert len(result["fixed"]) == 1  # the green heading only; bar text untouched

    after_doc = fitz.open(output_path)
    after_page = after_doc[0]
    after_text = after_page.get_text("text")
    assert after_text == before_text

    fills = _page_fill_colors(after_page)
    span_colors = _span_colors(after_page)
    fill_rgb = fills[0]
    for text, rgb in span_colors:
        if "Explore" in text:
            bg = fill_rgb
        else:
            bg = (255, 255, 255)
        assert contrast_ratio(rgb, bg) >= result["effective_target_ratio"]
    after_doc.close()
