"""
Coordinate-frame and region helpers for the correspondence extractor.

Three frames are in play, and a mix-up between them fails silently:

- Camelot bboxes: PDF points, origin bottom-left.
- The detector's boxes: normalized 0..1, origin top-left.
- PyMuPDF words and the rendered page image: points / pixels, origin
  top-left.

The conversions live together here, alongside the region slicing built
on them (text-layer words inside a box, page-image crop of a box).
Everything in this module is deterministic; no LLM, no Camelot.
"""

from __future__ import annotations

import io
from pathlib import Path
from typing import List, Optional, Tuple

from quber.agents.detector import DetectedTable

WordBox = Tuple[float, float, float, float, str]


def page_size_pts(page_image: Path, dpi: int) -> Tuple[float, float]:
    """Page width/height in PDF points, from the rendered image and its
    DPI. Camelot bboxes are in points; this lets us normalize them into
    the detector's 0..1 top-left frame.
    """
    from PIL import Image

    w, h = Image.open(page_image).size
    return w * 72.0 / dpi, h * 72.0 / dpi


def camelot_bbox_to_norm(
    bbox: Tuple[float, float, float, float], page_w_pts: float, page_h_pts: float
) -> Tuple[float, float, float, float]:
    """Convert a Camelot bbox (PDF points, bottom-left origin) to the
    detector's frame (normalized 0..1, top-left origin).
    """
    x1, y1, x2, y2 = bbox
    x_left = min(x1, x2) / page_w_pts
    x_right = max(x1, x2) / page_w_pts
    y_bottom_pts, y_top_pts = min(y1, y2), max(y1, y2)
    top = 1.0 - y_top_pts / page_h_pts
    bottom = 1.0 - y_bottom_pts / page_h_pts
    return (x_left, top, x_right, bottom)


def norm_bbox_to_table_area(
    bbox: Tuple[float, float, float, float], page_w_pts: float, page_h_pts: float
) -> str:
    """Inverse of camelot_bbox_to_norm: a normalized 0..1 top-left box ->
    a Camelot `table_areas` string 'x1,y1,x2,y2' in PDF points (origin
    bottom-left, so y1=top is the larger value).
    """
    x1, top, x2, bottom = bbox
    left = max(0.0, min(x1, x2)) * page_w_pts
    right = min(1.0, max(x1, x2)) * page_w_pts
    top_pts = (1.0 - max(0.0, min(top, bottom))) * page_h_pts
    bottom_pts = (1.0 - min(1.0, max(top, bottom))) * page_h_pts
    return f"{left:.1f},{top_pts:.1f},{right:.1f},{bottom_pts:.1f}"


def bbox_to_top_left(
    bbox: Tuple[float, float, float, float], page_h_pts: float, pad: float = 6.0
) -> Tuple[float, float, float, float]:
    """Camelot bbox (PDF points, bottom-left origin) -> a padded (left, top,
    right, bottom) box in the top-left point frame used by PyMuPDF words and by
    the rendered page image."""
    x1, y1, x2, y2 = bbox
    return (
        min(x1, x2) - pad,
        page_h_pts - max(y1, y2) - pad,
        max(x1, x2) + pad,
        page_h_pts - min(y1, y2) + pad,
    )


# The table image crop reaches this many points above the data box to capture
# the title/caption that sits just above the grid. Only the image is widened;
# a value text-layer slice stays tight to the data box.
CAPTION_PAD_PTS = 28.0


def table_crop_box(
    bbox: Tuple[float, float, float, float], page_h_pts: float
) -> Tuple[float, float, float, float]:
    """The image crop box (left, top, right, bottom; top-left points) for a table:
    its data box widened upward by the caption pad. Four agents see this same
    crop: the structure-vetting call in `correction.correct_structure`, the
    capture advisor (`camelot.recapture`), the status inspector
    (`set_of_mark.inspection`) and the page-scan cell reader
    (`quber.core.figures.capture`). Other agents frame a table differently:
    the split probe in `set_of_mark.split` gets the bare region, and the grid
    locator reads the whole page."""
    left, top, right, bottom = bbox_to_top_left(bbox, page_h_pts)
    return (left, max(0.0, top - CAPTION_PAD_PTS), right, bottom)


