"""Schémas de la frontière HTTP des intégrations BYOK (spec Imports T8).

**Aucune sortie ne porte de secret.** `IntegrationOut` n'a pas de champ pour la clé : elle
n'est pas « masquée à l'affichage », elle est absente du schéma — donc absente du contrat, de
la doc OpenAPI et de tout ce qu'un futur handler pourrait sérialiser par mégarde. Le seul
écho de la clé est `credentials_hint` (`••••••cd12`), calculé à l'écriture et stocké tel quel.
"""

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

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from app.imports.schemas import CountryCount

Provider = Literal["mangools"]
SyncMode = Literal["manual", "weekly", "monthly"]

# Clés de `config` dont l'api sait qu'elles ont un type, et qu'elle doit donc valider **à
# l'écriture**. Le reste du jsonb est libre par construction (réglages d'un fournisseur qu'on
# ne connaît pas encore) et n'est jamais interprété.
#
# Pourquoi valider ici plutôt que de se rattraper à la lecture : `config` est écrit par un
# `PUT`/`PATCH` qui répond 200, et relu **beaucoup plus tard** par le job d'import ou par le
# sweep du planificateur. Un `location_id` non entier accepté au 200 devient une panne
# différée, dans un process sans utilisateur devant, sur un rendez-vous qui n'est jamais celui
# qu'on regarde. Le 422 immédiat le refuse au seul endroit où quelqu'un peut le corriger.
CONFIG_INT_KEYS = ("location_id",)


def validate_config(config: dict[str, Any] | None) -> dict[str, Any] | None:
    """Refuse un `config` dont une clé typée ne l'est pas. Rendue telle quelle sinon."""
    if config is None:
        return None
    for key in CONFIG_INT_KEYS:
        if key not in config or config[key] is None:
            continue
        value = config[key]
        #  `bool` est un `int` en Python : `{"location_id": true}` passerait sans ce test.
        if isinstance(value, bool) or not isinstance(value, int):
            raise ValueError(f"config.{key} doit être un entier, reçu {value!r}")
    return config


# Bornes de saisie de la clé : assez large pour n'importe quel jeton de fournisseur, assez
# serrée pour qu'un collage accidentel (un fragment, un mot de passe, un bout de fichier) ne
# parte pas en base sous les traits d'une clé. Les jetons des fournisseurs BYOK visés font
# plusieurs dizaines de caractères ; 16 ne rejette aucun d'entre eux et attrape les fautes de
# copier-coller les plus courantes, que 8 laissait passer.
KEY_MIN = 16
KEY_MAX = 512


class IntegrationCreate(BaseModel):
    """Première saisie d'une clé.

    **`config` n'est pas un second endroit où ranger un secret** : il est stocké en clair et
    **renvoyé tel quel** par `IntegrationOut` — c'est fait pour des réglages publics du
    fournisseur (identifiant de localisation par défaut, domaine à interroger). Seul `api_key`
    est chiffré. Un client qui y glisserait un jeton le publierait à chaque rendu de l'écran.
    """

    model_config = ConfigDict(extra="forbid")

    provider: Provider
    api_key: str = Field(min_length=KEY_MIN, max_length=KEY_MAX)
    config: dict[str, Any] = Field(default_factory=dict)

    _check_config = field_validator("config")(staticmethod(validate_config))


class IntegrationUpdate(BaseModel):
    """Remplacement de la clé et/ou des réglages. Champ absent = inchangé."""

    model_config = ConfigDict(extra="forbid")

    api_key: str | None = Field(default=None, min_length=KEY_MIN, max_length=KEY_MAX)
    config: dict[str, Any] | None = None

    _check_config = field_validator("config")(staticmethod(validate_config))


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

    id: uuid.UUID
    provider: str
    #  Le seul écho de la clé, et il n'est pas réversible.
    credentials_hint: str
    config: dict[str, Any]
    status: str
    status_error: str | None
    checked_at: datetime | None
    created_at: datetime
    updated_at: datetime


class IntegrationListOut(BaseModel):
    items: list[IntegrationOut]


class ImportSummary(BaseModel):
    """Le « dernier rapport » qu'affiche l'écran Intégrations, par cible (§1.11)."""

    model_config = ConfigDict(from_attributes=True)

    id: uuid.UUID
    status: str
    fetched_at: datetime
    created_at: datetime
    error: str | None
    report: dict[str, Any] | None


