"""Les gestes de l'écran Réservoir : attache, listes, tags, historique (spec Imports §1.8, §6).

`test_keywords_api.py` couvre la **lecture** du réservoir. Ici, ce sont les écritures — et
trois invariants du modèle valent plus que le reste :

1. **l'attache manuelle est unique par (cible, mot-clé)** (décision n°6) et se remplace d'un
   seul geste : `PUT` réécrit l'URL déclarée, il n'empile pas une seconde décision ;
2. **une décision ne touche jamais une observation** : les maps `import_url` coexistent avec
   le `manual`, ne sont ni écrasés par lui ni supprimables à la main ;
3. **la rétention org** (décision n°4) : retirer d'une liste ou d'un tag ne supprime jamais le
   mot-clé ni ses métriques.

S'y ajoute la garde de représentation qui a coûté un bloquant en T4 : `kd`, `cpc` et
`position` doivent sortir en **nombres JSON**, jamais en chaînes — c'est ce que les schémas
Zod du web attendent (`app/keywords/schemas.py`, note de tête).
"""

import uuid
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from typing import Any

import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.keywords.models import (
    Keyword,
    KeywordList,
    KeywordListItem,
    KeywordMetric,
    KeywordPageMap,
    KeywordTag,
    Tag,
)
from app.sites.models import Site, SiteTarget
from tests.conftest import Fixture

NOW = datetime(2026, 8, 30, 12, 0, tzinfo=UTC)
LOCALE = "fr-FR"

Headers = dict[str, str]


@pytest.fixture
def headers(auth_headers: Any, sandbox: Fixture) -> Headers:
    return auth_headers(sandbox.org_id)


@pytest_asyncio.fixture
async def keyword(db: AsyncSession, sandbox: Fixture) -> Keyword:
    row = Keyword(org_id=sandbox.org_id, locale=LOCALE, keyword="chaussures de trail")
    db.add(row)
    await db.commit()
    return row


async def make_keyword(db: AsyncSession, sandbox: Fixture, text: str) -> Keyword:
    row = Keyword(org_id=sandbox.org_id, locale=LOCALE, keyword=text)
    db.add(row)
    await db.commit()
    return row


# --- attache manuelle -------------------------------------------------------------------------


async def test_put_map_creates_pending_attachment(
    client: AsyncClient, headers: Headers, sandbox: Fixture, keyword: Keyword
) -> None:
    """L'URL déclarée est normalisée, et l'attache naît « en attente de crawl »."""
    response = await client.put(
        f"/keywords/{keyword.id}/map",
        headers=headers,
        json={"site_target_id": str(sandbox.target_id), "url": "HTTPS://Exemple.FR/Page#ancre"},
    )
    assert response.status_code == 200, response.text
    body = response.json()
    assert body["url_normalized"] == "https://exemple.fr/Page"
    assert body["source"] == "manual"
    # `page_id` NULL : la table `pages` n'existe pas encore, et c'est l'état normal (§3.9).
    assert body["page_id"] is None
    assert body["position"] is None


async def test_put_map_replaces_the_single_manual_attachment(
    client: AsyncClient, headers: Headers, sandbox: Fixture, keyword: Keyword, db: AsyncSession
) -> None:
    """Changer d'avis sur la page à écrire : un seul geste, une seule ligne (décision n°6)."""
    for url in ("https://exemple.fr/a", "https://exemple.fr/b"):
        response = await client.put(
            f"/keywords/{keyword.id}/map",
            headers=headers,
            json={"site_target_id": str(sandbox.target_id), "url": url},
        )
        assert response.status_code == 200, response.text

    rows = (
        (
            await db.execute(
                select(KeywordPageMap.url_normalized, KeywordPageMap.id).where(
                    KeywordPageMap.keyword_id == keyword.id, KeywordPageMap.source == "manual"
                )
            )
        )
        .mappings()
        .all()
    )
    assert [row["url_normalized"] for row in rows] == ["https://exemple.fr/b"]
    # Même ligne réécrite, pas une seconde : l'identifiant du map n'a pas changé.
    assert response.json()["id"] == str(rows[0]["id"])


