"""Access resolution, delegation rules and role/override mutations.

Everything that decides "may this user do X in organization Y" goes through
`resolve_access`. Views never look at role names.
"""
from __future__ import annotations

from dataclasses import dataclass

from django.db import transaction
from django.db.models import Q
from django.utils import timezone

from apps.access.models import MembershipRole, Permission, Role, RolePermission, UserPermissionOverride
from apps.access.role_templates import expand_grants, templates_for
from apps.audit import services as audit
from apps.modules.registry import REGISTRY
from apps.modules.services import enabled_module_codes
from apps.organizations.models import Organization, OrganizationAssignment, OrganizationMembership
from domain.access_policy import (
    EMPTY_ACCESS,
    SCOPE_ORDER,
    EffectiveAccess,
    OverrideRule,
    RoleGrant,
    compute_effective_access,
    merge_effective_access,
)
from domain.exceptions import DomainError, PermissionDelegationError

PLATFORM_ONLY_SCOPES = {"assigned_organizations", "all"}


@dataclass
class AccessContext:
    user: object
    organization: Organization
    membership: OrganizationMembership
    via_platform: bool
    effective: EffectiveAccess
    enabled_modules: set

    def has(self, permission: str, min_scope: str | None = None) -> bool:
        return self.effective.has(permission, min_scope)


# --------------------------------------------------------------------------- resolution


def _permission_catalog() -> tuple[dict[str, str], set[str]]:
    rows = list(Permission.objects.values_list("code", "module_id", "is_platform_only"))
    return {code: module for code, module, _ in rows}, {code for code, _, platform_only in rows if platform_only}


def _load_rules(membership) -> tuple[list[RoleGrant], list[OverrideRule]]:
    grants = [
        RoleGrant(code, scope, delegate)
        for code, scope, delegate in RolePermission.objects.filter(
            role__membership_roles__membership=membership, role__is_active=True
        ).values_list("permission__code", "scope", "can_delegate")
    ]
    now = timezone.now()
    overrides = [
        OverrideRule(code, effect, scope, delegate)
        for code, effect, scope, delegate in UserPermissionOverride.objects.filter(membership=membership)
        .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
        .values_list("permission__code", "effect", "scope", "can_delegate")
    ]
    return grants, overrides


def resolve_access(user, organization: Organization) -> AccessContext | None:
    """Effective access of `user` inside `organization`, or None when they have no standing there.

    Two independent sources are merged (additively):
      * the user's own membership in the organization (roles + overrides);
      * for customer organizations, their NoTeam staff role, limited by data scope
        (`all`, or `assigned_organizations` when this organization is assigned to them).
    """
    if user is None or not getattr(user, "is_authenticated", False) or not user.is_active:
        return None

    modules_map, platform_only = _permission_catalog()
    enabled = enabled_module_codes(organization)

    membership = OrganizationMembership.objects.filter(user=user, organization=organization).first()
    own = EMPTY_ACCESS
    if membership is not None:
        grants, overrides = _load_rules(membership)
        own = compute_effective_access(
            organization_active=organization.is_active,
            membership_active=membership.is_active,
            enabled_modules=enabled,
            permission_modules=modules_map,
            role_grants=grants,
            overrides=overrides,
            blocked_permissions=set() if organization.is_platform else platform_only,
        )

    if organization.is_platform:
        if membership is None:
            return None
        return AccessContext(user, organization, membership, False, own, enabled)

    staff = EMPTY_ACCESS
    platform_membership = (
        OrganizationMembership.objects.filter(user=user, organization__kind=Organization.Kind.PLATFORM)
        .select_related("organization")
        .first()
    )
    if platform_membership is not None:
        assigned = OrganizationAssignment.objects.filter(
            membership=platform_membership, organization=organization
        ).exists()

        def scope_applies(scope: str) -> bool:
            return scope == "all" or (scope == "assigned_organizations" and assigned)

        grants, overrides = _load_rules(platform_membership)
        grants = [g for g in grants if scope_applies(g.scope)]
        overrides = [o for o in overrides if o.effect == "deny" or scope_applies(o.scope)]
        staff = compute_effective_access(
            organization_active=True,  # staff may still manage an inactive customer organization
            membership_active=platform_membership.is_active,
            enabled_modules=enabled,
            permission_modules=modules_map,
            role_grants=grants,
            overrides=overrides,
        )

    if membership is None and not staff.permissions:
        return None
    return AccessContext(
        user, organization, membership or platform_membership, bool(staff.permissions),
        merge_effective_access(own, staff), enabled,
    )