class TargetImportOut(BaseModel):
    """Le dernier import API d'une cible, **sans passer par un planning**.

    Séparé de `ScheduleOut` depuis la décision « pas de synchro récurrente » (2026-09-02) :
    l'écran n'affiche plus de cadence, il n'a donc plus de raison de créer une ligne
    `import_schedules` pour chaque cible — et une cible qui n'en a pas doit quand même montrer
    le rapport de son dernier import manuel. Lire cette information par la liste des plannings
    la lui aurait cachée, sans erreur ni message.
    """

    site_target_id: uuid.UUID
    last_import: ImportSummary


class TargetImportListOut(BaseModel):
    items: list[TargetImportOut]


class ScheduleUpsert(BaseModel):
    """Le mode de synchro d'une cible. Idempotent : le même corps deux fois = le même état.

    `enabled` et `config` sont **absents par défaut, pas remis à leur valeur d'usine** : l'écran
    n'envoie que `sync_mode`, et un défaut concret ferait de chaque changement de cadence une
    remise à zéro silencieuse de l'interrupteur et des réglages de la cible. Sans conséquence
    aujourd'hui (rien ne les pose encore), mais c'est précisément le genre de piège qui se
    déclenche six mois plus tard, le jour où un `config` par cible arrive.

    Sur une **création**, un champ absent prend le défaut du modèle (`enabled=True`, `config`
    vide) ; sur une mise à jour, il laisse la valeur en place.
    """

    model_config = ConfigDict(extra="forbid")

    site_target_id: uuid.UUID
    sync_mode: SyncMode
    enabled: bool | None = None
    config: dict[str, Any] | None = None

    _check_config = field_validator("config")(staticmethod(validate_config))


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

    id: uuid.UUID
    integration_id: uuid.UUID
    site_target_id: uuid.UUID
    sync_mode: str
    #  La « prochaine échéance » de l'écran. NULL en mode manuel, par contrainte de base.
    next_run_at: datetime | None
    enabled: bool
    config: dict[str, Any]
    last_import: ImportSummary | None = None


class ScheduleListOut(BaseModel):
    items: list[ScheduleOut]


# Bornes de la liste d'enrichissement. Le coût ne dépend pas d'elles — une unité de quota est
# consommée **par requête**, pas par mot-clé (`mangools_api`) — mais un corps HTTP n'est pas
# infini et un collage accidentel de tout un export ne doit pas devenir un import. 2000 laisse
# passer n'importe quelle liste tapée à la main tout en bornant le lot ; la longueur d'un
# mot-clé suit `keywords.keyword` en base.
KEYWORDS_MAX = 2_000
KEYWORD_LENGTH_MAX = 255

# --- listes enregistrées du compte KWFinder --------------------------------------------------

# Bornes d'une sélection de listes. Le plafond n'est pas là pour économiser du quota (ces
# endpoints ne consomment rien, mesuré 2026-09-02) mais pour borner l'**attente** : le
# recensement ouvre une liste par requête et les espace d'environ trois secondes pour respecter
# `reqs-per-short-period`, donc dix listes font déjà une trentaine de secondes. Le compte relevé
# en a huit ; au-delà de dix, l'écran demanderait de patienter plus qu'il n'est raisonnable.
LIST_IDS_MAX = 10
# Les identifiants relevés font 24 caractères hexadécimaux (des ObjectId Mongo). On borne la
# longueur sans figer ce format : l'identifiant part dans un **chemin d'URL** vers un tiers, et
# c'est là que la borne compte. Le caviardage des caractères de chemin, lui, appartient au
# client (`quote`), pas au schéma.
LIST_ID_LENGTH_MAX = 64


def clean_list_ids(value: list[str] | None) -> list[str] | None:
    """Détoure, écarte le vide, dédoublonne **en gardant l'ordre**, refuse une sélection vide."""
    if value is None:
        return None
    seen: dict[str, None] = {}
    for raw in value:
        identifier = raw.strip()
        if not identifier:
            continue
        if len(identifier) > LIST_ID_LENGTH_MAX:
            raise ValueError(f"identifiant de liste trop long ({len(identifier)} caractères)")
        seen.setdefault(identifier, None)
    if not seen:
        raise ValueError("aucune liste sélectionnée")
    return list(seen)


