"""Module lifecycle: install, enable, disable, upgrade, rollback.

All operations are tenant-scoped, transactional, audit-logged, and emit
events on the shared event bus. Dependencies are checked against the
*per-tenant* install table — you cannot enable ``crm`` if ``accounting``
isn't enabled when ``crm`` depends on it.
"""

from __future__ import annotations

import importlib
from typing import Any

from django.db import models, transaction
from django.utils import timezone

from simorgh.apps.events.bus import dispatch
from simorgh.apps.modules.compat import resolve_feature_key
from simorgh.apps.modules.models import (
    FeatureEntitlement,
    FeatureEntitlementSource,
    ModuleStatus,
    TenantFeatureOverride,
    TenantModule,
)
from simorgh.apps.modules.registry import (
    FeatureFlagSpec,
    FeatureSpec,
    ModuleError,
    ModuleManifest,
    get_module,
    resolve_install_order,
)
from simorgh.apps.organizations.models import OrganizationNode
from simorgh.apps.tenants.models import Tenant
from simorgh.core.audit import record_event


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _tenant_root(tenant: Tenant) -> OrganizationNode:
    """Pick the lowest-depth org node as the install anchor."""
    node = (
        OrganizationNode.objects.filter(tenant=tenant)
        .order_by("depth", "pk")
        .first()
    )
    if node is None:
        raise ModuleError(
            f"tenant {tenant.slug!r} has no organization nodes — bootstrap one first",
        )
    return node


def _run_hook(hook: str, **kwargs: Any) -> None:
    if not hook:
        return
    path, _, attr = hook.partition(":")
    module = importlib.import_module(path)
    try:
        fn = getattr(module, attr)
    except AttributeError as exc:
        raise ModuleError(f"hook {hook!r}: attribute {attr!r} missing") from exc
    fn(**kwargs)


def _audit(action: str, tenant: Tenant, module_name: str, after: dict[str, Any]) -> None:
    record_event(
        action,
        resource_type="module",
        resource_id=f"{tenant.slug}:{module_name}",
        after=after,
    )


def _installed_names(tenant: Tenant) -> set[str]:
    return set(
        TenantModule.objects.filter(
            tenant=tenant,
            status__in=(ModuleStatus.INSTALLED, ModuleStatus.ENABLED, ModuleStatus.DISABLED),
        ).values_list("name", flat=True),
    )


def _enabled_names(tenant: Tenant) -> set[str]:
    return set(
        TenantModule.objects.filter(tenant=tenant, status=ModuleStatus.ENABLED)
        .values_list("name", flat=True),
    )


# ---------------------------------------------------------------------------
# Install
# ---------------------------------------------------------------------------
@transaction.atomic
def install_module(
    tenant: Tenant,
    name: str,
    *,
    config: dict[str, Any] | None = None,
) -> list[TenantModule]:
    """Install ``name`` (and any missing dependencies) for ``tenant``.

    Returns the list of newly-created or updated :class:`TenantModule` rows
    in topological order. Idempotent: re-installing a tenant's already-
    installed module is a no-op for that row.
    """
    manifests = resolve_install_order([name])
    anchor = _tenant_root(tenant)
    already = _installed_names(tenant)
    results: list[TenantModule] = []
    now = timezone.now()
    for manifest in manifests:
        if manifest.name in already:
            # Pre-existing — skip but include in returned list.
            results.append(TenantModule.objects.get(tenant=tenant, name=manifest.name))
            continue
        # Verify deps are now satisfied.
        for dep in manifest.depends_on:
            if dep not in already and not any(r.name == dep for r in results):
                raise ModuleError(
                    f"dependency {dep!r} not installed for tenant {tenant.slug!r}",
                )
        row = TenantModule.objects.create(
            tenant=tenant,
            organization_node=anchor,
            name=manifest.name,
            version=manifest.version,
            status=ModuleStatus.INSTALLED,
            config=(config or {}) if manifest.name == name else {},
            installed_at=now,
        )
        _run_hook(manifest.install_hook, tenant=tenant, module=row, manifest=manifest)
        _audit(
            "modules.installed",
            tenant,
            manifest.name,
            {"version": manifest.version},
        )
        dispatch(
            "modules.installed",
            {
                "tenant_id": tenant.pk,
                "module": manifest.name,
                "version": manifest.version,
            },
        )
        results.append(row)
        already.add(manifest.name)
    return results


