# FILE: src/apps/public/schemas/records.py
"""
Schemas for the public (unauthenticated) records search + AI-search endpoints
(INDL-48).

`PublicRecordSummaryResponse` is intentionally a SEPARATE, minimal schema from
`src.apps.records.schemas.responses.RecordSummaryResponse` — that shared
schema backs authenticated staff endpoints and legitimately carries
`date_of_birth`/`date_of_death` (full dates), `gender`, `visibility_config`,
`status`, timestamps, etc. Reusing it for an unauthenticated public surface
would violate the INDL-48 security requirements (OWASP API3 / SEC-05 / S-08),
which require the public payload to expose an explicit, reviewed allowlist
and nothing else — in particular, no full dates of birth/death, no
address/contact/financial fields.

`maiden_name`/`occupation`/`city_of_residence` were added to the allowlist
after the initial delivery to match the reference prototype's result-card
design — conventional genealogy-style fields (comparable to what FindAGrave
displays publicly), not sensitive PII. This was a deliberate, reviewed
expansion of the allowlist, not scope creep.

`extra="forbid"` on both models is a defence-in-depth belt-and-suspenders
control: if a future field is ever added to `Record` and accidentally passed
into these models, construction fails loudly instead of silently leaking it.
"""
from typing import Literal, Optional
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field, model_validator

_SECTION_CODE_PATTERN = r"^[A-Za-z0-9\-]{1,20}$"


class PublicRecordSummaryResponse(BaseModel):
    """The only fields ever returned by /api/public/records and
    /api/public/records/ai-search. See SEC-05 / S-08 in the INDL-48 PRD."""

    model_config = ConfigDict(extra="forbid")

    id: UUID
    first_name: str
    last_name: str
    maiden_name: Optional[str] = None
    year_of_birth: Optional[int] = None
    year_of_death: Optional[int] = None
    plot_ref: Optional[str] = None
    section_code: Optional[str] = None
    interment_type: Optional[str] = None
    memorial_slug: Optional[str] = None
    occupation: Optional[str] = None
    city_of_residence: Optional[str] = None
    # Public plot coordinates (INDL-56) — already exposed on the public map;
    # included so a search result can offer "Get directions" without an extra
    # round-trip. Null when the plot has no GPS.
    latitude: Optional[float] = None
    longitude: Optional[float] = None


class AISearchRequest(BaseModel):
    """Raw request body for POST /api/public/records/ai-search. Length/blank
    validation is performed manually in the router so the exact PRD error
    messages ("Query is required" / "Query must be 500 characters or fewer")
    are returned instead of a generic Pydantic validation error."""

    query: str = Field(..., max_length=2000)


class AISearchExtraction(BaseModel):
    """Strict schema the model's tool-use response must validate against before
    any field is used to build a DB query (OWASP A08 / API10). Unexpected
    fields are dropped by raising instead of silently passing them through.

    Every filter here corresponds to something already visible on a public
    result card or memorial page. Fields the public site never displays —
    birthplace, religion, nationality, military branch, officiant, service
    notes — are deliberately absent: making them searchable on an anonymous
    endpoint would let a visitor enumerate people by, say, religion. `gender`
    is the long-standing example of that rule and stays extract-but-never-filter.
    """

    model_config = ConfigDict(extra="forbid")

    first_name: Optional[str] = Field(None, max_length=100)
    last_name: Optional[str] = Field(None, max_length=100)
    maiden_name: Optional[str] = Field(None, max_length=100)

    # Ranges, not a single "approx" year. The previous centre-year field was
    # applied as exact equality, so "born in the 1960s" matched only 1960.
    year_of_birth_from: Optional[int] = Field(None, ge=1800, le=2100)
    year_of_birth_to: Optional[int] = Field(None, ge=1800, le=2100)
    year_of_death_from: Optional[int] = Field(None, ge=1800, le=2100)
    year_of_death_to: Optional[int] = Field(None, ge=1800, le=2100)

    occupation: Optional[str] = Field(None, max_length=100)
    city_of_residence: Optional[str] = Field(None, max_length=100)
    plot_ref: Optional[str] = Field(None, max_length=50)
    is_veteran: Optional[bool] = None

    # Free text matched against published memorial biographies and approved
    # tributes — the "biographies and tributes" the /find page promises.
    free_text: Optional[str] = Field(None, max_length=200)

    # Server-side re-validation against the same allowlist enforced by the
    # tool-use JSON schema — the LLM's adherence to its own tool schema must
    # never be the only gate (OWASP A08 / V5.1.3). An out-of-allowlist value
    # fails Pydantic validation here and the whole extraction is treated as
    # unusable by the caller.
    interment_type: Optional[Literal["burial", "cremation_interred", "pre_need"]] = None
    section_code: Optional[str] = Field(None, max_length=20, pattern=_SECTION_CODE_PATTERN)
    # Extracted only to build the human-readable summary — never filtered on.
    gender: Optional[str] = Field(None, max_length=30)

    @model_validator(mode="after")
    def _normalise_year_ranges(self) -> "AISearchExtraction":
        """Tolerate a single year or a reversed range from the model."""
        if self.year_of_birth_from and not self.year_of_birth_to:
            self.year_of_birth_to = self.year_of_birth_from
        if self.year_of_birth_to and not self.year_of_birth_from:
            self.year_of_birth_from = self.year_of_birth_to
        if self.year_of_death_from and not self.year_of_death_to:
            self.year_of_death_to = self.year_of_death_from
        if self.year_of_death_to and not self.year_of_death_from:
            self.year_of_death_from = self.year_of_death_to
        if (
            self.year_of_birth_from and self.year_of_birth_to
            and self.year_of_birth_from > self.year_of_birth_to
        ):
            self.year_of_birth_from, self.year_of_birth_to = (
                self.year_of_birth_to, self.year_of_birth_from,
            )
        if (
            self.year_of_death_from and self.year_of_death_to
            and self.year_of_death_from > self.year_of_death_to
        ):
            self.year_of_death_from, self.year_of_death_to = (
                self.year_of_death_to, self.year_of_death_from,
            )
        return self
