"""Satellite registry API — the service layer behind ``/api/registry/*``
(SATELLITE-CONTRACT.md "The registry API"; ADR #014).

`with` is the system-of-record for People, Organizations, and Projects;
this is its public edge for suite satellites (``hosting`` first). A
satellite stores a ``party_id`` (an opaque UUID) and resolves the display
label / reference facts here, and creates new organizations through the
ONE write endpoint. Entities are suite-global (NOT tenant-scoped) — no
"whose books?" question on resolve/search/create.

Design notes:

  * The API vocabulary is **person / organization**; the DB discriminator
    is **person / org**. Every boundary maps between them (``_api_kind`` /
    ``_db_kind``) so ``parties.kind`` never leaks.
  * Creation goes through ``services.entities.parties.create_org_party``
    (the 1:1 subtype choke point, ADR #013) with a slug from the shared
    ``_allocate_slug`` allocator — never ``Party(...)`` directly.
  * Search does case-insensitive substring matching with LIKE wildcards
    (``% _ \\``) ESCAPED in user input, so a query of ``%`` matches the
    literal char, not every row.
  * The create write is AUDITED (``registry.create_org``) with a NULL
    tenant/actor — a satellite call has neither an app session nor a
    tenant context.

Routers translate ``ServiceError`` to the structured ApiError triple;
``OrgExistsError`` additionally carries the ``existing`` payload for the
409 body.
"""

from __future__ import annotations

import uuid
from types import SimpleNamespace

from sqlalchemy import case, func, select
from sqlalchemy.orm import Session

from app.models import Organization, Party, Project
from app.services.audit import write_audit
from app.services.entities.parties import create_org_party
from app.services.entities.resolve import _allocate_slug
from app.services.errors import ServiceError

#: The API surface's ``kind`` vocabulary <-> the DB discriminator.
_API_TO_DB_KIND = {"organization": "org", "person": "person"}
_DB_TO_API_KIND = {"org": "organization", "person": "person"}

MAX_NAME_LEN = 200
DEFAULT_SEARCH_LIMIT = 20
MAX_SEARCH_LIMIT = 50
MAX_NEAR_MATCHES = 5


class OrgExistsError(ServiceError):
    """409 for the create-org dedup guard — carries the existing party so
    the satellite can LINK it instead of minting a duplicate."""

    def __init__(self, existing: dict):
        super().__init__(
            409,
            "ORG_EXISTS",
            "An organization with that name already exists.",
            "Link the existing organization, or resend with allow_duplicate:true "
            "to create a distinct one anyway.",
        )
        self.existing = existing


def _api_kind(db_kind: str) -> str:
    # 'org' -> 'organization'; unknown kinds pass through (defensive).
    return _DB_TO_API_KIND.get(db_kind, db_kind)


def _party_dto(party: Party) -> dict:
    return {
        "id": str(party.id),
        "kind": _api_kind(party.kind),
        "name": party.name,
        "is_active": bool(party.is_active),
    }


def _row_dto(party_id: uuid.UUID, kind: str, name: str, is_active: bool) -> dict:
    return {
        "id": str(party_id),
        "kind": _api_kind(kind),
        "name": name,
        "is_active": bool(is_active),
    }


def _escape_like(term: str) -> str:
    """Escape LIKE metacharacters so user text is matched literally. Order
    matters — escape the escape char (``\\``) FIRST."""
    return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")


# --------------------------------------------------------------- resolve


def resolve_party(session: Session, party_id: uuid.UUID) -> dict:
    """GET /parties/{id} — resolve an opaque party id to its label."""
    party = session.get(Party, party_id)
    if party is None:
        raise ServiceError(
            404,
            "PARTY_NOT_FOUND",
            "No party with that id.",
            "The id may be wrong, or the entity was hard-removed (rare — "
            "`with` archives, it does not delete).",
        )
    return _party_dto(party)


# --------------------------------------------------------------- search


def search_parties(
    session: Session,
    *,
    query: str | None,
    kind: str | None,
    limit: int | None,
) -> dict:
    """GET /parties?query=&kind=&limit= — typeahead for a satellite's
    "link this record to a client" picker. Case-insensitive substring on
    name; exact (lower) name matches sort first, then name asc."""
    db_kind: str | None = None
    if kind is not None and kind.strip() != "":
        db_kind = _API_TO_DB_KIND.get(kind.strip().lower())
        if db_kind is None:
            raise ServiceError(
                422,
                "BAD_KIND",
                "kind must be 'organization' or 'person'.",
                f"Got {kind!r}. Omit kind to search both.",
            )

    capped = DEFAULT_SEARCH_LIMIT if limit is None else limit
    capped = max(1, min(int(capped), MAX_SEARCH_LIMIT))

    stmt = select(Party)
    if db_kind is not None:
        stmt = stmt.where(Party.kind == db_kind)

    term = (query or "").strip()
    term_lower = term.lower()
    if term:
        pattern = f"%{_escape_like(term_lower)}%"
        stmt = stmt.where(func.lower(Party.name).like(pattern, escape="\\"))

    # Exact (lowercased) name match first, then alphabetical.
    exact_first = case((func.lower(Party.name) == term_lower, 0), else_=1)
    stmt = stmt.order_by(exact_first, Party.name).limit(capped)

    return {"results": [_party_dto(p) for p in session.scalars(stmt).all()]}