def platform_context(user) -> AccessContext | None:
    org = Organization.objects.filter(kind=Organization.Kind.PLATFORM).first()
    return resolve_access(user, org) if org else None


def accessible_organizations(user) -> list[Organization]:
    """Organizations the user may select as their working context."""
    if user is None or not getattr(user, "is_authenticated", False):
        return []
    memberships = OrganizationMembership.objects.filter(
        user=user, status=OrganizationMembership.Status.ACTIVE
    ).select_related("organization")
    found: dict[int, Organization] = {
        m.organization_id: m.organization for m in memberships if m.organization.is_active
    }
    platform_membership = next((m for m in memberships if m.organization.is_platform), None)
    if platform_membership is not None:
        has_all = RolePermission.objects.filter(
            role__membership_roles__membership=platform_membership, role__is_active=True, scope="all"
        ).exists()
        customers = Organization.objects.filter(kind=Organization.Kind.CUSTOMER)
        if not has_all:
            customers = customers.filter(assigned_staff__membership=platform_membership)
        for org in customers:
            found[org.id] = org
    return sorted(found.values(), key=lambda o: o.id)


def build_navigation(ctx: AccessContext) -> list[dict]:
    """Menu for the selected organization: module enabled AND permission held."""
    items = []
    for code, spec in REGISTRY.items():
        if code not in ctx.enabled_modules or not spec.nav:
            continue
        for nav in spec.nav:
            if nav.platform_only and not ctx.organization.is_platform:
                continue
            if nav.permission is None or ctx.has(nav.permission, nav.min_scope):
                items.append(
                    {
                        "key": nav.key,
                        "label": nav.label_fa,
                        "path": nav.path,
                        "group": nav.group,
                        "module": code,
                        "sort_order": nav.sort_order,
                    }
                )
    return sorted(items, key=lambda i: (i["sort_order"], i["key"]))


# --------------------------------------------------------------------------- seeding


@transaction.atomic
def seed_roles(organization: Organization) -> list[Role]:
    """Create the template roles for a new organization (only roles that are missing)."""
    kind = "platform" if organization.is_platform else "customer"
    catalog = {p.code: p for p in Permission.objects.all()}
    roles = []
    for template in templates_for(kind):
        role, created = Role.objects.get_or_create(
            organization=organization,
            code=template.code,
            defaults={"name": template.name, "is_system": True},
        )
        if created:
            for code, (scope, can_delegate) in expand_grants(template).items():
                if code in catalog:
                    RolePermission.objects.create(
                        role=role, permission=catalog[code], scope=scope, can_delegate=can_delegate
                    )
        roles.append(role)
    return roles


# --------------------------------------------------------------------------- delegation


def _assert_not_self(actor: AccessContext, target: OrganizationMembership) -> None:
    if actor.membership.pk == target.pk:
        raise PermissionDelegationError("You cannot change your own access.")


def _assert_same_org(actor: AccessContext, organization: Organization) -> None:
    if organization.pk != actor.organization.pk:
        raise DomainError("The object does not belong to the selected organization.")


def _validate_scope(scope: str, organization: Organization) -> None:
    if scope not in SCOPE_ORDER:
        raise DomainError(f"Unknown data scope '{scope}'.")
    if scope in PLATFORM_ONLY_SCOPES and not organization.is_platform:
        raise DomainError(f"Scope '{scope}' is only valid for NoTeam staff roles.")


