# DOCS_REF:
# - docs/user/01-quickstart-local.md
# - docs/user/02-quickstart-distributed.md
# - docs/user/05-datastream-distributed-ops.md
# - docs/user/reference/api-surface.md
# DOCS_SYNC:
# - If runtime facade signatures/behavior change, update the referenced docs
#   and examples/docs/{quickstart_local_example.py,quickstart_distributed_example.py}.

from __future__ import annotations

import asyncio
import os
import queue
import re
import warnings
from typing import Any

from flownet.core.flow_program import FlowProgramRef, normalize_flow_uri
from flownet.runtime.contexts import system as context
from flownet.runtime.runtime import get_runtime


_DEFAULT_NODE_PORT = 8787
_DEFAULT_NODE_ADDRESS = "127.0.0.1:8787"
_NODE_ADDR_ENV = "FLOWNET_NODE_ADDR"
_FLOW_URI_SEGMENT_RE = re.compile(r"[^a-zA-Z0-9._-]+")


def _ensure_runtime():
    get_runtime()


def _normalize_node_address(address: str | None) -> str:
    normalized = str(address or "").strip()
    if not normalized:
        raise ValueError("Target node address must not be empty.")
    if ":" in normalized:
        return normalized
    return f"{normalized}:{_DEFAULT_NODE_PORT}"


def _default_node_address() -> str:
    return _normalize_node_address(os.environ.get(_NODE_ADDR_ENV) or _DEFAULT_NODE_ADDRESS)


def _local_runtime_schedulable() -> bool:
    runtime = get_runtime()
    view = getattr(runtime, "cluster_view", None)
    if view is None:
        return False
    local_node_id = getattr(view, "local_node_id", None)
    is_node_schedulable = getattr(view, "is_node_schedulable", None)
    if not callable(local_node_id) or not callable(is_node_schedulable):
        return False
    try:
        return bool(is_node_schedulable(local_node_id()))
    except Exception:
        return False


def _materialize_flow_registration_payload(flow_def_or_task):
    obj_type = type(flow_def_or_task)
    if obj_type.__module__ == "flownet.compiler.flow_def" and obj_type.__name__ in {
        "FlowDef",
        "BoundFlow",
    }:
        compile_fn = getattr(flow_def_or_task, "compile", None)
        if callable(compile_fn):
            return compile_fn()
    return flow_def_or_task


def _is_flow_program_conflict(exc: Exception) -> bool:
    message = str(exc).lower()
    return "already exists" in message


def _ensure_local_flow_program_shadow(
    payload: Any,
    *,
    flow_uri: str,
    version: str,
    metadata: dict[str, Any] | None,
    io_contract: dict[str, Any] | None,
) -> None:
    try:
        context.register_flow_program(
            payload,
            flow_uri=flow_uri,
            version=version,
            metadata=metadata,
            io_contract=io_contract,
        )
    except Exception as exc:
        if _is_flow_program_conflict(exc):
            return
        warnings.warn(
            f"Local flow-program shadow registration failed for {flow_uri}@{version}: {exc}",
            RuntimeWarning,
            stacklevel=2,
        )


def _sanitize_flow_uri_segment(value: str) -> str:
    normalized = _FLOW_URI_SEGMENT_RE.sub("-", str(value or "").strip()).strip("-.")
    if not normalized:
        raise ValueError("Flow URI segment must not be empty after normalization.")
    return normalized


def _normalize_namespace(namespace: str | None) -> str:
    raw = str(namespace or "root").strip().strip("/")
    if not raw:
        return "root"
    return "/".join(
        _sanitize_flow_uri_segment(part)
        for part in raw.split("/")
        if part.strip()
    )


def _infer_flow_program_name(flow_def_or_task: Any) -> str | None:
    flowfunction = getattr(flow_def_or_task, "flowfunction", None)
    if flowfunction is not None:
        qualname = getattr(flowfunction, "__qualname__", None) or getattr(flowfunction, "__name__", None)
        if qualname:
            return str(qualname)
    name = getattr(flow_def_or_task, "__name__", None)
    if name:
        return str(name)
    return None


