from __future__ import annotations

from collections import defaultdict
from collections.abc import Callable, Mapping, Sequence
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime, timezone
import re
from typing import Any, Literal, Protocol, runtime_checkable
import uuid

from sqlalchemy import event as sqlalchemy_event
from sqlalchemy.orm import Session

from govoplan_core.core.institutional import GovernedContextEnvelope


_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
_CONSUMER_ID_RE = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$")
_PENDING_EVENTS_KEY = "govoplan.pending_platform_events"
CAPABILITY_PLATFORM_EVENT_OUTBOX = "platform.eventOutbox"


def new_event_id() -> str:
    return str(uuid.uuid4())


def normalize_trace_id(value: str | None) -> str | None:
    if value is None:
        return None
    candidate = value.strip()
    return candidate if _TRACE_ID_RE.fullmatch(candidate) else None


@dataclass(frozen=True, slots=True)
class EventTrace:
    correlation_id: str
    causation_id: str | None = None


EventClassification = Literal["public", "internal", "confidential", "restricted"]


@dataclass(frozen=True, slots=True)
class EventActorRef:
    type: str
    id: str | None = None
    label: str | None = None

    def to_dict(self) -> dict[str, Any]:
        return _compact_dict({"type": self.type, "id": self.id, "label": self.label})


@dataclass(frozen=True, slots=True)
class EventTenantRef:
    id: str
    slug: str | None = None
    label: str | None = None

    def to_dict(self) -> dict[str, Any]:
        return _compact_dict({"id": self.id, "slug": self.slug, "label": self.label})


@dataclass(frozen=True, slots=True)
class EventObjectRef:
    type: str
    id: str | None = None
    label: str | None = None

    def to_dict(self) -> dict[str, Any]:
        return _compact_dict({"type": self.type, "id": self.id, "label": self.label})


_current_trace: ContextVar[EventTrace | None] = ContextVar("govoplan_event_trace", default=None)


@dataclass(frozen=True, slots=True)
class PlatformEvent:
    type: str
    module_id: str
    payload: Mapping[str, Any] = field(default_factory=dict)
    occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    event_id: str = field(default_factory=new_event_id)
    correlation_id: str | None = None
    causation_id: str | None = None
    actor: EventActorRef | None = None
    tenant: EventTenantRef | None = None
    subject: EventObjectRef | None = None
    resource: EventObjectRef | None = None
    classification: EventClassification = "internal"
    institutional_context: GovernedContextEnvelope | None = None

    def to_dict(self) -> dict[str, Any]:
        return {
            "type": self.type,
            "module_id": self.module_id,
            "payload": dict(self.payload),
            "occurred_at": self.occurred_at.isoformat(),
            "event_id": self.event_id,
            "correlation_id": self.correlation_id,
            "causation_id": self.causation_id,
            "actor": self.actor.to_dict() if self.actor else None,
            "tenant": self.tenant.to_dict() if self.tenant else None,
            "subject": self.subject.to_dict() if self.subject else None,
            "resource": self.resource.to_dict() if self.resource else None,
            "classification": self.classification,
            "institutional_context": (
                self.institutional_context.to_dict()
                if self.institutional_context is not None
                else None
            ),
        }


EventHandler = Callable[[PlatformEvent], None]
DurableEventHandler = Callable[[PlatformEvent, str], None]


@dataclass(frozen=True, slots=True)
class DurableEventConsumer:
    """Allowlisted durable consumer with an explicit disclosure boundary."""

    consumer_id: str
    handler: DurableEventHandler
    event_types: frozenset[str] = field(
        default_factory=lambda: frozenset({"*"})
    )
    classifications: frozenset[EventClassification] = field(
        default_factory=lambda: frozenset({"public", "internal"})
    )
    policy_decision_ref: str | None = None

    def __post_init__(self) -> None:
        if not _CONSUMER_ID_RE.fullmatch(self.consumer_id):
            raise ValueError("Durable event consumer id is invalid")
        if not self.event_types or any(
            item != "*" and not _TRACE_ID_RE.fullmatch(item)
            for item in self.event_types
        ):
            raise ValueError(
                "Durable event consumers require valid event-type allowlists"
            )
        invalid_classifications = set(self.classifications) - {
            "public",
            "internal",
            "confidential",
            "restricted",
        }
        if not self.classifications or invalid_classifications:
            raise ValueError(
                "Durable event consumer classifications are invalid"
            )
        if (
            self.classifications & {"confidential", "restricted"}
            and not normalize_trace_id(self.policy_decision_ref)
        ):
            raise ValueError(
                "Confidential or restricted event subscriptions require "
                "an explicit policy decision reference"
            )

    def accepts(self, event: PlatformEvent) -> bool:
        return (
            event.classification in self.classifications
            and (
                "*" in self.event_types
                or event.type in self.event_types
            )
        )

    def delivery_key(self, event: PlatformEvent) -> str:
        return f"{event.event_id}:{self.consumer_id}"


@runtime_checkable
class PlatformEventOutbox(Protocol):
    def enqueue(self, session: object, event: PlatformEvent) -> object:
        ...

    def dispatch_pending(
        self,
        session: object,
        *,
        consumers: Sequence[DurableEventConsumer] = (),
        observer: EventHandler | None = None,
        limit: int = 100,
    ) -> Mapping[str, int]:
        ...

    def replay_delivery(
        self,
        session: object,
        *,
        event_id: str,
        consumer_id: str,
        operator_id: str,
        reason: str,
    ) -> Mapping[str, object]:
        ...

    def purge_terminal(
        self,
        session: object,
        *,
        before: datetime,
        limit: int = 500,
    ) -> Mapping[str, int]:
        ...

    def delivery_metrics(
        self,
        session: object,
    ) -> Mapping[str, object]:
        ...