# ---------------------------------------------------------------------------
# Enable / disable
# ---------------------------------------------------------------------------
@transaction.atomic
def enable_module(tenant: Tenant, name: str) -> TenantModule:
    manifest = get_module(name)
    enabled = _enabled_names(tenant)
    for dep in manifest.depends_on:
        if dep not in enabled:
            raise ModuleError(
                f"cannot enable {name!r}: dependency {dep!r} is not enabled",
            )
    try:
        row = TenantModule.objects.get(tenant=tenant, name=name)
    except TenantModule.DoesNotExist as exc:
        raise ModuleError(
            f"module {name!r} is not installed for tenant {tenant.slug!r}",
        ) from exc
    if row.status == ModuleStatus.ENABLED:
        return row
    row.status = ModuleStatus.ENABLED
    row.enabled_at = timezone.now()
    row.save(update_fields=("status", "enabled_at", "updated_at"))
    _audit("modules.enabled", tenant, name, {"version": row.version})
    dispatch(
        "modules.enabled",
        {"tenant_id": tenant.pk, "module": name, "version": row.version},
    )
    _auto_grant_included(tenant, row, manifest)
    return row


@transaction.atomic
def disable_module(tenant: Tenant, name: str) -> TenantModule:
    try:
        row = TenantModule.objects.get(tenant=tenant, name=name)
    except TenantModule.DoesNotExist as exc:
        raise ModuleError(
            f"module {name!r} is not installed for tenant {tenant.slug!r}",
        ) from exc
    # Block if another *enabled* module depends on this one.
    enabled = _enabled_names(tenant)
    for other in enabled - {name}:
        manifest = get_module(other)
        if name in manifest.depends_on:
            raise ModuleError(
                f"cannot disable {name!r}: enabled module {other!r} depends on it",
            )
    if row.status == ModuleStatus.DISABLED:
        return row
    row.status = ModuleStatus.DISABLED
    row.disabled_at = timezone.now()
    row.save(update_fields=("status", "disabled_at", "updated_at"))
    _audit("modules.disabled", tenant, name, {"version": row.version})
    dispatch(
        "modules.disabled",
        {"tenant_id": tenant.pk, "module": name, "version": row.version},
    )
    return row


# ---------------------------------------------------------------------------
# Upgrade / rollback
# ---------------------------------------------------------------------------
@transaction.atomic
def upgrade_module(tenant: Tenant, name: str) -> TenantModule:
    """Move the tenant's installed version to the registry's current version."""
    manifest = get_module(name)
    try:
        row = TenantModule.objects.get(tenant=tenant, name=name)
    except TenantModule.DoesNotExist as exc:
        raise ModuleError(
            f"module {name!r} is not installed for tenant {tenant.slug!r}",
        ) from exc
    if row.version == manifest.version:
        return row
    if manifest.version < row.version:
        raise ModuleError(
            f"manifest version {manifest.version} < installed {row.version} — use rollback",
        )
    previous = row.version
    row.previous_version = previous
    row.version = manifest.version
    row.save(update_fields=("previous_version", "version", "updated_at"))
    _audit(
        "modules.upgraded",
        tenant,
        name,
        {"from_version": previous, "to_version": manifest.version},
    )
    dispatch(
        "modules.upgraded",
        {
            "tenant_id": tenant.pk,
            "module": name,
            "from_version": previous,
            "to_version": manifest.version,
        },
    )
    return row


@transaction.atomic
def rollback_module(tenant: Tenant, name: str) -> TenantModule:
    """Reverse the most recent upgrade by restoring ``previous_version``."""
    try:
        row = TenantModule.objects.get(tenant=tenant, name=name)
    except TenantModule.DoesNotExist as exc:
        raise ModuleError(
            f"module {name!r} is not installed for tenant {tenant.slug!r}",
        ) from exc
    if not row.previous_version:
        raise ModuleError(f"module {name!r}: no previous version to roll back to")
    target = row.previous_version
    row.version = target
    row.previous_version = None
    row.save(update_fields=("version", "previous_version", "updated_at"))
    _audit("modules.rolled_back", tenant, name, {"to_version": target})
    dispatch(
        "modules.rolled_back",
        {"tenant_id": tenant.pk, "module": name, "to_version": target},
    )
    return row


