"""Channel-level access control guard (RES-177 H1).

``assert_channel_access`` is the single chokepoint every channel-scoped
public route funnels through before touching the stores.

`PlatformConnection.selected_channels` is the **sync pick-list**, not the
authoritative set of channels a connection can reach: a Slack/Discord
connection legitimately BROWSES every channel its bot sees. That means
"channel has no matching connection in selected_channels" is not, on its
own, a cross-tenant exploit signal — it just means the caller is
browsing a channel they haven't synced yet. The guard therefore decides
as follows.

Allow when any of the following holds:

1. The caller's principal owns (via ``PlatformConnection.owner_principal_id``)
   at least one connection whose ``selected_channels`` includes the target
   channel.
2. The deployment is in single-tenant compatibility mode
   (``BEEVER_SINGLE_TENANT=true``, default for v1.0), the caller is a
   user principal, AND EITHER
   - every matching connection has ``owner_principal_id`` in
     ``{None, "legacy:shared"}``, OR
   - no connection currently lists the channel in ``selected_channels``
     (browsing path — the single operator is discovering a new channel
     before adding it to a sync pick-list).

Reject when:

- The caller is a bridge principal and there is no explicit owner match
  (bridge principals never inherit user channel ownership).
- The deployment is in multi-tenant mode (``BEEVER_SINGLE_TENANT=false``)
  and no matching connection is owned by the caller.

In practice bridge principals are also blocked upstream by ``require_user``
once ``BEEVER_ALLOW_BRIDGE_AS_USER`` flips to ``False`` (Group 6 did this).

The guard is async because it reads from MongoDB via the platform store
singleton. No caching layer is added here — the platform store already
relies on Mongo's own indexes and connections are small (handful per
deployment).
"""

from __future__ import annotations

import logging

from fastapi import HTTPException, status

from beever_atlas.infra.auth import Principal
from beever_atlas.infra.config import get_settings
from beever_atlas.stores import get_stores

logger = logging.getLogger(__name__)


_LEGACY_SHARED_OWNER = "legacy:shared"


def _principal_kind(principal: Principal | str) -> str:
    """Return the principal kind ('user' / 'bridge' / 'mcp').

    Accepts bare strings (test conftests that pre-date the Principal type)
    and falls back to inspecting the id prefix so MCP principals created
    directly as strings by middleware still route correctly. Unknown
    shapes default to ``'user'`` for backward compatibility.
    """
    kind = getattr(principal, "kind", None)
    if kind in ("user", "bridge", "mcp"):
        return kind  # type: ignore[return-value]
    # String-principal fallback: detect MCP by id prefix.
    pid = str(principal)
    if pid.startswith("mcp:"):
        return "mcp"
    if pid.startswith("bridge"):
        return "bridge"
    return "user"


def _principal_id(principal: Principal | str) -> str:
    """Return the principal id; falls back to ``str(principal)``."""
    pid = getattr(principal, "id", None)
    if pid:
        return str(pid)
    return str(principal)


