"""Check every cell the scan read against the parse's reading of the same cell.

The scan is the reader for a table printed as an image, and it has one failure
mode: it turns thousands separators into decimal points. The parse reads the same
image and is already in the artifacts. It is not a second table and contributes
no structure — it is a second opinion on what a cell says, available for free,
and it catches that failure.

Every cell in the scan's grid is checked. A cell leaves here confirmed by a
second reader, read off the page by an agent, or on the scan's word alone when
the agent could not be asked or gave no answer for it:

- The parse read the same text. Two independent readers agree, and the cell
  stands.
- The parse read something different. The agent reads the cell.
- The parse has no cell there. The agent reads the cell. Having no second
  opinion is a reason to look harder, not a reason to wave it through. With no
  parse table at all, every non-empty cell goes to the agent.
- No reader is configured, the reader's call fails, or it returns nothing for
  a cell. The cell keeps the scan's reading, and the gap is logged.

Cells pair by how much one box covers the other, taken against the smaller of
the two. The readers agree on where a cell is and disagree on how much room it
takes: a row heading is bound by the parse to the word itself and by the scan to
the whole row band, one box sitting entirely inside the other. Measured against
their combined area those overlap almost not at all.

Texts are compared as they stand. A currency symbol read as a letter, a comma
read as a period, a parenthesis dropped from a negative — each changes what the
document states, and a rule deciding which of those were too cosmetic to ask
about would be deciding the thing the agent is there to decide.

This checks values, not coverage. If the page prints a line the scan drew no box
around, the table is short a row and nothing here notices.
"""

from __future__ import annotations

from typing import Dict, List, Optional, Tuple

from docling_core.types.doc.base import CoordOrigin
from docling_core.types.doc.document import TableItem
from loguru import logger

from quber.agents.cell_reader import CellQuestion, CellReader
from quber.core.extractors.camelot.acquire import column_letter, grid_to_addressed_markdown
from quber.core.extractors.camelot.correspondence.geometry import coverage_fraction
from quber.core.figures.geometry import NormBox, norm_box
from quber.core.figures.models import ScannedTable

#: Minimum coverage for a scan cell and a parse cell to be the same cell,
#: measured against the smaller box so a tight box inside a wide one registers.
MATCH_FRACTION = 0.10


async def crosscheck_cells(
    scanned: ScannedTable,
    table_item: Optional[TableItem],
    page_dims: Tuple[float, float],
    table_image: bytes,
    reader: Optional[CellReader],
    match_fraction: float = MATCH_FRACTION,
) -> Tuple[List[List[str]], int]:
    """The scan's grid with every unconfirmed cell read off the page.

    Returns the grid and how many cells the agent read. The grid keeps its shape:
    only cell text changes, never the rows, the columns or the boxes.

    Without a reader, the grid is returned unchanged and the reason is logged.
    A run that cannot check is not the same as a run that checked and found
    nothing. Without a parse table to check against, every non-empty cell is
    sent to the reader.
    """
    if reader is None:
        logger.info("Cell check: page {} no reader configured; the scan's grid stands", scanned.page)
        return scanned.cells, 0

    questions, addresses = _questions(scanned, table_item, page_dims, match_fraction)
    if not questions:
        logger.info("Cell check: page {} every cell confirmed by the parse", scanned.page)
        return scanned.cells, 0

    logger.info(
        "Cell check: page {} asking about {} of {} cell(s)",
        scanned.page,
        len(questions),
        sum(1 for row in scanned.cells for c in row if c.strip()),
    )
    readings = await reader.read_cells(table_image, grid_to_addressed_markdown(scanned.cells), questions)

    grid = [list(row) for row in scanned.cells]
    read = 0
    asked = {q.address for q in questions}
    for cell in readings.cells:
        if cell.address not in asked:
            logger.warning(
                "Cell check: page {} the reader returned {} which it was not asked about; ignored",
                scanned.page,
                cell.address,
            )
            continue
        row, col = addresses[cell.address]
        if grid[row][col] != cell.value:
            logger.info(
                "Cell check: page {} {} {!r} -> {!r}",
                scanned.page,
                cell.address,
                grid[row][col],
                cell.value,
            )
        grid[row][col] = cell.value
        read += 1

    missing = asked - {c.address for c in readings.cells}
    if missing:
        logger.warning(
            "Cell check: page {} the reader returned nothing for {}; those cells keep the scan's reading",
            scanned.page,
            sorted(missing),
        )
    return grid, read


def _questions(
    scanned: ScannedTable,
    table_item: Optional[TableItem],
    page_dims: Tuple[float, float],
    match_fraction: float,
) -> Tuple[List[CellQuestion], Dict[str, Tuple[int, int]]]:
    """The cells the parse did not confirm, and where each address sits in the grid."""
    parse_cells = _parse_cells(table_item, page_dims)

    questions: List[CellQuestion] = []
    addresses: Dict[str, Tuple[int, int]] = {}
    for row, texts in enumerate(scanned.cells):
        for col, text in enumerate(texts):
            if not text.strip():
                continue
            box = norm_box(scanned.cell_boxes[row][col] if col < len(scanned.cell_boxes[row]) else None)
            other = _best_match(box, parse_cells, match_fraction)
            if other is not None and other == text:
                continue
            address = f"{column_letter(col)}{row + 1}"
            addresses[address] = (row, col)
            questions.append(CellQuestion(address=address, scan_read=text, parse_read=other))
    return questions, addresses


def _parse_cells(
    table_item: Optional[TableItem], page_dims: Tuple[float, float]
) -> List[Tuple[str, NormBox]]:
    """The parse's cells for this table, boxed in the normalized top-left frame."""
    if table_item is None:
        return []
    width, height = page_dims
    out: List[Tuple[str, NormBox]] = []
    for cell in table_item.data.table_cells:
        bbox = cell.bbox
        if bbox is None or not (cell.text or "").strip():
            continue
        if bbox.coord_origin == CoordOrigin.TOPLEFT:
            box = (bbox.l / width, min(bbox.t, bbox.b) / height, bbox.r / width, max(bbox.t, bbox.b) / height)
        else:
            box = (
                bbox.l / width,
                (height - max(bbox.t, bbox.b)) / height,
                bbox.r / width,
                (height - min(bbox.t, bbox.b)) / height,
            )
        out.append((cell.text, box))
    return out


def _best_match(
    box: Optional[NormBox], parse_cells: List[Tuple[str, NormBox]], match_fraction: float
) -> Optional[str]:
    """What the parse read in the cell covering `box` best, or none past the floor."""
    if box is None:
        return None
    best_text: Optional[str] = None
    best = match_fraction
    for text, other in parse_cells:
        cov = max(coverage_fraction(box, other), coverage_fraction(other, box))
        if cov >= best:
            best, best_text = cov, text
    return best_text
