"""Capture des réponses réelles de l'API Mangools KWFinder (règle « fixture réelle d'abord »).

Le format JSON de l'API n'est pas documenté (spec Imports §5). Ce script est la façon
**honnête** de le connaître : il appelle les endpoints avec une vraie clé, écrit les réponses
brutes telles quelles dans `api/tests/fixtures/real/mangools_api/`, et ne les interprète pas.
Le parse de `mangools_api.py` est écrit sur ces fichiers, pas sur une supposition.

**Ce répertoire n'est jamais commité** (`api/.gitignore`) : une capture porte l'état du compte
qui l'a lancée — plan souscrit, quotas restants — et l'intégralité des mots-clés du domaine
interrogé. Le geste attendu après une capture est donc d'en tirer à la main un **extrait
réduit** (quelques lignes par forme, libellés neutralisés) et de commiter celui-là, dans
`api/tests/fixtures/mangools_api/` — c'est lui que lisent les tests.

Usage :

    uv run python -m app.keywords.sources.capture_mangools example.com
    uv run python -m app.keywords.sources.capture_mangools example.com --locale fr-FR
    MANGOOLS_API_KEY=... uv run python -m app.keywords.sources.capture_mangools example.com

La clé est cherchée dans cet ordre :

1. `--api-key` (déconseillé : elle reste dans l'historique du shell) ;
2. la variable d'environnement `MANGOOLS_API_KEY` ;
3. **l'intégration `mangools` en base**, déchiffrée par `app.core.crypto` — le chemin normal
   dès que l'utilisateur a saisi sa clé dans l'écran Intégrations (`--org-id <org>`).

Ce que le script écrit :

- `locations.json` — table des `lid` (`GET /mangools/locations`), qui traduit un pays ;
- `competitor_keywords.json` — les mots-clés du domaine (`GET /kwfinder/competitor-keywords`) ;
- `keyword_imports.json` — les détails d'un petit lot de mots-clés
  (`POST /kwfinder/keyword-imports`), pris **dans** la réponse précédente si on sait les y
  lire, sinon depuis `--keywords` ;
- `limits.json` — les quotas (`GET /kwfinder/limits`), qui disent au passage ce que la capture
  a coûté.

**Chaque réponse est écrite même en erreur** (dans `<nom>.error.json`, avec son code HTTP et
son corps) : un 400 documenté vaut mieux qu'un fichier absent — il dit quel paramètre manque.
La clé n'est jamais écrite dans les fichiers ni dans la sortie.
"""

import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Any

import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker

from app.core import crypto
from app.core.config import get_settings
from app.db.engine import dispose_engine, get_engine
from app.integrations.models import Integration
from app.keywords.sources.mangools_api import (
    AUTH_HEADER,
    COMPETITOR_KEYWORDS_PATH,
    KEYWORD_IMPORTS_PATH,
    LIMITS_PATH,
    LOCATIONS_PATH,
    MIN_REQUEST_INTERVAL,
    SOURCE_NAME,
    redact,
)
from app.keywords.sources.mangools_api import CAPTURE_DIR as DEFAULT_CAPTURE_DIR

ENV_KEY = "MANGOOLS_API_KEY"
# **Deux, jamais plus** (règle utilisateur du 2026-09-02). Une unité de quota est consommée par
# requête et non par mot-clé : ce n'est donc pas le volume qui coûte, c'est le nombre d'appels.
# Mais une capture sert à relever une **forme**, et une forme se lit aussi bien sur deux lignes
# que sur cinquante — demander cinquante ne rapporte rien et gonfle le fichier à relire.
DEFAULT_KEYWORD_SAMPLE = 2
DEFAULT_ROW_SAMPLE = 2

# Aucun appel n'est parti encore : sert à n'espacer qu'à partir du second (voir `_call`).
_called_once = False


async def _key_from_db(org_id: str | None) -> str | None:
    """La clé de l'intégration `mangools` en base, déchiffrée. `None` s'il n'y en a pas."""
    async with async_sessionmaker(get_engine(), expire_on_commit=False)() as session:
        query = select(Integration.credentials).where(Integration.provider == SOURCE_NAME)
        if org_id is not None:
            query = query.where(Integration.org_id == org_id)
        rows = (await session.scalars(query.limit(2))).all()
    if not rows:
        return None
    if len(rows) > 1:
        raise SystemExit(
            "Plusieurs organisations ont une intégration Mangools : préciser --org-id."
        )
    return crypto.decrypt(rows[0])