class KeywordListOut(BaseModel):
    """Une liste du compte KWFinder, telle que l'étape 1 du wizard l'affiche.

    `keyword_count` est le compte **du fournisseur**, pas celui de ce qui sera importé : le
    cloisonnement par pays en écarte une partie, et c'est le recensement (`countries`) puis le
    rapport d'import qui le disent.
    """

    model_config = ConfigDict(from_attributes=True)

    id: str
    name: str
    keyword_count: int
    created_at: datetime | None
    updated_at: datetime | None


class KeywordListsOut(BaseModel):
    items: list[KeywordListOut]


class ListPreviewIn(BaseModel):
    """Ce que le wizard demande avant de choisir ses cibles : les marchés réellement présents.

    Le pendant exact de `POST /imports/preview` pour le chemin API. **Rien n'est persisté**, et
    rien n'est consommé chez le fournisseur (mesuré) : ouvrir une liste est gratuit, seul le
    débit est espacé.
    """

    model_config = ConfigDict(extra="forbid")

    list_ids: list[str] = Field(min_length=1, max_length=LIST_IDS_MAX)
    #  Facultatif, comme au preview CSV : sans cible l'api ne connaît aucun site, `target_ids`
    #  reste vide et le wizard proposera de créer chaque cible. Dégradé, jamais faux.
    site_target_id: uuid.UUID | None = None

    _clean_list_ids = field_validator("list_ids")(staticmethod(clean_list_ids))


class ListPreview(BaseModel):
    """Le recensement d'**une** liste : ce qu'elle contient, et pour quels marchés.

    Rendu par liste et non seulement en agrégat parce que c'est ce qui évite les imports vides :
    une liste entièrement française croisée avec une cible américaine ne produirait aucune
    ligne, et le wizard peut ne pas la lancer plutôt que de fabriquer un rapport à zéro.
    """

    list_id: str
    name: str
    #  Nombre de mots-clés **réellement rendus** par le fournisseur pour cette liste, qui peut
    #  différer de `KeywordListOut.keyword_count` (celui-ci compte des identifiants).
    keywords_total: int
    countries: list[CountryCount]


class ListPreviewOut(BaseModel):
    """Le recensement de la sélection : liste par liste, et tous marchés confondus."""

    items: list[ListPreview]
    #  L'agrégat, trié par nombre de lignes décroissant — la même forme que `PreviewOut.countries`
    #  du chemin CSV, donc le même composant d'écran et le même miroir Zod.
    countries: list[CountryCount]


