"""In-code workflow registry.

A workflow is a finite-state machine declared once and reused across tenants.
Definitions live in code (`apps.<app>.workflows`) so they can be reviewed,
diffed, and migrated like any other source artifact, but the schema is plain
JSON so they can also be exported to drive a visual editor or AI tooling.

Runtime state for a single subject (e.g. a CRM lead, an ITIL ticket) lives in
``WorkflowInstance`` rows; this module only owns the *definition* side.
"""

from __future__ import annotations

import re
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from threading import Lock
from typing import Any, ClassVar

Handler = Callable[["ActionContext"], None]


class WorkflowError(RuntimeError):
    """Raised for any registry / DSL misuse."""


_DOTTED_NAME = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
_IDENT = re.compile(r"^[a-z][a-z0-9_]*$")


# ---------------------------------------------------------------------------
# Action handler registry
# ---------------------------------------------------------------------------
_ACTION_HANDLERS: dict[str, Handler] = {}
_ACTION_LOCK = Lock()


def register_action(name: str) -> Callable[[Handler], Handler]:
    """Decorator: bind a Python callable to an action name used in DSL hooks.

    Idempotent for identical re-registration of the same callable so reloading
    a `workflows.py` (e.g. in tests) does not blow up.
    """

    if not _IDENT.match(name):
        raise WorkflowError(f"action name must be snake_case identifier, got {name!r}")

    def decorator(func: Handler) -> Handler:
        with _ACTION_LOCK:
            existing = _ACTION_HANDLERS.get(name)
            if existing is not None and existing is not func:
                raise WorkflowError(f"action {name!r} already registered to a different callable")
            _ACTION_HANDLERS[name] = func
        return func

    return decorator


def get_action_handler(name: str) -> Handler:
    handler = _ACTION_HANDLERS.get(name)
    if handler is None:
        raise WorkflowError(f"no action handler registered for {name!r}")
    return handler


def reset_actions_for_tests() -> None:
    with _ACTION_LOCK:
        _ACTION_HANDLERS.clear()


@dataclass
class ActionContext:
    """Runtime payload handed to action handlers + condition evaluators."""

    instance: Any  # WorkflowInstance, kept loose to avoid model import cycles.
    definition: WorkflowDefinition
    transition: TransitionSpec | None
    actor: Any | None = None
    payload: dict[str, Any] = field(default_factory=dict)


# ---------------------------------------------------------------------------
# DSL dataclasses
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ConditionSpec:
    """Boolean expression in the safe condition DSL.

    Shape:
        {"field": "path.to.attr", "op": "eq", "value": ...}
        {"all": [cond, cond, ...]}
        {"any": [cond, cond, ...]}
        {"not": cond}
        {"permission": "crm.lead.approve"}   # actor must hold this
    """

    expression: Mapping[str, Any]


@dataclass(frozen=True)
class ActionSpec:
    """A side-effect to run on enter/exit/transition."""

    name: str  # must match a `register_action` key
    params: Mapping[str, Any] = field(default_factory=dict)


@dataclass(frozen=True)
class StateSpec:
    name: str
    label_key: str
    is_initial: bool = False
    is_final: bool = False
    on_enter: tuple[ActionSpec, ...] = ()
    on_exit: tuple[ActionSpec, ...] = ()
    # If set, transitions out of this state require an approval record from
    # someone holding `approval_permission` (optionally filtered by role code).
    approval_permission: str = ""
    approval_role_code: str = ""

    def __post_init__(self) -> None:
        if not _IDENT.match(self.name):
            raise WorkflowError(f"state name must be snake_case, got {self.name!r}")
        if not self.label_key:
            raise WorkflowError(f"state {self.name!r} requires label_key")


@dataclass(frozen=True)
class TransitionSpec:
    name: str
    from_state: str
    to_state: str
    label_key: str
    trigger: str = ""            # event name; empty = manual only
    permission: str = ""         # required permission to fire manually
    conditions: tuple[ConditionSpec, ...] = ()
    actions: tuple[ActionSpec, ...] = ()
    requires_approval: bool = False

    def __post_init__(self) -> None:
        if not _IDENT.match(self.name):
            raise WorkflowError(f"transition name must be snake_case, got {self.name!r}")
        if not self.label_key:
            raise WorkflowError(f"transition {self.name!r} requires label_key")


