"""Schémas Pydantic du réservoir de mots-clés et des réglages d'organisation (spec Imports §6).

Une ligne du réservoir porte, **par métrique, la liste de ses observations** — une entrée par
source qui a une valeur pour ce champ, la plus récente de cette source (spec 2026-09-01 §3.2).
Plus rien n'est « élu » : l'écran affiche les valeurs côte à côte, chacune avec son badge de
provenance. C'est vrai des **deux** modes de vue — consolidée, ou par source où la liste compte
alors zéro ou une entrée — pour que l'UI n'ait qu'une seule forme à lire (§1.7).

## Représentation des décimales : `float`, partout, sans exception

`kd`, `cpc` et `position` sont des `NUMERIC` en base, donc des `Decimal` côté Python — et
**Pydantic v2 sérialise un `Decimal` en chaîne JSON** (`"12.5"`, pas `12.5`). Le piège a été
relevé par le contract-keeper T4 : un `z.number()` côté web, recopié depuis l'aperçu d'import
(où ces champs sont déjà des `float`), aurait explosé au premier rendu du réservoir.

La convention est donc tranchée ici et vaut pour **tous** les endpoints keywords : les schémas
de sortie typent ces champs en `float`, le web lit des nombres JSON. Le stockage, lui, reste en
`NUMERIC` : la précision n'est perdue qu'à l'affichage, jamais en base.

Deux chemins, une seule convention. `MetricPoint` et `MapOut.position` lisent des colonnes
`NUMERIC` par l'ORM : c'est bien Pydantic qui convertit le `Decimal`. Les métriques du
réservoir, elles, ne passent plus par un `Decimal` du tout depuis la consolidation
multi-sources — la fonction SQL les rend en jsonb et c'est le codec `json.loads` d'asyncpg qui
les décode en `int`/`float` Python (vérifié). Le `float` du schéma y est une garantie de
contrat, plus une conversion.
"""

import uuid
from datetime import datetime
from typing import Annotated, Any, Literal

from pydantic import BaseModel, ConfigDict, Field, StringConstraints

# Statut d'attache calculé côté SQL (§6). `pending` = des maps, mais aucun `page_id` posé —
# « en attente de crawl », un état de premier rang, jamais une erreur.
AttachStatus = Literal["orphan", "pending", "attached", "multi"]
ATTACH_STATUSES: tuple[str, ...] = ("orphan", "pending", "attached", "multi")

# Mode de vue du réservoir : la consolidation (décision n°3) ou les valeurs brutes d'une source.
CONSOLIDATED = "consolidated"

# Plafond d'un geste de masse (§1.8). Une page du réservoir vaut au plus 100 lignes : la
# sélection de l'écran tient toujours dessous. Le plafond n'est donc pas une contrainte
# fonctionnelle, c'est la borne qui empêche un corps de requête arbitrairement grand.
MAX_BULK = 500

ListName = Annotated[str, StringConstraints(min_length=1, max_length=200)]
TagName = Annotated[str, StringConstraints(min_length=1, max_length=100)]
# Une URL déclarée à la main : bornée ici, validée par `normalize_url` ensuite (422).
DeclaredUrl = Annotated[str, StringConstraints(min_length=1, max_length=2048)]
KeywordIds = Annotated[list[uuid.UUID], Field(min_length=1, max_length=MAX_BULK)]


class Observation[T](BaseModel):
    """Ce qu'**une** source a observé pour un champ, à une date (spec 2026-09-01 §3.2).

    Aucune valeur n'est élue : chaque métrique d'une ligne du réservoir est une **liste**
    d'observations, une par source qui a une valeur pour ce champ. L'ordre est celui du SQL et
    il est contractuel — la source préférée d'abord, puis `fetched_at` décroissant, puis le nom
    de source —, le web n'a rien à retrier. Un champ sans aucune observation rend `[]`, jamais
    `null` et jamais une entrée creuse.

    `value` est **requis** : une source sans valeur pour un champ n'apparaît pas dans la liste
    de ce champ. L'invariant est tenu par le SQL (`WHERE m.<col> IS NOT NULL` dans la fonction
    `keyword_metrics_latest`), **pas** par ce contrat — `Observation[Any]`, le cas de
    `serp_features`, accepte `value: None` sans broncher, puisque `Any` accepte tout.

    Ne **jamais** écrire `list[Observation]` sans paramètre : la validation de `value` est
    alors silencieusement désactivée, et aucun type-checker n'est configuré côté api pour
    l'attraper.
    """

    value: T
    source: str
    fetched_at: datetime


class ListBrief(BaseModel):
    id: uuid.UUID
    name: str


class TagBrief(BaseModel):
    id: uuid.UUID
    name: str


class KeywordRow(BaseModel):
    id: uuid.UUID
    keyword: str
    locale: str
    # Une entrée par source qui a une valeur pour ce champ, déjà ordonnées par le SQL. `[]`
    # quand aucune source ne l'a observé ; la vue par source rend la **même** forme, avec zéro
    # ou une entrée (décision Q13) — l'UI n'a qu'une branche à écrire.
    volume: list[Observation[int]]
    #  `float` et non `Decimal` : voir la note de représentation en tête de module.
    kd: list[Observation[float]]
    cpc: list[Observation[float]]
    intent: list[Observation[str]]
    serp_features: list[Observation[Any]]
    trend: list[Observation[list[int]]]
    # max(fetched_at) toutes sources en vue consolidée, fraîcheur de la source en vue simple.
    last_updated: datetime | None
    attach_status: AttachStatus
    # Listes **de la cible** demandée : le réservoir est toujours lu par cible (§6).
    lists: list[ListBrief]
    tags: list[TagBrief]