# ---------------------------------------------------------------------------
# Feature flags
# ---------------------------------------------------------------------------
def _flag_spec(manifest: ModuleManifest, flag_name: str) -> FeatureFlagSpec:
    return manifest.feature_flag(flag_name)


def is_feature_enabled(
    tenant: Tenant,
    module_name: str,
    flag_name: str,
    *,
    user_id: int | None = None,
) -> bool:
    """Resolve a feature flag using the precedence ``user → tenant → default``."""
    manifest = get_module(module_name)
    spec = _flag_spec(manifest, flag_name)
    try:
        row = TenantModule.objects.get(tenant=tenant, name=module_name)
    except TenantModule.DoesNotExist:
        return spec.default
    if row.status != ModuleStatus.ENABLED:
        return False
    fkey = f"{module_name}.{flag_name}"
    qs = TenantFeatureOverride.objects.filter(tenant=tenant, feature_key=fkey)
    if spec.scope == "user" and user_id is not None:
        user_row = qs.filter(user_id=user_id).first()
        if user_row is not None:
            return user_row.enabled
    tenant_row = qs.filter(user__isnull=True).first()
    if tenant_row is not None:
        return tenant_row.enabled
    return spec.default


@transaction.atomic
def set_feature_flag(
    tenant: Tenant,
    module_name: str,
    flag_name: str,
    *,
    enabled: bool,
    user_id: int | None = None,
) -> TenantFeatureOverride:
    manifest = get_module(module_name)
    spec = _flag_spec(manifest, flag_name)
    if user_id is not None and spec.scope != "user":
        raise ModuleError(
            f"flag {module_name}.{flag_name!r} has tenant scope; cannot set per-user",
        )
    if not TenantModule.objects.filter(tenant=tenant, name=module_name).exists():
        raise ModuleError(
            f"module {module_name!r} is not installed for tenant {tenant.slug!r}",
        )
    anchor = _tenant_root(tenant)
    fkey = f"{module_name}.{flag_name}"
    defaults = {
        "enabled": enabled,
        "organization_node": anchor,
    }
    obj, _ = TenantFeatureOverride.objects.update_or_create(
        tenant=tenant,
        feature_key=fkey,
        user_id=user_id,
        defaults=defaults,
    )
    _audit(
        "modules.feature_flag_changed",
        tenant,
        module_name,
        {"flag": flag_name, "enabled": enabled, "user_id": user_id},
    )
    dispatch(
        "modules.feature_flag_changed",
        {
            "tenant_id": tenant.pk,
            "module": module_name,
            "flag": flag_name,
            "enabled": enabled,
        },
    )
    return obj


# ---------------------------------------------------------------------------
# Feature entitlements (commerce-aware)
# ---------------------------------------------------------------------------
def _feature_spec(manifest: ModuleManifest, code: str) -> FeatureSpec:
    return manifest.feature(code)


def _auto_grant_included(
    tenant: Tenant,
    row: TenantModule,
    manifest: ModuleManifest,
) -> None:
    """Grant every ``billing='included'`` tenant-scoped feature on enable.

    Idempotent: re-enabling a module re-uses the existing entitlement rows
    and clears any previous ``revoked_at`` so the feature comes back alive.
    """
    anchor = _tenant_root(tenant)
    now = timezone.now()
    # Phase B — derive capability_key from manifest's default capability
    capability_key = f"{manifest.name}.{manifest.capability}" if manifest.capability else ""
    for spec in manifest.features:
        if spec.billing != "included" or spec.scope != "tenant":
            continue
        feature_key = f"{manifest.name}.{spec.code}"
        obj, created = FeatureEntitlement.objects.get_or_create(
            tenant=tenant,
            feature_key=feature_key,
            user=None,
            defaults={
                "organization_node": anchor,
                "source": FeatureEntitlementSource.INCLUDED,
                "enabled": spec.default_enabled,
                "starts_at": now,
                "capability_key": capability_key,
            },
        )
        if not created and obj.revoked_at is not None:
            obj.revoked_at = None
            obj.enabled = spec.default_enabled
            obj.save(update_fields=("revoked_at", "enabled", "updated_at"))
        if created:
            _audit(
                "modules.feature_granted",
                tenant,
                manifest.name,
                {"feature": spec.code, "source": FeatureEntitlementSource.INCLUDED},
            )
            dispatch(
                "modules.feature_granted",
                {
                    "tenant_id": tenant.pk,
                    "module": manifest.name,
                    "feature": spec.code,
                    "source": FeatureEntitlementSource.INCLUDED,
                },
            )


