"""Lectures/écritures du module Sites — toutes filtrées par org_id (spec 2026-08-31 §5).

Jamais de SELECT * : l'ORM émet la liste explicite des colonnes mappées, qui sont
exactement celles des schémas de réponse ; les cibles sont chargées par selectinload
(volume borné par MAX_TARGETS_PER_SITE).

L'`org_id` d'une cible n'est jamais lu d'un corps de requête : il dérive du site chargé,
lui-même filtré par l'org du jeton (`CurrentOrgDep`). Une cible d'une autre organisation
est donc inatteignable — 404, jamais 403.
"""

import uuid

from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from app.core.errors import AppError

# Le curseur vit désormais dans `app/db/cursor.py` : Imports et le réservoir paginent avec le
# même encodage (spec Imports §6). Ré-exporté ici, où le module Sites l'a inauguré.
from app.db.cursor import decode_cursor, encode_cursor
from app.sites.domain import DomainError, normalize_domain
from app.sites.models import Site, SiteTarget
from app.sites.schemas import MAX_TARGETS_PER_SITE, SiteCreate, SiteUpdate, TargetCreate

__all__ = [
    "add_target",
    "create_site",
    "decode_cursor",
    "delete_site",
    "delete_target",
    "encode_cursor",
    "get_site",
    "list_sites",
    "promote_target",
    "rename_site",
]


def _duplicate_domain(domain: str) -> AppError:
    return AppError(409, "Conflict", f"Le domaine {domain} est déjà suivi dans cette organisation")


async def create_site(session: AsyncSession, org_id: str, payload: SiteCreate) -> Site:
    try:
        domain = normalize_domain(payload.domain)
    except DomainError as exc:
        raise AppError(422, "Validation error", str(exc)) from exc
    # Pré-vérification pour un message propre ; la contrainte UNIQUE (org_id, domain)
    # reste le filet anti-course.
    duplicate = await session.scalar(
        select(Site.id).where(Site.org_id == org_id, Site.domain == domain)
    )
    if duplicate is not None:
        raise _duplicate_domain(domain)
    site = Site(org_id=org_id, domain=domain, name=payload.name or domain)
    site.targets = [
        SiteTarget(org_id=org_id, country=t.country, language=t.language, is_default=t.is_default)
        for t in payload.targets
    ]
    session.add(site)
    try:
        await session.commit()
    except IntegrityError as exc:
        await session.rollback()
        raise _duplicate_domain(domain) from exc
    return site


async def list_sites(
    session: AsyncSession, org_id: str, *, cursor: str | None, limit: int
) -> tuple[list[Site], str | None]:
    stmt = (
        select(Site)
        .options(selectinload(Site.targets))
        .where(Site.org_id == org_id)
        .order_by(Site.id.desc())
        .limit(limit + 1)
    )
    if cursor is not None:
        stmt = stmt.where(Site.id < decode_cursor(cursor))
    sites = list((await session.scalars(stmt)).all())
    next_cursor: str | None = None
    if len(sites) > limit:
        sites = sites[:limit]
        next_cursor = encode_cursor(sites[-1].id)
    return sites, next_cursor


async def get_site(session: AsyncSession, org_id: str, site_id: uuid.UUID) -> Site:
    site = await session.scalar(
        select(Site)
        .options(selectinload(Site.targets))
        .where(Site.id == site_id, Site.org_id == org_id)
    )
    if site is None:
        # 404 et non 403 : un site d'une autre org n'existe pas (architecture §9).
        raise AppError(404, "Not Found", "Site inconnu")
    return site


async def rename_site(
    session: AsyncSession, org_id: str, site_id: uuid.UUID, payload: SiteUpdate
) -> Site:
    site = await get_site(session, org_id, site_id)
    site.name = payload.name
    await session.commit()  # updated_at : onupdate=func.now(), relu via eager_defaults
    return site


async def delete_site(session: AsyncSession, org_id: str, site_id: uuid.UUID) -> None:
    site = await get_site(session, org_id, site_id)
    await session.delete(site)  # les cibles tombent par ON DELETE CASCADE
    await session.commit()


def _duplicate_couple(payload: TargetCreate) -> AppError:
    return AppError(
        409,
        "Conflict",
        f"La cible {payload.country}/{payload.language} existe déjà pour ce site",
    )


