"""API request/response shapes.

These are the single coupling point between backend and frontend: the TypeScript
client is generated from the OpenAPI schema these produce, inside the image build.
Never hand-write a TS interface mirroring one of these — regenerate instead.
"""

from datetime import datetime
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field

from app.constants import OptionStatus, ProjectStatus


class VocabularyItem(BaseModel):
    value: str
    label: str


class Meta(BaseModel):
    """Everything the UI needs before it knows who the caller is."""

    app: str
    version: str
    levels: list[VocabularyItem]
    project_statuses: list[VocabularyItem]
    option_statuses: list[VocabularyItem]
    # The review-game vocabularies (D9/D10). Rating values are "0".."3" in the
    # shared VocabularyItem shape; the UI converts with Number().
    aspects: list[VocabularyItem]
    rating_scale: list[VocabularyItem]


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

    username: str
    email: str
    first: str
    last: str
    display_name: str
    level: str
    all_instances: bool
    active: bool
    last_login: datetime | None


class Me(BaseModel):
    """The public sign-in state endpoint.

    `should_probe` is computed on the server because the loop-guard and opt-out
    cookies are HttpOnly — the SPA cannot read them itself.
    """

    authenticated: bool
    should_probe: bool
    sign_in_available: bool
    account: AccountOut | None = None
    # What this person may do, decided server-side. The UI switches on these,
    # never on a level name — policy lives in one place. While a View As is
    # active, every capability here answers for the EFFECTIVE user (that is the
    # point); the view-as block below carries the real one.
    is_owner: bool = False
    is_staff: bool = False
    can_create_projects: bool = False
    can_manage_accounts: bool = False
    # Which levels this caller may hand out — drives every level <select>.
    assignable_levels: list[str] = []
    # The central manage-account page (change password, link Google).
    account_url: str | None = None
    # --- View As (bw_view_as.me_fields, mode renamed view_as_mode) ---------
    impersonating: bool = False
    viewing_as: str | None = None
    viewing_as_label: str | None = None
    real_user: str | None = None
    view_as_mode: str | None = None
    can_write: bool = True
    # Computed on the REAL user: whether to offer the View As picker at all.
    can_view_as_others: bool = False


class ProjectCreate(BaseModel):
    name: str = Field(min_length=1, max_length=160)
    client_name: str = Field(default="", max_length=160)
    brief: str = Field(default="", max_length=8000)
    client_website: str = Field(default="", max_length=500)
    status: ProjectStatus = ProjectStatus.DRAFT


class ProjectUpdate(BaseModel):
    name: str | None = Field(default=None, min_length=1, max_length=160)
    client_name: str | None = Field(default=None, max_length=160)
    brief: str | None = Field(default=None, max_length=8000)
    client_website: str | None = Field(default=None, max_length=500)
    status: ProjectStatus | None = None


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

    id: int
    slug: str
    name: str
    client_name: str
    brief: str
    client_website: str
    status: str
    created_by: str
    created_at: datetime
    updated_at: datetime
    member_count: int = 0
    # This caller's standing on THIS project, resolved server-side.
    my_level: str | None = None
    can_manage: bool = False
    can_manage_members: bool = False
    can_view_results: bool = False
    can_delete: bool = False


class MemberOut(BaseModel):
    username: str
    display_name: str
    email: str
    # The level on THIS project (the kit's grant) — not the app-wide one.
    level: str
    # Their app-wide level, so admin screens can show that staff reach every
    # project without an explicit grant.
    account_level: str
    all_instances: bool
    active: bool
    added_by: str
    added_at: datetime


class MemberAdd(BaseModel):
    """Adding someone to a project by BW username.

    The username is resolved against BW at add time, which both validates that the
    account exists and gives Scout a real name to show before that person's first
    sign-in.
    """

    username: str = Field(min_length=1, max_length=64)
    level: str = Field(default="reviewer", max_length=40)