def _validate_grantable(organization: Organization, permission: Permission) -> None:
    if permission.is_platform_only and not organization.is_platform:
        raise PermissionDelegationError(
            f"'{permission.code}' cannot be granted inside a customer organization.", [permission.code]
        )
    if permission.module_id not in enabled_module_codes(organization):
        raise DomainError(f"Module '{permission.module_id}' is not enabled for this organization.")


def _assert_can_delegate(actor: AccessContext, items: list[tuple[str, str]]) -> None:
    denied = sorted({code for code, scope in items if not actor.effective.can_delegate(code, scope)})
    if denied:
        raise PermissionDelegationError(
            "You are not allowed to delegate: " + ", ".join(denied), permissions=denied
        )


# --------------------------------------------------------------------------- roles


@transaction.atomic
def create_role(*, actor: AccessContext, code: str, name: str, request=None) -> Role:
    if Role.objects.filter(organization=actor.organization, code=code).exists():
        raise DomainError("A role with this code already exists.")
    role = Role.objects.create(organization=actor.organization, code=code, name=name)
    audit.record(
        actor=actor.user, action="role.created", organization=actor.organization,
        entity_type="role", entity_id=role.pk, after={"code": code, "name": name}, request=request,
    )
    return role


@transaction.atomic
def update_role(*, actor: AccessContext, role: Role, name: str | None, is_active: bool | None, request=None) -> Role:
    _assert_same_org(actor, role.organization)
    before = {"name": role.name, "is_active": role.is_active}
    if name is not None:
        role.name = name
    if is_active is not None:
        if role.is_system and is_active is False:
            raise DomainError("System roles cannot be deactivated.")
        role.is_active = is_active
    role.save()
    audit.record(
        actor=actor.user, action="role.updated", organization=actor.organization, entity_type="role",
        entity_id=role.pk, before=before, after={"name": role.name, "is_active": role.is_active}, request=request,
    )
    return role


@transaction.atomic
def delete_role(*, actor: AccessContext, role: Role, request=None) -> None:
    _assert_same_org(actor, role.organization)
    if role.is_system:
        raise DomainError("System roles cannot be deleted.")
    if MembershipRole.objects.filter(role=role).exists():
        raise DomainError("This role is assigned to members; unassign it first.")
    snapshot = {"code": role.code, "name": role.name}
    role_id = role.pk
    role.delete()
    audit.record(
        actor=actor.user, action="role.deleted", organization=actor.organization, entity_type="role",
        entity_id=role_id, before=snapshot, request=request,
    )


@transaction.atomic
def set_role_permissions(*, actor: AccessContext, role: Role, grants: list[dict], request=None) -> Role:
    """Replace a role's permission set. Added or widened grants must be delegable by the actor."""
    _assert_same_org(actor, role.organization)
    if not actor.via_platform and MembershipRole.objects.filter(
        role=role, membership=actor.membership
    ).exists():
        raise PermissionDelegationError("You cannot edit a role that is assigned to you.")

    catalog = {p.code: p for p in Permission.objects.filter(code__in=[g["code"] for g in grants])}
    wanted: dict[str, tuple[str, bool]] = {}
    for grant in grants:
        code = grant["code"]
        if code not in catalog:
            raise DomainError(f"Unknown permission '{code}'.")
        scope = grant.get("scope", "self")
        _validate_scope(scope, role.organization)
        wanted[code] = (scope, bool(grant.get("can_delegate", False)))

    current = {
        rp.permission.code: (rp.scope, rp.can_delegate)
        for rp in role.grants.select_related("permission")
    }

    changed = [(code, value) for code, value in wanted.items() if current.get(code) != value]
    for code, _value in changed:
        _validate_grantable(role.organization, catalog[code])
    _assert_can_delegate(actor, [(code, value[0]) for code, value in changed])
    # Handing out the right to delegate further requires holding that right too.
    _assert_can_delegate(actor, [(code, value[0]) for code, value in changed if value[1]])

    role.grants.exclude(permission__code__in=wanted.keys()).delete()
    for code, (scope, can_delegate) in wanted.items():
        RolePermission.objects.update_or_create(
            role=role, permission=catalog[code], defaults={"scope": scope, "can_delegate": can_delegate}
        )
    audit.record(
        actor=actor.user, action="role.permissions_set", organization=actor.organization,
        entity_type="role", entity_id=role.pk,
        before={c: list(v) for c, v in current.items()}, after={c: list(v) for c, v in wanted.items()},
        request=request,
    )
    return role


