from __future__ import annotations

import asyncio
import json
import threading
import time
import uuid
from collections import deque
from dataclasses import dataclass, replace
from typing import Any, Awaitable, Callable, Iterable

from flownet.compiler.flowtask import FlowTask
from flownet.core.request import (
    FlowAckRoute,
    FlownetPacket,
    FlowRequest,
    FlowStack,
    TransformationFrame,
    coerce_flow_ack_route,
)
from flownet.core.stream_event import StreamEvent
from flownet.core.types import PayloadData
from flownet.runtime import loops
from flownet.runtime.loops import submit_runtime_coro
from flownet.runtime.topics.topic_identity import canonical_topic_key
from flownet.runtime.topics.topic_router import TopicRouter, TopicRoute

SUPPORTED_PRODUCER_POLICIES = frozenset(
    {"open", "multi_claimed", "single_strict", "single_with_failover"}
)
DEFAULT_PRODUCER_POLICY = "open"
DEFAULT_PRODUCER_CLAIM_TTL_SECONDS = 300
SERVICE_TOPIC_PREFIX = "service:"
_TOPIC_INGRESS_ACK_ROUTE_TAG = "__flownet_ack_route__"
_TRACK_REQUEST_ROOT_TAG = "__track_request_root__"
_TRACK_REQUEST_ROOT_TRUE_VALUES = {"1", "true", "yes", "on"}
_REQUEST_TRACKER_ORIGIN_TAG = "__request_tracker_origin__"


@dataclass(frozen=True)
class TopicSubscription:
    subscription_id: str
    topic_id: str
    flow_task: FlowTask
    execution_address: str
    tags: dict[str, str] | None = None


@dataclass(frozen=True)
class TopicSubscriptionSnapshot:
    epoch: int
    subscriptions: list[TopicSubscription]
    fetched_at: float


@dataclass(frozen=True)
class TopicProducerClaim:
    producer_id: str
    policy: str
    epoch: str | None
    lease_expires_at: float
    claimed_at: float