async def add_target(
    session: AsyncSession, org_id: str, site_id: uuid.UUID, payload: TargetCreate
) -> SiteTarget:
    site = await get_site(session, org_id, site_id)
    if len(site.targets) >= MAX_TARGETS_PER_SITE:
        raise AppError(
            422, "Validation error", f"Plafond de {MAX_TARGETS_PER_SITE} cibles par site atteint"
        )
    if any(t.country == payload.country and t.language == payload.language for t in site.targets):
        raise _duplicate_couple(payload)
    target = SiteTarget(
        # org_id vient du site chargé sous CurrentOrgDep, jamais du corps de requête.
        site_id=site.id,
        org_id=site.org_id,
        country=payload.country,
        language=payload.language,
        is_default=False,
    )
    session.add(target)
    try:
        await session.commit()
    except IntegrityError as exc:
        await session.rollback()
        raise _duplicate_couple(payload) from exc
    return target


async def _get_target(
    session: AsyncSession, org_id: str, site_id: uuid.UUID, target_id: uuid.UUID
) -> SiteTarget:
    # Le site est chargé d'abord : une org étrangère obtient « Site inconnu » et ne peut
    # donc pas distinguer un site inexistant d'un site qui ne lui appartient pas.
    site = await get_site(session, org_id, site_id)
    target = await session.scalar(
        select(SiteTarget).where(
            SiteTarget.id == target_id,
            SiteTarget.site_id == site.id,
            SiteTarget.org_id == site.org_id,
        )
    )
    if target is None:
        raise AppError(404, "Not Found", "Cible inconnue")
    return target


async def promote_target(
    session: AsyncSession, org_id: str, site_id: uuid.UUID, target_id: uuid.UUID
) -> SiteTarget:
    target = await _get_target(session, org_id, site_id, target_id)
    # Deux UPDATE explicites dans la même transaction ; l'index unique partiel garantit
    # l'invariant « exactement une défaut » même en cas de course. Pas d'affectation ORM :
    # si la cible est déjà la défaut, `True → True` ne serait pas flushé alors que le
    # premier UPDATE l'aurait démise.
    await session.execute(
        update(SiteTarget)
        .where(
            SiteTarget.site_id == site_id,
            # Défense en profondeur : le site est déjà filtré par org via `_get_target`,
            # mais aucun UPDATE de ce module ne part sans son org_id (architecture §9).
            SiteTarget.org_id == org_id,
            SiteTarget.is_default,
            SiteTarget.id != target.id,
        )
        .values(is_default=False)
    )
    await session.execute(
        update(SiteTarget).where(SiteTarget.id == target.id).values(is_default=True)
    )
    try:
        await session.commit()
    except IntegrityError as exc:
        # Filet anti-course : deux promotions concurrentes sur le même site peuvent violer
        # l'index unique partiel — l'appelant réessaie plutôt que de prendre un 500.
        await session.rollback()
        raise AppError(409, "Conflict", "Promotion concurrente, réessayer") from exc
    await session.refresh(target)
    return target


async def delete_target(
    session: AsyncSession, org_id: str, site_id: uuid.UUID, target_id: uuid.UUID
) -> None:
    # Le site d'abord : une org étrangère obtient « Site inconnu » (cf. `_get_target`).
    site = await get_site(session, org_id, site_id)
    # Le DELETE est l'arbitre, pas un pré-check : `NOT is_default` est évalué par la base
    # au moment de la suppression, donc une promotion concurrente ne peut pas faire
    # disparaître la cible par défaut. La dernière cible étant toujours la défaut, elle
    # n'est jamais supprimable.
    result = await session.execute(
        delete(SiteTarget)
        .where(
            SiteTarget.id == target_id,
            SiteTarget.site_id == site.id,
            SiteTarget.org_id == site.org_id,
            ~SiteTarget.is_default,
        )
        # Suppression en base sans synchronisation ORM : `site.targets` n'est plus relu et
        # la stratégie « evaluate » se prononcerait sur des instances potentiellement périmées.
        .execution_options(synchronize_session=False)
    )
    if result.rowcount == 0:
        # Rien supprimé : une seule relecture tranche entre cible inconnue et cible par
        # défaut — `_get_target` lève déjà le 404 « Cible inconnue ».
        await _get_target(session, org_id, site_id, target_id)
        raise AppError(
            409,
            "Conflict",
            "La cible par défaut ne peut pas être supprimée : en promouvoir une autre d'abord",
        )
    await session.commit()
