"""
Region-constrained Camelot, plus the grid-locator region pairing.

`camelot_targeted` runs one Camelot pass aimed at a box on one page. It is
live code outside the deprecated correspondence flow: the Set-of-Mark engine
calls it for every located table (`set_of_mark.pipeline`), to re-extract a
split sub-region (`set_of_mark.split`), and for the advised capture-repair
retry (`camelot.recapture`). The correspondence orchestrator calls it to
recover a detected table the full-page passes missed, and uses
`nearest_grid_region` to pick a grid-locator region when the detector's own
box drifted. There the recovery can only upgrade a miss or no-op, never do
worse than reporting it.
"""

from __future__ import annotations

import warnings
from pathlib import Path
from typing import Any, List, Optional, Tuple

from quber.agents.grid_locator import LocatedTable
from quber.core.extractors.camelot.acquire import (
    CamelotCandidate,
    cells_to_boxes,
    df_to_cells,
    grid_to_markdown,
)
from quber.core.extractors.camelot.tighten import tighten_cell_boxes


def init_recovery_warning_filter() -> None:
    """Silence camelot's expected 'No tables found in table area' miss.

    A region-constrained pass aims a Camelot parse at a box that may hold no
    grid. Each caller handles that miss itself, so the camelot warning is pure
    noise. A persistent process-global filter is used (not a per-call context
    manager) because the recovery runs across worker threads, where a context
    manager's save/restore races and lets warnings leak through.
    """
    warnings.filterwarnings("ignore", message="No tables found in table area", category=UserWarning)


init_recovery_warning_filter()


def camelot_targeted(
    source_str: str,
    page: int,
    area: str,
    ordinal: int,
    flavor: str = "stream",
    row_tol: Optional[int] = None,
    column_tol: Optional[int] = None,
) -> Optional[CamelotCandidate]:
    """Run a single region-constrained Camelot pass. Returns the
    first table found in the region, or None if Camelot found none. Imports
    camelot lazily (stream needs no OpenCV) and runs in-process; intended
    to be called via asyncio.to_thread. Raises on Camelot-internal errors
    (e.g. an empty region). Each caller handles a raise its own way. The
    correspondence orchestrator counts it as a miss: with grid recovery on it
    relocates the table and tries once more, and reports detected_not_extracted
    only when that fails too. The Set-of-Mark pipeline emits the table with an
    empty body, the split step abandons the split, and capture repair keeps
    the original grid.

    `row_tol`/`column_tol` override stream's grouping tolerances for a
    capture-repair retry (e.g. a totals row typeset on a raised baseline
    splits into two bands at the default tolerance and its numbers are
    dropped at the band boundary). They apply to the stream flavor only.
    """
    import camelot

    from quber.agents.completeness import page_words

    kwargs: dict[str, Any] = {}
    if flavor == "stream":
        if row_tol is not None:
            kwargs["row_tol"] = row_tol
        if column_tol is not None:
            kwargs["column_tol"] = column_tol
    tables = camelot.read_pdf(  # pyright: ignore[reportPrivateImportUsage,reportArgumentType]
        source_str, pages=str(page), flavor=flavor, table_areas=[area], **kwargs
    )
    if not tables:
        return None
    t = tables[0]
    # camelot exposes the table box only as the private `_bbox`; read it via
    # getattr so the access is not flagged as private use.
    raw_bbox = getattr(t, "_bbox", None)
    bbox = tuple(raw_bbox) if raw_bbox else None
    report = getattr(t, "parsing_report", {}) or {}
    cells = df_to_cells(t.df)
    cell_boxes = cells_to_boxes(getattr(t, "cells", None), cells)
    _, page_h, words = page_words(Path(source_str), page)
    cell_boxes = tighten_cell_boxes(cells, cell_boxes, words, page_h)
    return CamelotCandidate(
        candidate_id=f"recovery-p{page}-o{ordinal}",
        flavor="stream" if flavor == "stream" else "lattice",
        page=page,
        bbox=bbox,  # type: ignore[arg-type]
        accuracy=float(report.get("accuracy", 0.0) or 0.0),
        cells=cells,
        cell_boxes=cell_boxes,
        markdown=grid_to_markdown(cells),
    )


def nearest_grid_region(
    located: List[LocatedTable],
    consumed: set[int],
    detector_bbox: Optional[Tuple[float, float, float, float]],
) -> Optional[int]:
    """Pick the unconsumed grid-located region best matching a detected table.

    The detector's box drifts, but its vertical neighbourhood is roughly right
    even when its extent is wrong, so we pair by nearest region center-y. When
    the detector gave no box, fall back to reading order (first unconsumed).
    Returns an index into `located`, or None if all regions are consumed.
    """
    candidates = [i for i in range(len(located)) if i not in consumed]
    if not candidates:
        return None
    if detector_bbox is None:
        return candidates[0]
    center = (min(detector_bbox[1], detector_bbox[3]) + max(detector_bbox[1], detector_bbox[3])) / 2.0
    return min(candidates, key=lambda i: abs((located[i].region[1] + located[i].region[3]) / 2.0 - center))
