"""Workspace + Navigation + Access-control business logic.

Domain-boundary correction (Phase C):
  - Workspace                = pure UX shell (navigation, theme, icon).
  - WorkspaceAccessGrant     = access rule: Role → Workspace.
  - WorkspaceMembership      = materialized user-in-workspace record.
  - UserOrgContextPreference = user's active OrganizationNode per tenant.
  - UserWorkspacePreference  = user's active Workspace per tenant.

These three dimensions — org context, role/access, and workspace UX —
are independent. Switching one does not affect the others.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from django.db import models, transaction

from simorgh.apps.events.bus import dispatch
from simorgh.apps.iam.engine import is_allowed
from simorgh.apps.modules.contributions import get_contributions
from simorgh.apps.modules.services import _tenant_root, is_feature_available
from simorgh.apps.workspaces.models import (
    NavigationItem,
    UserOrgContextPreference,
    UserWorkspacePreference,
    Workspace,
    WorkspaceAccessGrant,
    WorkspaceKind,
    WorkspaceMembership,
    WorkspaceTemplate,
)
from simorgh.core.view_models import (
    build_navigation_tree,
    serialize_actor,
    serialize_scope,
    serialize_workspace,
)

if TYPE_CHECKING:
    from django.contrib.auth.models import AbstractUser

    from simorgh.apps.iam.models import Role
    from simorgh.apps.organizations.models import OrganizationNode
    from simorgh.apps.tenants.models import Tenant
    from simorgh.core.context import RequestContext


class WorkspaceError(RuntimeError):
    """Raised on workspace lifecycle / membership errors."""


# ---------------------------------------------------------------------------
# Workspace lifecycle
# ---------------------------------------------------------------------------
def create_workspace(
    tenant: Tenant,
    *,
    slug: str,
    name: str,
    kind: str = WorkspaceKind.INTERNAL,
    icon: str = "",
    theme: dict[str, Any] | None = None,
    default_module: str = "",
) -> Workspace:
    """Create a UX-shell workspace under ``tenant``.

    ``slug`` must be unique per tenant.  Workspaces are pure UX shells —
    they carry navigation, theme, and icon but do NOT define org scope
    or permissions.
    """
    if Workspace.objects.filter(tenant=tenant, slug=slug).exists():
        raise WorkspaceError(f"workspace {slug!r} already exists for tenant {tenant.slug!r}")

    anchor = _tenant_root(tenant)
    ws = Workspace.objects.create(
        tenant=tenant,
        organization_node=anchor,
        slug=slug,
        name=name,
        kind=kind,
        icon=icon,
        theme=theme or {},
        default_module=default_module,
    )
    dispatch(
        "workspaces.workspace_created",
        {"workspace_id": ws.pk, "tenant_id": tenant.pk, "kind": kind},
    )
    return ws


def update_workspace(workspace: Workspace, **fields: Any) -> Workspace:
    allowed = {"name", "icon", "theme", "default_module", "is_active"}
    bad = set(fields) - allowed
    if bad:
        raise WorkspaceError(f"cannot update fields: {sorted(bad)}")
    for k, v in fields.items():
        setattr(workspace, k, v)
    workspace.save(update_fields=[*fields, "updated_at"])
    dispatch(
        "workspaces.workspace_updated",
        {"workspace_id": workspace.pk, "tenant_id": workspace.tenant_id},
    )
    return workspace


def get_default_workspace(tenant: Tenant) -> Workspace | None:
    """Return the tenant's primary UX workspace, creating none implicitly."""
    return (
        Workspace.objects.filter(tenant=tenant, is_active=True, kind=WorkspaceKind.INTERNAL)
        .order_by("created_at")
        .first()
    )


def ensure_default_workspace(tenant: Tenant) -> Workspace:
    """Get or create the tenant's primary internal workspace."""
    ws = get_default_workspace(tenant)
    if ws is not None:
        return ws
    return create_workspace(
        tenant,
        slug="main",
        name="Main",
        kind=WorkspaceKind.INTERNAL,
    )


# ---------------------------------------------------------------------------
# Membership (materialized user-in-workspace record)
# ---------------------------------------------------------------------------
def add_member(
    workspace: Workspace,
    user: AbstractUser,
) -> WorkspaceMembership:
    """Record that *user* has access to *workspace*.

    Does NOT assign roles — role membership is managed by the IAM
    ``Membership`` model.  This is a materialized fact: the user
    belongs to the workspace because at least one of their roles
    has a ``WorkspaceAccessGrant`` for it.
    """
    membership, created = WorkspaceMembership.objects.get_or_create(
        workspace=workspace,
        user=user,
        defaults={
            "tenant": workspace.tenant,
            "organization_node": workspace.organization_node,
        },
    )
    if created:
        dispatch(
            "workspaces.member_added",
            {"workspace_id": workspace.pk, "user_id": user.pk},
        )
    return membership


def remove_member(workspace: Workspace, user: AbstractUser) -> None:
    deleted, _ = WorkspaceMembership.objects.filter(
        workspace=workspace, user=user,
    ).delete()
    if deleted:
        dispatch(
            "workspaces.member_removed",
            {"workspace_id": workspace.pk, "user_id": user.pk},
        )


