"""What the figure workflow produces: one record per scanned page, and one per figure.

A `FigureRun` is the run record, written as `<base>.figures.json`. It holds one
`PageScan` for every page the run scanned, including a page that came back
with nothing. A page whose scan or reading failed has no `PageScan` and is
reported in `errors` instead. Either way, a nominated page never disappears
between nomination and output. A chart in a financial document is often where
a number appears that appears nowhere else, so a page that was nominated and
produced nothing has to be visible.

A `FigureRecord` is one figure the scan returned, with the box the scan drew
around it. On the dpt-2 path its text is the scan's reading exactly as
returned. On the dpt-3 path it is the figure's description followed by its
value tables as markdown rows (`dpt3.digest.figure_text`). The text is not
parsed into fields: a chart description has no fixed shape, and two bar charts
in one response used different field names in a different order, so a parser
written against what has been seen encodes one shape and misreads the next.

A `ScannedTable` is one table the scan returned. Unlike a chart, a table is
kept as structure: a grid, with a box for each cell where the scan gave one.
Only the grids the capture step takes go on to the correction and grounding
every other table goes through (`capture.capture_tables`).

`ChartContext` is the rest of the page's returned text: on the dpt-2 path every
text and marginalia chunk, on the dpt-3 path every node with text that is
neither a figure nor a table. That is where the chart titles and the notes at
the foot of the page are, alongside the page's body text. It is kept as
returned and attached to no chart. One note commonly serves several charts on
a page, and matching a superscript to the note it points at is a separate
step, the same way it is for tables.
"""

from __future__ import annotations

from typing import Dict, List, Literal, Optional, Tuple

from pydantic import BaseModel, Field

#: Normalized box: 0..1 with the page's top-left as origin, keyed
#: left/top/right/bottom. A dpt-2 response uses these keys, and the dpt-3
#: digest renames its xmin/ymin/xmax/ymax to them. The page scanned was the
#: real page, so the box is already a source coordinate and needs no mapping
#: back.
Box = Dict[str, float]


class FigureRecord(BaseModel):
    """One chart a page scan returned."""

    page: int = Field(description="1-based source page the chart is printed on")
    text: str = Field(description="The scan's reading of the chart, stored exactly as returned")
    box: Optional[Box] = Field(default=None, description="Normalized box around the chart on the page")
    chunk_id: Optional[str] = Field(
        default=None, description="The scan's own id for this figure, for tracing back to the response"
    )
    job_id: Optional[str] = Field(
        default=None, description="The scan job that produced this record (see the page's PageScan)"
    )
    picture_ref: Optional[str] = Field(
        default=None,
        description=(
            "The picture in the refined parse this record was grafted onto. Absent when the "
            "figure overlapped no picture, which the run reports as an error."
        ),
    )


class ScannedTable(BaseModel):
    """One table a page scan returned, as a grid with a box on every cell.

    A table comes back shaped like a table: an HTML grid with an id on every
    cell, and a box for each of those ids. That is a different shape from a
    chart, which comes back as a description with no internal locations at all,
    and it is the right shape for the element it belongs to.

    The grid is dense — one entry per row and column, blank where a spanning
    cell covers a position — so it drops straight into the same correction and
    grounding steps every other table in the document goes through.
    """

    page: int = Field(description="1-based source page the table is printed on")
    cells: List[List[str]] = Field(description="The returned grid, rows of cell text")
    cell_boxes: List[List[Optional[Box]]] = Field(
        description="Normalized box per cell, shaped exactly like `cells`; absent where none was returned"
    )
    box: Optional[Box] = Field(default=None, description="Normalized box around the whole table")
    chunk_id: Optional[str] = Field(default=None, description="The scan's own id for this table")
    job_id: Optional[str] = Field(
        default=None, description="The scan job that produced this record (see the page's PageScan)"
    )
    table_ref: Optional[str] = Field(
        default=None,
        description=(
            "The table in the refined parse this grid replaced. Absent when the returned "
            "table overlapped no table in the parse, which the run reports as an error."
        ),
    )
    picture_ref: Optional[str] = Field(
        default=None,
        description=(
            "The picture in the parse this grid was printed over, when the parse detected "
            "the region but filed it as a picture rather than a table. The grid becomes a "
            "new table beside that picture."
        ),
    )
    table_id: Optional[str] = Field(
        default=None, description="The extracted table this grid produced, by its address"
    )


class ChartContext(BaseModel):
    """A title or note the scanned page returned, kept as returned and unattached."""

    page: int = Field(description="1-based source page the text is printed on")
    kind: str = Field(description="The chunk type the scan assigned, as returned (text, marginalia)")
    text: str = Field(description="The text, stored exactly as returned")
    box: Optional[Box] = Field(default=None, description="Normalized box around the text on the page")
    chunk_id: Optional[str] = Field(default=None, description="The scan's own id for this chunk")


PageStatus = Literal["figures", "tables", "empty", "dropped"]