async def _resolve_key(args: argparse.Namespace) -> str:
    if args.api_key:
        return args.api_key
    if env := os.environ.get(ENV_KEY):
        return env
    key = await _key_from_db(args.org_id)
    if key is None:
        raise SystemExit(
            f"Aucune clé : passer --api-key, poser {ENV_KEY} dans l'environnement, ou "
            "enregistrer l'intégration Mangools dans l'écran Intégrations."
        )
    return key


def _write(directory: Path, name: str, payload: Any, secret: str) -> Path:
    """Écrit une capture, **la clé caviardée**.

    Ces fichiers ne sont **pas** commités : `api/.gitignore` exclut tout le répertoire, parce
    qu'ils portent l'état du compte (plan souscrit, quotas restants) et l'intégralité des
    mots-clés du domaine interrogé. Ce qui est versionné, ce sont des extraits réduits qu'on en
    tire à la main, dans `tests/fixtures/mangools_api/`.

    Le caviardage reste, et il compte toujours : ces fichiers vivent sur un poste, se copient,
    s'attachent à un rapport de bug, se relisent à plusieurs. Le risque n'est pas théorique —
    un endpoint qui renvoie la requête en écho, un `transport_error` d'httpx citant l'URL, un
    corps d'erreur qui répète l'en-tête reçu. On caviarde donc le texte sérialisé entier, quel
    que soit l'endroit où la clé se trouve, plutôt que d'énumérer les champs où elle
    *pourrait* être — et un extrait tiré d'un fichier déjà caviardé l'est par construction.
    """
    path = directory / name
    path.parent.mkdir(parents=True, exist_ok=True)
    rendered = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
    path.write_text(redact(rendered, secret) + "\n")
    return path


async def _call(
    client: httpx.AsyncClient,
    directory: Path,
    name: str,
    method: str,
    url: str,
    secret: str,
    **kwargs: Any,
) -> Any | None:
    """Un appel, sa réponse écrite telle quelle (clé caviardée). Rend le corps, ou `None`.

    **Espacé comme l'adaptateur espace ses appels** (`MIN_REQUEST_INTERVAL`, ~3,3 s) : le plan
    n'autorise que `reqs-per-short-period` requêtes par courte période, et une capture en tire
    cinq à la suite. Elle est passée une fois ; la prochaine se prendrait un 429 au milieu,
    c'est-à-dire un fichier manquant et un quota consommé pour rien.
    """
    global _called_once
    if _called_once:
        await asyncio.sleep(MIN_REQUEST_INTERVAL)
    _called_once = True
    try:
        response = await client.request(method, url, **kwargs)
    except httpx.HTTPError as exc:
        _write(directory, f"{name}.error.json", {"transport_error": str(exc)}, secret)
        print(f"  {name} : échec réseau ({type(exc).__name__})", file=sys.stderr)
        return None
    try:
        body = response.json()
    except ValueError:
        body = {"non_json_body": response.text[:4_000]}
    if response.status_code >= 400:
        _write(
            directory,
            f"{name}.error.json",
            {"status": response.status_code, "body": body},
            secret,
        )
        print(f"  {name} : HTTP {response.status_code} → {name}.error.json", file=sys.stderr)
        return None
    path = _write(directory, f"{name}.json", body, secret)
    print(f"  {name} : HTTP {response.status_code} → {path.name}")
    return body


def _sample_keywords(payload: Any, explicit: list[str], count: int) -> list[str]:
    """Quelques mots-clés à détailler : ceux passés en argument, sinon repérés au jugé.

    Le repérage est **volontairement naïf et sans effet de bord** : on cherche des chaînes
    sous des clés plausibles, uniquement pour alimenter le second appel. Si ça ne trouve rien,
    le script le dit et passe — c'est la capture qui apprendra le vrai format, pas l'inverse.
    """
    if explicit:
        return explicit[:count]
    found: list[str] = []

    def walk(node: Any) -> None:
        if len(found) >= count:
            return
        if isinstance(node, dict):
            for key, value in node.items():
                if key in ("kw", "keyword", "query") and isinstance(value, str) and value:
                    found.append(value)
                    if len(found) >= count:
                        return
                else:
                    walk(value)
        elif isinstance(node, list):
            for item in node:
                walk(item)

    walk(payload)
    return found