async def test_put_map_same_url_twice_is_idempotent(
    client: AsyncClient, headers: Headers, sandbox: Fixture, keyword: Keyword, db: AsyncSession
) -> None:
    payload = {"site_target_id": str(sandbox.target_id), "url": "https://exemple.fr/page/"}
    first = await client.put(f"/keywords/{keyword.id}/map", headers=headers, json=payload)
    second = await client.put(f"/keywords/{keyword.id}/map", headers=headers, json=payload)
    assert first.status_code == second.status_code == 200
    assert first.json()["id"] == second.json()["id"]
    count = await db.scalar(
        select(func.count())
        .select_from(KeywordPageMap)
        .where(KeywordPageMap.keyword_id == keyword.id)
    )
    assert count == 1


async def test_manual_map_never_touches_observations(
    client: AsyncClient, headers: Headers, sandbox: Fixture, keyword: Keyword, db: AsyncSession
) -> None:
    """« Une observation se rafraîchit, une décision jamais » — les deux coexistent (§3.7)."""
    db.add(
        KeywordPageMap(
            org_id=sandbox.org_id,
            site_target_id=sandbox.target_id,
            keyword_id=keyword.id,
            url_normalized="https://exemple.fr/vue-par-semrush",
            source="import_url",
            position=Decimal("3.0"),
            seen_at=NOW,
        )
    )
    await db.commit()

    await client.put(
        f"/keywords/{keyword.id}/map",
        headers=headers,
        json={"site_target_id": str(sandbox.target_id), "url": "https://exemple.fr/a-ecrire"},
    )
    rows = (
        (
            await db.execute(
                select(KeywordPageMap.source, KeywordPageMap.url_normalized)
                .where(KeywordPageMap.keyword_id == keyword.id)
                .order_by(KeywordPageMap.source)
            )
        )
        .mappings()
        .all()
    )
    assert [(row["source"], row["url_normalized"]) for row in rows] == [
        ("import_url", "https://exemple.fr/vue-par-semrush"),
        ("manual", "https://exemple.fr/a-ecrire"),
    ]


async def test_delete_map_removes_only_the_manual_one(
    client: AsyncClient, headers: Headers, sandbox: Fixture, keyword: Keyword, db: AsyncSession
) -> None:
    db.add(
        KeywordPageMap(
            org_id=sandbox.org_id,
            site_target_id=sandbox.target_id,
            keyword_id=keyword.id,
            url_normalized="https://exemple.fr/observee",
            source="import_url",
            seen_at=NOW,
        )
    )
    await db.commit()
    await client.put(
        f"/keywords/{keyword.id}/map",
        headers=headers,
        json={"site_target_id": str(sandbox.target_id), "url": "https://exemple.fr/declaree"},
    )

    deleted = await client.delete(
        f"/keywords/{keyword.id}/map",
        headers=headers,
        params={"site_target_id": str(sandbox.target_id)},
    )
    assert deleted.status_code == 204
    remaining = (
        (
            await db.execute(
                select(KeywordPageMap.source).where(KeywordPageMap.keyword_id == keyword.id)
            )
        )
        .scalars()
        .all()
    )
    assert remaining == ["import_url"]

    # Plus rien à détacher : 404 franc, jamais un 204 complaisant.
    again = await client.delete(
        f"/keywords/{keyword.id}/map",
        headers=headers,
        params={"site_target_id": str(sandbox.target_id)},
    )
    assert again.status_code == 404


async def test_put_map_rejects_invalid_url(
    client: AsyncClient, headers: Headers, sandbox: Fixture, keyword: Keyword
) -> None:
    response = await client.put(
        f"/keywords/{keyword.id}/map",
        headers=headers,
        json={"site_target_id": str(sandbox.target_id), "url": "javascript:alert(1)"},
    )
    assert response.status_code == 422
    assert response.headers["content-type"].startswith("application/problem+json")


async def test_map_on_foreign_keyword_is_404(
    client: AsyncClient, headers: Headers, sandbox: Fixture, db: AsyncSession
) -> None:
    stranger = Keyword(org_id="org_autre", locale=LOCALE, keyword="pas à nous")
    db.add(stranger)
    await db.commit()
    response = await client.put(
        f"/keywords/{stranger.id}/map",
        headers=headers,
        json={"site_target_id": str(sandbox.target_id), "url": "https://exemple.fr/a"},
    )
    assert response.status_code == 404
    await db.delete(stranger)
    await db.commit()