def _live_entitlement(
    tenant: Tenant,
    feature_key: str,
    *,
    user_id: int | None,
) -> FeatureEntitlement | None:
    """Return the active entitlement for ``feature_key`` honouring ``user → tenant``."""
    now = timezone.now()
    base = FeatureEntitlement.objects.filter(
        tenant=tenant,
        feature_key=feature_key,
        revoked_at__isnull=True,
    ).filter(models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now))
    if user_id is not None:
        user_row = base.filter(user_id=user_id).first()
        if user_row is not None:
            return user_row
    return base.filter(user__isnull=True).first()


def is_feature_available(
    tenant: Tenant,
    module_name: str,
    feature_code: str,
    *,
    user_id: int | None = None,
) -> bool:
    """Resolve whether a feature is usable right now.

    Returns ``True`` only when the module is enabled, an unrevoked and
    unexpired entitlement exists (scoped per the feature spec), and the
    entitlement's ``enabled`` flag is on.
    """
    manifest = get_module(module_name)
    spec = _feature_spec(manifest, feature_code)
    try:
        row = TenantModule.objects.get(tenant=tenant, name=module_name)
    except TenantModule.DoesNotExist:
        return False
    if row.status != ModuleStatus.ENABLED:
        return False
    lookup_user = user_id if spec.scope == "user" else None
    full_key = resolve_feature_key(f"{module_name}.{feature_code}")
    ent = _live_entitlement(tenant, full_key, user_id=lookup_user)
    if ent is None:
        return False
    return bool(ent.enabled)


def list_feature_status(
    tenant: Tenant,
    module_name: str,
    *,
    user_id: int | None = None,
) -> list[dict[str, Any]]:
    """Return per-feature resolution for a module — useful for UI listings."""
    manifest = get_module(module_name)
    row = TenantModule.objects.filter(tenant=tenant, name=module_name).first()
    results: list[dict[str, Any]] = []
    for spec in manifest.features:
        ent = None
        if row is not None:
            lookup_user = user_id if spec.scope == "user" else None
            full_key = f"{module_name}.{spec.code}"
            ent = _live_entitlement(tenant, full_key, user_id=lookup_user)
        results.append(
            {
                "code": spec.code,
                "label_key": spec.label_key,
                "description": spec.description,
                "billing": spec.billing,
                "scope": spec.scope,
                "price_hint": spec.price_hint,
                "default_enabled": spec.default_enabled,
                "depends_on": list(spec.depends_on),
                "entitled": ent is not None,
                "enabled": bool(ent.enabled) if ent is not None else False,
                "source": ent.source if ent is not None else "",
                "expires_at": ent.expires_at if ent is not None else None,
                "available": is_feature_available(
                    tenant, module_name, spec.code, user_id=user_id,
                ),
            },
        )
    return results


# ---------------------------------------------------------------------------
# Sync registry → database
# ---------------------------------------------------------------------------
def seed_default_domains() -> int:
    """Ensure canonical Domain rows exist for the three domains.

    Idempotent — safe to call on every post-migrate.
    Returns the number of newly created rows.
    """
    from simorgh.apps.modules.models import Domain

    defaults = [
        {
            "code": "platform",
            "name": "Platform",
            "description": "Infrastructure and cross-cutting platform services.",
            "sort_order": 10,
        },
        {
            "code": "commerce",
            "name": "Commerce",
            "description": "Sales, marketing, and customer-facing operations.",
            "sort_order": 20,
        },
        {
            "code": "business",
            "name": "Business",
            "description": "Back-office operations: accounting, HR, projects, etc.",
            "sort_order": 30,
        },
    ]
    created = 0
    for entry in defaults:
        _, was_created = Domain.objects.get_or_create(
            code=entry["code"],
            defaults=entry,
        )
        if was_created:
            created += 1
    return created


def _resolve_domain(domain_code: str):
    """Look up a Domain by its string code.  Returns None for empty/unknown codes."""
    if not domain_code:
        return None
    from simorgh.apps.modules.models import Domain

    try:
        return Domain.objects.get(code=domain_code)
    except Domain.DoesNotExist:
        return None


def _resolve_capability(capability_slug: str):
    """Look up a Capability by its slug.  Returns None for empty/unknown slugs."""
    if not capability_slug:
        return None
    from simorgh.apps.modules.models import Capability

    try:
        return Capability.objects.get(slug=capability_slug)
    except Capability.DoesNotExist:
        return None


