"""Pydantic request/response models — the OpenAPI surface the TS client is
generated from (react.md: never hand-write a TS interface mirroring these)."""

from datetime import datetime

from typing import Literal

from pydantic import BaseModel, Field


# ------------------------------------------------------------------ shared

class Capabilities(BaseModel):
    can_view: bool
    can_manage: bool
    can_comment: bool
    can_select: bool
    can_approve: bool


class StatusCounts(BaseModel):
    client_open: int
    agency_open: int


class ProjectStatus(BaseModel):
    state: str
    counts: StatusCounts
    summary: str


# ------------------------------------------------------------------ projects

class ProjectSummary(BaseModel):
    id: str
    label: str
    state: str
    screen_count: int
    option_count: int
    sent_at: datetime | None
    description: str = ""
    # Open client-authored threads — the one actionable number on the index.
    open_feedback: int = 0
    # Latest event OR comment. Comments are the main activity signal and are not
    # events, so a project where the only news is a reply must not read as idle.
    last_activity: datetime | None = None
    # Whose turn it is, answered FOR THE ASKER: the same project is "needs you"
    # to the team when feedback is open, and to the client when it is their move.
    needs_you: bool = False


class ProjectList(BaseModel):
    projects: list[ProjectSummary]


class VariantOut(BaseModel):
    id: int
    axis: str
    axis_label: str
    key: str
    label: str
    css_class: str
    position: int = 0
    is_default: bool = False


class VariantClassesOut(BaseModel):
    """What the design says about itself: `classes` is every class it scopes to
    <html>, `unused` the ones no variant claims yet — the likely next choice."""
    classes: list[str] = []
    unused: list[str] = []


class OptionOut(BaseModel):
    id: int
    title: str
    concept_tag: str
    position: int
    has_files: bool
    file_count: int
    walkthrough_count: int = 0
    open_pin_count: int = 0
    variants: list[VariantOut] = []
    # "" | "new" | "changed" — against the viewer's previous visit. Two
    # different messages: another design to look at, versus one you already read
    # having moved on.
    since_last: str = ""
    # What the concept represents, in a line, and a picture of it (null until
    # the team uploads one). The conversation about the concept as a whole.
    blurb: str = ""
    thumbnail_url: str | None = None
    comment_count: int = 0
    open_comments: int = 0


class StageOut(BaseModel):
    """A stage of the project's roadmap (caddie's shape — see
    services/stages.py). The board draws the timeline from these."""
    id: int
    key: str
    position: int
    title: str
    body_md: str = ""
    status: str = "planned"        # planned | active | done | skipped
    client_visible: bool = True
    started_at: datetime | None = None
    closed_at: datetime | None = None


class StagesOut(BaseModel):
    stages: list[StageOut]


class UpdateStageIn(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=200)
    body_md: str | None = Field(default=None, max_length=20000)
    status: Literal["planned", "active", "done", "skipped"] | None = None
    client_visible: bool | None = None


class ThumbnailOut(BaseModel):
    thumbnail_url: str


class TourPointIn(BaseModel):
    """A project's own words for one introduction point. Empty strings clear
    that field; both empty removes the override (the shipped words return)."""
    title: str | None = Field(default=None, max_length=200)
    body: str | None = Field(default=None, max_length=4000)


class TourOverridesOut(BaseModel):
    tour_overrides: dict[str, dict[str, str]] = {}


class ScreenOut(BaseModel):
    id: int
    title: str
    slug: str
    position: int
    selected_option_id: int | None
    # The variant values locked in alongside the option — {"brand": "hive"}.
    selected_variants: dict[str, str] | None = None
    # An open conversation about the screen itself, counted separately from the
    # pins on its options — different act, different signal.
    open_comments: int = 0
    comment_count: int = 0
    is_new: bool = False
    # Draft screens are the team's alone until published.
    published: bool = True
    options: list[OptionOut]


class ProjectDetail(BaseModel):
    id: str
    label: str
    description: str
    state: str
    sent_at: datetime | None
    screens: list[ScreenOut]
    capabilities: Capabilities
    # None on a first visit — nothing is "new" when everything is.
    last_seen: datetime | None = None
    new_count: int = 0
    # The roadmap (what this side may see of it) and the client's round marker.
    stages: list[StageOut] = []
    feedback_completed_at: datetime | None = None
    feedback_completed_by: str | None = None
    # The team's own words for the introduction's points, by point key.
    tour_overrides: dict[str, dict[str, str]] = {}


