"""The global canonical area vocabulary, and the mapping from a project's
Systems onto it (milestone 307 step 1, decision note 3026).

A `System` is per-project. Nothing outside a project can reference one, so a
rule that spans projects has no way to say "this is about CI" without chaining
itself to one project's row. `CanonicalSystem` is that join key, and it is
GLOBAL — no `user_id`, so a shared project inherits the vocabulary instead of
re-earning it.

Two rules govern everything here:

- **Associate, never rewrite.** Mapping a System sets `systems.canonical_id`
  and nothing else. The local name stays whatever the project calls the area,
  and `record_systems` is never touched — no record's tags move.
- **Propose, never decide.** An exact slug hit is mechanical and maps on
  request; anything short of that is a PROPOSAL a human confirms. "CI &
  Release" vs "CI & runners" is a judgment call, and the cost of guessing it
  wrong silently is a rule surfacing in the wrong project.

Reads are open to any authenticated caller (the catalog is shared vocabulary,
not user data). Writes to the catalog itself are admin-only: a global table
that anyone can extend is how a shared vocabulary stops being shared.
"""
import logging
import re
from datetime import datetime, timezone

from sqlalchemy import select

from scribe.models import async_session
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.system import System
from scribe.models.user import User
from scribe.services import access

logger = logging.getLogger(__name__)

# Tokens that carry no meaning for matching — "&" becomes "and" before the
# split, so it would otherwise dominate the overlap score of every pair.
_NOISE_TOKENS = frozenset({"and", "the", "a", "of"})

_NON_ALNUM = re.compile(r"[^a-z0-9]+")


def canonical_slug(name: str) -> str:
    """The match key for an area name — NOT a display value.

    Folds exactly the spelling differences that produced three names for one
    area on the author's instance: `CI & Release`, `CI and Release` and
    `CI & release` all slug to `ci-and-release`, so they map mechanically.
    A real difference survives: `CI & runners` slugs to `ci-and-runners` and
    goes through the proposal path where a human decides.
    """
    lowered = name.strip().lower().replace("&", " and ")
    return "-".join(_NON_ALNUM.sub(" ", lowered).split())


def _tokens(slug: str) -> frozenset[str]:
    return frozenset(slug.split("-")) - _NOISE_TOKENS


async def _is_admin(user_id: int) -> bool:
    async with async_session() as session:
        role = await session.scalar(select(User.role).where(User.id == user_id))
    return role == "admin"


async def list_canonical_systems() -> list[CanonicalSystem]:
    """The whole catalog, in display order. Global — no ownership filter."""
    async with async_session() as session:
        result = await session.execute(
            select(CanonicalSystem)
            .where(CanonicalSystem.deleted_at.is_(None))
            .order_by(CanonicalSystem.order_index.asc(), CanonicalSystem.name.asc())
        )
        return list(result.scalars().all())


async def get_canonical_system(canonical_id: int) -> CanonicalSystem | None:
    async with async_session() as session:
        entry = await session.get(CanonicalSystem, canonical_id)
    return entry if entry is not None and entry.deleted_at is None else None


async def find_by_name(name: str) -> CanonicalSystem | None:
    """The exact-slug lookup — the mechanical half of matching."""
    slug = canonical_slug(name)
    if not slug:
        return None
    async with async_session() as session:
        return await session.scalar(
            select(CanonicalSystem).where(
                CanonicalSystem.slug == slug,
                CanonicalSystem.deleted_at.is_(None),
            )
        )


def _overlap(local: frozenset[str], other: frozenset[str]) -> float:
    return len(local & other) / max(len(local | other), 1)


async def best_overlap(name: str, catalog: list | None = None) -> dict | None:
    """The closest catalog entry that shares a meaningful word, or None.

    The ONE scorer behind both offers: the create-time suggestion and the
    review surface. Two scorers would eventually disagree about which area a
    name resembles, and the operator would be asked one question at create
    time and a different one at review.

    The threshold is any shared meaningful word, deliberately generous: a
    wrong offer costs one dismissal, a missing one costs a mapping nobody
    thinks to make again. Nothing here ever applies — `overlap` is always an
    offer (see propose_mappings).
    """
    slug = canonical_slug(name)
    if not slug:
        return None
    local = _tokens(slug)
    if not local:
        return None
    # A caller already holding the catalog passes it: this runs once per
    # unmapped System in the review sweep, and re-reading the table each time
    # would make an N+1 out of a report.
    if catalog is None:
        catalog = await list_canonical_systems()
    best, best_score = None, 0.0
    for entry in catalog:
        score = _overlap(local, _tokens(entry.slug))
        if score > best_score:
            best, best_score = entry, score
    if best is None or best_score <= 0:
        return None
    return {
        "id": best.id, "name": best.name,
        "basis": "overlap", "score": round(best_score, 3),
    }