async def assert_channel_access(principal: Principal | str, channel_id: str) -> None:
    """Raise ``HTTPException(403)`` if ``principal`` cannot access ``channel_id``.

    See module docstring for the full decision table.
    """
    stores = get_stores()
    connections = await stores.platform.list_connections()
    matching = [c for c in connections if channel_id in (c.selected_channels or [])]

    pid = _principal_id(principal)
    kind = _principal_kind(principal)
    settings = get_settings()
    single_tenant = bool(getattr(settings, "beever_single_tenant", True))

    if not matching:
        # No connection explicitly claims this channel. `selected_channels`
        # is a sync pick-list, not the authoritative access list — a
        # single-tenant operator legitimately browses channels before
        # syncing them. Allow user AND mcp principals through in
        # single-tenant mode (the MCP api-key represents the same
        # single tenant); in multi-tenant mode the operator must have
        # explicitly claimed the channel via `selected_channels` on an
        # owned connection first. Bridge principals stay strict — they
        # can be cross-tenant.
        if single_tenant and kind in ("user", "mcp"):
            return
        logger.info(
            "channel_access deny: channel=%s principal=%s kind=%s reason=no_matching_connection",
            channel_id,
            pid,
            kind,
        )
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Channel access denied",
        )

    # Explicit ownership match wins for both user and bridge principals.
    for conn in matching:
        owner = getattr(conn, "owner_principal_id", None)
        if owner and owner == pid:
            return

    # Single-tenant fallback: user AND mcp principals are admitted when
    # every matching row is un-owned or sentinel-owned. Bridge principals
    # stay strict (see the `kind == "bridge"` cross-tenant rationale in
    # assert_connection_owned). The MCP inclusion keeps this consistent
    # with list_connections and with assert_connection_owned so a caller
    # that can list a connection can also read the channels on it.
    if single_tenant and kind in ("user", "mcp"):
        all_legacy = all(
            (getattr(c, "owner_principal_id", None) in (None, _LEGACY_SHARED_OWNER))
            for c in matching
        )
        if all_legacy:
            return

    # Additional single-tenant fallback for MCP principals: dashboard-created
    # connections stamp ``owner_principal_id`` with a user principal id
    # (e.g. ``"user:<hash>"``), which the ``all_legacy`` branch above does
    # NOT admit. In single-tenant mode the MCP api-key represents the same
    # operator as the dashboard user, so MCP must still reach those rows.
    # Scoped to MCP only — user principals keep the stricter ``all_legacy``
    # check so two users in a single-tenant deployment don't see each
    # other's rows.
    if single_tenant and kind == "mcp":
        return

    logger.info(
        "channel_access deny: channel=%s principal=%s kind=%s reason=owner_mismatch",
        channel_id,
        pid,
        kind,
    )
    raise HTTPException(
        status_code=status.HTTP_403_FORBIDDEN,
        detail="Channel access denied",
    )


async def assert_channel_delete_access(principal: Principal | str, channel_id: str) -> None:
    """Raise ``HTTPException(403)`` if ``principal`` may not HARD-DELETE ``channel_id``.

    delete-channel-v2 Wave 0. This is the authz chokepoint for the
    DESTRUCTIVE path and is intentionally STRICTER than the read-path
    :func:`assert_channel_access`, which is orphan-permissive (it lets a
    single operator browse channels no connection has claimed). A purge is
    irreversible, so the orphan-permissive read fallback MUST NOT apply.

    Decision table (Principle #4 of the plan):

    Single-tenant (OSS default, ``BEEVER_SINGLE_TENANT=true``):
      - Allow ``user`` and ``mcp`` principals (shared key → one principal;
        single-operator trust). This mirrors the existing single-tenant
        allowance in ``assert_channel_access`` / ``assert_connection_owned``.
      - Bridge principals are NOT admitted here. (In practice they are also
        blocked UPSTREAM by ``require_user`` on the endpoint once
        ``BEEVER_ALLOW_BRIDGE_AS_USER=false``; this is defence-in-depth.)

    Multi-tenant (EE, ``BEEVER_SINGLE_TENANT=false``):
      - The principal must OWN (via ``owner_principal_id``) at least one
        connection whose ``selected_channels`` contains ``channel_id``.
      - Orphan (no connection currently lists the channel in any
        ``selected_channels``) → require an admin / elevated principal.
        Documented limitation: an orphan whose owning connection was deleted
        is admin-cleanup-only in EE until per-channel owner is persisted.

    Admin / elevated detection: there is no admin flag on ``Principal`` —
    admin auth is the separate ``X-Admin-Token`` path that resolves to the
    bare string ``"admin"`` (auth.py ``require_admin``). We therefore treat a
    principal whose id is ``"admin"`` as elevated. Wave 3 wires the endpoint;
    if a richer elevated-principal concept lands later, extend
    ``_is_elevated`` only.
    """
    pid = _principal_id(principal)
    kind = _principal_kind(principal)
    settings = get_settings()
    single_tenant = bool(getattr(settings, "beever_single_tenant", True))

    def _is_elevated() -> bool:
        # ``require_admin`` resolves to the literal "admin"; that is the only
        # elevated principal the system currently mints.
        return pid == "admin"

    if single_tenant:
        # Single-operator trust: any user/mcp principal may delete. Bridge
        # is excluded (and blocked upstream by require_user).
        if kind in ("user", "mcp") or _is_elevated():
            return
        logger.info(
            "channel_delete deny: channel=%s principal=%s kind=%s reason=single_tenant_non_user",
            channel_id,
            pid,
            kind,
        )
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Channel delete access denied",
        )

    # Multi-tenant (EE).
    stores = get_stores()
    connections = await stores.platform.list_connections()
    matching = [c for c in connections if channel_id in (c.selected_channels or [])]

    if not matching:
        # Orphan — no connection claims this channel. Admin-only cleanup.
        if _is_elevated():
            return
        logger.info(
            "channel_delete deny: channel=%s principal=%s kind=%s reason=orphan_requires_admin",
            channel_id,
            pid,
            kind,
        )
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Channel delete access denied",
        )

    # Owned-by-caller match wins.
    for conn in matching:
        owner = getattr(conn, "owner_principal_id", None)
        if owner and owner == pid:
            return

    # Elevated principals may delete any owned channel (admin override).
    if _is_elevated():
        return

    logger.info(
        "channel_delete deny: channel=%s principal=%s kind=%s reason=owner_mismatch",
        channel_id,
        pid,
        kind,
    )
    raise HTTPException(
        status_code=status.HTTP_403_FORBIDDEN,
        detail="Channel delete access denied",
    )


