"""Reconcile the values printed in a document's figures, with per-value provenance.

The scan reads each figure into prose, so a chart's numbers reach the document
with no per-value geometry and nothing checking them. This module gives every
plotted value a second, independent read and a trace to the page:

- The parse's positioned text cells are the fragment source. A born-digital
  page carries every printed chart value as a native cell with a box; a page
  docling read from its image carries the same cells from OCR, marked so.
- A local reader looks at the page image with the figure's fragments and
  reports each printed value with the fragment ids that ground it.
- A parser turns the scan's prose reading into the same value shape.
- A deterministic tie matches the two within each page and assigns statuses
  from the cell-status registry: agreement grounded in a fragment is
  `reconciled` with the fragment's box; a positional disagreement is
  `value_misread`; a one-sided value is `value_unreconciled` with the
  direction in the note.

Matching is value-first with label confirmation, consuming pairs so repeated
values resolve by count. Values are compared in a normalized form; the stored
value keeps the printed form. Boxes are normalized 0..1, top-left origin, the
same frame every other figure box in this package uses.
"""

from __future__ import annotations

import re
from pathlib import Path
from typing import Dict, List, Optional, Protocol, Sequence, Tuple

from loguru import logger
from pydantic import BaseModel, Field

from quber.core.figures.models import Box, FigureRecord, FigureValue, FigureValueRun, PageScan

#: Minimum fraction of a fragment's area inside a figure's box for the
#: fragment to belong to that figure, measured against the fragment.
SCOPE_OVERLAP = 0.5

#: Common words carrying no identity, dropped before label comparison.
STOP_WORDS = frozenset({"the", "of", "per", "to", "by", "and", "in", "a"})


class PageFragment(BaseModel):
    """One positioned text cell from the parse, in the page's normalized frame."""

    id: str
    text: str
    box: Box
    from_ocr: bool = False
    confidence: float = 1.0


class ParsedValue(BaseModel):
    """One labeled value read out of the scan's prose description of a figure."""

    label: str = Field(default="")
    series: str = Field(default="")
    value: str


class ReadValue(BaseModel):
    """One printed value the local reader saw on the page image."""

    chart_title: str = Field(default="")
    label: str = Field(default="")
    series: str = Field(default="")
    value: str
    fragment_ids: List[str] = Field(default_factory=list)


class ScanValueParser(Protocol):
    """Turns a figure's prose reading into labeled values."""

    async def parse(self, figure_texts: Sequence[str], page: int) -> List[ParsedValue]: ...


class LocalValueReader(Protocol):
    """Reads a page image's figure values, citing the fragments that ground them."""

    async def read(
        self, image_path: Path, fragments: Sequence[PageFragment], page: int
    ) -> List[ReadValue]: ...


def page_fragments(cells_page: dict) -> List[PageFragment]:
    """The parse's cells for one page, normalized to the 0..1 top-left frame.

    `cells_page` is one entry of the document's cells artifact: page_no,
    width, height, and cells each holding text, box in top-left PDF points,
    from_ocr, and confidence.
    """
    width = float(cells_page["width"]) or 1.0
    height = float(cells_page["height"]) or 1.0
    fragments: List[PageFragment] = []
    page_no = cells_page["page_no"]
    for i, cell in enumerate(cells_page.get("cells", [])):
        text = str(cell.get("text", "")).strip()
        if not text:
            continue
        left, top, right, bottom = cell["box"]
        fragments.append(
            PageFragment(
                id=f"p{page_no}.c{i}",
                text=text,
                box={
                    "left": left / width,
                    "top": top / height,
                    "right": right / width,
                    "bottom": bottom / height,
                },
                from_ocr=bool(cell.get("from_ocr", False)),
                confidence=float(cell.get("confidence", 1.0)),
            )
        )
    return fragments


def fragments_in_box(fragments: Sequence[PageFragment], box: Box) -> List[PageFragment]:
    """Fragments whose area sits mostly inside the box."""
    hits = []
    for frag in fragments:
        f = frag.box
        inter_w = max(0.0, min(f["right"], box["right"]) - max(f["left"], box["left"]))
        inter_h = max(0.0, min(f["bottom"], box["bottom"]) - max(f["top"], box["top"]))
        area = max((f["right"] - f["left"]) * (f["bottom"] - f["top"]), 1e-9)
        if (inter_w * inter_h) / area >= SCOPE_OVERLAP:
            hits.append(frag)
    return hits


def norm_value(raw: str) -> str:
    """A value reduced to its comparable core: sign, digits, decimal point."""
    s = str(raw).strip().replace("$", "").replace(",", "")
    s = s.replace("–", "-").replace("—", "-")
    negative = s.startswith("(") and s.endswith(")")
    if negative:
        s = s[1:-1]
    s = s.replace("(", "").replace(")", "").rstrip("%").strip()
    if s in {"-", "--", ""}:
        return "DASH"
    try:
        number = float(s)
    except ValueError:
        return s.lower()
    if negative and number > 0:
        number = -number
    return f"{number:g}"


def label_words(*parts: str) -> frozenset:
    words = set()
    for part in parts:
        words.update(re.findall(r"[a-z0-9/+.]+", str(part).lower()))
    return frozenset(words - STOP_WORDS)