class TopicRegistry:
    """
    v0 compatibility topic runtime service.

    Current behavior intentionally bundles:
    - subscription registration/sync,
    - topic publish/dispatch routing,
    - producer claim policy,
    - request tracking hooks.

    v1 flat model direction:
    - split URI/meta view concerns from coordinator state concerns;
    - keep coordinator-owned flow-process bindings and request ledgers as the
      canonical source of truth;
    - preserve explicit data-plane vs control-plane separation.
    """

    def __init__(
        self,
        call_manager: Any,
        local_address_fn: Callable[[], str],
        submit_coro: Callable[[Any], Any] = submit_runtime_coro,
        submit_user_coro: Callable[[Any], Any] | None = None,
        handle_flow_request: Callable[[Any], Awaitable[Any]] | None = None,
        topic_router: TopicRouter | None = None,
        cache_ttl_seconds: float = 2.0,
        track_request_delta: Callable[[str, str, int], Awaitable[None]] | None = None,
        track_request_producer_done: Callable[[str, str], Awaitable[None]] | None = None,
        send_topic_event_via_v1: Callable[[TopicRoute, StreamEvent, str | None, bool], Awaitable[None]] | None = None,
        v1_fail_open_legacy: bool = True,
        log_fn: Callable[[str], None] | None = None,
    ):
        self._call_manager = call_manager
        self._local_address_fn = local_address_fn
        self._submit_coro = submit_coro
        self._submit_user_coro = submit_user_coro or submit_coro
        self._handle_flow_request = handle_flow_request
        self._topic_router = topic_router
        self._cache_ttl_seconds = cache_ttl_seconds
        self._track_request_delta = track_request_delta
        self._track_request_producer_done = track_request_producer_done
        self._send_topic_event_via_v1 = send_topic_event_via_v1
        self._v1_fail_open_legacy = bool(v1_fail_open_legacy)
        self._log_fn = log_fn or print
        self._stats: dict[str, int] = {
            "subscription_sync_failures": 0,
            "subscription_fetch_failures": 0,
            "local_dispatch_failures": 0,
            "remote_dispatch_failures": 0,
            "request_tracking_failures": 0,
            "producer_claim_failures": 0,
            "producer_release_failures": 0,
        }
        self._subscriptions: dict[str, dict[str, TopicSubscription]] = {}
        self._subscriptions_by_id: dict[str, TopicSubscription] = {}
        self._topic_epochs: dict[str, int] = {}
        self._subscription_cache: dict[str, TopicSubscriptionSnapshot] = {}
        self._done_dispatch_seen: set[tuple[str, str, str, int]] = set()
        self._done_dispatch_order: deque[tuple[str, str, str, int]] = deque()
        self._done_dispatch_max_entries = 100_000
        self._producer_policy_by_topic: dict[str, str] = {}
        self._producer_claims_by_topic: dict[str, dict[str, TopicProducerClaim]] = {}
        self._lock = threading.Lock()

    def _log(self, message: str) -> None:
        self._log_fn(message)

    def stats_snapshot(self) -> dict[str, int]:
        return dict(self._stats)

    def attach_task(
        self,
        topic_id: str,
        flow_task: FlowTask,
        execution_address: str,
        tags: dict[str, str] | None = None,
    ) -> str:
        canonical_topic_id = self._canonical_topic_id(topic_id)
        subscription_id = uuid.uuid4().hex
        subscription = TopicSubscription(
            subscription_id=subscription_id,
            topic_id=canonical_topic_id,
            flow_task=flow_task,
            execution_address=execution_address,
            tags=tags,
        )
        route = self._resolve_route(canonical_topic_id)
        if route is None or self._is_local_coordinator(route):
            self._register_subscription(subscription)
        else:
            with self._lock:
                self._subscriptions_by_id[subscription_id] = subscription
            self._notify_coordinator_subscribe(route, subscription)
        return subscription_id

    def detach_task(self, subscription_id: str) -> bool:
        with self._lock:
            subscription = self._subscriptions_by_id.get(subscription_id)
        if subscription is None:
            return False
        canonical_topic_id = self._canonical_topic_id(subscription.topic_id)
        route = self._resolve_route(canonical_topic_id)
        if route is None or self._is_local_coordinator(route):
            return self._unregister_subscription(subscription_id, canonical_topic_id)
        with self._lock:
            self._subscriptions_by_id.pop(subscription_id, None)
        self._notify_coordinator_unsubscribe(route, subscription_id)
        return True

    def publish(
        self,
        topic_id: str,
        event_type: str,
        payload: Any = None,
        tags: dict[str, str] | None = None,
        *,
        producer_id: str | None = None,
        allow_reserved_namespace: bool = False,
        origin: str | None = None,
    ) -> None:
        canonical_topic_id = self._canonical_topic_id(topic_id)
        normalized_tags = dict(tags or {})
        normalized_producer_id = self._resolve_publish_producer_id(
            producer_id=producer_id,
            tags=normalized_tags,
        )
        route = self._resolve_route(canonical_topic_id)
        is_local_publish = route is None or self._is_local_coordinator(route)
        self._enforce_publish_gate(
            topic_id=canonical_topic_id,
            producer_id=normalized_producer_id,
            allow_reserved_namespace=allow_reserved_namespace,
            enforce_producer_policy=is_local_publish,
        )
        event = StreamEvent(
            source=canonical_topic_id,
            event_type=event_type,
            payload=payload,
            tags=normalized_tags,
            origin=origin or self._local_address_fn(),
        )
        if is_local_publish:
            subscriptions = self._list_local_subscriptions(canonical_topic_id)
            if not subscriptions:
                return
            self._dispatch_to_subscriptions(event, subscriptions)
            return

        async def _publish_remote_via_coordinator() -> None:
            await self._send_event_to_coordinator(
                route,
                event,
                producer_id=normalized_producer_id,
                allow_reserved_namespace=allow_reserved_namespace,
            )

        self._submit_coro(_publish_remote_via_coordinator())

    def handle_topic_event(self, event: StreamEvent) -> None:
        self.publish(
            event.source,
            event.event_type,
            event.payload,
            event.tags,
            producer_id=event.tags.get("producer_id"),
            allow_reserved_namespace=False,
            origin=event.origin,
        )

    def register_subscription(self, subscription: TopicSubscription) -> int:
        normalized_subscription = self._normalize_subscription_topic(subscription)
        self._register_subscription(normalized_subscription)
        return self._topic_epochs.get(normalized_subscription.topic_id, 0)

    def unregister_subscription(self, subscription_id: str) -> bool:
        with self._lock:
            subscription = self._subscriptions_by_id.get(subscription_id)
        if subscription is None:
            return False
        return self._unregister_subscription(subscription_id, subscription.topic_id)

    def snapshot_subscriptions(self, topic_id: str) -> tuple[int, list[TopicSubscription]]:
        canonical_topic_id = self._canonical_topic_id(topic_id)
        with self._lock:
            subscriptions = list(self._subscriptions.get(canonical_topic_id, {}).values())
            epoch = self._topic_epochs.get(canonical_topic_id, 0)
        return epoch, subscriptions

    def get_subscription(self, subscription_id: str) -> TopicSubscription | None:
        with self._lock:
            return self._subscriptions_by_id.get(subscription_id)

    def list_subscriptions(self) -> list[dict[str, Any]]:
        with self._lock:
            subscriptions = list(self._subscriptions_by_id.values())
        return [
            {
                "subscription_id": subscription.subscription_id,
                "topic_id": subscription.topic_id,
                "execution_address": subscription.execution_address,
                "tags": dict(subscription.tags or {}),
            }
            for subscription in subscriptions
        ]

    def list_topics(self) -> list[dict[str, Any]]:
        now = time.time()
        with self._lock:
            for topic_id in list(self._producer_claims_by_topic.keys()):
                self._prune_expired_claims_locked(topic_id, now)

            topic_ids = set(self._subscriptions.keys())
            topic_ids.update(self._topic_epochs.keys())
            topic_ids.update(self._producer_policy_by_topic.keys())
            topic_ids.update(self._producer_claims_by_topic.keys())

            records: list[dict[str, Any]] = []
            for topic_id in sorted(topic_ids):
                subscriptions = list(self._subscriptions.get(topic_id, {}).values())
                execution_addresses = sorted(
                    {
                        str(subscription.execution_address).strip()
                        for subscription in subscriptions
                        if str(subscription.execution_address or "").strip()
                    }
                )
                subscription_ids = sorted(
                    {
                        str(subscription.subscription_id).strip()
                        for subscription in subscriptions
                        if str(subscription.subscription_id or "").strip()
                    }
                )
                policy = str(
                    self._producer_policy_by_topic.get(topic_id, DEFAULT_PRODUCER_POLICY)
                    or DEFAULT_PRODUCER_POLICY
                )
                claims = sorted(
                    list(self._producer_claims_by_topic.get(topic_id, {}).values()),
                    key=lambda item: item.producer_id,
                )
                active_producer_id = self._select_active_producer_id(policy, claims)
                records.append(
                    {
                        "topic_id": topic_id,
                        "subscription_count": len(subscriptions),
                        "source": "runtime",
                        "metadata": {
                            "topic_epoch": int(self._topic_epochs.get(topic_id, 0)),
                            "execution_addresses": execution_addresses,
                            "subscription_ids": subscription_ids,
                            "producer_policy": policy,
                            "producer_claim_count": len(claims),
                            "active_producer_id": active_producer_id,
                            "producer_claims": [
                                {
                                    "producer_id": claim.producer_id,
                                    "policy": claim.policy,
                                    "epoch": claim.epoch,
                                    "lease_expires_at": claim.lease_expires_at,
                                    "claimed_at": claim.claimed_at,
                                }
                                for claim in claims
                            ],
                        },
                    }
                )
        return records

    def snapshot_producer_claims(self, topic_id: str) -> dict[str, Any]:
        canonical_topic_id = self._canonical_topic_id(topic_id)
        now = time.time()
        with self._lock:
            self._prune_expired_claims_locked(canonical_topic_id, now)
            policy = self._producer_policy_by_topic.get(canonical_topic_id, DEFAULT_PRODUCER_POLICY)
            claims = list(self._producer_claims_by_topic.get(canonical_topic_id, {}).values())
        claims_sorted = sorted(claims, key=lambda item: item.producer_id)
        active_producer_id = self._select_active_producer_id(policy, claims_sorted)
        return {
            "topic_id": canonical_topic_id,
            "policy": policy,
            "active_producer_id": active_producer_id,
            "claims": [
                {
                    "producer_id": claim.producer_id,
                    "policy": claim.policy,
                    "epoch": claim.epoch,
                    "lease_expires_at": claim.lease_expires_at,
                    "claimed_at": claim.claimed_at,
                }
                for claim in claims_sorted
            ],
            "fetched_at": now,
        }

    def claim_producer(
        self,
        topic_id: str,
        *,
        producer_id: str | None,
        policy: str | None = None,
        epoch: str | None = None,
        ttl: int | float | None = None,
    ) -> dict[str, Any]:
        canonical_topic_id = self._canonical_topic_id(topic_id)
        route = self._resolve_route(canonical_topic_id)
        if self._in_runtime_loop():
            if route is None or self._is_local_coordinator(route):
                return self._claim_producer_local(
                    topic_id=canonical_topic_id,
                    producer_id=producer_id,
                    policy=policy,
                    epoch=epoch,
                    ttl=ttl,
                )
            raise RuntimeError(
                "claim_producer() cannot block in runtime loop for remote coordinator path; "
                "use claim_producer_async()."
            )
        submitted = self._submit_coro(
            self.claim_producer_async(
                canonical_topic_id,
                producer_id=producer_id,
                policy=policy,
                epoch=epoch,
                ttl=ttl,
            )
        )
        if hasattr(submitted, "result"):
            return submitted.result()
        return submitted

    async def claim_producer_async(
        self,
        topic_id: str,
        *,
        producer_id: str | None,
        policy: str | None = None,
        epoch: str | None = None,
        ttl: int | float | None = None,
    ) -> dict[str, Any]:
        canonical_topic_id = self._canonical_topic_id(topic_id)
        route = self._resolve_route(canonical_topic_id)
        try:
            if route is None or self._is_local_coordinator(route):
                return self._claim_producer_local(
                    topic_id=canonical_topic_id,
                    producer_id=producer_id,
                    policy=policy,
                    epoch=epoch,
                    ttl=ttl,
                )
            return await self._send_claim_to_coordinator(
                route=route,
                topic_id=canonical_topic_id,
                producer_id=producer_id,
                policy=policy,
                epoch=epoch,
                ttl=ttl,
            )
        except Exception as exc:  # pragma: no cover - defensive branch
            self._stats["producer_claim_failures"] += 1
            self._log(f"[TopicRegistry] Producer claim failed: {exc}")
            return {
                "ok": False,
                "error": str(exc),
                "topic_id": canonical_topic_id,
                "producer_id": producer_id,
            }

    def release_producer(
        self,
        topic_id: str,
        *,
        producer_id: str | None,
        epoch: str | None = None,
    ) -> dict[str, Any]:
        canonical_topic_id = self._canonical_topic_id(topic_id)
        route = self._resolve_route(canonical_topic_id)
        if self._in_runtime_loop():
            if route is None or self._is_local_coordinator(route):
                return self._release_producer_local(
                    topic_id=canonical_topic_id,
                    producer_id=producer_id,
                    epoch=epoch,
                )
            raise RuntimeError(
                "release_producer() cannot block in runtime loop for remote coordinator path; "
                "use release_producer_async()."
            )
        submitted = self._submit_coro(
            self.release_producer_async(
                canonical_topic_id,
                producer_id=producer_id,
                epoch=epoch,
            )
        )
        if hasattr(submitted, "result"):
            return submitted.result()
        return submitted

    async def release_producer_async(
        self,
        topic_id: str,
        *,
        producer_id: str | None,
        epoch: str | None = None,
    ) -> dict[str, Any]:
        canonical_topic_id = self._canonical_topic_id(topic_id)
        route = self._resolve_route(canonical_topic_id)
        try:
            if route is None or self._is_local_coordinator(route):
                return self._release_producer_local(
                    topic_id=canonical_topic_id,
                    producer_id=producer_id,
                    epoch=epoch,
                )
            return await self._send_release_to_coordinator(
                route=route,
                topic_id=canonical_topic_id,
                producer_id=producer_id,
                epoch=epoch,
            )
        except Exception as exc:  # pragma: no cover - defensive branch
            self._stats["producer_release_failures"] += 1
            self._log(f"[TopicRegistry] Producer release failed: {exc}")
            return {
                "ok": False,
                "error": str(exc),
                "topic_id": canonical_topic_id,
                "producer_id": producer_id,
            }

    def _register_subscription(self, subscription: TopicSubscription) -> None:
        normalized_subscription = self._normalize_subscription_topic(subscription)
        with self._lock:
            self._subscriptions.setdefault(normalized_subscription.topic_id, {})[
                normalized_subscription.subscription_id
            ] = normalized_subscription
            self._subscriptions_by_id[normalized_subscription.subscription_id] = normalized_subscription
            self._topic_epochs[normalized_subscription.topic_id] = (
                self._topic_epochs.get(normalized_subscription.topic_id, 0) + 1
            )

    def _unregister_subscription(self, subscription_id: str, topic_id: str) -> bool:
        canonical_topic_id = self._canonical_topic_id(topic_id)
        with self._lock:
            subscription = self._subscriptions_by_id.pop(subscription_id, None)
            subscriptions = self._subscriptions.get(canonical_topic_id)
            if subscription is None or subscriptions is None:
                return False
            subscriptions.pop(subscription_id, None)
            if not subscriptions:
                self._subscriptions.pop(canonical_topic_id, None)
            self._topic_epochs[canonical_topic_id] = (
                self._topic_epochs.get(canonical_topic_id, 0) + 1
            )
        return True

    def _list_local_subscriptions(self, topic_id: str) -> list[TopicSubscription]:
        canonical_topic_id = self._canonical_topic_id(topic_id)
        with self._lock:
            return list(self._subscriptions.get(canonical_topic_id, {}).values())

    def _resolve_route(self, topic_id: str) -> TopicRoute | None:
        if self._topic_router is None:
            return None
        return self._topic_router.resolve(self._canonical_topic_id(topic_id))

    def _is_local_coordinator(self, route: TopicRoute) -> bool:
        if self._topic_router is None:
            return True
        return self._topic_router.is_local(route)

    def _notify_coordinator_subscribe(self, route: TopicRoute, subscription: TopicSubscription) -> None:
        payload = {
            "reply_to": self._local_address_fn(),
            "subscription": subscription,
        }

        async def _send(wait_for_ack: bool) -> None:
            packet = FlownetPacket(
                type="topic_subscribe",
                uuid=uuid.uuid4().hex,
                reply=wait_for_ack,
                packet_content=payload,
            )
            await self._call_manager.send_packet(route.coordinator, packet)

        if self._in_runtime_loop():
            asyncio.create_task(_send(False))
            return
        future = self._submit_coro(_send(True))
        try:
            future.result()
        except Exception as exc:  # pragma: no cover - best effort
            self._stats["subscription_sync_failures"] += 1
            self._log(f"[TopicRegistry] Failed to register subscription: {exc}")

    def _notify_coordinator_unsubscribe(self, route: TopicRoute, subscription_id: str) -> None:
        payload = {
            "reply_to": self._local_address_fn(),
            "subscription_id": subscription_id,
        }

        async def _send(wait_for_ack: bool) -> None:
            packet = FlownetPacket(
                type="topic_unsubscribe",
                uuid=uuid.uuid4().hex,
                reply=wait_for_ack,
                packet_content=payload,
            )
            await self._call_manager.send_packet(route.coordinator, packet)

        if self._in_runtime_loop():
            asyncio.create_task(_send(False))
            return
        future = self._submit_coro(_send(True))
        try:
            future.result()
        except Exception as exc:  # pragma: no cover - best effort
            self._stats["subscription_sync_failures"] += 1
            self._log(f"[TopicRegistry] Failed to unregister subscription: {exc}")

    async def _get_remote_subscriptions(
        self,
        route: TopicRoute,
        topic_id: str,
    ) -> list[TopicSubscription] | None:
        canonical_topic_id = self._canonical_topic_id(topic_id)
        cached = self._subscription_cache.get(canonical_topic_id)
        if cached is not None and time.time() - cached.fetched_at <= self._cache_ttl_seconds:
            return list(cached.subscriptions)
        payload = {
            "reply_to": self._local_address_fn(),
            "topic_id": canonical_topic_id,
        }
        packet = FlownetPacket(
            type="topic_list",
            uuid=uuid.uuid4().hex,
            reply=True,
            packet_content=payload,
        )
        response = await self._call_manager.send_packet(route.coordinator, packet)
        if response is None:
            return None
        epoch = int(response.get("epoch", 0))
        subscriptions = list(response.get("subscriptions", []))
        snapshot = TopicSubscriptionSnapshot(
            epoch=epoch,
            subscriptions=subscriptions,
            fetched_at=time.time(),
        )
        self._subscription_cache[canonical_topic_id] = snapshot
        return subscriptions

    def _claim_producer_local(
        self,
        *,
        topic_id: str,
        producer_id: str | None,
        policy: str | None,
        epoch: str | None,
        ttl: int | float | None,
    ) -> dict[str, Any]:
        normalized_producer_id = self._normalize_non_empty_str(producer_id)
        if normalized_producer_id is None:
            return {
                "ok": False,
                "error": "producer_id_required",
                "error_code": "producer_id_required",
                "topic_id": topic_id,
            }
        normalized_ttl = self._coerce_positive_int(ttl)
        if ttl is not None and normalized_ttl is None:
            return {
                "ok": False,
                "error": "ttl_must_be_positive_int",
                "error_code": "ttl_must_be_positive_int",
                "topic_id": topic_id,
                "producer_id": normalized_producer_id,
            }
        normalized_epoch = self._normalize_optional_str(epoch)
        if epoch is not None and normalized_epoch is None:
            return {
                "ok": False,
                "error": "epoch_must_be_non_empty_string",
                "error_code": "epoch_must_be_non_empty_string",
                "topic_id": topic_id,
                "producer_id": normalized_producer_id,
            }
        normalized_policy = self._normalize_policy(policy)
        if policy is not None and normalized_policy is None:
            return {
                "ok": False,
                "error": "unsupported_producer_policy",
                "error_code": "unsupported_producer_policy",
                "topic_id": topic_id,
                "producer_id": normalized_producer_id,
                "policy": policy,
                "supported_policies": sorted(SUPPORTED_PRODUCER_POLICIES),
            }

        now = time.time()
        lease_ttl = normalized_ttl or DEFAULT_PRODUCER_CLAIM_TTL_SECONDS
        with self._lock:
            self._prune_expired_claims_locked(topic_id, now)
            existing_policy = self._producer_policy_by_topic.get(topic_id)
            if existing_policy is None:
                effective_policy = normalized_policy or DEFAULT_PRODUCER_POLICY
                self._producer_policy_by_topic[topic_id] = effective_policy
            elif normalized_policy is not None and existing_policy != normalized_policy:
                return {
                    "ok": False,
                    "error": "producer_policy_immutable",
                    "error_code": "producer_policy_immutable",
                    "topic_id": topic_id,
                    "producer_id": normalized_producer_id,
                    "current_policy": existing_policy,
                    "requested_policy": normalized_policy,
                }
            else:
                effective_policy = existing_policy

            claims = self._producer_claims_by_topic.setdefault(topic_id, {})
            active_claim = self._select_active_claim(claims.values())
            if effective_policy == "single_strict":
                if (
                    active_claim is not None
                    and active_claim.producer_id != normalized_producer_id
                ):
                    return {
                        "ok": False,
                        "error": "producer_claim_conflict",
                        "error_code": "single_strict_owner_conflict",
                        "topic_id": topic_id,
                        "policy": effective_policy,
                        "producer_id": normalized_producer_id,
                        "active_producer_id": active_claim.producer_id,
                    }
                claims.clear()
            elif effective_policy == "single_with_failover":
                if (
                    active_claim is not None
                    and active_claim.producer_id != normalized_producer_id
                ):
                    if normalized_epoch is None or normalized_epoch == active_claim.epoch:
                        return {
                            "ok": False,
                            "error": "producer_claim_conflict",
                            "error_code": "single_with_failover_active_lease",
                            "topic_id": topic_id,
                            "policy": effective_policy,
                            "producer_id": normalized_producer_id,
                            "active_producer_id": active_claim.producer_id,
                            "active_epoch": active_claim.epoch,
                        }
                    # takeover is allowed when epoch advances.
                    claims.clear()

            previous = claims.get(normalized_producer_id)
            claim = TopicProducerClaim(
                producer_id=normalized_producer_id,
                policy=effective_policy,
                epoch=normalized_epoch,
                lease_expires_at=now + lease_ttl,
                claimed_at=previous.claimed_at if previous is not None else now,
            )
            claims[normalized_producer_id] = claim
            return {
                "ok": True,
                "topic_id": topic_id,
                "producer_id": normalized_producer_id,
                "policy": effective_policy,
                "epoch": normalized_epoch,
                "ttl": lease_ttl,
                "lease_expires_at": claim.lease_expires_at,
                "claimed_at": claim.claimed_at,
                "coordinator": self._local_address_fn(),
            }

    def _release_producer_local(
        self,
        *,
        topic_id: str,
        producer_id: str | None,
        epoch: str | None,
    ) -> dict[str, Any]:
        normalized_producer_id = self._normalize_non_empty_str(producer_id)
        if normalized_producer_id is None:
            return {
                "ok": False,
                "error": "producer_id_required",
                "error_code": "producer_id_required",
                "topic_id": topic_id,
            }
        normalized_epoch = self._normalize_optional_str(epoch)
        if epoch is not None and normalized_epoch is None:
            return {
                "ok": False,
                "error": "epoch_must_be_non_empty_string",
                "error_code": "epoch_must_be_non_empty_string",
                "topic_id": topic_id,
                "producer_id": normalized_producer_id,
            }

        now = time.time()
        with self._lock:
            self._prune_expired_claims_locked(topic_id, now)
            claims = self._producer_claims_by_topic.get(topic_id)
            if not claims:
                return {
                    "ok": False,
                    "error": "producer_claim_not_found",
                    "error_code": "producer_claim_not_found",
                    "topic_id": topic_id,
                    "producer_id": normalized_producer_id,
                }
            claim = claims.get(normalized_producer_id)
            if claim is None:
                return {
                    "ok": False,
                    "error": "producer_claim_not_found",
                    "error_code": "producer_claim_not_found",
                    "topic_id": topic_id,
                    "producer_id": normalized_producer_id,
                }
            if (
                normalized_epoch is not None
                and claim.epoch is not None
                and claim.epoch != normalized_epoch
            ):
                return {
                    "ok": False,
                    "error": "producer_claim_epoch_mismatch",
                    "error_code": "producer_claim_epoch_mismatch",
                    "topic_id": topic_id,
                    "producer_id": normalized_producer_id,
                    "claim_epoch": claim.epoch,
                    "request_epoch": normalized_epoch,
                }
            claims.pop(normalized_producer_id, None)
            if not claims:
                self._producer_claims_by_topic.pop(topic_id, None)
        return {
            "ok": True,
            "topic_id": topic_id,
            "producer_id": normalized_producer_id,
            "epoch": normalized_epoch,
            "coordinator": self._local_address_fn(),
        }

    async def _send_claim_to_coordinator(
        self,
        *,
        route: TopicRoute,
        topic_id: str,
        producer_id: str | None,
        policy: str | None,
        epoch: str | None,
        ttl: int | float | None,
    ) -> dict[str, Any]:
        payload = {
            "reply_to": self._local_address_fn(),
            "topic_id": topic_id,
            "producer_id": producer_id,
            "policy": policy,
            "epoch": epoch,
            "ttl": ttl,
        }
        packet = FlownetPacket(
            type="topic_claim_producer",
            uuid=uuid.uuid4().hex,
            reply=True,
            packet_content=payload,
        )
        response = await self._call_manager.send_packet(route.coordinator, packet)
        if isinstance(response, dict):
            return response
        return {
            "ok": False,
            "error": "invalid_claim_response",
            "topic_id": topic_id,
            "producer_id": producer_id,
        }

    async def _send_release_to_coordinator(
        self,
        *,
        route: TopicRoute,
        topic_id: str,
        producer_id: str | None,
        epoch: str | None,
    ) -> dict[str, Any]:
        payload = {
            "reply_to": self._local_address_fn(),
            "topic_id": topic_id,
            "producer_id": producer_id,
            "epoch": epoch,
        }
        packet = FlownetPacket(
            type="topic_release_producer",
            uuid=uuid.uuid4().hex,
            reply=True,
            packet_content=payload,
        )
        response = await self._call_manager.send_packet(route.coordinator, packet)
        if isinstance(response, dict):
            return response
        return {
            "ok": False,
            "error": "invalid_release_response",
            "topic_id": topic_id,
            "producer_id": producer_id,
        }

    @staticmethod
    def _canonical_topic_id(topic_id: str) -> str:
        return canonical_topic_key(topic_id)

    @classmethod
    def _normalize_subscription_topic(cls, subscription: TopicSubscription) -> TopicSubscription:
        canonical_topic_id = cls._canonical_topic_id(subscription.topic_id)
        if canonical_topic_id == subscription.topic_id:
            return subscription
        return replace(subscription, topic_id=canonical_topic_id)

    @staticmethod
    def _coerce_positive_int(value: int | float | None) -> int | None:
        if value is None:
            return None
        try:
            parsed = int(value)
        except (TypeError, ValueError):
            return None
        if parsed <= 0:
            return None
        return parsed

    @staticmethod
    def _normalize_non_empty_str(value: str | None) -> str | None:
        if value is None:
            return None
        if not isinstance(value, str):
            return None
        normalized = value.strip()
        if not normalized:
            return None
        return normalized

    @classmethod
    def _normalize_optional_str(cls, value: str | None) -> str | None:
        return cls._normalize_non_empty_str(value)

    @classmethod
    def _normalize_policy(cls, policy: str | None) -> str | None:
        if policy is None:
            return None
        normalized = cls._normalize_non_empty_str(policy)
        if normalized is None:
            return None
        if normalized not in SUPPORTED_PRODUCER_POLICIES:
            return None
        return normalized

    def _resolve_publish_producer_id(
        self,
        *,
        producer_id: str | None,
        tags: dict[str, str],
    ) -> str | None:
        raw_producer_id: Any = producer_id
        if raw_producer_id is None:
            raw_producer_id = tags.get("producer_id")
        if raw_producer_id is None:
            return None
        if not isinstance(raw_producer_id, str):
            raise ValueError("producer_id must be a non-empty string.")
        normalized = self._normalize_non_empty_str(raw_producer_id)
        if normalized is None:
            raise ValueError("producer_id must be a non-empty string.")
        return normalized

    def _enforce_publish_gate(
        self,
        *,
        topic_id: str,
        producer_id: str | None,
        allow_reserved_namespace: bool,
        enforce_producer_policy: bool,
    ) -> None:
        if topic_id.startswith(SERVICE_TOPIC_PREFIX) and not allow_reserved_namespace:
            raise ValueError(
                "Direct topic publish to reserved service namespace is not supported. "
                "Use emit_service_event(service_name, ...) instead."
            )
        if not enforce_producer_policy:
            return
        now = time.time()
        with self._lock:
            self._prune_expired_claims_locked(topic_id, now)
            policy = self._producer_policy_by_topic.get(topic_id, DEFAULT_PRODUCER_POLICY)
            claims = dict(self._producer_claims_by_topic.get(topic_id, {}))
        active_claim = self._select_active_claim(claims.values())

        if policy == "open":
            return
        if producer_id is None:
            raise PermissionError(
                f"[producer_id_required] topic={topic_id} policy={policy}"
            )
        if policy == "multi_claimed":
            if producer_id not in claims:
                raise PermissionError(
                    f"[producer_claim_required] topic={topic_id} policy={policy} producer_id={producer_id}"
                )
            return
        if policy == "single_strict":
            if active_claim is None:
                raise PermissionError(
                    f"[producer_claim_required] topic={topic_id} policy={policy}"
                )
            if active_claim.producer_id != producer_id:
                raise PermissionError(
                    "[single_strict_owner_conflict] "
                    f"topic={topic_id} policy={policy} producer_id={producer_id} "
                    f"active_producer_id={active_claim.producer_id}"
                )
            return
        if policy == "single_with_failover":
            if active_claim is None:
                raise PermissionError(
                    f"[producer_claim_required] topic={topic_id} policy={policy}"
                )
            if active_claim.producer_id != producer_id:
                raise PermissionError(
                    "[single_with_failover_owner_conflict] "
                    f"topic={topic_id} policy={policy} producer_id={producer_id} "
                    f"active_producer_id={active_claim.producer_id} "
                    f"active_epoch={active_claim.epoch}"
                )
            return
        raise RuntimeError(f"Unsupported producer policy configured at runtime: {policy!r}")

    @staticmethod
    def _select_active_claim(claims: Iterable[TopicProducerClaim]) -> TopicProducerClaim | None:
        claim_list = list(claims)
        if not claim_list:
            return None
        return max(
            claim_list,
            key=lambda claim: (claim.lease_expires_at, claim.claimed_at, claim.producer_id),
        )

    @classmethod
    def _select_active_producer_id(
        cls,
        policy: str,
        claims: list[TopicProducerClaim],
    ) -> str | None:
        if not claims:
            return None
        if policy in {"single_strict", "single_with_failover"}:
            active = cls._select_active_claim(claims)
            return active.producer_id if active is not None else None
        return None

    def _prune_expired_claims_locked(self, topic_id: str, now: float) -> None:
        claims = self._producer_claims_by_topic.get(topic_id)
        if not claims:
            return
        expired_ids = [
            producer_id
            for producer_id, claim in claims.items()
            if claim.lease_expires_at <= now
        ]
        for producer_id in expired_ids:
            claims.pop(producer_id, None)
        if not claims:
            self._producer_claims_by_topic.pop(topic_id, None)

    async def _send_event_to_coordinator(
        self,
        route: TopicRoute,
        event: StreamEvent,
        *,
        producer_id: str | None,
        allow_reserved_namespace: bool,
    ) -> None:
        if self._send_topic_event_via_v1 is not None:
            try:
                await self._send_topic_event_via_v1(
                    route,
                    event,
                    producer_id,
                    allow_reserved_namespace,
                )
                return
            except Exception:
                if not self._v1_fail_open_legacy:
                    raise

        payload = {
            "reply_to": self._local_address_fn(),
            "event": event,
            "publish_meta": {
                "producer_id": producer_id,
                "allow_reserved_namespace": allow_reserved_namespace,
            },
        }
        packet = FlownetPacket(
            type="topic_event",
            uuid=uuid.uuid4().hex,
            reply=False,
            packet_content=payload,
        )
        await self._call_manager.send_packet(route.coordinator, packet)

    def _dispatch_to_subscriptions(
        self,
        event: StreamEvent,
        subscriptions: Iterable[TopicSubscription],
    ) -> None:
        if self._in_runtime_loop():
            asyncio.create_task(self._dispatch_to_subscriptions_async(event, subscriptions))
            return
        async def _send():
            await self._dispatch_to_subscriptions_async(event, subscriptions)
        self._submit_coro(_send())

    async def _dispatch_to_subscriptions_async(
        self,
        event: StreamEvent,
        subscriptions: Iterable[TopicSubscription],
    ) -> None:
        local_address = self._local_address_fn()
        origin_address = event.origin or local_address
        request_id = event.request_ref_id
        track_request_root = self._should_track_request_root(
            event=event,
        )
        tracking_origin = self._resolve_request_tracking_origin(
            event=event,
            default_origin=origin_address,
        )
        is_done = (
            event.event_type == "__done__"
            or event.tags.get("__control__") == "done"
        )
        if is_done:
            await self._track_producer_done(
                request_id=request_id,
                origin_address=tracking_origin,
                track_root=track_request_root,
            )
        for subscription in subscriptions:
            ack_route = self._resolve_subscription_ack_route(subscription)
            if is_done:
                await self._dispatch_done_event(
                    subscription=subscription,
                    event=event,
                    local_address=local_address,
                    origin_address=origin_address,
                    ack_route=ack_route,
                    track_request_root=track_request_root,
                    tracking_origin=tracking_origin,
                )
                continue
            payload = PayloadData(args=[event], kwargs={})
            request = subscription.flow_task.make_request(
                payload,
                request_id=request_id,
                origin_address=origin_address,
                ack_route=ack_route,
            )
            self._attach_source_seq_meta(request, event)
            await self._track_request_root(
                request_id=request.request_id,
                origin_address=tracking_origin,
                track_root=track_request_root,
            )
            await self._dispatch_flow_request(
                request=request,
                execution_address=subscription.execution_address,
                local_address=local_address,
            )

    async def _dispatch_done_event(
        self,
        *,
        subscription: TopicSubscription,
        event: StreamEvent,
        local_address: str,
        origin_address: str,
        ack_route: FlowAckRoute | None,
        track_request_root: bool,
        tracking_origin: str,
    ) -> None:
        routers = self._select_done_target_routers(subscription.flow_task)
        if not routers:
            return
        request_id = event.request_ref_id or uuid.uuid4().hex
        source_final_seq = self._parse_non_negative_int(event.tags.get("__final_seq__"))
        if source_final_seq is None:
            source_final_seq = self._parse_non_negative_int(event.tags.get("__seq__"))
        for router in routers:
            shuffle_meta = (router.operator_config or {}).get("shuffle") or {}
            try:
                num_partitions = int(shuffle_meta.get("num_partitions", 1))
            except (TypeError, ValueError):
                num_partitions = 1
            for partition_id in range(num_partitions):
                if not self._mark_done_dispatch_once(
                    subscription_id=subscription.subscription_id,
                    request_id=request_id,
                    router=router,
                    partition_id=partition_id,
                ):
                    continue
                payload = PayloadData(args=[partition_id], kwargs={})
                done_next_meta: dict[str, Any] = {"control": "done"}
                if source_final_seq is not None:
                    done_next_meta["source_final_seq"] = source_final_seq
                request = FlowRequest(
                    flow_stack=FlowStack(
                        frames=[
                            TransformationFrame(
                                router,
                                payload,
                                # Keep source_final_seq in done metadata so
                                # reducers can defer finalize until sequenced
                                # data converges to watermark.
                                meta={"next_meta": done_next_meta},
                            )
                        ]
                    ),
                    request_id=request_id,
                    origin_address=origin_address,
                    ack_route=ack_route,
                )
                await self._track_request_root(
                    request_id=request.request_id,
                    origin_address=tracking_origin,
                    track_root=track_request_root,
                )
                await self._dispatch_flow_request(
                    request=request,
                    execution_address=subscription.execution_address,
                    local_address=local_address,
                )

    @staticmethod
    def _resolve_request_tracking_origin(
        *,
        event: StreamEvent,
        default_origin: str,
    ) -> str:
        tags = event.tags or {}
        tagged_origin = tags.get(_REQUEST_TRACKER_ORIGIN_TAG)
        if isinstance(tagged_origin, str) and tagged_origin.strip():
            return tagged_origin.strip()
        return default_origin

    @staticmethod
    def _should_track_request_root(
        *,
        event: StreamEvent,
    ) -> bool:
        tags = event.tags or {}
        marker = str(tags.get(_TRACK_REQUEST_ROOT_TAG) or "").strip().lower()
        return marker in _TRACK_REQUEST_ROOT_TRUE_VALUES

    @staticmethod
    def _select_done_target_routers(flow_task: FlowTask) -> list[Any]:
        # A2 v1 contract:
        # 1) Candidate routers are `sys/shuffle` and `sys/join`.
        # 2) Ingress router = candidate router with no `sys/*` upstream.
        # 3) If ingress detection yields empty set, fallback to all candidates.
        routers = [
            trans
            for trans in flow_task.pipeline
            if getattr(trans, "type", None) in {"sys/shuffle", "sys/join"}
        ]
        if not routers:
            return []

        ingress_routers: list[Any] = []
        for router in routers:
            upstreams = getattr(router, "upstreams", None) or []
            has_system_upstream = any(
                isinstance(getattr(upstream, "type", None), str)
                and str(getattr(upstream, "type", "")).startswith("sys/")
                for upstream in upstreams
            )
            if not has_system_upstream:
                ingress_routers.append(router)

        # Fallback: if ingress detection yields empty set, keep previous behavior.
        return ingress_routers or routers

    def _mark_done_dispatch_once(
        self,
        *,
        subscription_id: str,
        request_id: str,
        router: Any,
        partition_id: int,
    ) -> bool:
        router_id = getattr(router, "trans_id", None)
        if not isinstance(router_id, str) or not router_id:
            router_id = str(id(router))
        key = (subscription_id, request_id, router_id, partition_id)
        with self._lock:
            if key in self._done_dispatch_seen:
                return False
            self._done_dispatch_seen.add(key)
            self._done_dispatch_order.append(key)
            while len(self._done_dispatch_order) > self._done_dispatch_max_entries:
                oldest = self._done_dispatch_order.popleft()
                self._done_dispatch_seen.discard(oldest)
        return True

    async def _track_request_root(
        self,
        *,
        request_id: str | None,
        origin_address: str,
        track_root: bool,
    ) -> None:
        if self._track_request_delta is None:
            return
        if not track_root:
            return
        if not request_id:
            return
        if not origin_address:
            return
        try:
            await self._track_request_delta(origin_address, request_id, +1)
        except Exception as exc:  # pragma: no cover - best effort
            self._stats["request_tracking_failures"] += 1
            self._log(f"[TopicRegistry] Failed to track root request delta: {exc}")

    async def _track_producer_done(
        self,
        *,
        request_id: str | None,
        origin_address: str,
        track_root: bool,
    ) -> None:
        if self._track_request_producer_done is None:
            return
        if not track_root:
            return
        if not request_id:
            return
        if not origin_address:
            return
        try:
            await self._track_request_producer_done(origin_address, request_id)
        except Exception as exc:  # pragma: no cover - best effort
            self._stats["request_tracking_failures"] += 1
            self._log(f"[TopicRegistry] Failed to mark request producer_done: {exc}")

    def _attach_source_seq_meta(self, request: Any, event: StreamEvent) -> None:
        source_seq = self._parse_non_negative_int(event.tags.get("__seq__"))
        if source_seq is None:
            return

        flow_stack = getattr(request, "flow_stack", None)
        if flow_stack is None:
            return
        top_frame = flow_stack.top()
        if not isinstance(top_frame, TransformationFrame):
            return

        frame_meta = dict(top_frame.meta or {})
        next_meta_obj = frame_meta.get("next_meta")
        next_meta = dict(next_meta_obj) if isinstance(next_meta_obj, dict) else {}
        next_meta.setdefault("source_seq", source_seq)
        frame_meta["next_meta"] = next_meta
        top_frame.meta = frame_meta

    @staticmethod
    def _resolve_subscription_ack_route(
        subscription: TopicSubscription,
    ) -> FlowAckRoute | None:
        tags = subscription.tags
        if not isinstance(tags, dict):
            return None
        encoded_route = tags.get(_TOPIC_INGRESS_ACK_ROUTE_TAG)
        if isinstance(encoded_route, str):
            normalized_encoded = encoded_route.strip()
            if normalized_encoded:
                decoded: Any = normalized_encoded
                try:
                    decoded_json = json.loads(normalized_encoded)
                except Exception:
                    decoded_json = None
                if decoded_json is not None:
                    decoded = decoded_json
                resolved_route = coerce_flow_ack_route(decoded)
                if resolved_route is not None:
                    return resolved_route
        return None

    @staticmethod
    def _parse_non_negative_int(value: Any) -> int | None:
        if value is None:
            return None
        try:
            parsed = int(value)
        except (TypeError, ValueError):
            return None
        if parsed < 0:
            return None
        return parsed

    def _in_runtime_loop(self) -> bool:
        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:
            return False
        return loop is loops.get_runtime_loop()

    async def _dispatch_flow_request(
        self,
        *,
        request: FlowRequest,
        execution_address: str,
        local_address: str,
    ) -> None:
        if execution_address == local_address:
            if self._handle_flow_request is not None:
                try:
                    await self._handle_flow_request(request)
                    return
                except Exception as exc:  # pragma: no cover - best effort
                    self._stats["local_dispatch_failures"] += 1
                    self._log(f"[TopicRegistry] Local flow dispatch failed: {exc}")
            handler = getattr(self._call_manager, "runtime_handler", None)
            if handler is not None:
                try:
                    await handler(
                        FlownetPacket(
                            type="flowrequest",
                            uuid=uuid.uuid4().hex,
                            reply=False,
                            packet_content=request,
                        )
                    )
                    return
                except Exception as exc:  # pragma: no cover - best effort
                    self._stats["local_dispatch_failures"] += 1
                    self._log(f"[TopicRegistry] Local flow dispatch failed: {exc}")
        packet = FlownetPacket(
            type="flowrequest",
            uuid=uuid.uuid4().hex,
            reply=False,
            packet_content=request,
        )
        try:
            await self._call_manager.send_packet(execution_address, packet)
        except Exception as exc:  # pragma: no cover - best effort
            self._stats["remote_dispatch_failures"] += 1
            self._log(f"[TopicRegistry] Failed to send flowrequest to {execution_address}: {exc}")


__all__ = [
    "TopicRegistry",
    "TopicSubscription",
    "TopicSubscriptionSnapshot",
    "TopicProducerClaim",
]
