"""User-level forge connection CRUD — the keyring rows get_forges reads (#2778).

A connection is a user's read-only credential for one forge host; one row per
(user, host) keeps host-keyed resolution deterministic with no default-pointer
machinery. Everything here is own-rows-only: a connection is a credential, and
no caller — admin included — reads or edits another user's. The token never
leaves the server (model.to_dict omits it; routes mask "set/unset").

Validation matches what build_adapter will accept, checked here so a bad
value is a 400 at the form instead of a silently dead keyring row.
"""
from __future__ import annotations

from sqlalchemy import select

from scribe.models import async_session
from scribe.models.forge_connection import ForgeConnection
from scribe.models.project import Project
from scribe.services.forge import FORGE_KINDS, host_of


def validate_connection(kind: str, base_url: str) -> str | None:
    """The error a connection's non-secret values would earn, or None."""
    if kind not in FORGE_KINDS:
        return f"Unknown forge kind {kind!r} (one of: {', '.join(FORGE_KINDS)})"
    if not base_url.startswith(("http://", "https://")):
        return "Forge base URL must use http or https"
    if not host_of(base_url):
        return "Forge base URL carries no hostname"
    return None


async def list_connections(user_id: int) -> list[ForgeConnection]:
    async with async_session() as session:
        rows = await session.execute(
            select(ForgeConnection)
            .where(ForgeConnection.user_id == user_id)
            .order_by(ForgeConnection.host)
        )
        return list(rows.scalars().all())


async def get_connection(user_id: int, connection_id: int) -> ForgeConnection | None:
    async with async_session() as session:
        return (
            await session.execute(
                select(ForgeConnection).where(
                    ForgeConnection.id == connection_id,
                    ForgeConnection.user_id == user_id,
                )
            )
        ).scalar_one_or_none()


async def create_connection(
    user_id: int, *, kind: str, base_url: str, token: str
) -> ForgeConnection:
    """Create a keyring row. Raises ValueError on bad values or a host the
    user already holds — one row per (user, host) IS the resolution model,
    so a second token for the same host is an update, not a create."""
    kind = (kind or "").strip().lower()
    base_url = (base_url or "").strip().rstrip("/")
    token = token or ""
    error = validate_connection(kind, base_url)
    if error is None and not token:
        error = "A token is required (read scope is enough)"
    if error:
        raise ValueError(error)
    host = host_of(base_url)
    async with async_session() as session:
        existing = (
            await session.execute(
                select(ForgeConnection).where(
                    ForgeConnection.user_id == user_id,
                    ForgeConnection.host == host,
                )
            )
        ).scalar_one_or_none()
        if existing is not None:
            raise ValueError(
                f"You already have a connection for {host} — edit that one; "
                "resolution is by host, so a second row could never be reached"
            )
        row = ForgeConnection(
            user_id=user_id, kind=kind, base_url=base_url, host=host, token=token
        )
        session.add(row)
        await session.commit()
        await session.refresh(row)
        return row


async def update_connection(
    user_id: int,
    connection_id: int,
    *,
    kind: str = "",
    base_url: str = "",
    token: str = "",
) -> ForgeConnection | None:
    """Update own row; empty string = leave unchanged (the settings-form
    sentinel convention). None when the row isn't the caller's."""
    async with async_session() as session:
        row = (
            await session.execute(
                select(ForgeConnection).where(
                    ForgeConnection.id == connection_id,
                    ForgeConnection.user_id == user_id,
                )
            )
        ).scalar_one_or_none()
        if row is None:
            return None
        new_kind = (kind or "").strip().lower() or row.kind
        new_base = (base_url or "").strip().rstrip("/") or row.base_url
        error = validate_connection(new_kind, new_base)
        if error:
            raise ValueError(error)
        new_host = host_of(new_base)
        if new_host != row.host:
            clash = (
                await session.execute(
                    select(ForgeConnection.id).where(
                        ForgeConnection.user_id == user_id,
                        ForgeConnection.host == new_host,
                        ForgeConnection.id != row.id,
                    )
                )
            ).scalar_one_or_none()
            if clash is not None:
                raise ValueError(
                    f"You already have a connection for {new_host} — edit that one"
                )
        row.kind = new_kind
        row.base_url = new_base
        row.host = new_host
        if token:
            row.token = token
        await session.commit()
        await session.refresh(row)
        return row


async def delete_connection(user_id: int, connection_id: int) -> bool:
    """Delete own row. Project pins pointing at it go NULL (FK SET NULL) —
    those projects fall back to keyring resolution, which is the documented
    unpinned behavior, not a surprise."""
    async with async_session() as session:
        row = (
            await session.execute(
                select(ForgeConnection).where(
                    ForgeConnection.id == connection_id,
                    ForgeConnection.user_id == user_id,
                )
            )
        ).scalar_one_or_none()
        if row is None:
            return False
        await session.delete(row)
        await session.commit()
        return True


async def set_project_pin(
    owner_id: int, project_id: int, connection_id: int | None
) -> bool:
    """Point a project at one of its OWNER's connections, or clear the pin.

    The caller settles WHO may ask (routes check owner-or-admin); this
    settles WHOSE connection is eligible: only the project owner's — pinning
    a collaborator's token to someone else's project is the confused-deputy
    channel this feature exists to close. False = project or connection not
    eligible.
    """
    async with async_session() as session:
        project = (
            await session.execute(select(Project).where(Project.id == project_id))
        ).scalar_one_or_none()
        if project is None or (project.user_id or 0) != owner_id:
            return False
        if connection_id:
            held = (
                await session.execute(
                    select(ForgeConnection.id).where(
                        ForgeConnection.id == connection_id,
                        ForgeConnection.user_id == owner_id,
                    )
                )
            ).scalar_one_or_none()
            if held is None:
                return False
        project.forge_connection_id = connection_id or None
        await session.commit()
        return True
