"""Pydantic request and response schemas."""

from datetime import datetime
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator

from app.providers import PROVIDER_NAMES


class SetupRequest(BaseModel):
  username: str
  password: str


class SetupStatus(BaseModel):
  configured: bool


class TokenResponse(BaseModel):
  access_token: str
  token_type: str = "bearer"


# Declared storage-access level. Used on two sides of the same coin:
#   cross_app_access  — what THIS app's token can do against others
#   share_with_apps   — what others can do against THIS app
# Cross-app traffic passes only when BOTH sides permit. Defaults to
# 'none' on both; the agent opts an app in when the partner asks.
ShareLevel = Literal["none", "read", "write"]


class AppCreate(BaseModel):
  name: str
  description: str = ""
  # Required — the full JSX source of the app's default export.
  # Prefer `register_app.py`, which handles schema + compile + DB
  # writes in one call. Hitting this endpoint directly is only
  # needed for copying apps between instances.
  jsx_source: str
  chat_id: str | None = None
  # Absolute directory under /data/apps/ where this app's index.jsx
  # lives. Passed by register_app.py so the file watcher can resolve
  # file events to apps without slugify-guessing the name.
  source_dir: str | None = None
  cross_app_access: ShareLevel = "none"
  share_with_apps: ShareLevel = "none"
  offline_capable: bool = False
  # Note: `manifest_url` is NOT accepted here. It is the identity key
  # the install endpoint uses to discriminate install-vs-update — a
  # caller spoofing it via direct POST could trick a later legitimate
  # install of the same URL into update-mode (clobbering the spoofed
  # app's jsx_source + permissions). Only `POST /api/apps/install`
  # may set `manifest_url`; this route always leaves it NULL.


class AppUpdate(BaseModel):
  # Drawer rename uses `name`; 500-char cap matches ChatPatch.title
  # so a runaway agent can't bloat the apps list response.
  name: str | None = Field(default=None, max_length=500)
  description: str | None = None
  jsx_source: str | None = None
  # None means "omit from update" (the field is not changed).
  # To explicitly clear chat_id, pass an empty string ("").
  chat_id: str | None = None
  source_dir: str | None = None
  # Drawer pin toggle. True sets pinned_at = now, False clears it.
  pinned: bool | None = None
  cross_app_access: ShareLevel | None = None
  share_with_apps: ShareLevel | None = None
  offline_capable: bool | None = None


class AppOut(BaseModel):
  id: int
  name: str
  description: str
  compiled_path: str
  chat_id: str | None = None
  source_dir: str | None = None
  pinned_at: datetime | None = None
  cross_app_access: ShareLevel = "none"
  share_with_apps: ShareLevel = "none"
  offline_capable: bool = False
  # The app embeds an agent chat — surfaced as a badge. See models.App.
  embeds_agent: bool = False
  # Install authority — see models.App.manage_apps for the contract.
  manage_apps: bool = False
  # URL slug for the standalone PWA install at /apps/<slug>/. Null
  # only for legacy rows from before the slug column existed; lazy-
  # backfilled on first access via standalone routes (see
  # routes/apps.py:ensure_slug).
  slug: str | None = None
  # URL the app was installed from (manifest URL passed to
  # POST /api/apps/install). Null for user-built apps. The install
  # endpoint matches by this for update-vs-install discrimination.
  manifest_url: str | None = None
  # The manifest version currently installed (e.g. "1.7.0"). Null for
  # user-built apps and for rows installed before the column existed
  # (they backfill on their next update). The store reads this to show
  # "Installed · vX.Y.Z" and to detect when the catalog ships a newer
  # version — see models.App.version.
  version: str | None = None
  # Optional standalone PWA colors persisted from installed app manifests.
  theme_color: str | None = None
  background_color: str | None = None
  # Offline contract from the manifest `offline` block (P1-D). None when no
  # block was declared; otherwise the raw validated JSON object. Informational
  # for the agent + future store badge — no server-side enforcement.
  offline_contract: dict | None = None
  created_at: datetime
  updated_at: datetime

  model_config = {"from_attributes": True}


class AppInstall(BaseModel):
  """Body for POST /api/apps/install — atomic install from a manifest.

  Exactly one of `manifest_url` or `manifest` must be set:
    - `manifest_url`: the installer GETs the manifest, derives raw_base
      from the URL (everything before the trailing filename), and
      fetches the entry JSX + icon + storage_seed files relative to it.
    - `manifest`: an inline manifest object. The caller must also pass
      `raw_base` so the installer knows where to fetch referenced files
      from. Useful for tests + future "install from local tarball".
  """
  manifest_url: str | None = None
  manifest: dict | None = None
  raw_base: str | None = None


