"""Tests for the capture-drop repair loop.

Detection and acceptance are deterministic and hold all authority; the
advisor only proposes. These tests drive `repair_capture` with a mock
advisor and a monkeypatched Camelot retry, asserting the gate's verdicts:
a retry replaces the original only when it provably captures more.
"""

from __future__ import annotations

import asyncio
from pathlib import Path

from pytest import MonkeyPatch

from quber.agents.capture_advisor import MockCaptureAdvisor, RetryAdvice
from quber.core.extractors.camelot import recapture
from quber.core.extractors.camelot.acquire import CamelotCandidate, grid_to_markdown
from quber.core.extractors.camelot.recapture import dropped_numeric_keys, repair_capture

PAGE_H = 100.0
# Printed line inside the table: label + two values; the grid captured only one.
WORDS = [
    (10.0, 30.0, 30.0, 34.0, "Total"),
    (40.0, 30.0, 50.0, 34.0, "1,637"),
    (60.0, 30.0, 70.0, 34.0, "2,207,229"),
]


def candidate(cells) -> CamelotCandidate:
    return CamelotCandidate(
        candidate_id="recovery-p1-o1",
        flavor="stream",
        page=1,
        bbox=(0.0, 20.0, 100.0, 90.0),  # bottom-left points -> top-left band y 10..80
        accuracy=95.0,
        cells=cells,
        markdown=grid_to_markdown(cells),
    )


def patch_io(monkeypatch: MonkeyPatch, retry: CamelotCandidate | None):
    monkeypatch.setattr(recapture, "page_words", lambda source, page: (100.0, PAGE_H, list(WORDS)))
    monkeypatch.setattr(recapture, "crop_region_png", lambda image, region, dpi: b"\x89PNG")
    calls: list[dict] = []

    def fake_targeted(source, page, area, ordinal, flavor="stream", row_tol=None, column_tol=None):
        calls.append({"flavor": flavor, "row_tol": row_tol, "column_tol": column_tol})
        return retry

    monkeypatch.setattr(recapture, "camelot_targeted", fake_targeted)
    return calls


def run(cand, advisor):
    return asyncio.run(repair_capture(cand, "doc.pdf", 1, Path("page.png"), 200, 1, advisor))


ADVICE = RetryAdvice(diagnosis="totals row split into two bands", row_tol=4)


def test_dropped_numeric_keys_detects_missing_values():
    cells = [["Total", "1,637"]]
    assert dropped_numeric_keys(cells, WORDS) == {"2207229"}
    assert dropped_numeric_keys([["Total", "1,637", "2,207,229"]], WORDS) == set()


def test_no_drop_short_circuits_without_calling_the_advisor(monkeypatch: MonkeyPatch):
    advisor = MockCaptureAdvisor(advice=ADVICE)
    calls = patch_io(monkeypatch, retry=None)
    cand = candidate([["Total", "1,637", "2,207,229"]])
    assert run(cand, advisor) is cand
    assert advisor.calls == [] and calls == []


def test_accepted_retry_replaces_the_candidate(monkeypatch: MonkeyPatch):
    advisor = MockCaptureAdvisor(advice=ADVICE)
    retry = candidate([["Total", "1,637", "2,207,229"]])
    calls = patch_io(monkeypatch, retry=retry)
    out = run(candidate([["Total", "1,637"]]), advisor)
    assert out is retry
    assert calls == [{"flavor": "stream", "row_tol": 4, "column_tol": 0}]
    # the advisor saw the dropped token with its printed line
    assert "2207229" in advisor.calls[0][1][0] and "Total" in advisor.calls[0][1][0]


def test_retry_that_loses_a_value_is_rejected(monkeypatch: MonkeyPatch):
    advisor = MockCaptureAdvisor(advice=ADVICE)
    patch_io(monkeypatch, retry=candidate([["Total", "2,207,229"]]))  # recovers but drops 1,637
    cand = candidate([["Total", "1,637"]])
    assert run(cand, advisor) is cand


def test_retry_that_recovers_nothing_is_rejected(monkeypatch: MonkeyPatch):
    advisor = MockCaptureAdvisor(advice=ADVICE)
    patch_io(monkeypatch, retry=candidate([["Total", "1,637"]]))
    cand = candidate([["Total", "1,637"]])
    # force a drop: grid missing 2,207,229; retry identical -> reject
    assert run(cand, advisor) is cand


def test_advisor_returning_none_keeps_the_original(monkeypatch: MonkeyPatch):
    advisor = MockCaptureAdvisor(advice=None)
    calls = patch_io(monkeypatch, retry=candidate([["Total", "1,637", "2,207,229"]]))
    cand = candidate([["Total", "1,637"]])
    assert run(cand, advisor) is cand
    assert len(advisor.calls) == 1 and calls == []


def test_disabled_advisor_is_a_pure_noop(monkeypatch: MonkeyPatch):
    def boom(*a, **k):
        raise AssertionError("must not be called")

    monkeypatch.setattr(recapture, "page_words", boom)
    cand = candidate([["Total", "1,637"]])
    assert run(cand, None) is cand
