"""In-code registry for module manifests.

A *module* is a logical business unit (CRM, HelpDesk, …) that:

* Declares a stable dotted ``name`` (``crm``, ``helpdesk.itil``).
* Declares dependencies on other modules.
* Exposes a set of *feature flags* (named togglable capabilities).
* Optionally exports a *service contract* (a typed namespace other
  modules can import). Cross-module communication MUST go through these
  contracts or the event bus — never via direct ORM reach-across.

Manifests are declared in code (``<app>/module.py``), loaded once at boot,
and synced into the ``Module`` table on first access per tenant.
"""

from __future__ import annotations

import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from threading import Lock
from typing import Any, ClassVar


class ModuleError(RuntimeError):
    """Raised for manifest registry / dependency misuse."""


_DOTTED = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$")
_IDENT = re.compile(r"^[a-z][a-z0-9_]*$")
# Phase C: feature codes MUST use capability.feature_name format (exactly one dot).
# The full key becomes module.capability.feature_name (3-level).
# 2-level codes (module.feature_name) are rejected — use compat.py for old-key resolution.
_FEATURE_CODE = re.compile(r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$")


@dataclass(frozen=True)
class FeatureFlagSpec:
    """A togglable capability owned by a module.

    ``default`` is the value when no override exists. ``scope`` controls
    where overrides may be applied (``tenant`` / ``user``).
    """

    name: str
    label_key: str
    default: bool = False
    scope: str = "tenant"

    def __post_init__(self) -> None:
        if not _IDENT.match(self.name):
            raise ModuleError(f"feature flag name {self.name!r} must be snake_case")
        if not self.label_key:
            raise ModuleError(f"feature flag {self.name!r} missing label_key")
        if self.scope not in {"tenant", "user"}:
            raise ModuleError(
                f"feature flag {self.name!r}: scope must be 'tenant' or 'user'",
            )


_FEATURE_BILLINGS = frozenset({"included", "paid", "addon"})
_FEATURE_SCOPES = frozenset({"tenant", "user"})
# Phase B: Tier is decoupled from architecture — commercial decisions
# move to Plan/Addon configuration. Domain replaces module_type.
_DOMAINS = frozenset({"platform", "commerce", "business"})


@dataclass(frozen=True)
class CapabilitySpec:
    """A business capability within a module.

    Capabilities group related features under a sub-domain.
    Example: ``CapabilitySpec(slug="sales", label_key="crm.capabilities.sales")``
    groups ``FeatureSpec(code="sales.pipeline", ...)`` etc.

    ``slug`` is the short identifier (e.g. "sales", "contacts").
    The full feature key becomes ``module.capability_slug.feature_code``.
    """

    slug: str
    label_key: str
    description: str = ""
    icon: str = ""
    sort_order: int = 0

    def __post_init__(self) -> None:
        if not _IDENT.match(self.slug):
            raise ModuleError(f"capability slug {self.slug!r} must be snake_case")
        if not self.label_key:
            raise ModuleError(f"capability {self.slug!r} missing label_key")


@dataclass(frozen=True)
class FeatureSpec:
    """A commercially-managed capability owned by a module.

    Unlike :class:`FeatureFlagSpec` (a developer toggle), a *feature* is
    something a tenant or a single user can be *entitled* to — because the
    module includes it, the tenant purchased it, or an admin granted it.

    * ``billing="included"`` features are auto-granted when the module is
      enabled. They are still individually toggleable, so users can turn
      them off without losing entitlement.
    * ``billing="paid"`` features require an explicit grant/purchase.
    * ``billing="addon"`` features behave like ``paid`` but signal that no
      automated purchase flow exists (admin-grant only).

    ``scope="user"`` features are entitled per-user; ``scope="tenant"``
    features are entitled tenant-wide.
    """

    code: str
    label_key: str
    description: str = ""
    billing: str = "included"
    scope: str = "tenant"
    price_hint: str = ""  # display-only, e.g. "$9/mo"
    default_enabled: bool = True
    depends_on: tuple[str, ...] = ()
    # Phase 4 — licensing metadata
    is_always_on: bool = False            # always enabled; cannot be turned off
    is_platform_internal: bool = False    # never sold/licensed to tenants
    addon_price_hint: str = ""           # display-only, e.g. "﷼۵۰۰,۰۰۰/ماه"

    def __post_init__(self) -> None:
        if not _FEATURE_CODE.match(self.code):
            raise ModuleError(f"feature code {self.code!r} must be snake_case or capability.feature_name")
        if not self.label_key:
            raise ModuleError(f"feature {self.code!r} missing label_key")
        if self.billing not in _FEATURE_BILLINGS:
            raise ModuleError(
                f"feature {self.code!r}: billing must be one of {sorted(_FEATURE_BILLINGS)}",
            )
        if self.scope not in _FEATURE_SCOPES:
            raise ModuleError(
                f"feature {self.code!r}: scope must be one of {sorted(_FEATURE_SCOPES)}",
            )
        seen: set[str] = set()
        for dep in self.depends_on:
            if not _FEATURE_CODE.match(dep):
                raise ModuleError(f"feature {self.code!r}: bad dependency {dep!r}")
            if dep == self.code:
                raise ModuleError(f"feature {self.code!r}: cannot depend on itself")
            if dep in seen:
                raise ModuleError(
                    f"feature {self.code!r}: duplicate dependency {dep!r}",
                )
            seen.add(dep)


@dataclass(frozen=True)
class ServiceContract:
    """Typed service namespace a module exposes to other modules.

    ``provider`` is a dotted import path resolved lazily on
    :meth:`load`. Keeping it lazy means cycles in the module graph never
    crash import order.
    """

    name: str
    provider: str
    description: str = ""

    def __post_init__(self) -> None:
        if not _DOTTED.match(self.name):
            raise ModuleError(f"contract name {self.name!r} must be dotted")
        if ":" not in self.provider:
            raise ModuleError(
                f"contract {self.name!r}: provider must be 'pkg.module:attr'",
            )

    def load(self) -> Any:
        path, _, attr = self.provider.partition(":")
        import importlib

        module = importlib.import_module(path)
        try:
            return getattr(module, attr)
        except AttributeError as exc:
            raise ModuleError(
                f"contract {self.name!r}: provider attribute {attr!r} missing",
            ) from exc


@dataclass(frozen=True)
class ModuleManifest:
    """Declarative description of a module."""

    name: str
    version: int
    label_key: str
    description: str = ""
    # Phase A — taxonomy (replaces tier + module_type)
    domain: str = "business"            # "platform" | "commerce" | "business"
    default_capability: str = ""        # default capability slug, e.g. "crm-sales"
    depends_on: tuple[str, ...] = ()
    capabilities: tuple[CapabilitySpec, ...] = ()
    feature_flags: tuple[FeatureFlagSpec, ...] = ()
    features: tuple[FeatureSpec, ...] = ()
    contracts: tuple[ServiceContract, ...] = ()
    permissions: tuple[str, ...] = ()
    events: tuple[str, ...] = ()
    install_hook: str = ""  # optional dotted "pkg.mod:callable" run at install
    uninstall_hook: str = ""

    def __post_init__(self) -> None:
        if not _DOTTED.match(self.name):
            raise ModuleError(f"module name {self.name!r} must be dotted snake_case")
        if self.version < 1:
            raise ModuleError(f"module {self.name!r}: version must be >= 1")
        if not self.label_key:
            raise ModuleError(f"module {self.name!r}: label_key is required")
        if self.domain not in _DOMAINS:
            raise ModuleError(
                f"module {self.name!r}: domain must be one of {sorted(_DOMAINS)}",
            )
        seen: set[str] = set()
        for dep in self.depends_on:
            if not _DOTTED.match(dep):
                raise ModuleError(f"module {self.name!r}: bad dependency {dep!r}")
            if dep == self.name:
                raise ModuleError(f"module {self.name!r}: cannot depend on itself")
            if dep in seen:
                raise ModuleError(
                    f"module {self.name!r}: duplicate dependency {dep!r}",
                )
            seen.add(dep)
        flag_names: set[str] = set()
        for flag in self.feature_flags:
            if flag.name in flag_names:
                raise ModuleError(
                    f"module {self.name!r}: duplicate feature flag {flag.name!r}",
                )
            flag_names.add(flag.name)
        cap_slugs: set[str] = set()
        for cap in self.capabilities:
            if cap.slug in cap_slugs:
                raise ModuleError(
                    f"module {self.name!r}: duplicate capability {cap.slug!r}",
                )
            cap_slugs.add(cap.slug)
        feature_codes: set[str] = set()
        for feat in self.features:
            if feat.code in feature_codes:
                raise ModuleError(
                    f"module {self.name!r}: duplicate feature {feat.code!r}",
                )
            feature_codes.add(feat.code)
        for feat in self.features:
            for dep in feat.depends_on:
                if dep not in feature_codes:
                    raise ModuleError(
                        f"module {self.name!r}: feature {feat.code!r} depends on "
                        f"unknown feature {dep!r}",
                    )
        contract_names: set[str] = set()
        for c in self.contracts:
            if c.name in contract_names:
                raise ModuleError(
                    f"module {self.name!r}: duplicate contract {c.name!r}",
                )
            contract_names.add(c.name)
        for hook in (self.install_hook, self.uninstall_hook):
            if hook and ":" not in hook:
                raise ModuleError(
                    f"module {self.name!r}: hook {hook!r} must be 'pkg.mod:callable'",
                )

    def feature_flag(self, name: str) -> FeatureFlagSpec:
        for f in self.feature_flags:
            if f.name == name:
                return f
        raise ModuleError(f"module {self.name!r}: unknown feature flag {name!r}")

    def feature(self, code: str) -> FeatureSpec:
        for f in self.features:
            if f.code == code:
                return f
        raise ModuleError(f"module {self.name!r}: unknown feature {code!r}")

    def get_capability(self, slug: str) -> CapabilitySpec:
        for c in self.capabilities:
            if c.slug == slug:
                return c
        raise ModuleError(f"module {self.name!r}: unknown capability {slug!r}")

    def contract(self, name: str) -> ServiceContract:
        for c in self.contracts:
            if c.name == name:
                return c
        raise ModuleError(f"module {self.name!r}: unknown contract {name!r}")

    def serialize(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "version": self.version,
            "label_key": self.label_key,
            "description": self.description,
            "domain": self.domain,
            "default_capability": self.default_capability,
            "depends_on": list(self.depends_on),
            "capabilities": [
                {
                    "slug": c.slug,
                    "label_key": c.label_key,
                    "description": c.description,
                    "icon": c.icon,
                    "sort_order": c.sort_order,
                }
                for c in self.capabilities
            ],
            "feature_flags": [
                {
                    "name": f.name,
                    "label_key": f.label_key,
                    "default": f.default,
                    "scope": f.scope,
                }
                for f in self.feature_flags
            ],
            "features": [
                {
                    "code": f.code,
                    "label_key": f.label_key,
                    "description": f.description,
                    "billing": f.billing,
                    "scope": f.scope,
                    "price_hint": f.price_hint,
                    "default_enabled": f.default_enabled,
                    "depends_on": list(f.depends_on),
                    "is_always_on": f.is_always_on,
                    "is_platform_internal": f.is_platform_internal,
                    "addon_price_hint": f.addon_price_hint,
                }
                for f in self.features
            ],
            "contracts": [
                {"name": c.name, "provider": c.provider, "description": c.description}
                for c in self.contracts
            ],
            "permissions": list(self.permissions),
            "events": list(self.events),
            "install_hook": self.install_hook,
            "uninstall_hook": self.uninstall_hook,
        }


def deserialize(payload: Mapping[str, Any]) -> ModuleManifest:
    flags = tuple(
        FeatureFlagSpec(
            name=f["name"],
            label_key=f["label_key"],
            default=bool(f.get("default", False)),
            scope=f.get("scope", "tenant"),
        )
        for f in payload.get("feature_flags", ())
    )
    features = tuple(
        FeatureSpec(
            code=f["code"],
            label_key=f["label_key"],
            description=f.get("description", ""),
            billing=f.get("billing", "included"),
            scope=f.get("scope", "tenant"),
            price_hint=f.get("price_hint", ""),
            default_enabled=bool(f.get("default_enabled", True)),
            depends_on=tuple(f.get("depends_on", ())),
            is_always_on=bool(f.get("is_always_on", False)),
            is_platform_internal=bool(f.get("is_platform_internal", False)),
            addon_price_hint=f.get("addon_price_hint", ""),
        )
        for f in payload.get("features", ())
    )
    capabilities = tuple(
        CapabilitySpec(
            slug=c["slug"],
            label_key=c["label_key"],
            description=c.get("description", ""),
            icon=c.get("icon", ""),
            sort_order=int(c.get("sort_order", 0)),
        )
        for c in payload.get("capabilities", ())
    )
    contracts = tuple(
        ServiceContract(
            name=c["name"],
            provider=c["provider"],
            description=c.get("description", ""),
        )
        for c in payload.get("contracts", ())
    )
    return ModuleManifest(
        name=payload["name"],
        version=int(payload["version"]),
        label_key=payload["label_key"],
        description=payload.get("description", ""),
        domain=payload.get("domain", "business"),
        capability=payload.get("capability", ""),
        depends_on=tuple(payload.get("depends_on", ())),
        capabilities=capabilities,
        feature_flags=flags,
        features=features,
        contracts=contracts,
        permissions=tuple(payload.get("permissions", ())),
        events=tuple(payload.get("events", ())),
        install_hook=payload.get("install_hook", ""),
        uninstall_hook=payload.get("uninstall_hook", ""),
    )


# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
@dataclass
class _Registry:
    manifests: dict[str, ModuleManifest] = field(default_factory=dict)
    lock: ClassVar[Lock] = Lock()


_REGISTRY = _Registry()


def register_module(manifest: ModuleManifest) -> ModuleManifest:
    """Register a manifest. Idempotent for the same object."""
    with _REGISTRY.lock:
        existing = _REGISTRY.manifests.get(manifest.name)
        if existing is manifest:
            return manifest
        if existing is not None and existing != manifest:
            raise ModuleError(
                f"module {manifest.name!r} already registered with different manifest",
            )
        _REGISTRY.manifests[manifest.name] = manifest
    return manifest


def get_module(name: str) -> ModuleManifest:
    try:
        return _REGISTRY.manifests[name]
    except KeyError as exc:
        raise ModuleError(f"unknown module {name!r}") from exc


def list_modules() -> list[ModuleManifest]:
    return sorted(_REGISTRY.manifests.values(), key=lambda m: m.name)


def reset_registry_for_tests() -> None:
    with _REGISTRY.lock:
        _REGISTRY.manifests.clear()


# ---------------------------------------------------------------------------
# Dependency resolution
# ---------------------------------------------------------------------------
def resolve_install_order(targets: list[str] | None = None) -> list[ModuleManifest]:
    """Return manifests topologically ordered so dependencies come first.

    ``targets`` limits the result to the transitive closure of those
    module names. ``None`` means "every registered manifest". Raises
    :class:`ModuleError` on missing dependencies or cycles.
    """
    if targets is None:
        roots = list(_REGISTRY.manifests.keys())
    else:
        for name in targets:
            if name not in _REGISTRY.manifests:
                raise ModuleError(f"unknown module {name!r}")
        roots = list(targets)

    # Collect the transitive closure first.
    closure: set[str] = set()
    stack: list[str] = list(roots)
    while stack:
        n = stack.pop()
        if n in closure:
            continue
        closure.add(n)
        manifest = _REGISTRY.manifests.get(n)
        if manifest is None:
            raise ModuleError(f"unresolved dependency {n!r}")
        for dep in manifest.depends_on:
            if dep not in closure:
                stack.append(dep)

    # Kahn's algorithm.
    in_degree: dict[str, int] = dict.fromkeys(closure, 0)
    edges: dict[str, list[str]] = {n: [] for n in closure}
    for n in closure:
        for dep in _REGISTRY.manifests[n].depends_on:
            edges[dep].append(n)
            in_degree[n] += 1

    ready = sorted(n for n, d in in_degree.items() if d == 0)
    ordered: list[str] = []
    while ready:
        n = ready.pop(0)
        ordered.append(n)
        for child in edges[n]:
            in_degree[child] -= 1
            if in_degree[child] == 0:
                ready.append(child)
                ready.sort()

    if len(ordered) != len(closure):
        remaining = [n for n in closure if n not in ordered]
        raise ModuleError(
            f"dependency cycle detected involving {sorted(remaining)!r}",
        )
    return [_REGISTRY.manifests[n] for n in ordered]
