"""Board wire shapes (Pydantic v2). ORM rows serialize via from_attributes;
the TS client is generated from the OpenAPI these produce — never
hand-written interface mirrors (react.md)."""
from __future__ import annotations

import datetime
import uuid

from pydantic import BaseModel, ConfigDict, Field


class PlayerLite(BaseModel):
    """The board's player card: enough to render avatars + chips."""

    model_config = ConfigDict(from_attributes=True)

    id: uuid.UUID
    username: str
    display_name: str | None = None
    avatar_color: str | None = None


class BallOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: uuid.UUID
    court_id: uuid.UUID
    title: str
    body: str | None = None
    status: str
    review_state: str | None = None
    category: str | None = None
    tags: list[str] = Field(default_factory=list)
    url_staging: str | None = None
    url_live: str | None = None
    # M6: assignment arrays arrive rep()'d for the viewer on the board read
    # (services.projection) — ids here are always in the payload's players
    # list. Single-ball write responses carry the raw truth: the actor just
    # made (or was allowed to make) that assignment, so it is visible to
    # them by the §4.3b write rules.
    action_to: list[uuid.UUID] = Field(default_factory=list)
    prev_action_to: list[uuid.UUID] = Field(default_factory=list)
    # M6: set when the viewer's rep() of the holder bottoms out at a side
    # label (every action_to entry beyond their deepest net) — the chip
    # renders this text instead of avatars ("Bowden Works").
    holder_label: str | None = None
    author_id: uuid.UUID | None = None
    resolved_at: datetime.datetime | None = None
    position: int
    created_at: datetime.datetime
    # M2: set true on the PATCH response for a one-touch put-away, so the
    # actor's client can play the ace flourish. Never stored.
    ace: bool = False
    # Aging counts from the latest touch (plan §3.3). Stamped by the board
    # read (falls back to created_at); None on single-ball writes where it
    # isn't computed.
    last_touch_at: datetime.datetime | None = None
    # M6 (§4.3b): false when "Ready for Review" must not be offered to THIS
    # viewer on THIS ball (the auto-spike target — the author — is beyond
    # their net). Computed on the board read; defaults true elsewhere.
    can_spike: bool = True


class CourtOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: uuid.UUID
    config_key: str
    name: str
    position: int
    config: dict
    balls: list[BallOut] = Field(default_factory=list)


class MatchLite(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    slug: str
    name: str


class SideOut(BaseModel):
    """A side row the viewer's payload may name. Perspective-filtered: a
    sub-team-only viewer's list contains ONLY their own root side (§4.2 —
    naming the opposing side to Zeina would leak structure)."""

    id: uuid.UUID
    name: str
    position: int


class StripBandOut(BaseModel):
    """One band of the court strip (plan §4.5) — a court from
    courts_of(viewer): near ids left of the net line, far ids (or just
    far_label) across it. This replaces any client-side side-grouping:
    the strip renders these bands verbatim."""

    node_id: uuid.UUID
    name: str
    is_root: bool
    near: list[uuid.UUID] = Field(default_factory=list)
    far: list[uuid.UUID] = Field(default_factory=list)
    far_label: str


class MentionablePlayerOut(BaseModel):
    """GET /api/matches/{slug}/mentionable — the composer autocomplete's
    whole world: exactly the viewer's visible players (§4.3: Erin cannot
    mention Zeina — she doesn't know Zeina exists)."""

    model_config = ConfigDict(from_attributes=True)

    id: uuid.UUID
    username: str
    display_name: str | None = None


class BoardOut(BaseModel):
    """GET /api/matches/{slug}/board — the whole board in one read,
    projected for the REQUESTING viewer (M6, plan §4.3): players is the
    viewer's visible roster, strip is their courts_of() bands, ball
    assignment arrays are rep()'d. Additive over the M1 shape — nothing
    the M1 frontend consumed was removed."""

    match: MatchLite
    sides: list[SideOut] = Field(default_factory=list)
    strip: list[StripBandOut] = Field(default_factory=list)
    players: list[PlayerLite]
    visible_player_ids: list[uuid.UUID] = Field(default_factory=list)
    courts: list[CourtOut]


class BallCreate(BaseModel):
    """POST /api/matches/{slug}/balls — the serve sheet, court-appropriate
    fields only; status/review_state/position are server-assigned from the
    court config (no serve touch yet — touches are M2)."""

    court_id: uuid.UUID
    title: str
    body: str | None = None
    category: str | None = None
    tags: list[str] = Field(default_factory=list)
    url_staging: str | None = None
    url_live: str | None = None
    action_to: list[uuid.UUID] = Field(default_factory=list)


class BallPatch(BaseModel):
    """PATCH /api/balls/{id} — any subset; unset fields untouched. A status
    (or review_state) change triggers the court's status<->review FIELD
    coupling for the counterpart field UNLESS that field is also explicitly
    in the patch (an explicit value always wins over automation)."""

    status: str | None = None
    review_state: str | None = None
    category: str | None = None
    tags: list[str] | None = None
    action_to: list[uuid.UUID] | None = None
    title: str | None = None
    body: str | None = None
    url_staging: str | None = None
    url_live: str | None = None
    position: int | None = None