@dataclass(frozen=True)
class WorkflowDefinition:
    """Top-level FSM for one subject type (e.g. `crm.lead`)."""

    name: str
    version: int
    subject_type: str
    label_key: str
    states: tuple[StateSpec, ...]
    transitions: tuple[TransitionSpec, ...]
    description: str = ""

    def __post_init__(self) -> None:
        if not _DOTTED_NAME.match(self.name):
            raise WorkflowError(
                f"workflow name must be dotted lowercase (e.g. 'crm.lead.review'), got {self.name!r}",
            )
        if self.version < 1:
            raise WorkflowError("workflow version must be >= 1")
        if not _DOTTED_NAME.match(self.subject_type):
            raise WorkflowError(
                f"subject_type must be dotted lowercase, got {self.subject_type!r}",
            )
        if not self.states:
            raise WorkflowError(f"workflow {self.name!r} has no states")

        names = [s.name for s in self.states]
        if len(set(names)) != len(names):
            raise WorkflowError(f"workflow {self.name!r} has duplicate state names")

        initials = [s for s in self.states if s.is_initial]
        if len(initials) != 1:
            raise WorkflowError(
                f"workflow {self.name!r} must declare exactly one initial state, got {len(initials)}",
            )

        state_set = set(names)
        tnames = [t.name for t in self.transitions]
        if len(set(tnames)) != len(tnames):
            raise WorkflowError(f"workflow {self.name!r} has duplicate transition names")
        for t in self.transitions:
            if t.from_state not in state_set:
                raise WorkflowError(
                    f"transition {t.name!r} refs unknown from_state {t.from_state!r}",
                )
            if t.to_state not in state_set:
                raise WorkflowError(
                    f"transition {t.name!r} refs unknown to_state {t.to_state!r}",
                )

    # ------------------------------------------------------------------
    # Lookups
    # ------------------------------------------------------------------
    @property
    def initial_state(self) -> StateSpec:
        return next(s for s in self.states if s.is_initial)

    def state(self, name: str) -> StateSpec:
        for s in self.states:
            if s.name == name:
                return s
        raise WorkflowError(f"workflow {self.name!r} has no state {name!r}")

    def transitions_from(self, state_name: str) -> tuple[TransitionSpec, ...]:
        return tuple(t for t in self.transitions if t.from_state == state_name)

    def transition(self, name: str) -> TransitionSpec:
        for t in self.transitions:
            if t.name == name:
                return t
        raise WorkflowError(f"workflow {self.name!r} has no transition {name!r}")

    def transitions_for_event(self, event_name: str) -> tuple[TransitionSpec, ...]:
        return tuple(t for t in self.transitions if t.trigger == event_name)

    # ------------------------------------------------------------------
    # Wire format (importable + AI-readable)
    # ------------------------------------------------------------------
    def serialize(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "version": self.version,
            "subject_type": self.subject_type,
            "label_key": self.label_key,
            "description": self.description,
            "states": [
                {
                    "name": s.name,
                    "label_key": s.label_key,
                    "is_initial": s.is_initial,
                    "is_final": s.is_final,
                    "approval_permission": s.approval_permission,
                    "approval_role_code": s.approval_role_code,
                    "on_enter": [_serialize_action(a) for a in s.on_enter],
                    "on_exit": [_serialize_action(a) for a in s.on_exit],
                }
                for s in self.states
            ],
            "transitions": [
                {
                    "name": t.name,
                    "from_state": t.from_state,
                    "to_state": t.to_state,
                    "label_key": t.label_key,
                    "trigger": t.trigger,
                    "permission": t.permission,
                    "requires_approval": t.requires_approval,
                    "conditions": [dict(c.expression) for c in t.conditions],
                    "actions": [_serialize_action(a) for a in t.actions],
                }
                for t in self.transitions
            ],
        }