class CreateProjectIn(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    description: str = ""


class UpdateProjectIn(BaseModel):
    description: str | None = Field(default=None, max_length=4000)


class CreateScreenIn(BaseModel):
    title: str = Field(min_length=1, max_length=200)


class UpdateScreenIn(BaseModel):
    title: str | None = Field(default=None, max_length=200)
    position: int | None = None
    published: bool | None = None


class CreateOptionIn(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    concept_tag: str = Field(default="", max_length=120)


class UpdateOptionIn(BaseModel):
    title: str | None = Field(default=None, max_length=200)
    concept_tag: str | None = Field(default=None, max_length=120)
    entry_path: str | None = None
    blurb: str | None = Field(default=None, max_length=2000)


class SelectOptionIn(BaseModel):
    option_id: int | None
    # The variant values showing when they chose. Captured at the moment of
    # choosing rather than asked for separately: the toggles they are looking at
    # ARE the thing they are approving.
    variants: dict[str, str] | None = None


class CreateVariantIn(BaseModel):
    axis: str
    key: str
    label: str
    css_class: str
    axis_label: str = ""
    position: int = 0
    is_default: bool = False


class VariantSetupChoice(BaseModel):
    css_class: str
    label: str


class VariantSetupIn(BaseModel):
    """Turn the looks a design already declares into a toggle, in one act.

    The admin is asked only what the file cannot say: what to call each look.
    The axis, the base choice and the default are the app's job, not a form's.
    """
    axis: str = "version"
    axis_label: str = "Version"
    base_label: str = "Original"
    choices: list[VariantSetupChoice] = []


class OkOut(BaseModel):
    success: bool = True


class CreatedOut(BaseModel):
    success: bool = True
    id: int


class ProjectCreatedOut(BaseModel):
    success: bool = True
    id: str
    label: str


# ------------------------------------------------------------------ viewer

class ViewTokenOut(BaseModel):
    token: str
    nonce: str
    entry_url: str
    expires_in: int


class UploadedFileOut(BaseModel):
    rel_path: str
    bytes: int
    content_type: str


class UploadResultOut(BaseModel):
    success: bool = True
    files: list[UploadedFileOut]
    entry_path: str


# ------------------------------------------------------------------ pins

class CommentOut(BaseModel):
    id: int
    body_md: str
    author_username: str
    author_side: str
    created_at: datetime
    edited_at: datetime | None
    deleted: bool
    attachment_ids: list[int] = []
    reactions: list["ReactionOut"] = []


# The emoji a comment can carry. A short fixed set, like Slack's quick
# reactions: enough to say agreed, love it, funny, done or looking, and no
# free text riding in through a side door.
REACTION_EMOJI = ("👍", "❤️", "😂", "🎉", "👀", "✅")


class ReactionOut(BaseModel):
    emoji: str
    users: list[str]


class ReactIn(BaseModel):
    emoji: str = Field(min_length=1, max_length=16)


CommentOut.model_rebuild()


class PinAnchor(BaseModel):
    """Where a note sits, as a place on the design: a mockup element (a
    selector the design bridge built when the note was dropped), the point's
    place across that element as a fraction of its width, and its distance
    down from the element's top in CSS pixels. Down is pixels, not a
    fraction, because an element grows BELOW a point far more often than
    above it (an accordion opening further down a section), and a fraction
    of a height that grew slides the point with it. An area note carries a
    second set for its bottom-right corner."""
    s: str = Field(min_length=1, max_length=500)
    fx: float = Field(ge=0, le=1)
    oy: float = Field(ge=0, le=200000)
    s2: str | None = Field(default=None, min_length=1, max_length=500)
    fx2: float | None = Field(default=None, ge=0, le=1)
    oy2: float | None = Field(default=None, ge=0, le=200000)


class PinThreadOut(BaseModel):
    pin_id: int
    thread_id: int
    x_percent: float
    y_percent: float
    # An area note carries its size; a point carries none.
    w_percent: float | None = None
    h_percent: float | None = None
    anchor: PinAnchor | None = None
    number: int
    resolved: bool
    created_by: str
    created_at: datetime
    # The variation this note was left on, and its labels for display.
    variants: dict[str, str] | None = None
    variant_labels: list[str] = []
    comments: list[CommentOut]


class ScreenThreadOut(BaseModel):
    """A conversation about a SCREEN as a whole — "the tone feels formal",
    "I prefer the second one" — which is feedback a pin cannot hold, because it
    is not about a spot on the page."""
    thread_id: int | None = None
    resolved: bool = False
    comments: list[CommentOut] = []


class AttachmentOut(BaseModel):
    """A file uploaded for a project's conversations (Interaction Standard
    addendum): `markdown` is what the composer embeds, and the comment is then
    a normal comment — there is no attachment object in the conversation."""
    url: str
    name: str
    display: str
    is_image: bool
    markdown: str


class OptionThreadOut(BaseModel):
    """The conversation about a CONCEPT as a whole — the general remark that
    is not about a spot ("this one, but the header feels heavy")."""
    thread_id: int | None = None
    resolved: bool = False
    comments: list[CommentOut] = []


class SubjectThreadOut(BaseModel):
    """One subject's conversation as the discussion read carries it — a page,
    a version, a walkthrough point. Same shape as the per-subject reads."""
    thread_id: int | None = None
    resolved: bool = False
    comments: list[CommentOut] = []


class PointThreadOut(BaseModel):
    step_id: int
    step_order: int
    title: str
    # The point's own words: in the discussion it is the ROOT of its thread,
    # and the comments on it are the replies.
    body_md: str = ""
    thread: SubjectThreadOut


class DiscussionOut(BaseModel):
    """Everything a person can talk about on one design, in one read: the
    notes dropped on it (pins), the page as a whole, this version as a whole,
    and each walkthrough point. The viewer's Discussion mode lists them all."""
    pins: list[PinThreadOut]
    page: SubjectThreadOut
    version: SubjectThreadOut
    points: list[PointThreadOut]


class StepThreadOut(BaseModel):
    """A conversation about one BEAT of a walkthrough — "is this the final
    wording?", "why is this number so big?". A point in the tour is a subject
    like a screen is, so the question is asked where the point is shown,
    inside the tour, and nobody pauses it to drop a pin near the spot."""
    thread_id: int | None = None
    resolved: bool = False
    comments: list[CommentOut] = []


class CommentRevisionOut(BaseModel):
    body_md: str
    replaced_at: datetime


class CommentHistoryOut(BaseModel):
    """Oldest first — the original wording is the first entry."""
    revisions: list[CommentRevisionOut] = []


class PinListOut(BaseModel):
    pins: list[PinThreadOut]


class CreatePinIn(BaseModel):
    x_percent: float = Field(ge=0, le=100)
    y_percent: float = Field(ge=0, le=100)
    # Both present = an area note (x/y is its top-left); neither = a point.
    w_percent: float | None = Field(default=None, gt=0, le=100)
    h_percent: float | None = Field(default=None, gt=0, le=100)
    body_md: str = Field(min_length=1, max_length=8000)
    # Which variation was showing. Sent by the viewer, not chosen by the client.
    variants: dict[str, str] | None = None
    # The place on the design, when the bridge could name one.
    anchor: PinAnchor | None = None


class MovePinIn(BaseModel):
    x_percent: float = Field(ge=0, le=100)
    y_percent: float = Field(ge=0, le=100)
    # An area note may also be resized; both or neither.
    w_percent: float | None = Field(default=None, gt=0, le=100)
    h_percent: float | None = Field(default=None, gt=0, le=100)
    # The new place on the design. Absent clears it: a moved note's old anchor
    # names the spot it was moved away from.
    anchor: PinAnchor | None = None


class AddCommentIn(BaseModel):
    body_md: str = Field(min_length=1, max_length=8000)


class MoveThreadIn(BaseModel):
    """Re-file a conversation under another subject of the same design: the
    page as a whole (screen), this version (option), or a walkthrough point
    (step). A note on the design can be moved OFF its spot this way; moving
    something ONTO a spot needs a click on the design, so it is not here."""
    subject_type: Literal["screen", "option", "step"]
    subject_id: int


class ThreadMovedOut(BaseModel):
    thread_id: int
    subject_type: str
    subject_id: int


class PinCreatedOut(BaseModel):
    success: bool = True
    pin: PinThreadOut


# ------------------------------------------------------------------ walkthrough

class StepOut(BaseModel):
    id: int
    step_order: int
    title: str
    body_md: str
    # A CSS selector into the mockup document is the PREFERRED target: the
    # bridge measures the real element, so the spotlight tracks the design.
    # `rect` (document percentages) is the fallback when no stable selector
    # exists, and what drag-to-author produces.
    target_selector: str
    rect: dict | None
    requires_approval: bool
    # A demo click fired when the beat opens, and how long before the design is
    # handed back. 0 = leave it open.
    click_selector: str = ""
    click_delay_seconds: int = 2
    click_dismiss_seconds: int = 3
    # Said in a centred card, no highlight (a welcome).
    modal: bool = False
    approved_by_me: bool
    client_approved: bool
    # The conversation about this beat (StepThreadOut): how many live comments
    # it has and whether it is still open — the timeline badges the point.
    comment_count: int = 0
    thread_open: bool = False


class WalkthroughOut(BaseModel):
    steps: list[StepOut]


class CreateStepIn(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    body_md: str = Field(default="", max_length=8000)
    target_selector: str = Field(default="", max_length=255)
    rect: dict | None = None
    step_order: int | None = None
    requires_approval: bool = False
    click_selector: str = Field(default="", max_length=255)
    click_delay_seconds: int = Field(default=2, ge=0, le=600)
    click_dismiss_seconds: int = Field(default=3, ge=0, le=600)
    modal: bool = False


class ReorderStepsIn(BaseModel):
    """The whole walkthrough in the order it should read.

    A set, not a series of nudges: renumbering one point at a time leaves the
    walkthrough briefly scrambled, and a client reading it at that moment sees
    the wrong story. Ids missing from the list keep their place at the end.
    """
    step_ids: list[int]


class UpdateStepIn(BaseModel):
    title: str | None = Field(default=None, max_length=200)
    body_md: str | None = Field(default=None, max_length=8000)
    target_selector: str | None = Field(default=None, max_length=255)
    rect: dict | None = None
    step_order: int | None = None
    requires_approval: bool | None = None
    click_selector: str | None = Field(default=None, max_length=255)
    click_delay_seconds: int | None = Field(default=None, ge=0, le=600)
    click_dismiss_seconds: int | None = Field(default=None, ge=0, le=600)
    modal: bool | None = None


class ImportStepsIn(BaseModel):
    text: str = Field(min_length=1, max_length=60000)
    # Replace wipes the option's existing beats first. Off by default: an import
    # that silently destroyed an authored walkthrough would be unforgivable.
    replace: bool = False


class ImportStepsOut(BaseModel):
    success: bool = True
    created: int
    replaced: int = 0


class ApproveStepIn(BaseModel):
    comment: str | None = Field(default=None, max_length=2000)


# ------------------------------------------------------------------ bell

class NotificationOut(BaseModel):
    id: int
    actor: str
    app: str
    kind: str
    category: str
    context_label: str
    body: str
    url: str
    project_id: str | None
    occurred_at: datetime
    read: bool


class NotificationListOut(BaseModel):
    notifications: list[NotificationOut]
    unread: int
    needs_you: bool


class MarkReadIn(BaseModel):
    ids: list[int]


# ---------------------------------------------------------------- onboarding

class TourStateOut(BaseModel):
    version: int
    seen_version: int
    completed: bool
    should_offer: bool


class ContentsBeatOut(BaseModel):
    """One point of an entry's walkthrough, as the tour outline lists it —
    enough to show the points of every design without loading each one."""
    id: int
    title: str
    requires_approval: bool = False
    approved: bool = False
    comment_count: int = 0
    open: bool = False


class ContentsItemOut(BaseModel):
    key: str
    title: str
    href: str | None = None
    steps: int = 0
    required: int = 0
    approved: int = 0
    done: bool = False
    open_pins: int = 0
    chosen: bool = False
    beats: list[ContentsBeatOut] = []


class ContentsSectionOut(BaseModel):
    key: str
    kind: str            # "tool" | "screen"
    title: str
    items: list[ContentsItemOut]
    total: int = 0
    done_count: int = 0
    done: bool = False


class ContentsOut(BaseModel):
    tool_tour: TourStateOut
    sections: list[ContentsSectionOut]


class MarkTourSeenIn(BaseModel):
    completed: bool = False


class PresenceRowOut(BaseModel):
    username: str
    last_active_at: datetime | None = None
    last_seen_at: datetime | None = None


class PresenceOut(BaseModel):
    people: list[PresenceRowOut] = []


# ---------------------------------------------------------------- the round

class DecidedOut(BaseModel):
    screen: str
    option: str
    variants: list[str] = []


class UndecidedOut(BaseModel):
    screen: str
    options: int


class OutstandingOut(BaseModel):
    where: str
    author: str
    excerpt: str
    url: str
    kind: str            # "note" (pinned to a spot) | "page" (about a screen)


class RoundSummaryOut(BaseModel):
    """What was decided, what is outstanding, and whose move it is — the answer
    to a round, which is otherwise scattered across selections, approvals and
    two kinds of thread."""
    state: str
    waiting_on: list[str] = []
    decided: list[DecidedOut] = []
    undecided: list[UndecidedOut] = []
    outstanding: list[OutstandingOut] = []
    approvals_required: int = 0
    approvals_done: int = 0


# ------------------------------------------------------------------ outbound

class SettingsOut(BaseModel):
    dev_mode: bool
    held: int = 0


class SetDevModeIn(BaseModel):
    dev_mode: bool


class PersonOut(BaseModel):
    username: str
    name: str


class PeopleOut(BaseModel):
    people: list[PersonOut] = []


class DigestRunIn(BaseModel):
    # None = the configured quiet window; 0 = everyone with something waiting.
    quiet_minutes: int | None = Field(default=None, ge=0, le=100000)


class DigestRunOut(BaseModel):
    queued: int
    retried: int


class OutboundOut(BaseModel):
    id: int
    recipient: str
    to_email: str
    kind: str
    subject: str
    body: str
    url: str
    project_id: str | None = None
    created_at: datetime
    sent_at: datetime | None = None
    held_reason: str = ""


class OutboxOut(BaseModel):
    messages: list[OutboundOut] = []