class KeywordListPage(BaseModel):
    items: list[KeywordRow]
    next_cursor: str | None
    # Mode effectivement appliqué : `consolidated` ou le nom de la source demandée.
    view: str
    # Source préférée de l'organisation : elle **ordonne** les observations de chaque métrique
    # — la sienne en tête — et n'en masque aucune (spec 2026-09-01 §3.4).
    preferred_source: str | None
    # `true` quand la recherche exacte n'a rien rendu et que le repli trigramme a pris le
    # relais : les lignes sont des **à-peu-près**, et l'écran doit le dire plutôt que de les
    # faire passer pour des résultats. Toujours `false` hors recherche.
    fuzzy: bool = False


# --- suggestions au typing (§1.7) -------------------------------------------------------------


class KeywordSuggestion(BaseModel):
    """Une entrée du menu de complétion : de quoi l'afficher, et rien de plus.

    Le volume est le **maximum toutes sources** (§3.5) : il n'y a plus de valeur consolidée à
    afficher, et un menu de complétion n'a pas la place d'en montrer plusieurs — le plus gros
    est le repère de taille attendu, et il ne dépend d'aucun réglage. `None` reste un état
    normal : un mot-clé attaché à la main n'a aucune observation.
    """

    keyword_id: uuid.UUID
    keyword: str
    volume: int | None


class KeywordSuggestions(BaseModel):
    items: list[KeywordSuggestion]


# --- historique d'une métrique (popover, §1.7) ---------------------------------------------


class MetricPoint(BaseModel):
    """Une observation brute, telle que la source l'a livrée à cette date."""

    model_config = ConfigDict(from_attributes=True)

    source: str
    fetched_at: datetime
    volume: int | None = None
    kd: float | None = None
    cpc: float | None = None
    intent: str | None = None
    serp_features: Any = None
    trend: list[int] | None = None


class MetricHistory(BaseModel):
    """Tout ce que le popover affiche : le mot-clé, et ses observations les plus récentes."""

    keyword_id: uuid.UUID
    keyword: str
    locale: str
    items: list[MetricPoint]


# --- listes de destination (§3.4) ------------------------------------------------------------


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

    id: uuid.UUID
    site_target_id: uuid.UUID
    name: str
    source: str | None
    created_from_import_id: uuid.UUID | None
    created_at: datetime
    # Décompte des membres : l'écran affiche « Semrush — 2026-08-31 (1 240) » dans son filtre,
    # et le calculer côté web imposerait une requête par liste.
    item_count: int


class KeywordListsPage(BaseModel):
    items: list[KeywordListOut]
    next_cursor: str | None


class KeywordListCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    site_target_id: uuid.UUID
    name: ListName


class KeywordIdsIn(BaseModel):
    """Corps d'un geste de masse : les mots-clés sélectionnés dans la table (§1.8)."""

    model_config = ConfigDict(extra="forbid")

    keyword_ids: KeywordIds


class ItemsAdded(BaseModel):
    #  Nombre de lignes **réellement** créées : un mot-clé déjà membre n'est pas compté
    #  (`ON CONFLICT DO NOTHING`), et l'écran peut le dire honnêtement.
    added: int


class ItemsRemoved(BaseModel):
    removed: int


# --- tags (§3.6) ------------------------------------------------------------------------------


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

    id: uuid.UUID
    name: str
    created_at: datetime


class TagsPage(BaseModel):
    items: list[TagOut]
    next_cursor: str | None


class TagCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: TagName


class KeywordTagsIn(BaseModel):
    model_config = ConfigDict(extra="forbid")

    tag_id: uuid.UUID
    keyword_ids: KeywordIds


# --- attache manuelle (§3.7, §3.9) ------------------------------------------------------------


class MapIn(BaseModel):
    """« Attacher à une page » : l'URL déclarée à la main, page publiée ou non (§1.8)."""

    model_config = ConfigDict(extra="forbid")

    site_target_id: uuid.UUID
    url: DeclaredUrl


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

    id: uuid.UUID
    site_target_id: uuid.UUID
    keyword_id: uuid.UUID
    url_normalized: str
    # `NULL` = « en attente de crawl » : la page n'est pas encore connue de la base, ce qui
    # est un état de premier rang et jamais une erreur (§3.9).
    page_id: uuid.UUID | None
    source: str
    position: float | None
    seen_at: datetime
    created_at: datetime


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

    preferred_source: str | None
    updated_at: datetime | None = None


class OrgSettingsUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    # `None` retire la préférence : les observations restent toutes rendues, simplement
    # ordonnées par fraîcheur puis par nom de source (spec 2026-09-01 §3.4).
    preferred_source: Annotated[str, StringConstraints(min_length=1, max_length=64)] | None = None
