from __future__ import annotations

import asyncio
import inspect
import json
from threading import Lock
from typing import Any, Callable, Mapping
import uuid
import warnings

from flownet.compiler.flowtask import FlowTask
from flownet.core.request import (
    FlowAckRoute,
    FlowRequest,
    FlowStack,
    TransformationFrame,
    build_flow_ack_route,
    coerce_flow_ack_route,
)
from flownet.core.types import PayloadData
from flownet.runtime.flows.connectors import (
    EGRESS_BUFFERED,
    EGRESS_TOPIC,
    EGRESS_RETURN_ONCE,
    INGRESS_DATASET,
    INGRESS_MANUAL_PUSH,
    INGRESS_TOPIC,
    resolve_egress_connector,
    resolve_ingress_connector,
)
from flownet.runtime.flows.control_output import RunControlOutputCoordinator
from flownet.runtime.flows.topic_output import RunTopicEgressChannel
from flownet.runtime.flows.program_catalog import FlowProgramCatalog
from flownet.runtime.flows.run import FlowRunRef, FlowRunRegistry

_TOPIC_INGRESS_ACK_ROUTE_TAG = "__flownet_ack_route__"
_FLOW_RUN_SERVICE_DEPRECATED_MESSAGE = (
    "FlowRunService is deprecated for FlowProgram catalog evolution. "
    "Use topic-first FlowProgram APIs under flownet.runtime.topics "
    "(TopicFlowProgramService) as the primary model path."
)


