"""
TableDetector Protocol — the page image is the arbiter of what is a
table and where.

The detector looks at a rendered page image and returns the real tables
on that page, in top-to-bottom reading order, each marked with a rough
bounding box and a short description. Its list is the reference point
for the whole Camelot-correspondence flow: it sets *how many* tables a
page has and *roughly where* each one sits. Camelot chunks are matched
to these detected tables downstream, in
`quber.core.extractors.camelot.correspondence` (`matching.assign_chunks`,
called from `orchestrator.py`).

The boxes should tightly bound each table: correspondence matches Camelot
chunks to detected tables by 2D bbox overlap, so box accuracy is
load-bearing. The description plays no part in matching. It becomes the
table's title when structure correction supplies none, and it is recorded
as `detected_description`.

Kept separate from `LLMClient`/`TableClassifier`/`TableUnifier` for the
same single-responsibility reasons: its own system prompt, its own
output schema, its own retry handling, and a model that can be tuned
independently of the other agents.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional, Protocol, Tuple, runtime_checkable

from loguru import logger
from pydantic import BaseModel, Field

from quber.agents.factory import supports_sampling_temperature
from quber.agents.langsmith_tracer import LangSmithTracer, usage_metadata_from
from quber.settings import DEFAULT_LLM_MODEL, get_settings


class DetectedTable(BaseModel):
    ordinal: int = Field(
        ge=1,
        description="1-based position of this table in top-to-bottom reading order on the page",
    )
    bbox: Optional[Tuple[float, float, float, float]] = Field(
        default=None,
        description=(
            "Box tightly enclosing the table, normalized to 0..1 with the page's "
            "top-left as origin: (x1, y1, x2, y2). Correspondence matches Camelot "
            "chunks to detected tables by 2D bbox overlap, so accuracy matters."
        ),
    )
    description: str = Field(
        default="",
        description=(
            "Short description of what this table is — its visible title/caption "
            "or, if none, a one-line summary of its columns. The content hook the "
            "correspondence step matches Camelot chunks against."
        ),
    )


class DetectorResult(BaseModel):
    tables: List[DetectedTable] = Field(
        default_factory=list,
        description="Real tables on the page, ordered top to bottom. Empty if the page has none.",
    )


# Schema embedded so the model is the single source of truth for the
# response shape; same pattern as the classifier and unifier prompts. The
# page image is sent alongside this prompt as the ground truth.
DETECT_TABLES_PROMPT = f"""\
You are looking at a rendered image of a single PDF page. Identify the
*real data tables* on the page.

A real data table is a rectangular grid: a header naming columns AND at
least one row of aligned data cells. Report a table ONLY if you can see
both.

NEVER report these as tables:
- Charts or graphs (bar, line, pie) — even when they carry numeric data
  labels. A chart is not a table.
- Page furniture: footers, headers, decorative banners, logos, page
  numbers, section dividers.
- Footnote lists, narrative paragraphs, bullet lists, multi-column prose,
  Table-of-Contents entries.

Return every real table on the page, in top-to-bottom reading order:

- Number them with `ordinal` starting at 1 for the topmost table.
- Give each a `bbox` normalized to 0..1 (page top-left is the origin):
  (x1, y1, x2, y2). The box must TIGHTLY enclose the table's header and
  data cells — not the surrounding whitespace, caption, or footer. If you
  are not sure a table is present, do not report it.
- Give each a short `description`: its visible title/caption if it has
  one, otherwise a one-line summary of its columns. This is what later
  steps use to recognize the table, so make it specific.

If the page has no real tables, return an empty list.

Return a JSON object conforming exactly to this schema:

{json.dumps(DetectorResult.model_json_schema(), indent=2)}