def _resolve_flow_uri(
    flow_def_or_task: Any,
    *,
    flow_uri: str | None,
    name: str | None,
    namespace: str | None,
) -> str:
    if flow_uri is not None:
        return normalize_flow_uri(flow_uri)

    inferred_name = str(name or _infer_flow_program_name(flow_def_or_task) or "").strip()
    if not inferred_name:
        raise ValueError(
            "flow_uri is required when flow name cannot be inferred. "
            "Pass flow_uri='flow://.../programs/<name>' or name='<name>'."
        )
    normalized_namespace = _normalize_namespace(namespace)
    normalized_name = _sanitize_flow_uri_segment(inferred_name)
    return f"flow://{normalized_namespace}/programs/{normalized_name}"


def _normalize_flow_run_read_item(item: Any) -> dict[str, Any] | None:
    if item is None:
        return None
    if isinstance(item, dict) and "kind" in item:
        return dict(item)

    item_type_name = item.__class__.__name__
    request_id = getattr(item, "request_id", None)
    request_ref_id = getattr(item, "request_ref_id", None) or request_id

    if item_type_name == "RunOutputDone":
        result = {
            "kind": "done",
            "request_id": request_id,
            "request_ref_id": request_ref_id,
            "final_seq": getattr(item, "final_seq", None),
            "reason": getattr(item, "reason", None),
        }
        expected_total_events = getattr(item, "expected_total_events", None)
        if expected_total_events is not None:
            result["expected_total_events"] = expected_total_events
        request_done = getattr(item, "request_done", None)
        if request_done is not None:
            result["request_done"] = bool(request_done)
        status = getattr(item, "status", None)
        if isinstance(status, str) and status.strip():
            result["status"] = status.strip()
        subscriber_done = getattr(item, "subscriber_done", None)
        if subscriber_done is not None:
            result["subscriber_done"] = bool(subscriber_done)
        return result
    if item_type_name == "RunOutputError":
        return {
            "kind": "error",
            "request_id": request_id,
            "request_ref_id": request_ref_id,
            "error_type": getattr(item, "error_type", None),
            "message": getattr(item, "message", None),
            "traceback_text": getattr(item, "traceback_text", None),
            "event": getattr(item, "event", None),
        }
    if item_type_name == "RunOutputGap":
        return {
            "kind": "gap",
            "request_id": request_id,
            "request_ref_id": request_ref_id,
            "seq": getattr(item, "seq", None),
            "reason": getattr(item, "reason", None),
        }
    return {"kind": "data", "value": item}


def lookup_actor(actor_id: str):
    _ensure_runtime()
    return context.lookup_actor(actor_id)


def create_actor(actor_class: type, *args, actor_config: Any | None = None, **kwargs):
    _ensure_runtime()
    # Deployment default:
    # - If current runtime is schedulable compute, deploy on current node runtime.
    # - If current runtime is io-only/attach-only, deploy to attached node address.
    # TODO(flownet-placement): replace this binary local-vs-attached-node branch
    # with scheduler placement that consumes ClusterView snapshots and policy
    # hints (resource locality/capacity) before selecting a target runtime.
    if _local_runtime_schedulable():
        return context.create_actor(actor_class, *args, actor_config=actor_config, **kwargs)
    return context.create_actor_on(
        _default_node_address(),
        actor_class,
        *args,
        actor_config=actor_config,
        **kwargs,
    )


def create_local_actor(actor_class: type, *args, actor_config: Any | None = None, **kwargs):
    _ensure_runtime()
    # Explicit local-creation escape hatch. Keep this disabled for io-only user
    # runtimes so host-local compute does not silently bypass placement policy.
    if not _local_runtime_schedulable():
        raise RuntimeError(
            "create_local_actor() requires a schedulable compute runtime host. "
            "Current runtime is io-only/attach-only."
        )
    return context.create_local_actor(actor_class, *args, actor_config=actor_config, **kwargs)


def local_address() -> str:
    _ensure_runtime()
    return context.local_address()


def cluster_view():
    _ensure_runtime()
    return context.cluster_view()