async def create_canonical_system(
    user_id: int, name: str, description: str | None = None,
) -> CanonicalSystem | dict | None:
    """Add an area to the global catalog. Admin only.

    Duplicate-gated on the SLUG, not the raw name, so "CI and Release" cannot
    be added alongside "CI & Release" — that is the drift this table exists to
    end. Returns the existing entry's id instead of creating a second one.
    """
    if not await _is_admin(user_id):
        return None
    slug = canonical_slug(name)
    if not slug:
        return None
    existing = await find_by_name(name)
    if existing is not None:
        return {
            "duplicate": True,
            "existing_id": existing.id,
            "message": (
                f"'{existing.name}' (#{existing.id}) already covers this area — "
                f"both names reduce to '{slug}'. Map Systems to it, or "
                "update_canonical_system if the charter needs revising."
            ),
        }
    async with async_session() as session:
        highest = await session.scalar(
            select(CanonicalSystem.order_index)
            .order_by(CanonicalSystem.order_index.desc())
            .limit(1)
        )
        entry = CanonicalSystem(
            name=" ".join(name.split()),
            slug=slug,
            description=description,
            order_index=(highest or 0) + 1,
        )
        session.add(entry)
        await session.commit()
        await session.refresh(entry)
        return entry


async def update_canonical_system(
    user_id: int, canonical_id: int, **fields: object,
) -> CanonicalSystem | None:
    """Rename or re-charter a catalog entry. Admin only.

    A rename recomputes the slug — the display name and the match key must not
    be allowed to disagree, or the exact-match path silently stops finding it.
    """
    if not await _is_admin(user_id):
        return None
    allowed = {"name", "description", "order_index"}
    async with async_session() as session:
        entry = await session.get(CanonicalSystem, canonical_id)
        if entry is None or entry.deleted_at is not None:
            return None
        for key, value in fields.items():
            if key in allowed and value is not None:
                setattr(entry, key, value)
        if "name" in fields and fields["name"]:
            entry.name = " ".join(str(fields["name"]).split())
            entry.slug = canonical_slug(entry.name)
        entry.updated_at = datetime.now(timezone.utc)
        await session.commit()
        await session.refresh(entry)
        return entry


async def set_system_canonical(
    user_id: int, system_id: int, canonical_id: int | None,
) -> System | None:
    """Map (or unmap) one project System onto a catalog entry.

    Authorised by the PROJECT, not the catalog: mapping changes the project's
    row, so project write access is the right gate (rule 78 — never a bare
    owner filter). Passing None clears the mapping.

    Touches `canonical_id` and nothing else — the System's own name, charter
    and record associations are left exactly as they are.
    """
    if canonical_id is not None and await get_canonical_system(canonical_id) is None:
        return None
    async with async_session() as session:
        system = await session.get(System, system_id)
        if system is None or system.deleted_at is not None:
            return None
        if not await access.can_write_project(user_id, system.project_id):
            return None
        system.canonical_id = canonical_id
        system.updated_at = datetime.now(timezone.utc)
        await session.commit()
        await session.refresh(system)
        return system


async def propose_mappings(user_id: int, project_id: int) -> list[dict]:
    """Suggest a catalog entry for each of a project's UNMAPPED Systems.

    Returns proposals, never applied changes — `set_system_canonical` is the
    only thing that writes. Each carries a `basis` so the reviewer knows what
    they are approving:

    - `exact`   — the two names reduce to the same slug. Mechanical.
    - `overlap` — they share a meaningful word ("CI & runners" / "CI &
      Release"). A judgment call, and the reason this is a proposal at all.

    A System with no plausible match simply gets no proposal: unmapped is a
    perfectly good resting state, so silence here is an answer, not a gap.
    """
    if not await access.can_read_project(user_id, project_id):
        return []
    catalog = await list_canonical_systems()
    if not catalog:
        return []
    async with async_session() as session:
        result = await session.execute(
            select(System).where(
                System.project_id == project_id,
                System.canonical_id.is_(None),
                System.deleted_at.is_(None),
            ).order_by(System.order_index.asc(), System.created_at.asc())
        )
        systems = list(result.scalars().all())

    by_slug = {entry.slug: entry for entry in catalog}
    proposals: list[dict] = []
    for system in systems:
        slug = canonical_slug(system.name)
        if not slug:
            continue
        exact = by_slug.get(slug)
        if exact is not None:
            match = {"id": exact.id, "name": exact.name, "basis": "exact", "score": 1.0}
        else:
            # Same scorer the create-time offer uses, so the two surfaces can
            # never name different areas for one System.
            match = await best_overlap(system.name, catalog)
            if match is None:
                continue
        proposals.append({
            "system_id": system.id,
            "system_name": system.name,
            "canonical_id": match["id"],
            "canonical_name": match["name"],
            "basis": match["basis"],
            "score": match["score"],
        })
    proposals.sort(key=lambda p: (-p["score"], p["system_name"]))
    return proposals