class FlowRunService:
    """
    Legacy run-oriented flow service.

    Deprecated:
    - FlowProgram catalog/model evolution is moving to topic-first services
      under `flownet.runtime.topics`.
    - This class remains for compatibility while submit/run APIs transition.
    """

    _deprecation_warned = False

    @classmethod
    def _warn_deprecated_once(cls) -> None:
        if cls._deprecation_warned:
            return
        warnings.warn(
            _FLOW_RUN_SERVICE_DEPRECATED_MESSAGE,
            DeprecationWarning,
            stacklevel=3,
        )
        cls._deprecation_warned = True

    def __init__(self):
        self._warn_deprecated_once()
        self._runs = FlowRunRegistry()
        self._programs = FlowProgramCatalog()

    def submit_flow(
        self,
        flow_def_or_task,
        *,
        ingress=None,
        egress=None,
        run_config: Mapping[str, Any] | None = None,
    ) -> FlowRunRef:
        # ARCH_NOTE:
        # - submit_flow materializes one executable run and returns FlowRunRef.
        # - The default registry contract is run_id -> FlowRunRef.
        # - This service does not yet persist a reusable logical-flow catalog;
        #   callers should treat it as runtime run indexing.
        config = dict(run_config or {})
        ingress_connector = resolve_ingress_connector(ingress)
        output_connector = resolve_egress_connector(egress)

        run_id = str(config.get("run_id") or uuid.uuid4().hex)
        default_flow_id = str(config.get("flow_id") or self._resolve_flow_id(flow_def_or_task))
        native_flow_id = str(config.get("native_flow_id") or default_flow_id)
        augmented_task_id = config.get("augmented_task_id")
        if augmented_task_id is not None:
            augmented_task_id = str(augmented_task_id)

        ack_route_holder: dict[str, FlowAckRoute | None] = {
            "value": self._resolve_ack_route(run_config=config, output=None),
        }
        dispatch_fn = config.get("dispatch")
        task = None
        if dispatch_fn is None:
            task = self._resolve_task(flow_def_or_task, config)
            if task is not None:
                dispatch_fn = self._build_task_dispatcher(
                    task,
                    ack_route_provider=lambda: ack_route_holder["value"],
                )
        else:
            flow_task = config.get("flow_task")
            if isinstance(flow_task, FlowTask):
                task = flow_task

        output = config.get("output")
        if output is None:
            output_factory = config.get("output_factory")
            if callable(output_factory):
                output = output_factory()
        if output is None and output_connector.kind in {EGRESS_BUFFERED, EGRESS_RETURN_ONCE}:
            target_ref = config.get("target_ref")
            if target_ref is not None:
                from flownet.runtime.flows.output_channel import RunOutputChannel
                output = RunOutputChannel(
                    target_ref,
                    runtime_managed=True,
                    ordered_capacity=int(config.get("ordered_event_capacity", 256)),
                    ordered_backpressure=str(config.get("ordered_event_backpressure", "block")),
                    ordered_terminal_hold_s=float(config.get("ordered_event_terminal_hold_s", 0.05)),
                )
        if output is None and output_connector.kind == EGRESS_TOPIC:
            topic_egress = self._resolve_topic_egress_binding(
                egress=egress,
                run_config=config,
            )
            if topic_egress is not None:
                target_ref = self._resolve_target_ref(
                    flow_def_or_task=flow_def_or_task,
                    run_config=config,
                )
                if target_ref is not None:
                    output = RunTopicEgressChannel(
                        target=target_ref,
                        topic_id=topic_egress["topic_id"],
                        event_type=topic_egress["event_type"],
                        done_event_type=topic_egress["done_event_type"],
                        error_event_type=topic_egress["error_event_type"],
                        tags=topic_egress["tags"],
                    )
        control_output = config.get("control_output")
        if control_output is None:
            control_output_factory = config.get("control_output_factory")
            if callable(control_output_factory):
                control_output = control_output_factory()

        ack_route_output = output if output is not None else control_output
        resolved_ack_route = self._resolve_ack_route(
            run_config=config,
            output=ack_route_output,
        )
        if output is None and control_output is None and resolved_ack_route is None:
            control_output = RunControlOutputCoordinator(runtime_managed=True)
            resolved_ack_route = self._resolve_ack_route(
                run_config=config,
                output=control_output,
            )
        ack_route_holder["value"] = resolved_ack_route

        capabilities = output_connector.capabilities
        if output is None:
            capabilities = frozenset(cap for cap in capabilities if cap not in {"read", "subscribe"})
        else:
            if "read" in capabilities and not callable(getattr(output, "read", None)):
                capabilities = frozenset(cap for cap in capabilities if cap != "read")
            if "subscribe" in capabilities and not callable(getattr(output, "subscribe", None)):
                capabilities = frozenset(cap for cap in capabilities if cap != "subscribe")
        dispatch_tracks_requests_config = config.get("dispatch_tracks_requests")
        if dispatch_tracks_requests_config is None:
            dispatch_tracks_requests = bool(task is not None and ingress_connector.writable)
        else:
            dispatch_tracks_requests = bool(dispatch_tracks_requests_config)

        run = FlowRunRef(
            run_id=run_id,
            flow_id=native_flow_id,
            ingress_kind=ingress_connector.kind,
            ingress_writable=ingress_connector.writable,
            output_mode=output_connector.kind,
            native_flow_id=native_flow_id,
            augmented_task_id=augmented_task_id,
            _capabilities=capabilities,
            _dispatch_fn=dispatch_fn,
            _dispatch_tracks_requests=dispatch_tracks_requests,
            _request_id_factory=self._build_request_id_factory(config),
        )
        finish_request: Callable[[str], bool] | None = None
        if ingress_connector.kind == INGRESS_MANUAL_PUSH and task is not None:
            emit_done = self._build_manual_done_request_emitter(task)
            finished_request_ids: set[str] = set()
            finished_lock = Lock()

            def _finish_request(request_id: str) -> bool:
                if not request_id:
                    return False
                with finished_lock:
                    if request_id in finished_request_ids:
                        return False
                    finished_request_ids.add(request_id)
                done = bool(run.mark_request_producer_done(request_id))
                if emit_done is not None:
                    emit_done(run, (request_id,))
                return done

            finish_request = _finish_request

            def _close_hook(current_run: FlowRunRef):
                pending_request_ids = current_run.pending_request_ids()
                for request_id in pending_request_ids:
                    _finish_request(request_id)

            run._close_ingress_fn = _close_hook
        if task is not None:
            run.attach_output("task", task)
        run.attach_output("ack_route", resolved_ack_route)
        if control_output is not None:
            run.attach_output("control_output", control_output)
        if output is not None:
            run.attach_output("output", output)
        tracker_binding_target = output if output is not None else control_output
        if tracker_binding_target is not None:
            bind_runtime_tracker = getattr(tracker_binding_target, "bind_runtime_tracker", None)
            if callable(bind_runtime_tracker):
                def start_request(request_id, initial_delta=1):
                    return run.start_request(
                        request_id,
                        initial_delta=initial_delta,
                    )

                def ack_request_delta(request_id, delta):
                    return run.ack_request_delta(
                        request_id,
                        delta,
                    )

                def next_request_meta(request_id):
                    return run.next_request_meta(request_id)

                finish_request_cb = finish_request
                close_ingress = run.close_ingress
                request_done = run.request_done
                try:
                    bind_runtime_tracker(
                        start_request=start_request,
                        ack_request_delta=ack_request_delta,
                        next_request_meta=next_request_meta,
                        finish_request=finish_request_cb,
                        close_ingress=close_ingress,
                        request_done=request_done,
                    )
                except TypeError:
                    try:
                        bind_runtime_tracker(
                            start_request=start_request,
                            ack_request_delta=ack_request_delta,
                            next_request_meta=next_request_meta,
                            request_done=request_done,
                        )
                    except TypeError:
                        bind_runtime_tracker(
                            start_request=start_request,
                            ack_request_delta=ack_request_delta,
                        )

        ingress_connector.start(run)
        output_connector.start(run)
        run.set_status("running")

        if ingress_connector.kind == INGRESS_TOPIC:
            topic_binding = self._resolve_topic_ingress_binding(
                ingress=ingress,
                run_config=config,
            )
            if topic_binding is not None:
                ingress_task = self._resolve_topic_ingress_task(
                    default_task=task,
                    output=output,
                )
                self._bind_topic_ingress(
                    run=run,
                    task=ingress_task,
                    topic_id=topic_binding[0],
                    tags=topic_binding[1],
                )
        elif ingress_connector.kind == INGRESS_DATASET:
            dataset_binding = self._resolve_dataset_ingress_binding(
                ingress=ingress,
                run_config=config,
                run_id=run_id,
            )
            if dataset_binding is not None:
                ingress_task = self._resolve_topic_ingress_task(
                    default_task=task,
                    output=output,
                )
                self._bind_dataset_ingress(
                    run=run,
                    task=ingress_task,
                    binding=dataset_binding,
                )

        self._runs.register(run)
        return run

    def lookup_run(self, run_id: str) -> FlowRunRef | None:
        return self._runs.get(run_id)

    def register_flow_program(
        self,
        flow_def_or_task,
        *,
        flow_uri: str,
        version: str,
        metadata: Mapping[str, Any] | None = None,
        io_contract: Mapping[str, Any] | None = None,
    ) -> dict[str, Any]:
        payload = self._resolve_task(flow_def_or_task, config={})
        if payload is None:
            payload = flow_def_or_task
        record = self._programs.register(
            flow_uri=flow_uri,
            version=version,
            payload=payload,
            metadata=metadata,
            io_contract=io_contract,
        )
        return record.to_public_dict()

    def resolve_flow_program_payload(self, flow_uri: str, version: str) -> Any:
        record = self._programs.get(flow_uri, version=version)
        if record is None:
            raise ValueError(f"FlowProgram not found: {flow_uri}@{version}")
        return record.payload

    def list_flow_programs(
        self,
        *,
        prefix: str | None = None,
        tags: Mapping[str, Any] | None = None,
        limit: int | None = None,
    ) -> list[dict[str, Any]]:
        records = self._programs.list(prefix=prefix, tags=tags, limit=limit)
        return [record.to_public_dict() for record in records]

    def get_flow_program(self, flow_uri: str, version: str | None = None) -> dict[str, Any] | None:
        record = self._programs.get(flow_uri, version=version)
        if record is None:
            return None
        return record.to_public_dict()

    def delete_flow_program(self, flow_uri: str, version: str | None = None) -> dict[str, Any]:
        deleted = self._programs.delete(flow_uri, version=version)
        return {"deleted": deleted}

    def submit_flow_program(
        self,
        flow_uri: str,
        version: str | None = None,
        *,
        bindings: Mapping[str, Any] | None = None,
        ingress=None,
        egress=None,
        run_config: Mapping[str, Any] | None = None,
    ) -> FlowRunRef:
        record = self._programs.get(flow_uri, version=version)
        if record is None:
            if version is None:
                raise ValueError(f"FlowProgram not found: {flow_uri}")
            raise ValueError(f"FlowProgram not found: {flow_uri}@{version}")

        connector_defaults = record.io_contract.get("connector_defaults")
        if isinstance(connector_defaults, Mapping):
            if ingress is None:
                ingress = connector_defaults.get("ingress")
            if egress is None:
                egress = connector_defaults.get("egress")

        effective_run_config: dict[str, Any] = dict(run_config or {})
        if isinstance(bindings, Mapping):
            effective_run_config["bindings"] = dict(bindings)
        flow_id = record.flow_id()
        effective_run_config.setdefault("flow_id", flow_id)
        effective_run_config.setdefault("native_flow_id", flow_id)
        effective_run_config.setdefault(
            "flow_program",
            {
                "program_id": record.program_id,
                "flow_uri": record.flow_uri,
                "version": record.version,
                "program_hash": record.program_hash,
            },
        )
        return self.submit_flow(
            record.payload,
            ingress=ingress,
            egress=egress,
            run_config=effective_run_config,
        )

    def _build_request_id_factory(self, config: Mapping[str, Any]) -> Callable[[Any | None], str]:
        request_id_factory = config.get("request_id_factory")
        tracked_request_id_factory = config.get("tracked_request_id_factory")

        def _call(factory, tracker):
            try:
                return factory(tracker)
            except TypeError:
                return factory()

        def _make_request_id(tracker: Any | None = None) -> str:
            if tracker is not None and callable(tracked_request_id_factory):
                return _call(tracked_request_id_factory, tracker)
            if callable(request_id_factory):
                return _call(request_id_factory, tracker)
            return uuid.uuid4().hex

        return _make_request_id

    def _resolve_task(self, flow_def_or_task, config: Mapping[str, Any]) -> FlowTask | None:
        flow_task = config.get("flow_task")
        if isinstance(flow_task, FlowTask):
            return flow_task
        if isinstance(flow_def_or_task, FlowTask):
            return flow_def_or_task

        get_task = getattr(flow_def_or_task, "_get_task", None)
        if callable(get_task):
            instance = config.get("instance")
            return get_task(instance)
        return None

    def _resolve_flow_id(self, flow_def_or_task) -> str:
        method_name = getattr(flow_def_or_task, "_method_name", None)
        if isinstance(method_name, str) and method_name:
            return method_name
        flow_fn = getattr(flow_def_or_task, "flowfunction", None)
        fn_name = getattr(flow_fn, "__name__", None)
        if isinstance(fn_name, str) and fn_name:
            return fn_name
        return f"flow:{type(flow_def_or_task).__name__}"

    def _resolve_target_ref(
        self,
        *,
        flow_def_or_task: Any,
        run_config: Mapping[str, Any],
    ):
        from flownet.api.actor_method_ref import ActorMethodRef

        target_ref = run_config.get("target_ref")
        if isinstance(target_ref, ActorMethodRef):
            return target_ref

        to_ref_internal = getattr(flow_def_or_task, "_to_ref_internal", None)
        if callable(to_ref_internal):
            instance = run_config.get("instance")
            try:
                resolved = to_ref_internal(instance)
            except TypeError:
                resolved = to_ref_internal()
            if isinstance(resolved, ActorMethodRef):
                return resolved

        to_ref = getattr(flow_def_or_task, "to_ref", None)
        if not callable(to_ref):
            return None
        instance = run_config.get("instance")
        try:
            resolved = to_ref(instance)
        except TypeError:
            resolved = to_ref()
        if isinstance(resolved, ActorMethodRef):
            return resolved
        return None

    def _build_task_dispatcher(
        self,
        task: FlowTask,
        *,
        ack_route_provider: Callable[[], FlowAckRoute | None] | None = None,
    ) -> Callable[..., str]:
        def _dispatch(*args, request_id: str, _next_meta: dict[str, Any] | None = None, **kwargs):
            from flownet.runtime.kernel import get_runtime_kernel

            kernel = get_runtime_kernel()
            payload = PayloadData(args=list(args), kwargs=kwargs)
            ack_route = ack_route_provider() if callable(ack_route_provider) else None
            request = task.make_request(
                payload,
                request_id=request_id,
                origin_address=kernel.local_address(),
                ack_route=ack_route,
            )
            if isinstance(_next_meta, dict) and _next_meta:
                top = request.flow_stack.top()
                if isinstance(top, TransformationFrame):
                    top.meta = {"next_meta": dict(_next_meta)}
            kernel.require_flows().submit(request)
            return request_id

        return _dispatch

    def _resolve_topic_ingress_binding(
        self,
        *,
        ingress: Any,
        run_config: Mapping[str, Any],
    ) -> tuple[str, dict[str, str] | None] | None:
        if isinstance(ingress, Mapping):
            kind = ingress.get("kind")
            if kind is None:
                kind = INGRESS_MANUAL_PUSH
            if kind != INGRESS_TOPIC:
                return None
            topic_id = ingress.get("topic_id")
            if topic_id is None:
                topic_id = ingress.get("topic")
            tags = ingress.get("tags")
        elif isinstance(ingress, str):
            if ingress != INGRESS_TOPIC:
                return None
            topic_id = run_config.get("ingress_topic_id")
            tags = run_config.get("ingress_topic_tags")
        else:
            return None

        if topic_id is None:
            return None
        if not isinstance(topic_id, str) or not topic_id:
            raise ValueError("topic ingress topic_id must be a non-empty string.")

        normalized_tags: dict[str, str] | None = None
        if tags is not None:
            if not isinstance(tags, Mapping):
                raise ValueError("topic ingress tags must be a mapping.")
            normalized_tags = {str(k): str(v) for k, v in tags.items()}
        return topic_id, normalized_tags

    def _resolve_topic_egress_binding(
        self,
        *,
        egress: Any,
        run_config: Mapping[str, Any],
    ) -> dict[str, Any] | None:
        if isinstance(egress, Mapping):
            kind = egress.get("kind")
            if kind is None:
                kind = EGRESS_BUFFERED
            if kind != EGRESS_TOPIC:
                return None
            topic_id = egress.get("topic_id")
            if topic_id is None:
                topic_id = egress.get("topic")
            tags = egress.get("tags")
            event_type = egress.get("event_type", "data")
            done_event_type = egress.get("done_event_type", "done")
            error_event_type = egress.get("error_event_type", "error")
        elif isinstance(egress, str):
            if egress != EGRESS_TOPIC:
                return None
            topic_id = run_config.get("egress_topic_id")
            tags = run_config.get("egress_topic_tags")
            event_type = run_config.get("egress_topic_event_type", "data")
            done_event_type = run_config.get("egress_topic_done_event_type", "done")
            error_event_type = run_config.get("egress_topic_error_event_type", "error")
        else:
            return None

        if topic_id is None:
            return None
        if not isinstance(topic_id, str) or not topic_id:
            raise ValueError("topic egress topic_id must be a non-empty string.")
        if not isinstance(event_type, str) or not event_type:
            raise ValueError("topic egress event_type must be a non-empty string.")
        if not isinstance(done_event_type, str) or not done_event_type:
            raise ValueError("topic egress done_event_type must be a non-empty string.")
        if not isinstance(error_event_type, str) or not error_event_type:
            raise ValueError("topic egress error_event_type must be a non-empty string.")

        normalized_tags: dict[str, str] | None = None
        if tags is not None:
            if not isinstance(tags, Mapping):
                raise ValueError("topic egress tags must be a mapping.")
            normalized_tags = {str(k): str(v) for k, v in tags.items()}

        return {
            "topic_id": topic_id,
            "event_type": event_type,
            "done_event_type": done_event_type,
            "error_event_type": error_event_type,
            "tags": normalized_tags,
        }

    def _resolve_dataset_ingress_binding(
        self,
        *,
        ingress: Any,
        run_config: Mapping[str, Any],
        run_id: str,
    ) -> dict[str, Any] | None:
        if isinstance(ingress, Mapping):
            kind = ingress.get("kind")
            if kind is None:
                kind = INGRESS_MANUAL_PUSH
            if kind != INGRESS_DATASET:
                return None
            source_spec = (
                ingress.get("source")
                or ingress.get("source_spec")
                or ingress.get("dataset")
            )
            topic_id = ingress.get("topic_id")
            if topic_id is None:
                topic_id = ingress.get("topic")
            tags = ingress.get("tags")
            source_id = ingress.get("source_id")
            restart_policy = ingress.get("restart_policy", "none")
            iter_batch_size = ingress.get("batch_size")
            iter_timeout = ingress.get("timeout")
        else:
            return None

        if source_spec is None:
            return None
        normalized_topic_id = str(
            topic_id
            or run_config.get("dataset_ingress_topic_id")
            or f"topic:dataset-ingress:{run_id}"
        ).strip()
        if not normalized_topic_id:
            raise ValueError("dataset ingress topic_id must be a non-empty string.")

        normalized_source_id = str(source_id or normalized_topic_id).strip()
        if not normalized_source_id:
            raise ValueError("dataset ingress source_id must be a non-empty string.")

        normalized_tags: dict[str, str] | None = None
        if tags is not None:
            if not isinstance(tags, Mapping):
                raise ValueError("dataset ingress tags must be a mapping.")
            normalized_tags = {str(k): str(v) for k, v in tags.items()}

        normalized_restart_policy = str(restart_policy or "none").strip() or "none"
        normalized_source_spec = self._coerce_dataset_source_spec(
            source_spec=source_spec,
            iter_batch_size=iter_batch_size,
            iter_timeout=iter_timeout,
        )

        return {
            "topic_id": normalized_topic_id,
            "tags": normalized_tags,
            "source_id": normalized_source_id,
            "source_spec": normalized_source_spec,
            "restart_policy": normalized_restart_policy,
        }

    @staticmethod
    def _coerce_dataset_source_spec(
        *,
        source_spec: Any,
        iter_batch_size: Any,
        iter_timeout: Any,
    ) -> Any:
        if hasattr(source_spec, "iter_rows") and callable(getattr(source_spec, "iter_rows")):
            def _dataset_iter_source():
                iter_kwargs: dict[str, Any] = {}
                if iter_batch_size is not None:
                    iter_kwargs["batch_size"] = iter_batch_size
                if iter_timeout is not None:
                    iter_kwargs["timeout"] = iter_timeout
                yield from source_spec.iter_rows(**iter_kwargs)

            return _dataset_iter_source
        if inspect.isgenerator(source_spec) or inspect.isasyncgen(source_spec):
            return source_spec
        if callable(source_spec):
            return source_spec
        if isinstance(source_spec, Mapping):
            raise TypeError("dataset ingress source/dataset cannot be a mapping object.")
        if isinstance(source_spec, (str, bytes)):
            raise TypeError("dataset ingress source/dataset cannot be a string or bytes object.")
        if hasattr(source_spec, "__iter__"):
            def _iterable_source():
                yield from source_spec

            return _iterable_source
        raise TypeError(
            f"Unsupported dataset ingress source type: {type(source_spec)}. "
            "Expected callable/generator/iterable or object with iter_rows()."
        )

    def _bind_topic_ingress(
        self,
        *,
        run: FlowRunRef,
        task: FlowTask | None,
        topic_id: str,
        tags: dict[str, str] | None,
    ) -> None:
        if task is None:
            raise ValueError(
                "topic ingress binding requires a FlowTask payload; "
                "submit_flow() received no task to subscribe."
            )

        from flownet.runtime.kernel import get_runtime_kernel

        kernel = get_runtime_kernel()
        topic_registry = kernel.require_topic_registry()
        effective_tags = dict(tags or {})
        ack_route = self._run_ack_route(run)
        if ack_route is not None:
            serialized_ack_route = self._serialize_topic_ack_route(ack_route)
            if serialized_ack_route is not None:
                effective_tags.setdefault(_TOPIC_INGRESS_ACK_ROUTE_TAG, serialized_ack_route)
        bound_tags = effective_tags or None
        subscription_id = topic_registry.attach_task(
            topic_id,
            task,
            kernel.local_address(),
            tags=bound_tags,
        )
        run.attach_output("ingress_topic_id", topic_id)
        run.attach_output("ingress_topic_tags", bound_tags)
        run.attach_output("ingress_topic_subscription_id", subscription_id)

        previous_close_hook = run._close_ingress_fn

        def _close_topic_ingress(_run: FlowRunRef) -> None:
            if callable(previous_close_hook):
                previous_close_hook(_run)

            current_subscription_id = _run.output("ingress_topic_subscription_id")
            if not isinstance(current_subscription_id, str) or not current_subscription_id:
                return
            try:
                topic_registry.detach_task(current_subscription_id)
            finally:
                _run.attach_output("ingress_topic_subscription_id", None)

        run._close_ingress_fn = _close_topic_ingress

    def _bind_dataset_ingress(
        self,
        *,
        run: FlowRunRef,
        task: FlowTask | None,
        binding: Mapping[str, Any],
    ) -> None:
        from flownet.runtime.kernel import get_runtime_kernel

        kernel = get_runtime_kernel()
        source_services = kernel.require_sources()
        topic_id = str(binding["topic_id"])
        tags = binding.get("tags")
        source_id = str(binding["source_id"])
        source_spec = binding["source_spec"]
        restart_policy = str(binding.get("restart_policy") or "none")

        self._bind_topic_ingress(
            run=run,
            task=task,
            topic_id=topic_id,
            tags=tags if isinstance(tags, dict) else None,
        )
        source_services.start_source(
            source_id,
            source_spec,
            restart_policy=restart_policy,
        )
        run.attach_output("ingress_dataset_source_id", source_id)
        run.attach_output("ingress_dataset_topic_id", topic_id)
        run.attach_output("ingress_dataset_restart_policy", restart_policy)

        previous_close_hook = run._close_ingress_fn

        def _close_dataset_ingress(_run: FlowRunRef) -> None:
            if callable(previous_close_hook):
                previous_close_hook(_run)
            current_source_id = _run.output("ingress_dataset_source_id")
            if not isinstance(current_source_id, str) or not current_source_id:
                return
            try:
                source_services.stop_source(current_source_id)
            finally:
                _run.attach_output("ingress_dataset_source_id", None)

        run._close_ingress_fn = _close_dataset_ingress

    def _resolve_topic_ingress_task(
        self,
        *,
        default_task: FlowTask | None,
        output: Any,
    ) -> FlowTask | None:
        """
        Prefer runtime output wrapper task when available.

        For topic ingress + runtime-managed egress adapters, subscribing source
        events directly to the raw task drops return payloads from output
        adapters. Wrapping ingress through `output_task` preserves return path
        (`...pipe(target).map(feed)`), matching manual_push write semantics.
        """
        output_task = getattr(output, "output_task", None)
        compile_fn = getattr(output_task, "compile", None)
        if callable(compile_fn):
            compiled = compile_fn()
            if isinstance(compiled, FlowTask):
                return compiled
        return default_task

    def _build_manual_done_request_emitter(
        self,
        task: FlowTask,
    ) -> Callable[[FlowRunRef, tuple[str, ...]], Any] | None:
        routers = self._select_done_target_routers(task)
        if not routers:
            return None

        def _emit_done_for_requests(run: FlowRunRef, request_ids: tuple[str, ...]):
            if not request_ids:
                return
            from flownet.runtime.kernel import get_runtime_kernel

            kernel = get_runtime_kernel()
            local_address = kernel.local_address()
            prefix_transformation = self._resolve_output_prefix_transformation(run)
            ack_route = self._run_ack_route(run)
            done_requests: list[FlowRequest] = []
            for request_id in request_ids:
                if run.request_pending(request_id) is None:
                    continue
                source_final_seq = run.request_source_final_seq(request_id)
                request_done_requests: list[FlowRequest] = []
                for router in routers:
                    num_partitions = self._router_num_partitions(router)
                    for partition_id in range(num_partitions):
                        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
                        frames: list[TransformationFrame] = []
                        if prefix_transformation is not None:
                            frames.append(
                                TransformationFrame(
                                    prefix_transformation,
                                    PayloadData(args=[None], kwargs={}),
                                    meta=None,
                                )
                            )
                        frames.append(
                            TransformationFrame(
                                router,
                                payload,
                                meta={"next_meta": done_next_meta},
                            )
                        )
                        request_done_requests.append(
                            FlowRequest(
                                flow_stack=FlowStack(frames=frames),
                                request_id=request_id,
                                origin_address=local_address,
                                ack_route=ack_route,
                            )
                        )
                if request_done_requests:
                    # Synthetic done events are additional request roots and must
                    # participate in request-delta accounting.
                    run.start_request(request_id, initial_delta=len(request_done_requests))
                    done_requests.extend(request_done_requests)
            if not done_requests:
                return

            async def _emit_done() -> None:
                # Let already-queued data requests run first on the runtime loop.
                await asyncio.sleep(0)
                flows = kernel.require_flows()
                for request in done_requests:
                    await flows.handle(request)

            kernel.require_tasks().submit_runtime_coro(_emit_done())

        return _emit_done_for_requests

    @staticmethod
    def _select_done_target_routers(flow_task: FlowTask) -> list[Any]:
        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)
        return ingress_routers or routers

    @staticmethod
    def _router_num_partitions(router: Any) -> int:
        shuffle_meta = (getattr(router, "operator_config", None) or {}).get("shuffle") or {}
        try:
            num_partitions = int(shuffle_meta.get("num_partitions", 1))
        except (TypeError, ValueError):
            num_partitions = 1
        return max(1, num_partitions)

    @staticmethod
    def _resolve_output_prefix_transformation(run: FlowRunRef) -> Any | None:
        output = run.output("output")
        if output is None:
            return None
        output_task = getattr(output, "output_task", None)
        if output_task is None:
            return None
        compile_fn = getattr(output_task, "compile", None)
        if not callable(compile_fn):
            return None
        try:
            task = compile_fn()
        except Exception:
            return None
        pipeline = getattr(task, "pipeline", None) or []
        for trans in pipeline:
            if getattr(trans, "type", None) == "pipe":
                return trans
        return None

    @staticmethod
    def _resolve_output_ack_route(output: Any) -> FlowAckRoute | None:
        if output is None:
            return None
        resolve_ack_route = getattr(output, "ack_route", None)
        if callable(resolve_ack_route):
            try:
                resolved = coerce_flow_ack_route(resolve_ack_route())
            except Exception:
                resolved = None
            if resolved is not None:
                return resolved
        get_actor_id = getattr(output, "_get_actor_id", None)
        if not callable(get_actor_id):
            return None
        try:
            actor_id = get_actor_id()
        except Exception:
            return None
        return build_flow_ack_route(coordinator_target=actor_id)

    @staticmethod
    def _serialize_topic_ack_route(ack_route: FlowAckRoute | None) -> str | None:
        if ack_route is None:
            return None
        payload: dict[str, str] = {
            "coordinator_target": str(ack_route.coordinator_target),
            # Compatibility key for readers not migrated to coordinator naming.
            "actor_id": str(ack_route.actor_id),
        }
        if ack_route.coordinator_address:
            payload["coordinator_address"] = str(ack_route.coordinator_address)
        try:
            return json.dumps(payload, sort_keys=True, separators=(",", ":"))
        except Exception:
            return None

    def _resolve_ack_route(
        self,
        *,
        run_config: Mapping[str, Any],
        output: Any,
    ) -> FlowAckRoute | None:
        explicit = coerce_flow_ack_route(run_config.get("ack_route"))
        if explicit is not None:
            return explicit
        return self._resolve_output_ack_route(output)

    def _run_ack_route(self, run: FlowRunRef) -> FlowAckRoute | None:
        route = coerce_flow_ack_route(run.output("ack_route"))
        if route is not None:
            return route
        control_output_route = self._resolve_output_ack_route(run.output("control_output"))
        if control_output_route is not None:
            return control_output_route
        return self._resolve_output_ack_route(run.output("output"))


__all__ = ["FlowRunService"]