def resolve_plan(
    *,
    stage_id: str,
    request_id: str,
    num_partitions: int,
    affinity_hint: str | None = None,
    addresses=None,
    merge_addresses=None,
    merge_partitions=None,
    include_merge_address: bool = True,
    merge_sharded: bool = False,
    epoch: int = 1,
    plan_id: str | None = None,
    require_healthy: bool = True,
    locality_order=("local", "zone", "region", "remote"),
    resource_requirements=None,
    allow_resource_fallback: bool = True,
    lease_owner_kind: str | None = None,
    lease_owner_id: str | None = None,
    reservation_timeout_ms: int | None = None,
    autoscale_min_workers: int | None = None,
    autoscale_max_workers: int | None = None,
    autoscale_queue_depth: int | None = None,
    autoscale_target_queue_per_worker: int | None = None,
    autoscale_observed_latency_ms: float | None = None,
    autoscale_latency_budget_ms: float | None = None,
    autoscale_observed_throughput_rps: float | None = None,
    autoscale_target_throughput_rps: float | None = None,
    autoscale_cooldown_ms: int | None = None,
    spillover_stage1_tiers=("local", "zone"),
    spillover_stage2_tiers=("region",),
    spillover_allow_remote: bool = True,
    spillover_max_remote_nodes: int | None = None,
    spillover_ttl_ms: int | None = None,
    spillover_claim_id: str | None = None,
):
    _ensure_runtime()
    return context.resolve_plan(
        stage_id=stage_id,
        request_id=request_id,
        num_partitions=num_partitions,
        affinity_hint=affinity_hint,
        addresses=addresses,
        merge_addresses=merge_addresses,
        merge_partitions=merge_partitions,
        include_merge_address=include_merge_address,
        merge_sharded=merge_sharded,
        epoch=epoch,
        plan_id=plan_id,
        require_healthy=require_healthy,
        locality_order=locality_order,
        resource_requirements=resource_requirements,
        allow_resource_fallback=allow_resource_fallback,
        lease_owner_kind=lease_owner_kind,
        lease_owner_id=lease_owner_id,
        reservation_timeout_ms=reservation_timeout_ms,
        autoscale_min_workers=autoscale_min_workers,
        autoscale_max_workers=autoscale_max_workers,
        autoscale_queue_depth=autoscale_queue_depth,
        autoscale_target_queue_per_worker=autoscale_target_queue_per_worker,
        autoscale_observed_latency_ms=autoscale_observed_latency_ms,
        autoscale_latency_budget_ms=autoscale_latency_budget_ms,
        autoscale_observed_throughput_rps=autoscale_observed_throughput_rps,
        autoscale_target_throughput_rps=autoscale_target_throughput_rps,
        autoscale_cooldown_ms=autoscale_cooldown_ms,
        spillover_stage1_tiers=spillover_stage1_tiers,
        spillover_stage2_tiers=spillover_stage2_tiers,
        spillover_allow_remote=spillover_allow_remote,
        spillover_max_remote_nodes=spillover_max_remote_nodes,
        spillover_ttl_ms=spillover_ttl_ms,
        spillover_claim_id=spillover_claim_id,
    )


def cluster_scheduler_metrics():
    _ensure_runtime()
    return context.cluster_scheduler_metrics()


def reset_cluster_scheduler_metrics():
    _ensure_runtime()
    return context.reset_cluster_scheduler_metrics()


def node_control_call(op: str, *args, node_address: str | None = None, **kwargs):
    _ensure_runtime()
    if node_address is not None:
        return context.node_control_call_on(
            _normalize_node_address(node_address),
            op,
            *args,
            **kwargs,
        )
    if _local_runtime_schedulable():
        return context.node_control_call(op, *args, **kwargs)
    return context.node_control_call_on(
        _default_node_address(),
        op,
        *args,
        **kwargs,
    )


def node_control_call_on(node_address: str, op: str, *args, **kwargs):
    _ensure_runtime()
    return context.node_control_call_on(
        _normalize_node_address(node_address),
        op,
        *args,
        **kwargs,
    )