@transaction.atomic
def sync_features_to_db() -> int:
    """Upsert all registered manifest features into the ``FeatureCatalog`` table.

    Idempotent — existing rows are updated in-place when fields change.
    Populates licensing metadata: is_always_on, is_platform_internal.
    Phase B: category and addon_price removed — replaced by domain+capability taxonomy
    and Plan/Addon entitlement configuration.
    """
    from simorgh.apps.modules.models import FeatureCatalog
    from simorgh.apps.modules.registry import list_modules

    total = 0
    for manifest in list_modules():
        # Phase A — resolve capability for feature grouping
        resolved_capability = _resolve_capability(manifest.capability) if manifest.capability else None

        for spec in manifest.features:
            FeatureCatalog.objects.update_or_create(
                code=f"{manifest.name}.{spec.code}",
                defaults={
                    "label": spec.label_key,
                    "module_name": manifest.name,
                    "description": spec.description,
                    "is_always_on": spec.is_always_on,
                    "is_platform_internal": spec.is_platform_internal,
                    # Phase A — capability linkage
                    "capability": resolved_capability,
                },
            )
            total += 1
    return total


@transaction.atomic
def sync_modules_to_db() -> int:
    """Upsert all registered manifests into the ``ModuleCatalog`` table.

    Phase B: module_type removed — domain replaces tier+type grouping.
    """
    from simorgh.apps.modules.models import ModuleCatalog
    from simorgh.apps.modules.registry import list_modules

    total = 0
    for manifest in list_modules():
        # Phase A — resolve domain
        resolved_domain = _resolve_domain(manifest.domain)
        ModuleCatalog.objects.update_or_create(
            name=manifest.name,
            defaults={
                "version": manifest.version,
                "label": manifest.label_key,
                "description": manifest.description,
                # Phase A — domain replaces module_type
                "domain": resolved_domain,
                "depends_on": list(manifest.depends_on),
                "permissions": list(manifest.permissions),
                "events": list(manifest.events),
                "feature_flags": [f.name for f in manifest.feature_flags],
            },
        )
        total += 1
    return total


def _resolve_module_row(tenant: Tenant, name: str) -> TenantModule:
    try:
        return TenantModule.objects.get(tenant=tenant, name=name)
    except TenantModule.DoesNotExist as exc:
        raise ModuleError(
            f"module {name!r} is not installed for tenant {tenant.slug!r}",
        ) from exc


@transaction.atomic
def grant_feature(
    tenant: Tenant,
    module_name: str,
    feature_code: str,
    *,
    source: str = FeatureEntitlementSource.GRANTED,
    user_id: int | None = None,
    expires_at: Any | None = None,
    metadata: dict[str, Any] | None = None,
) -> FeatureEntitlement:
    """Grant an entitlement to ``feature_code`` for ``tenant`` (or a user).

    Idempotent: a second call refreshes ``source`` / ``expires_at`` /
    ``metadata`` and re-enables a previously revoked entitlement. Feature
    dependencies declared on the manifest must already be entitled.
    """
    manifest = get_module(module_name)
    spec = _feature_spec(manifest, feature_code)
    if source not in dict(FeatureEntitlementSource.choices):
        raise ModuleError(f"unknown entitlement source {source!r}")
    if user_id is not None and spec.scope != "user":
        raise ModuleError(
            f"feature {module_name}.{feature_code!r} has tenant scope; "
            "cannot grant per-user",
        )
    _resolve_module_row(tenant, module_name)  # ensures module is installed
    # Verify feature dependencies are also entitled in the same scope.
    for dep in spec.depends_on:
        if not is_feature_available(tenant, module_name, dep, user_id=user_id):
            raise ModuleError(
                f"feature {feature_code!r}: dependency {dep!r} is not available",
            )
    anchor = _tenant_root(tenant)
    full_key = f"{module_name}.{feature_code}"
    # Phase B — derive capability_key from manifest's default capability
    capability_key = f"{module_name}.{manifest.default_capability}" if manifest.default_capability else ""
    obj, created = FeatureEntitlement.objects.get_or_create(
        tenant=tenant,
        feature_key=full_key,
        user_id=user_id,
        defaults={
            "organization_node": anchor,
            "source": source,
            "enabled": spec.default_enabled,
            "starts_at": timezone.now(),
            "expires_at": expires_at,
            "metadata": metadata or {},
            "capability_key": capability_key,
        },
    )
    if not created:
        obj.source = source
        obj.expires_at = expires_at
        obj.revoked_at = None
        if metadata is not None:
            obj.metadata = metadata
        if not obj.enabled:
            obj.enabled = spec.default_enabled
        obj.save(
            update_fields=(
                "source", "expires_at", "revoked_at", "metadata", "enabled", "updated_at",
            ),
        )
    _audit(
        "modules.feature_granted",
        tenant,
        module_name,
        {"feature": feature_code, "source": source, "user_id": user_id},
    )
    dispatch(
        "modules.feature_granted",
        {
            "tenant_id": tenant.pk,
            "module": module_name,
            "feature": feature_code,
            "source": source,
        },
    )
    return obj