class PageScan(BaseModel):
    """One nominated page and what became of it.

    `status` says which: `figures` for a scanned page that returned at least one
    figure, `tables` for one that returned no figure but at least one table,
    `empty` for a scanned page that returned neither, `dropped` for a page the
    filter answered no on, which is never scanned and so is never billed.

    `figures` does not promise a chart. Nothing here filters on what a figure
    contains, and a page's logo comes back as a figure of its own, so a page can
    reach `figures` on the strength of its letterhead alone.

    `reused` marks a page whose records come from a scan a previous run already
    paid for. `credits` is what that scan cost when it was made, so the two
    together separate what this run spent from what it read.
    """

    page: int = Field(description="1-based source page")
    status: PageStatus
    reason: str = Field(default="", description="Why a page was dropped; empty for a scanned page")
    picture_classes: List[str] = Field(
        default_factory=list, description="The picture classes that nominated this page"
    )
    table_refs: List[str] = Field(
        default_factory=list,
        description="Tables in the parse that nominated this page, read off the page image",
    )
    job_id: Optional[str] = Field(default=None, description="The scan job id, absent on a dropped page")
    model: Optional[str] = Field(default=None, description="The model requested for the scan")
    version: Optional[str] = Field(default=None, description="The model version the scan reported")
    credits: Optional[float] = Field(default=None, description="Credits the scan was billed when it was made")
    reused: bool = Field(
        default=False, description="True when the records come from a scan an earlier run paid for"
    )
    response_artifact: Optional[str] = Field(
        default=None, description="Filename of the raw response this page's records came from"
    )
    figures: List[FigureRecord] = Field(default_factory=list)
    tables: List[ScannedTable] = Field(default_factory=list)
    context: List[ChartContext] = Field(default_factory=list)


class RemovedText(BaseModel):
    """One text the run took out of the parse because a figure had been read over it.

    Recorded so a completeness check can tell a deliberate removal from a silent
    loss. Both look the same from outside: text the page prints that the document
    no longer holds. The check reads the source PDF's own text layer, which is the
    layer this text came out of, so the same printed line is present in both
    readings and matches on its words and on where it sits.

    The box is the normalized top-left frame the rest of the workflow states
    positions in, so no caller converts. Text alone would be too loose — a page
    printing `0.0%` as a gridline and again in a footnote gives one string for two
    places, and forgiving the gridline would forgive losing the footnote.
    """

    page: int = Field(description="The 1-based page it was printed on")
    text: str = Field(description="What it said, verbatim")
    box: Optional[Tuple[float, float, float, float]] = Field(
        default=None, description="Where it sat: (x1, y1, x2, y2), 0..1 from the page's top-left"
    )
    reason: str = Field(default="", description="Why it was taken to be the figure's furniture")


class FigureValue(BaseModel):
    """One plotted value read off a figure, reconciled and traced to the page.

    Two independent readers produce the inputs: the page scan's prose reading
    and a local read of the page image grounded in the parse's positioned text
    cells. A value both agree on, whose cited fragment prints it, is
    `reconciled` and carries that fragment's box. A positional disagreement is
    `value_misread`; a value only one reader produced is `value_unreconciled`
    with the direction in the note. Statuses come from the cell-status
    registry, so review surfaces treat figure values and table cells alike.
    """

    page: int = Field(description="1-based source page")
    picture_ref: Optional[str] = Field(
        default=None, description="The parse picture the value's figure was grafted onto"
    )
    chart_title: str = Field(default="", description="The figure's printed title, as read")
    label: str = Field(description="Category or axis label the value belongs to")
    series: str = Field(default="", description="Series name when the figure has more than one")
    value: str = Field(description="The value as printed, including currency and sign marks")
    status: str = Field(description="A code from the cell-status registry")
    note: Optional[str] = Field(default=None, description="One line of evidence or direction")
    fragment_ids: List[str] = Field(
        default_factory=list, description="Ids of the page fragments grounding the value"
    )
    box: Optional[Box] = Field(default=None, description="Normalized box of the fragment printing the value")


class FigureValueRun(BaseModel):
    """Every reconciled figure value one run produced, written as its own artifact."""

    document: str = Field(description="The source document's base name")
    values: List[FigureValue] = Field(default_factory=list)
    errors: List[str] = Field(default_factory=list)

    @property
    def reconciled(self) -> int:
        return sum(1 for v in self.values if v.status == "reconciled")

    @property
    def flagged(self) -> int:
        return sum(1 for v in self.values if v.status != "reconciled")


class FigureRun(BaseModel):
    """Everything one figure run produced, for the CLI to print and callers to read."""

    document: str = Field(description="The source document's base name")
    scans: List[PageScan] = Field(default_factory=list)
    errors: List[str] = Field(
        default_factory=list,
        description="Surfaced problems: a chart that matched no picture in the parse, a page whose scan failed",
    )
    removed: List[RemovedText] = Field(
        default_factory=list,
        description="Text taken out of the parse as a read figure's furniture, so a completeness check can account for it",
    )

    @property
    def nominated(self) -> int:
        """Pages with a scan record, the same count as `scanned`.

        It is not the count of pages the parse nominated. It includes pages
        added by `pages="all"` and stored dpt-3 pages, and leaves out a
        nominated page whose scan failed.
        """
        return len(self.scans)

    @property
    def scanned(self) -> int:
        """Pages with a scan record, whether this run paid for the scan or reused one."""
        return sum(1 for s in self.scans if s.status != "dropped")

    @property
    def submitted(self) -> int:
        """Pages with a scan record that this run submitted.

        A page that was billed but whose dpt-3 response could not be read has
        no record, so it is not counted.
        """
        return sum(1 for s in self.scans if s.status != "dropped" and not s.reused)

    @property
    def with_figures(self) -> int:
        """Pages whose status is `figures`, after the graft dropped the records over page furniture."""
        return sum(1 for s in self.scans if s.status == "figures")

    @property
    def with_tables(self) -> int:
        """Scanned pages that returned at least one table."""
        return sum(1 for s in self.scans if s.tables)

    @property
    def figures(self) -> List[FigureRecord]:
        """Every figure record the run produced, in page order."""
        return [c for s in self.scans for c in s.figures]

    @property
    def tables(self) -> List[ScannedTable]:
        """Every table the run read off a page image, in page order."""
        return [t for s in self.scans for t in s.tables]

    @property
    def credits(self) -> float:
        """Credits billed for the pages counted by `submitted`.

        A reused scan was paid for by the run that made it.
        """
        return sum(s.credits or 0.0 for s in self.scans if not s.reused)