# --------------------------------------------------------------- create


def _active_org_by_lower_name(session: Session, name_lower: str) -> Party | None:
    return session.scalars(
        select(Party)
        .where(
            Party.kind == "org",
            Party.is_active.is_(True),
            func.lower(Party.name) == name_lower,
        )
        .order_by(Party.created_at)
        .limit(1)
    ).first()


def _similar_orgs(
    session: Session, name_lower: str, exclude_id: uuid.UUID
) -> list[dict]:
    """ACTIVE orgs similar to the new name — substring in EITHER direction
    on lower(name) (the new name contains theirs, or theirs contains the
    new name), ≤5, excluding self. The either-direction test is done in
    Python so it stays portable across SQLite (tests) and Postgres (prod);
    a cheap SQL prefilter narrows candidates to active orgs first."""
    rows = session.execute(
        select(Party.id, Party.kind, Party.name, Party.is_active)
        .where(
            Party.kind == "org",
            Party.is_active.is_(True),
            Party.id != exclude_id,
        )
        .order_by(func.lower(Party.name))
    ).all()
    out: list[dict] = []
    for pid, kind, name, is_active in rows:
        other_lower = name.lower()
        if name_lower in other_lower or other_lower in name_lower:
            out.append(_row_dto(pid, kind, name, is_active))
        if len(out) >= MAX_NEAR_MATCHES:
            break
    return out


def create_organization(
    session: Session, *, name: str, allow_duplicate: bool = False
) -> dict:
    """POST /organizations — the ONE write endpoint. Creates a party anchor
    + its ``organizations`` detail row atomically via the choke point, with
    a soft duplicate guard. Raises OrgExistsError (409) on an active
    same-name clash unless ``allow_duplicate`` is set. Audited."""
    if name is None:
        raise ServiceError(422, "ORG_NAME_REQUIRED", "name is required.")
    clean = name.strip()
    if not clean:
        raise ServiceError(
            422, "ORG_NAME_REQUIRED", "name is required.", "Provide a non-empty name."
        )
    if len(clean) > MAX_NAME_LEN:
        raise ServiceError(
            422,
            "ORG_NAME_TOO_LONG",
            f"name must be at most {MAX_NAME_LEN} characters.",
            f"Got {len(clean)} characters.",
        )

    name_lower = clean.lower()
    if not allow_duplicate:
        existing = _active_org_by_lower_name(session, name_lower)
        if existing is not None:
            raise OrgExistsError(_party_dto(existing))

    party = create_org_party(
        session, name=clean, slug=_allocate_slug(session, clean)
    )

    # Audit: a satellite write into the hub's source of truth. NULL tenant,
    # NULL actor — a registry call has neither an app session nor a tenant
    # context (SATELLITE-CONTRACT.md; the audit row is forensic).
    registry_actor = SimpleNamespace(
        tenant_id=None, user_id=None, is_viewing_as=False, real_user_id=None
    )
    write_audit(
        session,
        registry_actor,
        action="registry.create_org",
        entity_type="party",
        entity_id=party.id,
        after={"via": "registry", "name": clean, "party_id": str(party.id)},
    )

    near = _similar_orgs(session, name_lower, party.id)
    session.commit()

    body = _party_dto(party)
    body["near_matches"] = near
    return body


# --------------------------------------------------------------- projects


def get_project(session: Session, project_id: uuid.UUID) -> dict:
    """GET /projects/{id} — for the rare genuinely project-scoped record."""
    project = session.get(Project, project_id)
    if project is None:
        raise ServiceError(404, "PROJECT_NOT_FOUND", "No project with that id.")
    return {
        "id": str(project.id),
        "name": project.name,
        "client_party_id": str(project.client_party_id),
        "tenant_id": str(project.tenant_id),
    }


# --------------------------------------------------------------- org reference


def get_org_reference(session: Session, party_id: uuid.UUID) -> dict:
    """GET /organizations/{id}/reference — shared client reference facts
    (Drive folder, Mosiah folder) off the ``organizations`` detail row.
    404 if the id is not an org party."""
    row = session.execute(
        select(
            Organization.party_id,
            Organization.drive_folder_url,
            Organization.mosiah_folder,
        )
        .join(Party, Party.id == Organization.party_id)
        .where(Party.id == party_id, Party.kind == "org")
    ).first()
    if row is None:
        raise ServiceError(
            404,
            "ORG_NOT_FOUND",
            "No organization with that id.",
            "The id must belong to an organization party (kind=organization).",
        )
    return {
        "party_id": str(row[0]),
        "drive_folder_url": row[1],
        "mosiah_folder": row[2],
    }