class AccountLevelUpdate(BaseModel):
    level: str = Field(max_length=40)
    all_instances: bool | None = None


class MemberLevelUpdate(BaseModel):
    level: str = Field(max_length=40)


class AccountActiveUpdate(BaseModel):
    active: bool


# --- view as -----------------------------------------------------------------


class ViewAsTarget(BaseModel):
    username: str
    label: str


class ViewAsStart(BaseModel):
    target: str = Field(min_length=1, max_length=64)
    # 'readonly' unless the owner explicitly asks to act.
    mode: str = Field(default="readonly", pattern="^(readonly|act)$")


# --- level administration ------------------------------------------------------


class PermissionInfo(BaseModel):
    value: str
    label: str
    scope: str
    description: str
    enforced: bool


class LevelDef(BaseModel):
    name: str
    permissions: list[str]
    assignable: list[str]
    in_use_by: int = 0


class LevelsOut(BaseModel):
    levels: list[LevelDef]
    known_permissions: list[PermissionInfo]
    # What THIS caller's mutations may reference; the kit still enforces.
    assignable_by_me: list[str]
    can_edit: bool


class LevelCreate(BaseModel):
    name: str = Field(min_length=1, max_length=40)
    permissions: list[str] = []
    assignable: list[str] = []


class LevelUpdate(BaseModel):
    permissions: list[str] | None = None
    assignable: list[str] | None = None


# --- access matrix ---------------------------------------------------------


class MatrixProject(BaseModel):
    id: str
    label: str


class MatrixUser(BaseModel):
    username: str
    display_name: str
    level: str
    all_instances: bool
    active: bool
    cells: dict[str, str | None]


class AccessMatrix(BaseModel):
    projects: list[MatrixProject]
    users: list[MatrixUser]


# --- invites / resets --------------------------------------------------------


class InviteRequest(BaseModel):
    username: str = Field(min_length=2, max_length=31)
    email: str = Field(min_length=3, max_length=255)
    first: str = Field(default="", max_length=120)
    last: str = Field(default="", max_length=120)
    # Optional: also grant a level on one project (the scoped-inviter flow).
    project_id: int | None = None
    project_level: str | None = Field(default=None, max_length=40)
    # Optional app-wide standing; assignability enforced server-side.
    level: str | None = Field(default=None, max_length=40)


class InviteResult(BaseModel):
    username: str
    created: bool
    emailed: bool
    existing_with_password: bool
    summary: str


class ForgotRequest(BaseModel):
    username: str = Field(min_length=1, max_length=64)


# --- auth admin ------------------------------------------------------------


class BWStatus(BaseModel):
    configured: bool
    client_id: str | None
    auth_host: str
    domain_mode: str
    owner: str
    reachable: bool | None


class CrossAppInstance(BaseModel):
    id: str = ""
    label: str = ""
    level: str = ""


class CrossAppAccess(BaseModel):
    app: str = ""
    host: str = ""
    description: str = ""
    level: str = ""
    instances: list[CrossAppInstance] = []


# --- options (admin) --------------------------------------------------------


class OptionAdminOut(BaseModel):
    """The full, attributed option — admin eyes only."""

    model_config = ConfigDict(from_attributes=True)

    id: int
    slug: str
    display_label: str
    descriptor: str
    source_name: str
    source_url: str
    why_selected: str
    design_notes: str
    reveal_source: bool
    status: str
    sort_order: int
    created_at: datetime
    updated_at: datetime
    review_count: int = 0
    # Whether captures exist on disk-backed columns; populated by the router
    # helper (the paths themselves are server-internal and never serialized).
    has_desktop: bool = False
    has_mobile: bool = False


class OptionUpdate(BaseModel):
    display_label: str | None = Field(default=None, min_length=1, max_length=40)
    descriptor: str | None = Field(default=None, max_length=160)
    source_name: str | None = Field(default=None, max_length=200)
    source_url: str | None = Field(default=None, max_length=500)
    why_selected: str | None = Field(default=None, max_length=8000)
    design_notes: str | None = Field(default=None, max_length=8000)
    reveal_source: bool | None = None
    status: OptionStatus | None = None