Return only the JSON object — no prose, no markdown code fences.
"""


@runtime_checkable
class TableDetector(Protocol):
    async def detect(self, page_image: Path) -> DetectorResult: ...


class PydanticAITableDetector:
    """TableDetector backed by pydantic-ai with an Anthropic model.

    Sends the page image as `BinaryContent`; the image is the arbiter of
    what tables exist. Auth resolution mirrors the other agents:
    `ANTHROPIC_AUTH_TOKEN` (OAuth subscription) preferred over
    `ANTHROPIC_API_KEY`.

    Model defaults to Haiku 4.5 — the codebase default — but the detector
    is the agent most likely to move to a stronger model, since the table
    count it returns is load-bearing for everything downstream.
    """

    # The detector's table count and rough boxes are load-bearing for the
    # whole correspondence flow, so we want them as stable run-to-run as
    # the model allows. Greedy decoding (temperature 0) removes the
    # sampling variance; it does not make the model bit-reproducible, but
    # it stabilizes the discrete decisions (how many tables, which ones)
    # and shrinks bbox jitter. Haiku 4.5 accepts a temperature setting.
    DEFAULT_TEMPERATURE = 0.0

    def __init__(
        self,
        model: Optional[str] = None,
        auth_token: Optional[str] = None,
        api_key: Optional[str] = None,
        temperature: Optional[float] = DEFAULT_TEMPERATURE,
    ) -> None:
        from pydantic_ai import Agent
        from pydantic_ai.models.anthropic import AnthropicModel
        from pydantic_ai.providers.anthropic import AnthropicProvider
        from pydantic_ai.settings import ModelSettings

        llm_settings = get_settings().llm
        model = model or llm_settings.model or DEFAULT_LLM_MODEL
        if temperature is not None and not supports_sampling_temperature(model):
            temperature = None
        resolved_auth = auth_token or llm_settings.anthropic_auth_token
        resolved_key = api_key or llm_settings.anthropic_api_key

        if resolved_auth:
            from quber.agents._oauth_gate import make_oauth_anthropic_model

            anth_model = make_oauth_anthropic_model(model, resolved_auth)
        elif resolved_key:
            provider = AnthropicProvider(api_key=resolved_key)
            anth_model = AnthropicModel(model, provider=provider)
        else:
            raise RuntimeError(
                "PydanticAITableDetector: neither ANTHROPIC_AUTH_TOKEN nor "
                "ANTHROPIC_API_KEY is set. Provide one via env or constructor."
            )

        self.model = model
        self.temperature = temperature
        model_settings = None if temperature is None else ModelSettings(temperature=temperature)
        self.agent = Agent(
            anth_model,
            output_type=DetectorResult,
            system_prompt=DETECT_TABLES_PROMPT,
            model_settings=model_settings,
        )

        # LangSmith tracer is a no-op if `TRACE_TO_LANGSMITH` is unset.
        self.tracer = LangSmithTracer(run_name="quber-detector")

    def trace_inputs(self) -> Dict[str, Any]:
        return {
            "messages": [
                {"role": "system", "content": DETECT_TABLES_PROMPT},
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": "Detect the tables on this page."},
                        {"type": "image", "text": "[page image attached]"},
                    ],
                },
            ]
        }

    async def detect(self, page_image: Path) -> DetectorResult:
        from pydantic_ai import BinaryContent

        image = BinaryContent(data=Path(page_image).read_bytes(), media_type="image/png")
        inputs = self.trace_inputs()
        try:
            async with self.tracer.llm_run("detect_tables", inputs, model=self.model) as run:
                result = await self.agent.run(["Detect the tables on this page.", image])
                output = result.output
                run.outputs = {
                    "messages": [{"role": "assistant", "content": output.model_dump_json()}],
                    "table_count": len(output.tables),
                    "usage_metadata": usage_metadata_from(result.usage),
                }
            return output
        except Exception as exc:
            logger.error(
                "detect: LLM call failed; returning empty detection page_image={} exc={}",
                Path(page_image).name,
                exc,
            )
            return DetectorResult(tables=[])


class MockTableDetector:
    """Returns a canned DetectorResult. For tests."""

    def __init__(self, result: Optional[DetectorResult] = None) -> None:
        self.result = result or DetectorResult(tables=[])

    async def detect(self, page_image: Path) -> DetectorResult:
        _ = page_image
        return self.result


DetectorBackend = Literal["api", "mock"]


def get_detector(backend: Optional[DetectorBackend] = None) -> TableDetector:
    selected = backend or get_settings().llm.detector_backend
    if selected == "api":
        return PydanticAITableDetector()
    if selected == "mock":
        return MockTableDetector()
    raise ValueError(f"Unknown QUBER_DETECTOR_BACKEND: {selected!r}. Expected api|mock.")