async def test_map_on_foreign_target_is_404(
    client: AsyncClient, headers: Headers, keyword: Keyword, db: AsyncSession
) -> None:
    site = Site(org_id="org_autre", domain=f"{uuid.uuid4().hex}.example", name="Autre")
    db.add(site)
    await db.flush()
    target = SiteTarget(site_id=site.id, org_id="org_autre", country="FR", language="fr")
    db.add(target)
    await db.commit()
    response = await client.put(
        f"/keywords/{keyword.id}/map",
        headers=headers,
        json={"site_target_id": str(target.id), "url": "https://exemple.fr/a"},
    )
    assert response.status_code == 404
    await db.delete(site)
    await db.commit()


# --- listes -----------------------------------------------------------------------------------


async def test_list_lifecycle_and_membership(
    client: AsyncClient, headers: Headers, sandbox: Fixture, db: AsyncSession
) -> None:
    """Créer une liste, y verser une sélection, la retrouver dans le réservoir, l'en retirer."""
    created = await client.post(
        "/keyword-lists",
        headers=headers,
        json={"site_target_id": str(sandbox.target_id), "name": "Semrush — 2026-08-31"},
    )
    assert created.status_code == 201, created.text
    list_id = created.json()["id"]
    assert created.json()["item_count"] == 0

    first = await make_keyword(db, sandbox, "trail running")
    second = await make_keyword(db, sandbox, "chaussures trail")
    added = await client.post(
        f"/keyword-lists/{list_id}/items",
        headers=headers,
        json={"keyword_ids": [str(first.id), str(second.id)]},
    )
    assert added.status_code == 200
    assert added.json() == {"added": 2}

    # Ré-ajouter la même sélection ne duplique rien et le dit (`ON CONFLICT DO NOTHING`).
    again = await client.post(
        f"/keyword-lists/{list_id}/items",
        headers=headers,
        json={"keyword_ids": [str(first.id), str(second.id)]},
    )
    assert again.json() == {"added": 0}

    listing = await client.get(
        "/keyword-lists", headers=headers, params={"site_target_id": str(sandbox.target_id)}
    )
    assert listing.status_code == 200
    assert [(row["name"], row["item_count"]) for row in listing.json()["items"]] == [
        ("Semrush — 2026-08-31", 2)
    ]

    # Les deux mots-clés appartiennent maintenant au réservoir de la cible (prédicat §6).
    reservoir = await client.get(
        "/keywords", headers=headers, params={"site_target_id": str(sandbox.target_id)}
    )
    assert {row["keyword"] for row in reservoir.json()["items"]} == {
        "trail running",
        "chaussures trail",
    }

    removed = await client.request(
        "DELETE",
        f"/keyword-lists/{list_id}/items",
        headers=headers,
        json={"keyword_ids": [str(first.id)]},
    )
    assert removed.json() == {"removed": 1}
    # Rétention org (décision n°4) : le mot-clé retiré existe toujours dans l'organisation.
    assert await db.scalar(select(func.count()).select_from(Keyword).where(Keyword.id == first.id))


async def test_duplicate_list_name_is_409(
    client: AsyncClient, headers: Headers, sandbox: Fixture
) -> None:
    payload = {"site_target_id": str(sandbox.target_id), "name": "Doublon"}
    assert (await client.post("/keyword-lists", headers=headers, json=payload)).status_code == 201
    conflict = await client.post("/keyword-lists", headers=headers, json=payload)
    assert conflict.status_code == 409
    assert conflict.headers["content-type"].startswith("application/problem+json")


async def test_list_items_ignores_foreign_keywords(
    client: AsyncClient, headers: Headers, sandbox: Fixture, db: AsyncSession
) -> None:
    """Un identifiant étranger est ignoré, pas fatal : le décompte rendu dit la vérité."""
    created = await client.post(
        "/keyword-lists",
        headers=headers,
        json={"site_target_id": str(sandbox.target_id), "name": "Sélection"},
    )
    list_id = created.json()["id"]
    mine = await make_keyword(db, sandbox, "à moi")
    stranger = Keyword(org_id="org_autre", locale=LOCALE, keyword="pas à nous")
    db.add(stranger)
    await db.commit()

    added = await client.post(
        f"/keyword-lists/{list_id}/items",
        headers=headers,
        json={"keyword_ids": [str(mine.id), str(stranger.id), str(uuid.uuid4())]},
    )
    assert added.json() == {"added": 1}
    members = (
        (
            await db.execute(
                select(KeywordListItem.keyword_id).where(
                    KeywordListItem.list_id == uuid.UUID(list_id)
                )
            )
        )
        .scalars()
        .all()
    )
    assert members == [mine.id]
    await db.delete(stranger)
    await db.commit()


