"""Deterministic reconciliation of Workspace-sourced project memberships (design D8, D14, D29, D30).

Pure planning over snapshots. The Workspace projection (staff_directory) is the only
authority for workspace-sourced state. This module decides the operations that bring
memberships, workspace-sourced role bindings, and workspace-sourced suspension holds
into line with it, and never touches local state:

- only source=workspace role bindings are created or removed;
- only source=workspace suspension holds are added or removed;
- local bindings, local holds, and legacy holds are never changed here;
- owner is the is_owner relation, not a role binding.

Operations are keyed by email and emitted in a fixed phase order, so the same snapshot
always yields the same list, and a converged project yields no operations.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from enum import StrEnum
from uuid import UUID

WORKSPACE = "workspace"
LOCAL = "local"
LEGACY = "legacy"


class ProjectionError(Exception):
    """The Workspace snapshot cannot be projected safely. Nothing must be applied."""


class OpKind(StrEnum):
    UPDATE_OWNER = "UPDATE_OWNER"
    CREATE_MEMBERSHIP = "CREATE_MEMBERSHIP"
    REMOVE_SUSPENSION = "REMOVE_SUSPENSION"
    GRANT_ROLE = "GRANT_ROLE"
    REVOKE_ROLE = "REVOKE_ROLE"
    ADD_SUSPENSION = "ADD_SUSPENSION"
    LINK_CLOUD_USER = "LINK_CLOUD_USER"


# Owner demotion (-1) precedes everything. A promoted owner must have its workspace hold
# lifted first (owners are never held), and the owner flag moves only after the row exists.
_PHASE = {
    OpKind.REMOVE_SUSPENSION: 0,
    OpKind.CREATE_MEMBERSHIP: 1,
    OpKind.GRANT_ROLE: 3,
    OpKind.REVOKE_ROLE: 4,
    OpKind.ADD_SUSPENSION: 5,
    OpKind.LINK_CLOUD_USER: 6,
}


@dataclass(frozen=True)
class DesiredIdentity:
    """A Workspace principal projected for this project (staff_directory output)."""

    email: str
    is_owner: bool
    admin: bool


@dataclass(frozen=True)
class CurrentBinding:
    role_id: UUID
    source: str


@dataclass(frozen=True)
class CurrentMembership:
    membership_id: UUID
    email: str
    source: str
    is_owner: bool
    cloud_app_user_id: str | None
    bindings: frozenset[CurrentBinding] = field(default_factory=frozenset)
    holds: frozenset[str] = field(default_factory=frozenset)

    @property
    def suspended(self) -> bool:
        return bool(self.holds)


@dataclass(frozen=True)
class Operation:
    kind: OpKind
    email: str
    role_id: UUID | None = None
    source: str | None = None
    value: bool | str | None = None


@dataclass(frozen=True)
class Plan:
    operations: tuple[Operation, ...]
    blocked: tuple[str, ...]
    held: tuple[str, ...] = ()


def normalize_email(email: str) -> str:
    return (email or "").strip().lower()


def plan_reconciliation(
    desired: list[DesiredIdentity],
    current: list[CurrentMembership],
    *,
    admin_role_id: UUID | None,
    cloud_user_ids: dict[str, str] | None = None,
) -> Plan:
    cloud_user_ids = {normalize_email(k): v for k, v in (cloud_user_ids or {}).items()}
    by_email: dict[str, DesiredIdentity] = {}
    for identity in desired:
        email = normalize_email(identity.email)
        if email in by_email:
            raise ProjectionError(f"duplicate_workspace_principal:{email}")
        admin = identity.admin and not identity.is_owner
        by_email[email] = DesiredIdentity(email, identity.is_owner, admin)

    owners = [d for d in by_email.values() if d.is_owner]
    if len(owners) != 1:
        raise ProjectionError("workspace_owner_missing_or_ambiguous")
    if any(d.admin for d in by_email.values()) and admin_role_id is None:
        raise ProjectionError("admin_role_unavailable")

    cur: dict[str, CurrentMembership] = {}
    for m in current:
        email = normalize_email(m.email)
        if email in cur:
            raise ProjectionError(f"duplicate_membership:{email}")
        cur[email] = m

    ops: list[Operation] = []
    blocked: list[str] = []
    held: list[str] = []
    owner_email = owners[0].email

    # The owner flag follows the Workspace owner. Clear any other holder before promotion.
    for email in sorted(cur):
        if cur[email].is_owner and email != owner_email:
            ops.append(Operation(OpKind.UPDATE_OWNER, email, value=False))

    for email in sorted(by_email):
        if email not in cur:
            d = by_email[email]
            ops.append(
                Operation(OpKind.CREATE_MEMBERSHIP, email, source=WORKSPACE, value=d.is_owner)
            )

    # Owner: lift the workspace hold so the owner row is active. Any other hold blocks the
    # projection, because an owner cannot be promoted while held.
    owner_m = cur.get(owner_email)
    if owner_m is not None and owner_m.holds:
        if WORKSPACE in owner_m.holds:
            ops.append(Operation(OpKind.REMOVE_SUSPENSION, owner_email, source=WORKSPACE))
        remaining = owner_m.holds - {WORKSPACE}
        if remaining:
            raise ProjectionError(f"owner_held_by:{','.join(sorted(remaining))}")
    if owner_m is not None and not owner_m.is_owner:
        ops.append(Operation(OpKind.UPDATE_OWNER, owner_email, value=True))

    # Desired admins: ensure the workspace admin binding; lift a workspace hold on return.
    for email in sorted(by_email):
        d = by_email[email]
        if d.is_owner:
            continue
        m = cur.get(email)
        if d.admin:
            has = m is not None and any(
                b.role_id == admin_role_id and b.source == WORKSPACE for b in m.bindings
            )
            if not has:
                ops.append(
                    Operation(OpKind.GRANT_ROLE, email, role_id=admin_role_id, source=WORKSPACE)
                )
        if m is not None and m.holds:
            if WORKSPACE in m.holds:
                ops.append(Operation(OpKind.REMOVE_SUSPENSION, email, source=WORKSPACE))
            if LEGACY in m.holds:
                blocked.append(f"legacy_hold_requires_reinstatement:{email}")
            remaining = m.holds - {WORKSPACE}
            if remaining:
                held.append(f"{email}:{','.join(sorted(remaining))}")

    # Revoke workspace-sourced bindings no longer desired.
    for email in sorted(cur):
        m = cur[email]
        d = by_email.get(email)
        desired_admin = d is not None and d.admin and not d.is_owner
        for b in sorted(m.bindings, key=lambda b: str(b.role_id)):
            if b.source != WORKSPACE:
                continue
            if desired_admin and b.role_id == admin_role_id:
                continue
            ops.append(Operation(OpKind.REVOKE_ROLE, email, role_id=b.role_id, source=WORKSPACE))

    # Identities that left the Workspace lose workspace authority. Local authority remains,
    # so a membership takes a workspace hold only when no local binding remains. A former
    # owner was demoted above and gets the same hold.
    for email in sorted(cur):
        m = cur[email]
        if email in by_email:
            continue
        if m.source != WORKSPACE or WORKSPACE in m.holds:
            continue
        if not any(b.source == LOCAL for b in m.bindings):
            ops.append(Operation(OpKind.ADD_SUSPENSION, email, source=WORKSPACE))

    for email in sorted(by_email):
        if email in cloud_user_ids:
            m = cur.get(email)
            if m is None or m.cloud_app_user_id != cloud_user_ids[email]:
                ops.append(
                    Operation(OpKind.LINK_CLOUD_USER, email, value=cloud_user_ids[email])
                )

    ops.sort(key=_phase_key)
    return Plan(operations=tuple(ops), blocked=tuple(blocked), held=tuple(held))


def _phase_key(op: Operation) -> tuple[int, str, str]:
    if op.kind is OpKind.UPDATE_OWNER:
        phase = -1 if op.value is False else 2
    else:
        phase = _PHASE[op.kind]
    return (phase, op.email, str(op.role_id or ""))