class OptionReorder(BaseModel):
    ordered_ids: list[int]


class ImportResultOut(BaseModel):
    created: list[str]
    updated: list[str]


# --- the review game (client) -----------------------------------------------


class ClientOptionOut(BaseModel):
    """The anonymized option a reviewer reacts to (D8). Source fields are empty
    strings unless the admin flipped reveal_source for this option."""

    id: int
    display_label: str
    descriptor: str
    reveal_source: bool
    source_name: str = ""
    source_url: str = ""
    has_desktop: bool
    has_mobile: bool


class MyReviewOut(BaseModel):
    option_id: int
    rating: int | None
    note: str


class MyAspectVoteOut(BaseModel):
    option_id: int
    aspect: str
    vote: int


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

    option_id: int | None
    closing_note: str
    completed_at: datetime | None


class ReviewBundle(BaseModel):
    """Everything the review game needs in one fetch: the caller's options in
    THEIR stable-random order (D11), plus their own saved state — never anyone
    else's (D7)."""

    project_id: int
    project_name: str
    client_name: str
    options: list[ClientOptionOut]
    my_reviews: list[MyReviewOut]
    my_aspect_votes: list[MyAspectVoteOut]
    final_pick: FinalPickOut | None


class ReviewPatch(BaseModel):
    """Autosave (D12): only the fields present in the request are written, so a
    rating tap can never blank a note saved a moment earlier."""

    rating: int | None = Field(default=None, ge=0, le=3)
    note: str | None = Field(default=None, max_length=4000)


class AspectVoteSet(BaseModel):
    vote: Literal[-1, 0, 1]


class FinalPickPatch(BaseModel):
    option_id: int | None = None
    closing_note: str | None = Field(default=None, max_length=4000)
    completed: bool | None = None


# --- results (admin) --------------------------------------------------------


class OptionRatingRow(BaseModel):
    username: str
    display_name: str
    # The reviewer's level ON THIS PROJECT — used to separate staff previews
    # from real client answers, never averaged together (D7).
    level: str
    is_staff: bool
    rating: int | None
    note: str


class AspectTally(BaseModel):
    aspect: str
    up: int
    down: int


class OptionResult(BaseModel):
    option: OptionAdminOut
    ratings: list[OptionRatingRow]
    distribution: dict[str, int]
    mean: float | None
    aspect_tallies: list[AspectTally]
    final_pick_count: int


class ReviewerStatus(BaseModel):
    username: str
    display_name: str
    level: str
    is_staff: bool
    rated_count: int
    total_published: int
    completed_at: datetime | None
    has_final_pick: bool


class FinalPickRow(BaseModel):
    username: str
    display_name: str
    level: str
    is_staff: bool
    option_id: int | None
    option_label: str
    closing_note: str
    completed_at: datetime | None


class BiggestSplit(BaseModel):
    """The largest client disagreement on one option — splits are signal for the
    admin, not noise to average away (D7)."""

    option_id: int
    option_label: str
    high_username: str
    high_display: str
    high_rating: int
    low_username: str
    low_display: str
    low_rating: int


class ResultsOut(BaseModel):
    reviewers: list[ReviewerStatus]
    options: list[OptionResult]
    final_picks: list[FinalPickRow]
    biggest_split: BiggestSplit | None


# --- materials (admin) ------------------------------------------------------


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

    id: int
    kind: str
    title: str
    body: str
    url: str
    file_name: str
    mime: str
    size: int
    added_by: str
    created_at: datetime


class NoteCreate(BaseModel):
    title: str = Field(default="", max_length=200)
    body: str = Field(min_length=1, max_length=64000)


class LinkCreate(BaseModel):
    title: str = Field(default="", max_length=200)
    url: str = Field(min_length=1, max_length=500)
