"""Repo -> project binding resolution.

The SessionStart hook sends the working repo's git remote; this module
normalizes it to a stable `repo_key` and resolves it to a project id. The key
deliberately collapses ssh/https forms of the same remote so the operator binds
a repo once regardless of how it's cloned.
"""
from __future__ import annotations

import re

from sqlalchemy import select

from scribe.models import async_session
from scribe.models.repo_binding import RepoBinding


def normalize_repo_key(raw: str) -> str:
    """Reduce a git remote URL to a stable, scheme-agnostic ``host/owner/repo``.

    Collapses the equivalent clone URLs to one key, e.g.::

        git@git.fabledsword.com:bvandeusen/FabledScribe.git
        https://git.fabledsword.com/bvandeusen/fabledscribe.git
        ssh://git@git.fabledsword.com:22/bvandeusen/fabledscribe

    all normalize to ``git.fabledsword.com/bvandeusen/fabledscribe``.

    Returns "" for empty/garbage input so callers can treat it as "no repo".
    """
    s = (raw or "").strip()
    if not s:
        return ""

    # scp-like syntax: git@host:owner/repo(.git) -> ssh://host/owner/repo
    scp = re.match(r"^[^/@]+@([^:/]+):(.+)$", s)
    if scp and "://" not in s:
        s = f"//{scp.group(1)}/{scp.group(2)}"
    else:
        # strip an explicit scheme (https://, http://, ssh://, git://, git+ssh://)
        s = re.sub(r"^[a-z][a-z0-9+.\-]*://", "//", s, flags=re.IGNORECASE)
        if not s.startswith("//"):
            s = "//" + s

    body = s[2:]  # drop leading //
    # strip userinfo (user@ or user:pass@) on the authority
    body = re.sub(r"^[^/]*@", "", body)
    # drop an explicit port on the host (host:22/...)
    body = re.sub(r"^([^/:]+):\d+", r"\1", body)
    # strip trailing .git and surrounding slashes
    body = body.strip("/")
    if body.endswith(".git"):
        body = body[:-4]
    return body.lower()


async def resolve_project(user_id: int, raw_repo: str) -> int | None:
    """Return the bound project id for a repo remote, or None if unbound."""
    key = normalize_repo_key(raw_repo)
    if not key:
        return None
    async with async_session() as session:
        row = await session.execute(
            select(RepoBinding.project_id).where(
                RepoBinding.user_id == user_id, RepoBinding.repo_key == key
            )
        )
        return row.scalar_one_or_none()


async def set_binding(
    user_id: int, raw_repo: str, project_id: int, ref: str | None = None,
) -> RepoBinding:
    """Create or update the binding for a repo. Idempotent on (user, repo_key).

    ``ref`` (#2873) is the branch the coverage refresh reads for this
    binding: a name sets it, ``""`` clears it back to the forge's default
    branch, ``None`` leaves whatever stands (a re-bind that only moves the
    project keeps the ref it had).
    """
    key = normalize_repo_key(raw_repo)
    if not key:
        raise ValueError("repo remote is empty or unparseable")
    async with async_session() as session:
        existing = await session.execute(
            select(RepoBinding).where(
                RepoBinding.user_id == user_id, RepoBinding.repo_key == key
            )
        )
        binding = existing.scalar_one_or_none()
        if binding is None:
            binding = RepoBinding(user_id=user_id, repo_key=key, project_id=project_id)
            session.add(binding)
        else:
            binding.project_id = project_id
        if ref is not None:
            binding.ref = ref.strip() or None
        await session.commit()
        await session.refresh(binding)
        return binding


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


async def bindings_for_project(user_id: int, project_id: int) -> list[RepoBinding]:
    """Every binding of a project — key AND the ref its ledger follows (#2873)."""
    async with async_session() as session:
        rows = await session.execute(
            select(RepoBinding).where(
                RepoBinding.user_id == user_id,
                RepoBinding.project_id == project_id,
            ).order_by(RepoBinding.repo_key)
        )
        return list(rows.scalars().all())


async def keys_for_project(user_id: int, project_id: int) -> list[str]:
    """Every repo key bound to a project — the snippet→forge join (#2691).

    Recorded snippet locations carry free-form repo names ("Scribe"), which
    can't address a forge API. The project's binding is the identity that can:
    a snippet reaches its forge repo through the project it belongs to.
    """
    async with async_session() as session:
        rows = await session.execute(
            select(RepoBinding.repo_key).where(
                RepoBinding.user_id == user_id,
                RepoBinding.project_id == project_id,
            )
        )
        return [k for (k,) in rows.all()]


async def bindings_for_key(raw_repo: str) -> list[RepoBinding]:
    """All bindings (ANY user) for a repo key — the webhook's entry point.

    A push webhook carries no Scribe caller, only the repository it happened
    to; the flag it writes is about each record's truth, so every user who
    bound the repo gets their project's snippets considered — each write still
    lands as that record's owner.
    """
    key = normalize_repo_key(raw_repo)
    if not key:
        return []
    async with async_session() as session:
        rows = await session.execute(
            select(RepoBinding).where(RepoBinding.repo_key == key)
        )
        return list(rows.scalars().all())


async def delete_binding(user_id: int, raw_repo: str) -> bool:
    """Remove a repo's binding. Returns True if a row was deleted."""
    key = normalize_repo_key(raw_repo)
    if not key:
        return False
    async with async_session() as session:
        row = await session.execute(
            select(RepoBinding).where(
                RepoBinding.user_id == user_id, RepoBinding.repo_key == key
            )
        )
        binding = row.scalar_one_or_none()
        if binding is None:
            return False
        await session.delete(binding)
        await session.commit()
        return True