def workspaces_for_user(user: AbstractUser, tenant: Tenant) -> list[Workspace]:
    """Return workspaces the user can access in *tenant*.

    A user can access any workspace where at least one of their roles
    has a ``WorkspaceAccessGrant``.  Superusers see all workspaces.
    """
    base_qs = Workspace.objects.filter(tenant=tenant, is_active=True).distinct().order_by("name")
    if user.is_superuser:
        return list(base_qs)
    return list(base_qs.filter(members__user=user))


# ---------------------------------------------------------------------------
# Access grants — role → workspace access rules
# ---------------------------------------------------------------------------

def add_access_grant(workspace: Workspace, role: Role) -> WorkspaceAccessGrant:
    """Declare that *role* grants access to *workspace*.

    Idempotent.  If the grant is newly created the service also backfills
    all existing tenant users who already hold *role* via an active
    :class:`~simorgh.apps.memberships.models.Membership`.
    """
    anchor = _tenant_root(workspace.tenant)
    grant, created = WorkspaceAccessGrant.objects.get_or_create(
        workspace=workspace,
        role=role,
        defaults={
            "tenant": workspace.tenant,
            "organization_node": anchor,
        },
    )
    if created:
        dispatch(
            "workspaces.access_grant_added",
            {"workspace_id": workspace.pk, "role_id": str(role.pk)},
        )
        _sync_members_for_access_grant(workspace, role)
    return grant


def remove_access_grant(workspace: Workspace, role: Role) -> None:
    """Remove the access rule for *role* from *workspace*."""
    deleted, _ = WorkspaceAccessGrant.objects.filter(workspace=workspace, role=role).delete()
    if deleted:
        dispatch(
            "workspaces.access_grant_removed",
            {"workspace_id": workspace.pk, "role_id": str(role.pk)},
        )


def list_access_grants(workspace: Workspace):
    """Return queryset of :class:`WorkspaceAccessGrant` for *workspace*."""
    return (
        WorkspaceAccessGrant.objects.filter(workspace=workspace)
        .select_related("role")
        .order_by("role__name")
    )


def _sync_members_for_access_grant(workspace: Workspace, role: Role) -> int:
    """Backfill: enroll all tenant users who hold *role* into *workspace*.

    Looks at active :class:`~simorgh.apps.memberships.models.Membership`
    records.  Returns the number of memberships created or updated.
    """
    from simorgh.apps.memberships.models import Membership, MembershipStatus  # noqa: PLC0415

    enrolled = 0
    memberships = (
        Membership.objects.filter(
            tenant=workspace.tenant,
            role=role,
            status=MembershipStatus.ACTIVE,
        )
        .prefetch_related("users")
    )
    for membership in memberships:
        for user in membership.users.all():
            add_member(workspace, user)
            enrolled += 1
    return enrolled


# ---------------------------------------------------------------------------
# Navigation install + resolve
# ---------------------------------------------------------------------------

@transaction.atomic
def install_navigation_from_contributions(workspace: Workspace, module: str) -> int:
    """Materialise a module's nav contributions into ``workspace``.

    Idempotent on the (workspace, key) unique constraint — re-running
    after a module upgrade updates the row in-place. Returns the count
    of items written.
    """
    contribs = get_contributions(module)
    if not contribs.navigation:
        return 0

    by_key: dict[str, NavigationItem] = {}
    written = 0
    pending: list = list(contribs.navigation)
    # First pass: root-level items
    for nav in [n for n in pending if not n.parent_key]:
        item, _ = NavigationItem.objects.update_or_create(
            workspace=workspace,
            key=nav.key,
            defaults={
                "tenant": workspace.tenant,
                "organization_node": workspace.organization_node,
                "parent": None,
                "label_key": nav.label_key,
                "icon": nav.icon,
                "route": nav.route,
                "module": module,
                "permission": nav.permission,
                "order": nav.order,
            },
        )
        by_key[nav.key] = item
        written += 1
    for nav in [n for n in pending if n.parent_key]:
        parent = by_key.get(nav.parent_key) or NavigationItem.objects.filter(
            workspace=workspace, key=nav.parent_key,
        ).first()
        if parent is None:
            raise WorkspaceError(
                f"nav {nav.key!r}: parent {nav.parent_key!r} not found in workspace "
                f"{workspace.slug!r}",
            )
        item, _ = NavigationItem.objects.update_or_create(
            workspace=workspace,
            key=nav.key,
            defaults={
                "tenant": workspace.tenant,
                "organization_node": workspace.organization_node,
                "parent": parent,
                "label_key": nav.label_key,
                "icon": nav.icon,
                "route": nav.route,
                "module": module,
                "permission": nav.permission,
                "order": nav.order,
            },
        )
        by_key[nav.key] = item
        written += 1

    if written:
        dispatch("workspaces.navigation_changed", {"workspace_id": workspace.pk})
    return written