def cluster_view_snapshot(*, schema: str = "v1", node_address: str | None = None):
    return node_control_call(
        "cluster_view_snapshot",
        schema=schema,
        node_address=node_address,
    )


def cluster_gossip_stats(*, node_address: str | None = None):
    return node_control_call(
        "cluster_gossip_stats",
        node_address=node_address,
    )


def list_runtime_topics(*, node_address: str | None = None):
    return node_control_call("list_runtime_topics", node_address=node_address)


def list_runtime_actors(*, node_address: str | None = None):
    return node_control_call("list_runtime_actors", node_address=node_address)


def list_runtime_nodes(*, node_address: str | None = None):
    return node_control_call("list_runtime_nodes", node_address=node_address)


def handle_flow_request(request):
    _ensure_runtime()
    return context.handle_flow_request(request).result()


def submit_flow(flow_def_or_task, *, ingress=None, egress=None, run_config=None):
    # ARCH_NOTE:
    # - submit_flow() returns/registers a FlowRunRef keyed by run_id.
    # - This is a run-scoped index, not a durable global flow-template catalog.
    # - lookup_flow_run(run_id) reads that runtime run registry.
    _ensure_runtime()
    return context.submit_flow(
        flow_def_or_task,
        ingress=ingress,
        egress=egress,
        run_config=run_config,
    )


def lookup_flow_run(run_id: str):
    _ensure_runtime()
    normalized_run_id = str(run_id or "").strip()
    if not normalized_run_id:
        raise ValueError("run_id must be a non-empty string.")
    if _local_runtime_schedulable():
        return context.lookup_flow_run(normalized_run_id)
    return node_control_call(
        "lookup_flow_run_descriptor",
        normalized_run_id,
    )


def lookup_flow_run_descriptor(run_id: str, *, node_address: str | None = None):
    normalized_run_id = str(run_id or "").strip()
    if not normalized_run_id:
        raise ValueError("run_id must be a non-empty string.")
    if node_address is not None:
        return node_control_call(
            "lookup_flow_run_descriptor",
            normalized_run_id,
            node_address=node_address,
        )
    return lookup_flow_run(normalized_run_id)


def flow_program_ref(flow_uri: str, version: str) -> FlowProgramRef:
    return FlowProgramRef(flow_uri=flow_uri, version=version)


def register_flow_program(
    flow_def_or_task,
    *,
    flow_uri: str | None = None,
    version: str = "v1",
    name: str | None = None,
    namespace: str = "root",
    metadata: dict[str, Any] | None = None,
    io_contract: dict[str, Any] | None = None,
):
    _ensure_runtime()
    resolved_flow_uri = _resolve_flow_uri(
        flow_def_or_task,
        flow_uri=flow_uri,
        name=name,
        namespace=namespace,
    )
    if _local_runtime_schedulable():
        return context.register_flow_program(
            flow_def_or_task,
            flow_uri=resolved_flow_uri,
            version=version,
            metadata=metadata,
            io_contract=io_contract,
        )
    payload = _materialize_flow_registration_payload(flow_def_or_task)
    remote_error: Exception | None = None
    remote_result: Any = None
    try:
        remote_result = context.register_flow_program_on(
            _default_node_address(),
            payload,
            flow_uri=resolved_flow_uri,
            version=version,
            metadata=metadata,
            io_contract=io_contract,
        )
    except Exception as exc:
        remote_error = exc
        if not _is_flow_program_conflict(exc):
            raise

    # io-only runtimes still execute local FlowDef call paths; keep a local
    # catalog shadow so FlowProgramRef composition resolves in user-runtime
    # call/open flows without requiring schedulable local placement.
    _ensure_local_flow_program_shadow(
        payload,
        flow_uri=resolved_flow_uri,
        version=version,
        metadata=metadata,
        io_contract=io_contract,
    )

    if remote_error is not None:
        raise remote_error
    return remote_result


def list_flow_programs(
    *,
    prefix: str | None = None,
    tags: dict[str, Any] | None = None,
    limit: int | None = None,
):
    _ensure_runtime()
    if _local_runtime_schedulable():
        return context.list_flow_programs(prefix=prefix, tags=tags, limit=limit)
    return context.list_flow_programs_on(
        _default_node_address(),
        prefix=prefix,
        tags=tags,
        limit=limit,
    )