def current_event_trace() -> EventTrace | None:
    return _current_trace.get()


@contextmanager
def event_context(
    *,
    correlation_id: str | None = None,
    causation_id: str | None = None,
):
    parent = current_event_trace()
    trace = EventTrace(
        correlation_id=normalize_trace_id(correlation_id) or (parent.correlation_id if parent else new_event_id()),
        causation_id=normalize_trace_id(causation_id) or (parent.causation_id if parent else None),
    )
    token = _current_trace.set(trace)
    try:
        yield trace
    finally:
        _current_trace.reset(token)


def event_trace_for(event: PlatformEvent) -> EventTrace:
    active = current_event_trace()
    return EventTrace(
        correlation_id=normalize_trace_id(event.correlation_id) or (active.correlation_id if active else event.event_id),
        causation_id=normalize_trace_id(event.causation_id) or (active.causation_id if active else None),
    )


def ensure_event_trace(event: PlatformEvent) -> PlatformEvent:
    trace = event_trace_for(event)
    if event.correlation_id == trace.correlation_id and event.causation_id == trace.causation_id:
        return event
    return PlatformEvent(
        type=event.type,
        module_id=event.module_id,
        payload=event.payload,
        occurred_at=event.occurred_at,
        event_id=event.event_id,
        correlation_id=trace.correlation_id,
        causation_id=trace.causation_id,
        actor=event.actor,
        tenant=event.tenant,
        subject=event.subject,
        resource=event.resource,
        classification=event.classification,
        institutional_context=event.institutional_context,
    )


class EventBus:
    def __init__(self) -> None:
        self._subscribers: dict[str, list[EventHandler]] = defaultdict(list)

    def subscribe(self, event_type: str, handler: EventHandler) -> None:
        self._subscribers[event_type].append(handler)

    def publish(self, event: PlatformEvent) -> None:
        traced = ensure_event_trace(event)
        with event_context(correlation_id=traced.correlation_id, causation_id=traced.event_id):
            for handler in self._subscribers.get(traced.type, ()):
                handler(traced)
            for handler in self._subscribers.get("*", ()):
                handler(traced)


_default_event_bus = EventBus()
_current_event_bus: ContextVar[EventBus | None] = ContextVar("govoplan_event_bus", default=None)


def platform_event_bus() -> EventBus:
    return _current_event_bus.get() or _default_event_bus


@contextmanager
def event_bus_context(bus: EventBus):
    token = _current_event_bus.set(bus)
    try:
        yield bus
    finally:
        _current_event_bus.reset(token)


def publish_platform_event(event: PlatformEvent) -> None:
    platform_event_bus().publish(event)


def platform_event_outbox(
    registry: object | None = None,
) -> PlatformEventOutbox | None:
    if registry is None:
        from govoplan_core.core.runtime import get_registry

        registry = get_registry()
    if (
        registry is None
        or not hasattr(registry, "has_capability")
        or not hasattr(registry, "capability")
        or not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
    ):
        return None
    capability = registry.capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
    return capability if isinstance(capability, PlatformEventOutbox) else None


def emit_platform_event(
    session: Session,
    event: PlatformEvent,
    *,
    registry: object | None = None,
) -> None:
    """Persist an event with its transaction or publish it after commit.

    The durable outbox is optional so reduced module combinations remain
    usable. Without it, the event is kept on the SQLAlchemy session and only
    delivered to the process-local bus after the outer transaction commits.
    """

    traced = ensure_event_trace(event)
    outbox = platform_event_outbox(registry)
    if outbox is not None:
        outbox.enqueue(session, traced)
        return
    transaction = (
        session.get_nested_transaction()
        or session.get_transaction()
        or session.begin()
    )
    pending_by_transaction = session.info.setdefault(
        _PENDING_EVENTS_KEY,
        {},
    )
    pending_by_transaction.setdefault(transaction, []).append(
        (platform_event_bus(), traced)
    )


@sqlalchemy_event.listens_for(Session, "after_commit")
def _publish_committed_events(session: Session) -> None:
    transaction = session.get_nested_transaction() or session.get_transaction()
    if transaction is None:
        return
    pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
    if not isinstance(pending_by_transaction, dict):
        return
    pending = pending_by_transaction.pop(transaction, ())
    parent = transaction.parent
    if parent is not None:
        pending_by_transaction.setdefault(parent, []).extend(pending)
    else:
        for bus, event in pending:
            bus.publish(event)
    if not pending_by_transaction:
        session.info.pop(_PENDING_EVENTS_KEY, None)


@sqlalchemy_event.listens_for(Session, "after_rollback")
def _discard_rolled_back_events(session: Session) -> None:
    transaction = session.get_nested_transaction() or session.get_transaction()
    pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
    if transaction is None or not isinstance(pending_by_transaction, dict):
        return
    pending_by_transaction.pop(transaction, None)
    if transaction.parent is None or not pending_by_transaction:
        session.info.pop(_PENDING_EVENTS_KEY, None)


def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
    return {key: item for key, item in value.items() if item is not None}