class AppInstallOut(AppOut):
  """Install endpoint response — AppOut plus install-only fields."""
  # 'install' for a fresh row, 'update' if a same-manifest app already
  # existed and got its jsx_source / seeds / cron refreshed in place,
  # 'conflict' if the per-app git model is enabled and merging the new
  # upstream into local edits conflicted (feature 084). On 'conflict'
  # the served app is UNCHANGED — local edits are preserved and the
  # conflicting files are named in `conflict_paths` for an agent to
  # resolve. 'conflict' never occurs while the flag is explicitly off.
  mode: Literal["install", "update", "conflict"]
  # The manifest's declared version that ended up applied. Lets the
  # store mini-app refresh its installed-versions map without round-
  # tripping the manifest itself.
  version: str
  # Steps that were skipped because mini-app permissions could only
  # take them so far — e.g. "icon: 404 in source repo", "schedule:
  # no shell access (manual agent step)". Empty list = full success.
  warnings: list[str] = Field(default_factory=list)
  # Files that conflicted when merging the new upstream into local
  # edits. Non-empty only when `mode == "conflict"`. The store surfaces
  # these so the owner can ask the agent to resolve them.
  conflict_paths: list[str] = Field(default_factory=list)
  # How the local working branch related to the upstream this update
  # carried. Meaningful only when per-app git is enabled; conflicts are
  # carried by `mode == "conflict"`, not this field.
  divergence: Literal["none", "fast_forward", "clean_merge"] = "none"


class AppScheduleUpdate(BaseModel):
  """Body for updating one installed app's cron schedule."""

  cron: str
  job: str | None = None


class ConflictFile(BaseModel):
  path: str
  merged_with_markers: str


class UpdatePreviewOut(BaseModel):
  app_id: int
  status: Literal["clean", "conflict"]
  upstream_version: str | None = None
  upstream_commit: str | None = None
  conflict_paths: list[str] = Field(default_factory=list)
  conflicts: list[ConflictFile] = Field(default_factory=list)
  upstream_diff: str | None = None


class ProviderCodeRequest(BaseModel):
  code: str


class AppTokenRequest(BaseModel):
  app_id: int


class ChatMessage(BaseModel):
  role: str
  content: str


class ChatRequest(BaseModel):
  messages: list[ChatMessage]
  chat_id: str = ""


class ChatStopRequest(BaseModel):
  chat_id: str = ""


class AgentSettingsOverride(BaseModel):
  """Per-chat agent settings override. Unknown fields are rejected
  (422) so a typo'd or experimental key can't silently land in the
  persisted chat row + every GET response. To add a new field,
  declare it explicitly below."""

  model_config = ConfigDict(extra="forbid")

  model: str | None = None
  # Union of both SDKs' effort enums:
  #   Codex `ReasoningEffort` (openai-codex 0.131+): none, minimal,
  #     low, medium, high, xhigh.
  #   Claude `EffortLevel` (claude-agent-sdk): low, medium, high,
  #     xhigh, max (xhigh + max are Opus-tier only).
  # The picker enforces per-provider scoping. Runners forward the
  # value as-is to the SDK; a model/effort mismatch (e.g. `max` on a
  # non-Opus Claude) surfaces as a 400 at turn time, not at PATCH.
  # Acceptable per the platform's "reversibility over prevention"
  # philosophy.
  effort: Literal[
    "none", "minimal", "low", "medium", "high", "xhigh", "max"
  ] | None = None
  # Per-provider memory of the last-picked effort. The enums are
  # NOT comparable across providers — Codex `medium` is roughly
  # Claude `low`, not Claude `medium` — so the picker has to
  # remember each provider's last value separately and swap
  # `effort` to that value when the user switches providers.
  # Frontend writes the full dict each PATCH; backend stores it
  # verbatim under this key.
  effort_by_provider: dict[str, str] | None = None