def value_fragment(
    read: ReadValue, fragments_by_id: Dict[str, PageFragment]
) -> Tuple[Optional[PageFragment], bool]:
    """The cited fragment printing the value, and whether any cited one does.

    Returns the first cited fragment whose text contains the value in either
    printed or normalized form. When citations exist but none contains the
    value, the first cited fragment anchors the position of the disagreement.
    """
    cited = [fragments_by_id[i] for i in read.fragment_ids if i in fragments_by_id]
    target = norm_value(read.value)
    for frag in cited:
        if read.value in frag.text or (target != "DASH" and target in norm_value(frag.text)):
            return frag, True
    return (cited[0] if cited else None), False


def picture_for_value(figures: Sequence[FigureRecord], box: Optional[Box], raw_value: str) -> Optional[str]:
    """The picture a value belongs to, or None when no figure claims it.

    A boxed value belongs to the figure whose box contains the value's
    center, the smallest such figure when they nest. An unboxed value
    belongs to the one figure whose scan text prints it, and to no figure
    when the value appears in several or in none — a wrong anchor misleads
    where an absent one just falls back to the page.
    """
    if box is not None:
        cx = (box["left"] + box["right"]) / 2
        cy = (box["top"] + box["bottom"]) / 2
        best: Optional[Tuple[float, Optional[str]]] = None
        for fig in figures:
            fb = fig.box
            if fb is None or fig.picture_ref is None:
                continue
            if fb["left"] <= cx <= fb["right"] and fb["top"] <= cy <= fb["bottom"]:
                area = (fb["right"] - fb["left"]) * (fb["bottom"] - fb["top"])
                if best is None or area < best[0]:
                    best = (area, fig.picture_ref)
        if best is not None:
            return best[1]
    if raw_value:
        holders = {fig.picture_ref for fig in figures if fig.picture_ref and raw_value in fig.text}
        if len(holders) == 1:
            return next(iter(holders))
    return None


def tie_page(
    page: int,
    figures: Sequence[FigureRecord],
    scan_values: Sequence[ParsedValue],
    local_values: Sequence[ReadValue],
    fragments: Sequence[PageFragment],
) -> List[FigureValue]:
    """Match the two readings of one page's figures and assign statuses."""
    fragments_by_id = {f.id: f for f in fragments}
    remaining = list(scan_values)
    out: List[FigureValue] = []

    def take_match(read: ReadValue, require_label: bool) -> Optional[ParsedValue]:
        target = norm_value(read.value)
        read_words = label_words(read.label, read.series)
        for candidate in remaining:
            if norm_value(candidate.value) != target:
                continue
            candidate_words = label_words(candidate.label, candidate.series)
            overlap = bool(read_words & candidate_words)
            if require_label and not overlap:
                continue
            if not require_label and candidate_words and read_words and not overlap:
                continue
            remaining.remove(candidate)
            return candidate
        return None

    for read in local_values:
        matched = take_match(read, require_label=True) or take_match(read, require_label=False)
        fragment, contains = value_fragment(read, fragments_by_id)
        if matched and contains and fragment is not None:
            status, note = "reconciled", None
        elif matched and fragment is not None:
            status = "value_misread"
            note = f"the page prints {fragment.text!r} where this value should appear - possible misread"
        elif matched:
            status = "value_unreconciled"
            note = "corroborated by both readings, but not anchored to printed text on the page"
        else:
            status = "value_unreconciled"
            note = "one measurement, read from the page; uncontradicted, not independently corroborated"
        box = fragment.box if fragment is not None else None
        out.append(
            FigureValue(
                page=page,
                picture_ref=picture_for_value(figures, box, read.value),
                chart_title=read.chart_title,
                label=read.label,
                series=read.series,
                value=read.value,
                status=status,
                note=note,
                fragment_ids=list(read.fragment_ids),
                box=box,
            )
        )

    for leftover in remaining:
        out.append(
            FigureValue(
                page=page,
                picture_ref=picture_for_value(figures, None, leftover.value),
                label=leftover.label,
                series=leftover.series,
                value=leftover.value,
                status="value_unreconciled",
                note="one measurement, from the figure description; uncontradicted, not corroborated on the printed page",
            )
        )
    return out


async def read_figure_values(
    scans: Sequence[PageScan],
    cells_pages: Dict[int, dict],
    page_images: Dict[int, Path],
    parser: ScanValueParser,
    reader: LocalValueReader,
    document: str,
) -> FigureValueRun:
    """Reconcile every scanned figure page's values.

    In: the run's page scans, the parse's cells keyed by page, a rendered
    image per scanned page, and the two readers. Out: one FigureValueRun with
    a FigureValue per value either reader produced.
    """
    run = FigureValueRun(document=document)
    for scan in scans:
        if not scan.figures:
            continue
        page = scan.page
        cells_page = cells_pages.get(page)
        image = page_images.get(page)
        if cells_page is None or image is None:
            run.errors.append(f"page {page}: missing cells or render; figure values skipped")
            continue
        fragments = page_fragments(cells_page)
        try:
            scan_values = await parser.parse([f.text for f in scan.figures], page)
            local_values = await reader.read(image, fragments, page)
        except Exception as exc:
            run.errors.append(f"page {page}: figure-value read failed: {exc}")
            logger.warning("figure values page {}: {}", page, exc)
            continue
        run.values.extend(tie_page(page, scan.figures, scan_values, local_values, fragments))
    counts = {"reconciled": run.reconciled, "flagged": run.flagged}
    logger.info("figure values: {} pages -> {}", sum(1 for s in scans if s.figures), counts)
    return run