async def test_list_of_another_org_is_404(
    client: AsyncClient, headers: Headers, sandbox: Fixture, db: AsyncSession, auth_headers: Any
) -> None:
    created = await client.post(
        "/keyword-lists",
        headers=headers,
        json={"site_target_id": str(sandbox.target_id), "name": "Privée"},
    )
    list_id = created.json()["id"]
    intruder = auth_headers("org_intrus")
    response = await client.post(
        f"/keyword-lists/{list_id}/items",
        headers=intruder,
        json={"keyword_ids": [str(uuid.uuid4())]},
    )
    assert response.status_code == 404


async def test_keyword_lists_cursor(
    client: AsyncClient, headers: Headers, sandbox: Fixture
) -> None:
    for index in range(3):
        await client.post(
            "/keyword-lists",
            headers=headers,
            json={"site_target_id": str(sandbox.target_id), "name": f"Liste {index}"},
        )
    first = await client.get(
        "/keyword-lists",
        headers=headers,
        params={"site_target_id": str(sandbox.target_id), "limit": 2},
    )
    page = first.json()
    assert len(page["items"]) == 2
    assert page["next_cursor"]
    second = await client.get(
        "/keyword-lists",
        headers=headers,
        params={
            "site_target_id": str(sandbox.target_id),
            "limit": 2,
            "cursor": page["next_cursor"],
        },
    )
    assert len(second.json()["items"]) == 1
    assert second.json()["next_cursor"] is None
    # Aucun doublon entre les deux pages : le keyset ne recoupe jamais.
    ids = {row["id"] for row in page["items"]} | {row["id"] for row in second.json()["items"]}
    assert len(ids) == 3


async def test_unreadable_cursor_is_422(
    client: AsyncClient, headers: Headers, sandbox: Fixture
) -> None:
    response = await client.get(
        "/keyword-lists",
        headers=headers,
        params={"site_target_id": str(sandbox.target_id), "cursor": "pas-un-curseur!"},
    )
    assert response.status_code == 422


# --- tags -------------------------------------------------------------------------------------


async def test_tag_lifecycle(
    client: AsyncClient, headers: Headers, sandbox: Fixture, db: AsyncSession
) -> None:
    created = await client.post("/tags", headers=headers, json={"name": "priorité"})
    assert created.status_code == 201, created.text
    tag_id = created.json()["id"]

    conflict = await client.post("/tags", headers=headers, json={"name": "priorité"})
    assert conflict.status_code == 409

    keyword = await make_keyword(db, sandbox, "marathon")
    added = await client.post(
        "/keyword-tags",
        headers=headers,
        json={"tag_id": tag_id, "keyword_ids": [str(keyword.id)]},
    )
    assert added.json() == {"added": 1}

    listing = await client.get("/tags", headers=headers)
    assert [row["name"] for row in listing.json()["items"]] == ["priorité"]

    removed = await client.request(
        "DELETE",
        "/keyword-tags",
        headers=headers,
        json={"tag_id": tag_id, "keyword_ids": [str(keyword.id)]},
    )
    assert removed.json() == {"removed": 1}
    # Le tag survit à son dernier mot-clé, et le mot-clé survit à son dernier tag.
    assert await db.scalar(select(func.count()).select_from(Tag).where(Tag.id == uuid.UUID(tag_id)))
    assert not await db.scalar(
        select(func.count()).select_from(KeywordTag).where(KeywordTag.keyword_id == keyword.id)
    )


async def test_tag_of_another_org_is_404(client: AsyncClient, headers: Headers) -> None:
    response = await client.post(
        "/keyword-tags",
        headers=headers,
        json={"tag_id": str(uuid.uuid4()), "keyword_ids": [str(uuid.uuid4())]},
    )
    assert response.status_code == 404