def coverage_fraction(
    chunk: Tuple[float, float, float, float], det: Tuple[float, float, float, float]
) -> float:
    """How much of the DETECTED box the chunk covers: intersection area as
    a fraction of the detected box's area. Both rectangles are normalized
    0..1 top-left.

    We measure against the detected box (not the chunk) so the metric
    answers "does this chunk fill this table" and is comparable across
    chunks of different sizes when picking the best match for a table.
    """
    ix1, iy1 = max(chunk[0], det[0]), max(chunk[1], det[1])
    ix2, iy2 = min(chunk[2], det[2]), min(chunk[3], det[3])
    inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
    det_area = max(1e-9, (det[2] - det[0]) * (det[3] - det[1]))
    return inter / det_area


def neighbor_bounded_bbox(
    detected: List[DetectedTable], di: int, pad: float = 0.02
) -> Optional[Tuple[float, float, float, float]]:
    """The crop box for the table at index `di`, clamped vertically so it
    does not bleed into the adjacent detected tables.

    `detected` is in top-to-bottom order. The completeness audit checks
    the table's top/bottom edges, so a crop that includes a neighbor's
    rows makes it read the neighbor as a continuation. We split the gutter
    to each neighbor's midpoint; only the outer edges keep the small pad.
    """
    d = detected[di]
    if d.bbox is None:
        return None
    x1, y1, x2, y2 = d.bbox
    top = min(y1, y2)
    bottom = max(y1, y2)
    prev_bbox = detected[di - 1].bbox if di > 0 else None
    if prev_bbox is not None:
        prev_bottom = max(prev_bbox[1], prev_bbox[3])
        top = max(top, (top + prev_bottom) / 2)
    else:
        top = top - pad
    next_bbox = detected[di + 1].bbox if di + 1 < len(detected) else None
    if next_bbox is not None:
        next_top = min(next_bbox[1], next_bbox[3])
        bottom = min(bottom, (bottom + next_top) / 2)
    else:
        bottom = bottom + pad
    return (x1, top, x2, bottom)


def region_text_in_bbox(words: List[WordBox], region: Tuple[float, float, float, float]) -> str:
    """Text-layer words inside the region (top-left point frame), grouped into
    lines top-to-bottom, left-to-right. This is the value 'well' for the table,
    scoped so no adjacent table can bleed in."""
    left, top, right, bottom = region
    ins = [w for w in words if w[0] >= left and w[2] <= right and w[1] >= top and w[3] <= bottom]
    ins.sort(key=lambda w: (round(w[1] / 3), w[0]))
    lines: List[str] = []
    cur_y: Optional[float] = None
    cur: List[str] = []
    for _x0, y0, _x1, _y1, txt in ins:
        if cur_y is None or abs(y0 - cur_y) > 4:
            if cur:
                lines.append(" ".join(cur))
            cur, cur_y = [txt], y0
        else:
            cur.append(txt)
    if cur:
        lines.append(" ".join(cur))
    return "\n".join(lines)


def crop_region_png(page_image: Path, region: Tuple[float, float, float, float], dpi: int) -> bytes:
    """Crop the rendered page PNG to the table region (top-left points -> pixels
    at `dpi`). This cropped image is the structural arbiter for the vetting LLM."""
    from PIL import Image

    s = dpi / 72.0
    left, top, right, bottom = region
    img = Image.open(page_image)
    crop = img.crop((int(max(0.0, left * s)), int(max(0.0, top * s)), int(right * s), int(bottom * s)))
    buf = io.BytesIO()
    crop.save(buf, format="PNG")
    return buf.getvalue()