async def capture(args: argparse.Namespace) -> int:
    api_key = await _resolve_key(args)
    base_url = (args.base_url or get_settings().mangools_api_base_url).rstrip("/")
    directory = Path(args.out or DEFAULT_CAPTURE_DIR)
    headers = {AUTH_HEADER: api_key, "accept": "application/json"}

    print(f"Capture Mangools sur {base_url} → {directory}")
    async with httpx.AsyncClient(timeout=args.timeout, headers=headers) as client:
        await _call(client, directory, "limits", "GET", f"{base_url}{LIMITS_PATH}", api_key)
        #  `query` est obligatoire et **non vide** : sans lui l'endpoint rend `[]` avec un 200,
        #  ce qui se lit comme « aucune localisation » alors que c'est « tu n'as rien demandé ».
        #  Capturé sur un terme large : la réponse porte les pays ET leurs subdivisions.
        await _call(
            client,
            directory,
            "locations",
            "GET",
            f"{base_url}{LOCATIONS_PATH}",
            api_key,
            params={"query": args.location_query},
        )
        competitor = await _call(
            client,
            directory,
            "competitor_keywords",
            "GET",
            f"{base_url}{COMPETITOR_KEYWORDS_PATH}",
            api_key,
            #  `url` — et pas `domain` : l'API rend un 422 « Parameter url is mandatory ». Elle
            #  accepte un domaine nu (elle répond `url_type: RootDomain`) comme une URL.
            params={"url": args.domain, "location_id": args.location_id, "limit": args.limit},
        )
        sample = _sample_keywords(competitor, args.keywords, DEFAULT_KEYWORD_SAMPLE)
        if sample:
            await _call(
                client,
                directory,
                "keyword_imports",
                "POST",
                f"{base_url}{KEYWORD_IMPORTS_PATH}",
                api_key,
                json={"keywords": sample, "location_id": args.location_id},
            )
        else:
            print(
                "  keyword_imports : aucun mot-clé repéré dans la réponse précédente — "
                "relancer avec --keywords mot1 mot2 pour capturer cet endpoint.",
                file=sys.stderr,
            )
        #  Relu après coup : la différence avec la première lecture dit ce que la capture a
        #  consommé, information utile avant d'écrire un import qui en consommera bien plus.
        await _call(client, directory, "limits_after", "GET", f"{base_url}{LIMITS_PATH}", api_key)

    print(
        "\nCaptures écrites — et NON commitables (api/.gitignore). Prochaine étape : relire "
        "le parse de `mangools_api.py` sur ces fichiers, et si le format a bougé, en tirer un "
        "extrait réduit dans tests/fixtures/mangools_api/ puis mettre les tests à jour."
    )
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="python -m app.keywords.sources.capture_mangools",
        description="Capture les réponses brutes de l'API Mangools KWFinder (BYOK).",
    )
    parser.add_argument("domain", help="Domaine à interroger (ex. example.com)")
    parser.add_argument("--api-key", default=None, help=f"Clé en clair (sinon {ENV_KEY} ou base)")
    parser.add_argument("--org-id", default=None, help="Organisation dont lire la clé en base")
    parser.add_argument("--location-id", type=int, default=None, help="`lid` Mangools forcé")
    parser.add_argument(
        "--location-query",
        default="united",
        help="Terme cherché par /mangools/locations (obligatoire côté API, sinon []) ",
    )
    parser.add_argument(
        "--limit",
        type=int,
        default=DEFAULT_ROW_SAMPLE,
        help="Nombre de lignes demandées (défaut 2 : on relève une forme, pas un volume)",
    )
    parser.add_argument("--keywords", nargs="*", default=[], help="Mots-clés à détailler")
    parser.add_argument("--base-url", default=None, help="Racine de l'API (bouchon, test)")
    parser.add_argument("--out", default=None, help="Répertoire de sortie")
    parser.add_argument("--timeout", type=float, default=30.0)
    return parser


async def _main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        return await capture(args)
    finally:
        await dispose_engine()


def main() -> None:
    raise SystemExit(asyncio.run(_main()))


if __name__ == "__main__":
    main()