def resolve_navigation(workspace: Workspace, ctx: RequestContext) -> list[dict[str, Any]]:
    """Return the visible navigation tree for ``ctx`` in ``workspace``."""
    items = list(NavigationItem.objects.filter(workspace=workspace).prefetch_related("features").order_by("order"))
    visible: set[int] = set()
    for item in items:
        if item.permission and not is_allowed(ctx, item.permission):
            continue
        required_features = list(item.features.values_list("code", flat=True))
        if any(
            not is_feature_available(workspace.tenant, item.module or "", code, user=ctx.actor)
            for code in required_features
        ):
            continue
        visible.add(item.id)
    return build_navigation_tree(items, visible_ids=visible)


# ---------------------------------------------------------------------------
# Bootstrap payload (the "view model" the SPA shell consumes)
# ---------------------------------------------------------------------------
def workspace_bootstrap(workspace: Workspace, ctx: RequestContext) -> dict[str, Any]:
    """Return the full payload a workspace shell needs to render."""
    return {
        "workspace": serialize_workspace(workspace),
        "actor": serialize_actor(ctx),
        "scope": serialize_scope(ctx),
        "navigation": resolve_navigation(workspace, ctx),
    }


# ---------------------------------------------------------------------------
# Workspace templates
# ---------------------------------------------------------------------------


def list_workspace_templates() -> models.QuerySet:
    """Return all available WorkspaceTemplates ordered by name."""
    from simorgh.apps.workspaces.models import WorkspaceTemplate as _WT  # noqa: PLC0415

    return _WT.objects.all()


@transaction.atomic
def create_workspace_from_template(
    tenant: Any,
    template_slug: str,
    *,
    name: str | None = None,
    slug: str | None = None,
) -> Workspace:
    """Instantiate a :class:`WorkspaceTemplate` into a tenant :class:`Workspace`.

    The resulting workspace inherits *kind*, *icon*, *theme*, and
    *default_module* from the template.  Its ``NavigationItems`` are
    materialised from ``template.navigation_schema``.

    Raises
    ------
    WorkspaceTemplate.DoesNotExist
        If no template with *template_slug* exists.
    """
    template = WorkspaceTemplate.objects.get(slug=template_slug)
    workspace_slug = slug or template_slug
    workspace_name = name or template.name

    workspace = create_workspace(
        tenant,
        slug=workspace_slug,
        name=workspace_name,
        kind=template.kind,
        icon=template.icon,
        theme=template.theme,
        default_module=template.default_module,
    )

    for item in template.navigation_schema:
        NavigationItem.objects.create(
            tenant=workspace.tenant,
            organization_node=workspace.organization_node,
            workspace=workspace,
            parent=None,
            key=item.get("key", ""),
            label_key=item.get("label_key", ""),
            icon=item.get("icon", ""),
            route=item.get("route", ""),
            module=item.get("module", ""),
            permission=item.get("permission", ""),
            order=item.get("order", 0),
        )

    return workspace


# ---------------------------------------------------------------------------
# User context preferences — independent org-context & workspace preferences
# ---------------------------------------------------------------------------


def get_user_org_context(user: Any, tenant: Any) -> UserOrgContextPreference:
    """Return (or lazily create) the user's org-context preference for *tenant*."""
    pref, _ = UserOrgContextPreference.objects.get_or_create(user=user, tenant=tenant)
    return pref


def get_user_workspace_context(user: Any, tenant: Any) -> UserWorkspacePreference:
    """Return (or lazily create) the user's workspace preference for *tenant*."""
    pref, _ = UserWorkspacePreference.objects.get_or_create(user=user, tenant=tenant)
    return pref


def switch_active_node(
    user: Any,
    tenant: Any,
    *,
    node_id: int | None,
) -> UserOrgContextPreference:
    """Change the active organization node (data scope) for *user* in *tenant*.

    Pass *node_id=None* to clear the active node (reset to tenant root).
    The node must belong to *tenant*; raises ``OrganizationNode.DoesNotExist``
    otherwise.

    This only affects data scoping — the user's workspace and permissions
    are unchanged.
    """
    from simorgh.apps.organizations.models import OrganizationNode  # noqa: PLC0415

    pref = get_user_org_context(user, tenant)

    if node_id is None:
        pref.active_organization_node = None
    else:
        node = OrganizationNode.objects.get(pk=node_id, tenant=tenant)
        pref.active_organization_node = node

    pref.save(update_fields=["active_organization_node", "updated_at"])
    return pref


def switch_active_workspace(
    user: Any,
    tenant: Any,
    *,
    workspace_slug: str | None,
) -> UserWorkspacePreference:
    """Change the active workspace (UX shell) for *user* in *tenant*.

    This only affects the UX shell — the user's data scope (org node)
    and permissions are unchanged.
    """
    pref = get_user_workspace_context(user, tenant)

    if workspace_slug is None:
        pref.active_workspace = None
    else:
        workspace = Workspace.objects.get(slug=workspace_slug, tenant=tenant)
        pref.active_workspace = workspace

    pref.save(update_fields=["active_workspace", "updated_at"])
    return pref