async def assert_connection_owned(principal: Principal | str, connection_id: str) -> None:
    """Raise ``ConnectionAccessDenied`` if ``principal`` doesn't own ``connection_id``.

    Mirrors :func:`assert_channel_access` semantics for connection-scoped
    operations (e.g. listing the channels of a single connection):

    Allow when:

    1. The connection's ``owner_principal_id`` equals ``principal.id``.
    2. Single-tenant mode (``BEEVER_SINGLE_TENANT=true``), the principal is
       a ``user`` or ``mcp`` kind, AND the connection is un-owned
       (``owner_principal_id in {None, "legacy:shared"}``) — the legacy
       fallback for pre-migration rows and env-provisioned connections.
       MCP keys inherit because in single-tenant deployments the api-key
       represents the same caller as the dashboard user; without this,
       ``list_connections`` (which already admits the fallback) and
       ``list_channels`` (which calls this function) would disagree and
       leave MCP clients with empty channel lists.

    Bridge principals never inherit the single-tenant fallback — they can
    be cross-tenant by design and must always carry an explicit owner.

    Raises:
        ConnectionAccessDenied: when the connection does not exist OR the
            principal is not permitted to access it. The two cases are not
            distinguished in the raised exception so callers cannot probe
            for connection existence without ownership.
    """
    # Import here to avoid a circular import: `capabilities.errors` is a
    # small leaf module but keeping this local makes the dependency
    # direction obvious (infra → capabilities.errors, not the reverse).
    from beever_atlas.capabilities.errors import ConnectionAccessDenied

    stores = get_stores()
    conn = await stores.platform.get_connection(connection_id)

    pid = _principal_id(principal)
    kind = _principal_kind(principal)
    settings = get_settings()
    single_tenant = bool(getattr(settings, "beever_single_tenant", True))

    if conn is None:
        logger.info(
            "connection_access deny: connection=%s principal=%s kind=%s reason=not_found",
            connection_id,
            pid,
            kind,
        )
        raise ConnectionAccessDenied(connection_id)

    owner = getattr(conn, "owner_principal_id", None)

    # Explicit ownership match wins for any principal kind.
    if owner and owner == pid:
        return

    # Single-tenant fallback: user AND mcp principals are admitted on
    # un-owned / sentinel-owned rows. Bridge principals don't inherit
    # this — they can be cross-tenant by design and must have an
    # explicit match. The MCP inclusion keeps `list_channels` consistent
    # with `list_connections` (capabilities/connections.py) so the same
    # principal sees the same connection set on both endpoints.
    if single_tenant and kind in ("user", "mcp") and owner in (None, _LEGACY_SHARED_OWNER):
        return

    # Additional single-tenant fallback for MCP: dashboard-created
    # connections are stamped with a user principal id (e.g. ``"user:<hash>"``),
    # which the branch above does NOT admit. In single-tenant mode the MCP
    # api-key represents the same operator as the dashboard user, so MCP
    # must still reach those rows. Scoped to MCP only — user principals
    # keep the stricter ``owner in (None, "legacy:shared")`` check.
    if single_tenant and kind == "mcp":
        return

    logger.info(
        "connection_access deny: connection=%s principal=%s kind=%s reason=owner_mismatch",
        connection_id,
        pid,
        kind,
    )
    raise ConnectionAccessDenied(connection_id)