def get_flow_program(flow_uri: str, version: str | None = None):
    _ensure_runtime()
    if _local_runtime_schedulable():
        return context.get_flow_program(flow_uri, version=version)
    return context.get_flow_program_on(
        _default_node_address(),
        flow_uri,
        version=version,
    )


def delete_flow_program(flow_uri: str, version: str | None = None):
    _ensure_runtime()
    if _local_runtime_schedulable():
        return context.delete_flow_program(flow_uri, version=version)
    return context.delete_flow_program_on(
        _default_node_address(),
        flow_uri,
        version=version,
    )


def submit_flow_program(
    flow_uri: str,
    version: str | None = None,
    *,
    bindings: dict[str, Any] | None = None,
    ingress=None,
    egress=None,
    run_config: dict[str, Any] | None = None,
):
    _ensure_runtime()
    if _local_runtime_schedulable():
        return context.submit_flow_program(
            flow_uri,
            version=version,
            bindings=bindings,
            ingress=ingress,
            egress=egress,
            run_config=run_config,
        )
    # io-only runtime path:
    # submit on attached node and return a serializable acceptance descriptor
    # instead of a local FlowRunRef object.
    return node_control_call(
        "submit_flow_program_accept",
        flow_uri,
        version=version,
        bindings=bindings,
        ingress=ingress,
        egress=egress,
        run_config=run_config,
    )


def cancel_flow_run(run_id: str, *, node_address: str | None = None) -> bool:
    normalized_run_id = str(run_id or "").strip()
    if not normalized_run_id:
        raise ValueError("run_id must be a non-empty string.")
    if node_address is not None:
        return bool(
            node_control_call(
                "cancel_flow_run",
                normalized_run_id,
                node_address=node_address,
            )
        )
    _ensure_runtime()
    if _local_runtime_schedulable():
        run = context.lookup_flow_run(normalized_run_id)
        if run is None:
            return False
        cancel_fn = getattr(run, "cancel", None)
        if not callable(cancel_fn):
            raise RuntimeError("flow_run_cancel_not_supported")
        return bool(cancel_fn())
    return bool(node_control_call("cancel_flow_run", normalized_run_id))


def flow_run_request_delivery_status(
    run_id: str,
    request_id: str,
    *,
    node_address: str | None = None,
):
    normalized_run_id = str(run_id or "").strip()
    if not normalized_run_id:
        raise ValueError("run_id must be a non-empty string.")
    normalized_request_id = str(request_id or "").strip()
    if not normalized_request_id:
        raise ValueError("request_id must be a non-empty string.")

    if node_address is not None:
        status = node_control_call(
            "flow_run_request_delivery_status",
            normalized_run_id,
            normalized_request_id,
            node_address=node_address,
        )
    else:
        _ensure_runtime()
        if _local_runtime_schedulable():
            run = context.lookup_flow_run(normalized_run_id)
            if run is None:
                return None
            status_fn = getattr(run, "request_delivery_status", None)
            if not callable(status_fn):
                raise RuntimeError("flow_run_request_delivery_status_not_supported")
            status = status_fn(normalized_request_id)
        else:
            status = node_control_call(
                "flow_run_request_delivery_status",
                normalized_run_id,
                normalized_request_id,
            )
    if status is None:
        return None
    if not isinstance(status, dict):
        return status
    normalized = dict(status)
    request_ref_id = str(
        normalized.get("request_ref_id")
        or normalized.get("request_id")
        or normalized_request_id
    )
    normalized.setdefault("request_id", request_ref_id)
    normalized.setdefault("request_ref_id", request_ref_id)
    return normalized