def _serialize_action(a: ActionSpec) -> dict[str, Any]:
    return {"name": a.name, "params": dict(a.params)}


# ---------------------------------------------------------------------------
# Importer — JSON → WorkflowDefinition (round-trips serialize())
# ---------------------------------------------------------------------------
def deserialize(payload: Mapping[str, Any]) -> WorkflowDefinition:
    try:
        states = tuple(
            StateSpec(
                name=s["name"],
                label_key=s["label_key"],
                is_initial=bool(s.get("is_initial", False)),
                is_final=bool(s.get("is_final", False)),
                approval_permission=s.get("approval_permission", "") or "",
                approval_role_code=s.get("approval_role_code", "") or "",
                on_enter=tuple(_deserialize_action(a) for a in s.get("on_enter", ())),
                on_exit=tuple(_deserialize_action(a) for a in s.get("on_exit", ())),
            )
            for s in payload["states"]
        )
        transitions = tuple(
            TransitionSpec(
                name=t["name"],
                from_state=t["from_state"],
                to_state=t["to_state"],
                label_key=t["label_key"],
                trigger=t.get("trigger", "") or "",
                permission=t.get("permission", "") or "",
                requires_approval=bool(t.get("requires_approval", False)),
                conditions=tuple(ConditionSpec(expression=dict(c)) for c in t.get("conditions", ())),
                actions=tuple(_deserialize_action(a) for a in t.get("actions", ())),
            )
            for t in payload["transitions"]
        )
        return WorkflowDefinition(
            name=payload["name"],
            version=int(payload["version"]),
            subject_type=payload["subject_type"],
            label_key=payload["label_key"],
            description=payload.get("description", "") or "",
            states=states,
            transitions=transitions,
        )
    except KeyError as exc:
        raise WorkflowError(f"workflow payload missing key: {exc}") from exc


def _deserialize_action(payload: Mapping[str, Any]) -> ActionSpec:
    return ActionSpec(name=payload["name"], params=dict(payload.get("params", {})))


# ---------------------------------------------------------------------------
# Definition registry
# ---------------------------------------------------------------------------
@dataclass
class _DefinitionRegistry:
    entries: dict[tuple[str, int], WorkflowDefinition] = field(default_factory=dict)
    lock: ClassVar[Lock] = Lock()


_REGISTRY = _DefinitionRegistry()


def register_workflow(definition: WorkflowDefinition) -> WorkflowDefinition:
    """Register `definition`. Idempotent for identical re-registration."""

    key = (definition.name, definition.version)
    with _DefinitionRegistry.lock:
        existing = _REGISTRY.entries.get(key)
        if existing is not None and existing != definition:
            raise WorkflowError(
                f"workflow {definition.name!r} v{definition.version} already registered with a different spec",
            )
        _REGISTRY.entries[key] = definition
    return definition


def get_workflow(name: str, version: int | None = None) -> WorkflowDefinition:
    with _DefinitionRegistry.lock:
        if version is not None:
            wf = _REGISTRY.entries.get((name, version))
            if wf is None:
                raise WorkflowError(f"workflow {name!r} v{version} is not registered")
            return wf
        candidates = [d for (n, _), d in _REGISTRY.entries.items() if n == name]
    if not candidates:
        raise WorkflowError(f"workflow {name!r} is not registered")
    return max(candidates, key=lambda d: d.version)


def list_workflows() -> list[WorkflowDefinition]:
    with _DefinitionRegistry.lock:
        return sorted(_REGISTRY.entries.values(), key=lambda d: (d.name, d.version))


def workflows_for_event(event_name: str) -> Iterable[WorkflowDefinition]:
    """All registered definitions that have at least one transition for `event_name`."""

    for wf in list_workflows():
        if wf.transitions_for_event(event_name):
            yield wf


def reset_registry_for_tests() -> None:
    with _DefinitionRegistry.lock:
        _REGISTRY.entries.clear()