class ImportTrigger(BaseModel):
    """« Importer maintenant » pour une cible (§1.11) — et le mode de l'import API.

    **Trois modes, exclusifs**, miroir de `FetchSpec` (`app/keywords/sources/mangools_api.py`) :

    - **listes enregistrées** (`list_ids`) — `GET /kwfinder/lists/{id}` : les mots-clés des
      listes que l'utilisateur a déjà constituées dans son compte KWFinder. **Le seul mode que
      l'interface expose** depuis la reprise du 2026-09-02 ;
    - **découverte** (`domain`) — `competitor-keywords` : les mots-clés sur lesquels un domaine
      est déjà vu ;
    - **enrichissement** (`keywords`) — `keyword-imports` : les métriques de mots-clés qu'on
      nomme, KD compris.

    Les deux derniers restent **acceptés par le contrat** mais ne sont plus atteignables depuis
    le wizard : ils sont dormants, pas supprimés (`docs/pre-prod-checklist.md`).

    **Aucun mode = découverte du domaine de la cible**, ce que faisait cet endpoint avant que le
    champ n'existe. Plus aucun écran n'envoie ce corps-là : le bouton « Importer maintenant » des
    Réglages, qui envoyait `{site_target_id}` seul, a été supprimé le 2026-09-02 au profit du
    parcours d'import de listes. Ce qu'on refuse ici, c'est l'ambiguïté — deux champs à la
    fois — parce qu'elle laisserait le choix du mode à l'ordre des `if` du service plutôt qu'à
    l'utilisateur. Et `keywords`, accepté par ce schéma, est **refusé à l'enfilage** (422) :
    faute de nom de liste stable, il n'a nulle part où faire atterrir ses mots-clés.

    Le **pays n'est pas dans ce corps** : il vient de la cible (`site_target_id`). Pour les deux
    modes dormants, la locale de la cible détermine le `location_id` Mangools et la réponse ne
    contient donc que le bon marché. Pour les listes, il n'y a pas de `location_id` à
    envoyer — une liste enregistrée peut mélanger plusieurs marchés —, et c'est le pipeline qui
    écarte et **compte** (`rows_off_target`) ce qui n'est pas de la cible.
    """

    model_config = ConfigDict(extra="forbid")

    site_target_id: uuid.UUID
    list_ids: list[str] | None = Field(default=None, max_length=LIST_IDS_MAX)
    domain: str | None = Field(default=None, max_length=255)
    keywords: list[str] | None = Field(default=None, max_length=KEYWORDS_MAX)

    _clean_list_ids = field_validator("list_ids")(staticmethod(clean_list_ids))

    @field_validator("domain")
    @classmethod
    def _clean_domain(cls, value: str | None) -> str | None:
        #  Une chaîne vide n'est pas « un domaine vide » mais « pas de domaine » : la ramener à
        #  `None` évite un `FetchSpec` qui lèverait dans le worker, loin de l'utilisateur.
        if value is None:
            return None
        cleaned = value.strip()
        return cleaned or None

    @field_validator("keywords")
    @classmethod
    def _clean_keywords(cls, value: list[str] | None) -> list[str] | None:
        if value is None:
            return None
        seen: dict[str, None] = {}
        for raw in value:
            term = raw.strip()
            if not term:
                continue
            if len(term) > KEYWORD_LENGTH_MAX:
                raise ValueError(f"mot-clé trop long ({len(term)} caractères) : {term[:40]}…")
            #  Dédoublonnage **en gardant l'ordre de saisie** : demander deux fois le même
            #  mot-clé ne coûterait rien de plus (une unité par requête), mais le rapport
            #  compterait deux lignes là où l'utilisateur n'en a nommé qu'une.
            seen.setdefault(term, None)
        if not seen:
            raise ValueError("aucun mot-clé exploitable : la liste ne contient que du vide")
        return list(seen)

    @model_validator(mode="after")
    def _one_mode_at_most(self) -> "ImportTrigger":
        modes = [self.list_ids, self.domain, self.keywords]
        if sum(1 for mode in modes if mode is not None) > 1:
            raise ValueError(
                "Un import API porte un seul mode : `list_ids` (listes enregistrées), `domain` "
                "(découverte) ou `keywords` (enrichissement) — jamais plusieurs à la fois."
            )
        return self


class QuotaOut(BaseModel):
    """Le reste de quota du fournisseur — ce que le récapitulatif du wizard annonce.

    Lu par `GET /kwfinder/limits`, **gratuit** : l'appel ne bouge aucun compteur de `resources`
    (mesuré). Il reste un appel réseau vers un tiers, donc il n'a rien à faire sur un chemin
    déclenché à chaque frappe — une fois à l'ouverture du récapitulatif, et c'est tout.

    `error` plutôt qu'un code HTTP d'échec, même philosophie que `check_integration` : un
    fournisseur muet est un **état affichable**, pas une panne de notre api. Le récapitulatif
    doit pouvoir dire « quota indisponible » et laisser lancer l'import quand même.

    **`remaining` se compte en requêtes, pas en mots-clés** : un lot de 200 mots-clés coûte une
    unité, comme un lot d'un seul.
    """

    provider: str
    resource: str
    limit: int | None
    remaining: int | None
    error: str | None


class ImportTriggerItem(BaseModel):
    """Un import enfilé. Un par **liste** en mode liste, un seul dans les deux autres modes."""

    import_id: uuid.UUID
    job_id: uuid.UUID
    #  Faux quand un import de la même période était déjà en vol : l'appel est alors un no-op
    #  qui rend l'existant, pas une erreur — cliquer deux fois ne doit rien casser.
    created: bool
    #  De quelle liste vient cet import. `None` pour les deux modes dormants, qui n'en ont pas.
    list_id: str | None = None
    list_name: str | None = None


class ImportTriggerOut(BaseModel):
    """**Une enveloppe, même quand il n'y a qu'un import** — et c'est le point.

    Un déclenchement peut désormais enfiler plusieurs imports d'un coup : « un import par
    (liste, cible) », donc autant d'imports que de listes cochées. Garder les champs plats
    d'avant à côté d'une liste aurait produit un contrat à deux lectures — laquelle des N est
    « l'import » ? — et un écran qui n'en suit qu'un sur N sans jamais le dire.
    """

    items: list[ImportTriggerItem]