def flow_run_read(
    run_id: str,
    *,
    request_id: str | None = None,
    timeout: float = 5.0,
    from_seq: int | None = None,
    node_address: str | None = None,
):
    normalized_run_id = str(run_id or "").strip()
    if not normalized_run_id:
        raise ValueError("run_id must be a non-empty string.")
    normalized_request_id: str | None = None
    if request_id is not None:
        normalized_request_id = str(request_id).strip()
        if not normalized_request_id:
            raise ValueError("request_id must be a non-empty string when provided.")

    if node_address is not None:
        item = node_control_call(
            "flow_run_read",
            normalized_run_id,
            request_id=normalized_request_id,
            timeout=float(timeout),
            from_seq=from_seq,
            node_address=node_address,
        )
        return _normalize_flow_run_read_item(item)

    _ensure_runtime()
    if _local_runtime_schedulable():
        run = context.lookup_flow_run(normalized_run_id)
        if run is None:
            return None
        read_fn = getattr(run, "read", None)
        if not callable(read_fn):
            raise RuntimeError("flow_run_read_not_supported")
        try:
            if from_seq is None:
                item = read_fn(
                    request_id=normalized_request_id,
                    timeout=float(timeout),
                )
            else:
                item = read_fn(
                    request_id=normalized_request_id,
                    timeout=float(timeout),
                    from_seq=from_seq,
                )
        except TypeError:
            item = read_fn(
                request_id=normalized_request_id,
                timeout=float(timeout),
            )
        except queue.Empty:
            return None
        return _normalize_flow_run_read_item(item)

    item = node_control_call(
        "flow_run_read",
        normalized_run_id,
        request_id=normalized_request_id,
        timeout=float(timeout),
        from_seq=from_seq,
    )
    return _normalize_flow_run_read_item(item)


def stream_tracker_stats(*, node_address: str | None = None):
    if node_address is not None:
        return node_control_call("stream_tracker_stats", node_address=node_address)
    _ensure_runtime()
    return context.stream_tracker_stats()


def stream_tracker_request_status(request_id: str, *, node_address: str | None = None):
    _ensure_runtime()
    if node_address is not None:
        status = node_control_call(
            "stream_tracker_request_status",
            request_id,
            node_address=node_address,
        )
    else:
        status = context.stream_tracker_request_status(request_id)
    if not isinstance(status, dict):
        return status
    normalized = dict(status)
    request_ref_id = str(
        normalized.get("request_ref_id")
        or normalized.get("request_id")
        or request_id
    )
    normalized.setdefault("request_id", request_ref_id)
    normalized.setdefault("request_ref_id", request_ref_id)
    return normalized


def handle_actor_call(rmf, *args, **kwargs) -> Any:
    _ensure_runtime()
    return context.handle_actor_call(rmf, *args, **kwargs)


def make_object_actor(actor_object: Any, actor_config: Any | None = None):
    _ensure_runtime()
    return context.make_object_actor(actor_object, actor_config=actor_config)


def sync_wait_asyncio_future(fut: asyncio.Future):
    _ensure_runtime()
    return context.sync_wait_asyncio_future(fut)


def get_runtime_loop():
    _ensure_runtime()
    return context.get_runtime_loop()


def submit_runtime_coro(coro):
    _ensure_runtime()
    return context.submit_runtime_coro(coro)


__all__ = [
    "lookup_actor",
    "create_actor",
    "create_local_actor",
    "local_address",
    "cluster_view",
    "resolve_plan",
    "cluster_scheduler_metrics",
    "reset_cluster_scheduler_metrics",
    "cluster_view_snapshot",
    "cluster_gossip_stats",
    "node_control_call",
    "node_control_call_on",
    "list_runtime_topics",
    "list_runtime_actors",
    "list_runtime_nodes",
    "handle_flow_request",
    "submit_flow",
    "lookup_flow_run",
    "lookup_flow_run_descriptor",
    "flow_program_ref",
    "register_flow_program",
    "list_flow_programs",
    "get_flow_program",
    "delete_flow_program",
    "submit_flow_program",
    "cancel_flow_run",
    "flow_run_request_delivery_status",
    "flow_run_read",
    "stream_tracker_stats",
    "stream_tracker_request_status",
    "handle_actor_call",
    "make_object_actor",
    "sync_wait_asyncio_future",
    "get_runtime_loop",
    "submit_runtime_coro",
]