# --------------------------------------------------------------------------- member access


@transaction.atomic
def set_member_roles(
    *, actor: AccessContext, target: OrganizationMembership, roles: list[Role], request=None
) -> list[Role]:
    _assert_same_org(actor, target.organization)
    _assert_not_self(actor, target)

    for role in roles:
        if role.organization_id != target.organization_id:
            raise DomainError("A role from another organization cannot be assigned.")
        if not role.is_active:
            raise DomainError("An inactive role cannot be assigned.")

    current_ids = set(target.membership_roles.values_list("role_id", flat=True))
    added = [r for r in roles if r.pk not in current_ids]
    # permissions of modules that are switched off in this organization grant nothing, so they are not
    # part of what is being delegated (the standard manager role lists every module's permissions)
    usable = enabled_module_codes(target.organization)
    items = [
        (rp.permission.code, rp.scope)
        for role in added
        for rp in role.grants.select_related("permission")
        if rp.permission.module_id in usable
    ]
    _assert_can_delegate(actor, items)

    wanted_ids = {r.pk for r in roles}
    target.membership_roles.exclude(role_id__in=wanted_ids).delete()
    for role in added:
        MembershipRole.objects.create(membership=target, role=role)

    audit.record(
        actor=actor.user, action="member.roles_set", organization=actor.organization,
        entity_type="membership", entity_id=target.pk,
        before={"roles": sorted(current_ids)}, after={"roles": sorted(wanted_ids)}, request=request,
    )
    return roles


@transaction.atomic
def set_override(
    *, actor: AccessContext, target: OrganizationMembership, permission_code: str, effect: str,
    scope: str = "self", can_delegate: bool = False, reason: str = "", expires_at=None, request=None,
) -> UserPermissionOverride:
    _assert_same_org(actor, target.organization)
    _assert_not_self(actor, target)
    if effect not in ("allow", "deny"):
        raise DomainError("effect must be 'allow' or 'deny'.")
    try:
        permission = Permission.objects.get(code=permission_code)
    except Permission.DoesNotExist:
        raise DomainError(f"Unknown permission '{permission_code}'.")

    if effect == "allow":
        _validate_scope(scope, target.organization)
        _validate_grantable(target.organization, permission)
        _assert_can_delegate(actor, [(permission_code, scope)])

    existing = UserPermissionOverride.objects.filter(membership=target, permission=permission).first()
    before = (
        {"effect": existing.effect, "scope": existing.scope, "can_delegate": existing.can_delegate}
        if existing else None
    )
    override, _ = UserPermissionOverride.objects.update_or_create(
        membership=target, permission=permission,
        defaults={
            "effect": effect,
            "scope": scope if effect == "allow" else "self",
            "can_delegate": can_delegate if effect == "allow" else False,
            "reason": reason,
            "expires_at": expires_at,
            "created_by": actor.user,
        },
    )
    audit.record(
        actor=actor.user, action="member.override_set", organization=actor.organization,
        entity_type="membership", entity_id=target.pk, before=before,
        after={"permission": permission_code, "effect": effect, "scope": override.scope}, request=request,
    )
    return override


@transaction.atomic
def remove_override(
    *, actor: AccessContext, target: OrganizationMembership, permission_code: str, request=None
) -> None:
    _assert_same_org(actor, target.organization)
    _assert_not_self(actor, target)
    existing = UserPermissionOverride.objects.filter(
        membership=target, permission__code=permission_code
    ).first()
    if existing is None:
        return
    before = {"permission": permission_code, "effect": existing.effect, "scope": existing.scope}
    existing.delete()
    audit.record(
        actor=actor.user, action="member.override_removed", organization=actor.organization,
        entity_type="membership", entity_id=target.pk, before=before, request=request,
    )