async def test_empty_bulk_selection_is_422(client: AsyncClient, headers: Headers) -> None:
    """Un geste de masse sur zéro ligne est une erreur d'appel, pas un no-op silencieux."""
    response = await client.post(
        "/keyword-tags", headers=headers, json={"tag_id": str(uuid.uuid4()), "keyword_ids": []}
    )
    assert response.status_code == 422


# --- historique par source --------------------------------------------------------------------


async def test_metric_history_is_sorted_and_typed(
    client: AsyncClient, headers: Headers, sandbox: Fixture, keyword: Keyword, db: AsyncSession
) -> None:
    """Le popover lit les observations brutes, la plus récente d'abord, sources séparées."""
    for source, moment, volume, kd in (
        ("semrush_magic", NOW, 990, Decimal("42.5")),
        ("semrush_magic", NOW - timedelta(days=30), 880, Decimal("41")),
        ("generic_csv", NOW - timedelta(days=60), 770, None),
    ):
        db.add(
            KeywordMetric(
                org_id=sandbox.org_id,
                keyword_id=keyword.id,
                source=source,
                fetched_at=moment,
                volume=volume,
                kd=kd,
                cpc=Decimal("1.25") if kd is not None else None,
                raw={},
            )
        )
    await db.commit()

    response = await client.get(
        f"/keywords/{keyword.id}/metrics",
        headers=headers,
        params={"site_target_id": str(sandbox.target_id)},
    )
    assert response.status_code == 200, response.text
    body = response.json()
    assert body["keyword"] == keyword.keyword
    assert [(row["source"], row["volume"]) for row in body["items"]] == [
        ("semrush_magic", 990),
        ("semrush_magic", 880),
        ("generic_csv", 770),
    ]
    # LA garde de représentation : des nombres JSON, jamais des chaînes (piège T4).
    assert body["items"][0]["kd"] == 42.5
    assert body["items"][0]["cpc"] == 1.25
    assert isinstance(body["items"][0]["kd"], float)


async def test_reservoir_metrics_are_json_numbers(
    client: AsyncClient, headers: Headers, sandbox: Fixture, keyword: Keyword, db: AsyncSession
) -> None:
    """Même garde sur `GET /keywords` : les deux endpoints parlent le même dialecte."""
    db.add(
        KeywordMetric(
            org_id=sandbox.org_id,
            keyword_id=keyword.id,
            source="semrush_magic",
            fetched_at=NOW,
            volume=100,
            kd=Decimal("12.50"),
            cpc=Decimal("0.75"),
            raw={},
        )
    )
    db.add(KeywordList(org_id=sandbox.org_id, site_target_id=sandbox.target_id, name="Une liste"))
    await db.flush()
    list_id = await db.scalar(
        select(KeywordList.id).where(KeywordList.site_target_id == sandbox.target_id)
    )
    db.add(KeywordListItem(list_id=list_id, keyword_id=keyword.id, org_id=sandbox.org_id))
    await db.commit()

    response = await client.get(
        "/keywords", headers=headers, params={"site_target_id": str(sandbox.target_id)}
    )
    #  Une métrique est désormais une **liste** d'observations : ici une seule source, donc
    #  une seule entrée. La conversion vient du codec `json.loads` d'asyncpg (les colonnes
    #  arrivent en jsonb) et non plus de Pydantic — la garde de représentation, elle, ne
    #  change pas : des nombres JSON, jamais des chaînes.
    row = response.json()["items"][0]
    assert row["kd"][0]["value"] == 12.5
    assert isinstance(row["kd"][0]["value"], float)
    assert isinstance(row["cpc"][0]["value"], float)


async def test_metric_history_of_foreign_keyword_is_404(
    client: AsyncClient, headers: Headers, sandbox: Fixture, db: AsyncSession
) -> None:
    stranger = Keyword(org_id="org_autre", locale=LOCALE, keyword="pas à nous")
    db.add(stranger)
    await db.commit()
    response = await client.get(
        f"/keywords/{stranger.id}/metrics",
        headers=headers,
        params={"site_target_id": str(sandbox.target_id)},
    )
    assert response.status_code == 404
    await db.delete(stranger)
    await db.commit()