@transaction.atomic
def revoke_feature(
    tenant: Tenant,
    module_name: str,
    feature_code: str,
    *,
    user_id: int | None = None,
) -> FeatureEntitlement:
    full_key = f"{module_name}.{feature_code}"
    try:
        ent = FeatureEntitlement.objects.get(
            tenant=tenant,
            feature_key=full_key,
            user_id=user_id,
        )
    except FeatureEntitlement.DoesNotExist as exc:
        raise ModuleError(
            f"no entitlement for {module_name}.{feature_code!r}",
        ) from exc
    if ent.revoked_at is None:
        ent.revoked_at = timezone.now()
        ent.enabled = False
        ent.save(update_fields=("revoked_at", "enabled", "updated_at"))
        _audit(
            "modules.feature_revoked",
            tenant,
            module_name,
            {"feature": feature_code, "user_id": user_id},
        )
        dispatch(
            "modules.feature_revoked",
            {"tenant_id": tenant.pk, "module": module_name, "feature": feature_code},
        )
    return ent


@transaction.atomic
def purchase_feature(
    tenant: Tenant,
    module_name: str,
    feature_code: str,
    *,
    user_id: int | None = None,
    expires_at: Any | None = None,
    metadata: dict[str, Any] | None = None,
) -> FeatureEntitlement:
    """Tenant- or user-initiated purchase of a paid feature.

    Wraps :func:`grant_feature` with ``source=PURCHASED`` plus its own
    ``modules.feature_purchased`` event so billing / receipt subscribers
    can react without overlapping with admin grants.
    """
    manifest = get_module(module_name)
    spec = _feature_spec(manifest, feature_code)
    if spec.billing not in {"paid", "addon"}:
        raise ModuleError(
            f"feature {module_name}.{feature_code!r} is {spec.billing!r}, not purchasable",
        )
    ent = grant_feature(
        tenant,
        module_name,
        feature_code,
        source=FeatureEntitlementSource.PURCHASED,
        user_id=user_id,
        expires_at=expires_at,
        metadata=metadata,
    )
    dispatch(
        "modules.feature_purchased",
        {"tenant_id": tenant.pk, "module": module_name, "feature": feature_code},
    )
    return ent


@transaction.atomic
def toggle_feature(
    tenant: Tenant,
    module_name: str,
    feature_code: str,
    *,
    enabled: bool,
    user_id: int | None = None,
) -> FeatureEntitlement:
    """Flip an existing entitlement's ``enabled`` flag without revoking it."""
    manifest = get_module(module_name)
    spec = _feature_spec(manifest, feature_code)
    _resolve_module_row(tenant, module_name)  # ensures module is installed
    lookup_user = user_id if spec.scope == "user" else None
    full_key = f"{module_name}.{feature_code}"
    ent = _live_entitlement(tenant, full_key, user_id=lookup_user)
    if ent is None:
        raise ModuleError(
            f"no active entitlement for {module_name}.{feature_code!r} to toggle",
        )
    if ent.enabled == enabled:
        return ent
    ent.enabled = enabled
    ent.save(update_fields=("enabled", "updated_at"))
    _audit(
        "modules.feature_toggled",
        tenant,
        module_name,
        {"feature": feature_code, "enabled": enabled, "user_id": user_id},
    )
    dispatch(
        "modules.feature_toggled",
        {
            "tenant_id": tenant.pk,
            "module": module_name,
            "feature": feature_code,
            "enabled": enabled,
        },
    )
    return ent