class ChatPatch(BaseModel):
  """Partial-update payload for chat runtime settings."""

  agent_settings_json: AgentSettingsOverride | None = None
  clear_agent_settings: bool = False
  provider: str | None = None
  # Drawer rename uses this. Empty string is rejected so a misfired
  # blur on an empty input can't blank the title in the sidebar.
  # 500-char cap defends against runaway-agent megabyte payloads
  # bloating the chats list response on every refresh.
  title: str | None = Field(default=None, max_length=500)
  # Drawer pin toggle. True sets pinned_at = now, False clears it.
  pinned: bool | None = None

  @field_validator("provider")
  @classmethod
  def validate_provider(cls, value: str | None) -> str | None:
    """Reject unknown provider IDs at request-deserialize time."""
    if value is not None and value not in PROVIDER_NAMES:
      raise ValueError(f"unknown provider: {value}")
    return value


class SendMessage(BaseModel):
  content: str
  attachments: list[dict] | None = None
  timezone: str | None = None
  viewport: dict | None = None
  hidden: bool = False
  # Internal UI hint: Stop can collapse already-queued messages and ask
  # the backend to steer them into the live turn even when the chat's
  # normal send-while-running behavior is queueing.
  force_steer: bool = False
  consume_pending_ts: list[int] | None = None
  # When `hidden=True` and the user is answering an AskUserQuestion,
  # frontend includes the resolved answers here. Backend writes them
  # into the question block in the same transaction that appends the
  # hidden user message — eliminating the POST /question-answers +
  # POST /messages race that left answers missing on mid-stream
  # remounts.
  answers: dict | None = None
  # Optional identity of the question being answered (the runner-
  # published PendingQuestion id). When supplied, the backend writes
  # the answers into the question block with this exact `question_id`
  # instead of the latest assistant message's question block — fixing
  # the wrong-block bug when two questions are open at once. Optional
  # so older clients that omit it keep working via the latest-question
  # fallback (no behaviour change when absent).
  question_id: str | None = None


class PushKeys(BaseModel):
  p256dh: str
  auth: str


class PushSubscribeRequest(BaseModel):
  endpoint: str
  keys: PushKeys


class PushUnsubscribeRequest(BaseModel):
  endpoint: str


class NotificationAction(BaseModel):
  action: str
  title: str
  target: str | None = None


class NotificationSendRequest(BaseModel):
  title: str
  body: str | None = None
  icon: str | None = None
  target: str | None = None
  actions: list[NotificationAction] | None = None
  # Defaults to 'agent' so the common agent-authored curl works
  # with just {title, body}. Apps should pass 'app' + their id.
  source_type: str = "agent"
  source_id: str | None = None


class SettingsUpdate(BaseModel):
  """Owner-level settings updates."""

  gemini_api_key: str | None = None
  provider: str | None = None
  # Opt-in to offering SDK skills to the Claude agent. Behavior-
  # shifting and default-off (see providers.skills_enabled); persisted
  # to the shared agent-settings.json rather than the frozen Owner
  # model. None means "leave unchanged".
  skills_enabled: bool | None = None

  @field_validator("provider")
  @classmethod
  def validate_provider(cls, value: str | None) -> str | None:
    """Reject unknown provider IDs at request-deserialize time."""
    if value is not None and value not in PROVIDER_NAMES:
      raise ValueError(f"unknown provider: {value}")
    return value


class ModelEntry(BaseModel):
  """One row in the model registry — what the picker renders."""

  id: str
  label: str
  provider: str
  # True when the live API confirms the model is currently available.
  # KNOWN_MODELS entries the live API didn't return get `False`
  # here (kept for chats that persisted them as aliases). On full
  # fallback every entry is True since we have no live signal to
  # contradict it.
  available: bool = True


class ModelRegistryResponse(BaseModel):
  """`GET /api/models` response shape."""

  # Map provider id → ordered list of models.
  providers: dict[str, list[ModelEntry]]


class ModelPrefsUpdate(BaseModel):
  """`PATCH /api/owner/model-prefs` body.

  `hidden_ids` replaces the persisted list verbatim — the modal
  always sends the full set so partial-update merge logic isn't
  needed. Passing an empty list clears all hidden entries (shows
  everything). Unknown IDs are tolerated; they no-op until/unless
  the registry surfaces them again.
  """

  model_config = ConfigDict(extra="forbid")

  hidden_ids: list[str] = Field(default_factory=list)


class NotificationOut(BaseModel):
  id: str
  source_type: str
  source_id: str | None
  title: str
  body: str | None
  icon: str | None
  target: str | None
  actions: list | None
  sent_at: datetime
  clicked_at: datetime | None

  model_config = {"from_attributes": True}
