"""Reactive configuration parser (actor-first, minimal port).

This is a lightweight adaptation of the v2 parser. It loads YAML/JSON files,
interpolates env vars minimally, and produces ReactiveConfig with RouteConfig
entries (stream/graph). Template rendering is intentionally simplified.
"""

from __future__ import annotations

import logging
import os
import re
from pathlib import Path
from typing import Any

import yaml
from pydantic import BaseModel, Field

from cleveragents.actor.config import _V3_ACTOR_TYPES
from cleveragents.actor.utils import infer_provider_from_model
from cleveragents.core.exceptions import ConfigurationError
from cleveragents.reactive.route import BridgeConfig, RouteConfig, RouteType
from cleveragents.reactive.stream_router import StreamType

logger = logging.getLogger(__name__)


class AgentConfig(BaseModel):
    name: str
    type: str
    config: dict[str, Any] = Field(default_factory=dict)


class LangGraphConfig(BaseModel):
    name: str
    nodes: dict[str, dict[str, Any]] = Field(default_factory=dict)
    edges: list[dict[str, Any]] = Field(default_factory=list)
    entry_point: str = "start"
    checkpointing: bool = False
    checkpoint_dir: str | None = None
    enable_time_travel: bool = False
    parallel_execution: bool = True
    state_class: str | None = None
    metadata: dict[str, Any] = Field(default_factory=dict)
    template_config: dict[str, Any] | None = None


class HybridPipelineConfig(BaseModel):
    name: str
    stages: list[dict[str, Any]] = Field(default_factory=list)
    metadata: dict[str, Any] = Field(default_factory=dict)


class ReactiveConfig(BaseModel):
    agents: dict[str, AgentConfig] = Field(default_factory=dict)
    routes: dict[str, RouteConfig] = Field(default_factory=dict)
    merges: list[dict[str, Any]] = Field(default_factory=list)
    splits: list[dict[str, Any]] = Field(default_factory=list)
    pipelines: dict[str, HybridPipelineConfig] = Field(default_factory=dict)
    templates: dict[str, dict[str, Any]] = Field(default_factory=dict)
    instances: dict[str, dict[str, Any]] = Field(default_factory=dict)
    global_context: dict[str, Any] = Field(default_factory=dict)
    template_engine: str = "JINJA2"
    prompts: dict[str, Any] = Field(default_factory=dict)


def _synthesise_single_node_route(
    rc: ReactiveConfig,
    actor_name: str,
    route_name: str | None = None,
) -> None:
    """Add a minimal graph route with a ``message_router`` + actor to *rc*.

    This is the fix for issue #10807: when a v3 ``type:llm`` or
    ``type:tool`` actor is parsed, or when the nested ``actors:`` map
    format produces agents without routes, a graph route must be created
    so that ``run_single_shot()`` can find it via ``_get_graph_route()``
    and execute the agent through ``GraphExecutor``.

    The synthesised route consists of:
    * A ``message_router`` node (``__router__``) with a single catch-all
      rule that forwards every prompt to the actor node.
    * An ``actor`` node wired to the registered agent.
    * An edge from the actor node to ``end`` so that ``execute()``
      returns the result after the agent processes the prompt.

    Without this route the RxPY ``__input__`` stream receives the prompt
    but has no downstream subscribers, causing the LLM to never be
    invoked and ``run_single_shot()`` to return ``""``.
    """
    name = route_name or f"{actor_name}_run"
    router_node_name = "__router__"
    rc.routes[name] = RouteConfig(
        name=name,
        type=RouteType.GRAPH,
        nodes={
            router_node_name: {
                "type": "message_router",
                "rules": [
                    {
                        "match_type": "prefix",
                        "pattern": "",
                        "target": actor_name,
                    },
                ],
            },
            actor_name: {"type": "actor", "agent": actor_name},
        },
        edges=[{"source": actor_name, "target": "end"}],
        entry_point=router_node_name,
        metadata={"v3_actor": actor_name, "synthesised": True},
    )


class ReactiveConfigParser:
    def __init__(self) -> None:
        self.env_pattern = re.compile(r"\${([A-Za-z0-9_]+)(?::([^}]*))?}")

    def parse_files(self, config_files: list[Path]) -> ReactiveConfig:
        combined: dict[str, Any] = {}
        for file_path in config_files:
            content = Path(file_path).read_text(encoding="utf-8")
            data = yaml.safe_load(content) or {}
            self._merge(combined, data)
        combined = self._interpolate_env(combined)
        return self._build(combined)

    def _merge(self, base: dict[str, Any], new: dict[str, Any]) -> None:
        if new is None:
            return
        for k, v in new.items():
            if k in base and isinstance(base[k], dict) and isinstance(v, dict):
                self._merge(base[k], v)
            elif k in base and isinstance(base[k], list) and isinstance(v, list):
                base[k].extend(v)
            else:
                base[k] = v

    def _interpolate_env(self, value: Any) -> Any:
        if isinstance(value, dict):
            return {k: self._interpolate_env(v) for k, v in value.items()}
        if isinstance(value, list):
            return [self._interpolate_env(v) for v in value]
        if isinstance(value, str):

            def repl(match: re.Match[str]) -> str:
                var = match.group(1)
                default = match.group(2)
                val = os.environ.get(var, default)
                if val is None:
                    raise ConfigurationError(f"Environment variable '{var}' is not set")
                return str(val)

            value = self.env_pattern.sub(repl, value)
        return value

    @staticmethod
    def _is_v3_format(data: dict[str, Any]) -> bool:
        """Return ``True`` when *data* matches the v3 ``ActorConfigSchema``."""
        actor_type = data.get("type")
        return isinstance(actor_type, str) and actor_type.lower() in _V3_ACTOR_TYPES

    def _build(self, data: dict[str, Any]) -> ReactiveConfig:
        rc = ReactiveConfig()

        # v3 Actor YAML: synthesise a reactive agent from the top-level config.
        if ReactiveConfigParser._is_v3_format(data):
            return ReactiveConfigParser._build_from_v3(data, rc)

        agents_data = data.get("agents") or data.get("actors") or {}
        for name, agent_data in (agents_data or {}).items():
            raw_config = dict(agent_data.get("config", {}) or {})
            # Fix #10807-C: translate v3 nested actor config format to
            # reactive format.  The v3 spec stores the model as
            # ``actor: "provider/model"`` inside each actor's ``config:``
            # block.  ``SimpleLLMAgent._resolve_llm()`` expects
            # ``provider`` and ``model`` keys.  When the ``actor`` key is
            # present but ``provider``/``model`` are absent, split it here
            # so the LLM can be instantiated correctly.
            if "actor" in raw_config and not raw_config.get("provider"):
                actor_ref = str(raw_config.pop("actor"))
                if "/" in actor_ref:
                    inferred_provider, inferred_model = actor_ref.split("/", 1)
                    raw_config["provider"] = inferred_provider
                    if "model" not in raw_config:
                        raw_config["model"] = inferred_model
                elif "model" not in raw_config:
                    raw_config["model"] = actor_ref
            rc.agents[name] = AgentConfig(
                name=name,
                type=agent_data.get("type", "llm"),
                config=raw_config,
            )

        routes_cfg = data.get("routes", {}) or {}
        if isinstance(routes_cfg, list):
            routes_cfg = {f"route_{idx}": cfg for idx, cfg in enumerate(routes_cfg)}

        for name, route_data in routes_cfg.items():
            route_type = RouteType(route_data.get("type", "stream"))
            bridge = None
            if route_data.get("bridge"):
                b = route_data["bridge"]
                bridge = BridgeConfig(
                    upgrade_conditions=b.get("upgrade_conditions", {}),
                    downgrade_conditions=b.get("downgrade_conditions", {}),
                    state_extractor=b.get("state_extractor"),
                    state_flattener=b.get("state_flattener"),
                    preserve_subscriptions=b.get("preserve_subscriptions", True),
                    preserve_checkpointing=b.get("preserve_checkpointing", True),
                )
            if route_type == RouteType.STREAM:
                rc.routes[name] = RouteConfig(
                    name=name,
                    type=RouteType.STREAM,
                    stream_type=StreamType(route_data.get("stream_type", "cold")),
                    operators=route_data.get("operators", []),
                    subscriptions=route_data.get("subscriptions", []),
                    publications=route_data.get("publications", []),
                    agents=route_data.get("agents", []),
                    initial_value=route_data.get("initial_value"),
                    buffer_size=route_data.get("buffer_size", 1),
                    template_config=route_data.get("template_config"),
                    bridge=bridge,
                    metadata=route_data.get("metadata", {}),
                )
            else:
                rc.routes[name] = RouteConfig(
                    name=name,
                    type=RouteType.GRAPH,
                    nodes=route_data.get("nodes", {}),
                    edges=route_data.get("edges", []),
                    entry_point=route_data.get("entry_point", "start"),
                    checkpointing=route_data.get("checkpointing", False),
                    checkpoint_dir=route_data.get("checkpoint_dir"),
                    enable_time_travel=route_data.get("enable_time_travel", False),
                    parallel_execution=route_data.get("parallel_execution", True),
                    state_class=route_data.get("state_class"),
                    metadata=route_data.get("metadata", {}),
                    bridge=bridge,
                )

        # Fix #10807-B: when the YAML defines agents (e.g. via the nested
        # ``actors:`` map format from spec-compliant v3 configs) but no
        # ``routes:`` key, synthesise a default single-node graph route
        # using ``cleveragents.default_actor`` (or the first agent).  This
        # ensures run_single_shot() can invoke the LLM.
        if rc.agents and not rc.routes:
            cleveragents_meta = data.get("cleveragents") or {}
            default_actor_name = str(cleveragents_meta.get("default_actor") or "")
            agent_name = (
                default_actor_name
                if default_actor_name and default_actor_name in rc.agents
                else next(iter(rc.agents))
            )
            _synthesise_single_node_route(rc, agent_name)

        rc.merges = data.get("merges", []) or []
        rc.splits = data.get("splits", []) or []
        rc.pipelines = {
            name: HybridPipelineConfig(
                name=name,
                stages=cfg.get("stages", []),
                metadata=cfg.get("metadata", {}),
            )
            for name, cfg in (data.get("pipelines", {}) or {}).items()
        }
        rc.templates = data.get("templates", {}) or {}
        rc.instances = data.get("instances", {}) or {}
        rc.global_context = data.get("global_context", {}) or {}
        rc.template_engine = data.get("template_engine", rc.template_engine)
        rc.prompts = data.get("prompts", {}) or {}
        return rc

    @staticmethod
    def _build_from_v3(
        data: dict[str, Any],
        rc: ReactiveConfig,
    ) -> ReactiveConfig:
        """Synthesise a ``ReactiveConfig`` from a v3 ``ActorConfigSchema`` blob.

        For ``type: llm`` actors a single reactive agent is created with the
        model, system prompt, and tool references from the v3 config.  For
        ``type: graph`` actors the route nodes are mapped to reactive agents
        and the edges are mapped to graph routes.  Skills and LSP bindings
        are propagated via the agent config so the runtime can attach them.

        All v3 fields — ``context_view``, ``memory``, ``context``,
        ``env_vars``, ``response_format``, ``lsp_capabilities``,
        ``lsp_context_enrichment``, and ``options`` — are propagated
        into the agent config so the runtime can consume them.
        """
        # m4: guard against None-to-string coercion for all three fields.
        actor_type = str(data.get("type") or "llm").lower()
        actor_name = str(data.get("name") or "v3_actor")
        model = str(data.get("model") or "")

        # m8: validate that model is non-empty for types that require it.
        if not model and actor_type in ("llm", "graph"):
            raise ConfigurationError(
                f"v3 actor '{actor_name}' of type '{actor_type}' requires a "
                f"non-empty 'model' field."
            )

        # m11: Resolve provider per spec resolution order (step 2):
        # check explicit top-level ``provider`` field before falling back
        # to inference from the model string.  This fixes issue #10926
        # where models without a ``/`` separator (e.g. Anthropic's bare
        # model IDs) would otherwise be inferred as ``"custom"``, causing
        # an ``Unknown provider type: custom`` error at runtime.
        explicit_provider = data.get("provider")
        if explicit_provider and isinstance(explicit_provider, str):
            provider = explicit_provider
        else:
            provider = infer_provider_from_model(model)

        # Collect v3 metadata for runtime attachment.
        # m12: validate element types for skills list.
        raw_skills = data.get("skills", []) or []
        skills: list[str] = (
            [str(s) for s in raw_skills] if isinstance(raw_skills, list) else []
        )
        lsp_raw = data.get("lsp")
        lsp_bindings: list[str] | dict[str, Any] | None = None
        if isinstance(lsp_raw, list):
            lsp_bindings = [str(s) for s in lsp_raw]
        elif isinstance(lsp_raw, dict):
            lsp_bindings = dict(lsp_raw)

        agent_config: dict[str, Any] = {
            "provider": provider,
            "model": model,
            "system_prompt": data.get("system_prompt", ""),
        }
        if skills:
            agent_config["skills"] = skills
        if lsp_bindings is not None:
            agent_config["lsp"] = lsp_bindings

        # Attach tool references for tool-bearing actors.
        tools_raw = data.get("tools", []) or []
        if tools_raw:
            agent_config["tools"] = tools_raw

        # M6: propagate context_view.
        context_view = data.get("context_view")
        if context_view is not None:
            agent_config["context_view"] = context_view

        # M7: propagate memory and context configuration objects.
        memory_raw = data.get("memory")
        if isinstance(memory_raw, dict):
            agent_config["memory"] = memory_raw

        context_raw = data.get("context")
        if isinstance(context_raw, dict):
            agent_config["context"] = context_raw

        # M8: propagate lsp_capabilities and lsp_context_enrichment.
        lsp_caps = data.get("lsp_capabilities")
        if lsp_caps is not None:
            agent_config["lsp_capabilities"] = lsp_caps

        lsp_enrichment = data.get("lsp_context_enrichment")
        if isinstance(lsp_enrichment, dict):
            agent_config["lsp_context_enrichment"] = lsp_enrichment

        # m2: propagate env_vars.
        env_vars = data.get("env_vars")
        if isinstance(env_vars, dict):
            agent_config["env_vars"] = env_vars

        # m3: propagate response_format.
        response_format = data.get("response_format")
        if isinstance(response_format, dict):
            agent_config["response_format"] = response_format

        # M5: propagate actor options block for custom backend support (#11223).
        options_raw = data.get("options")
        if isinstance(options_raw, dict):
            agent_config["options"] = dict(options_raw)

        if actor_type in ("llm", "tool"):
            # Single-agent synthesis.
            rc.agents[actor_name] = AgentConfig(
                name=actor_name,
                type=actor_type,
                config=agent_config,
            )
            # Fix #10807-A: synthesise a single-node graph route so that
            # run_single_shot() can find a graph route and invoke the LLM
            # instead of falling through to the RxPY stream path (which
            # has no subscribers and silently returns "").
            _synthesise_single_node_route(rc, actor_name)
        elif actor_type == "graph":
            # Map route nodes → agents and edges → graph routes.
            route_raw = data.get("route")
            if not isinstance(route_raw, dict):
                # m7: graph actor without route is a configuration error.
                raise ConfigurationError(
                    f"v3 graph actor '{actor_name}' requires a 'route' "
                    f"mapping but none was provided."
                )

            nodes_list = route_raw.get("nodes") or []
            edges_list = route_raw.get("edges") or []
            entry_node = route_raw.get("entry_node") or "start"

            nodes_map: dict[str, dict[str, Any]] = {}
            for node in nodes_list:
                if not isinstance(node, dict):
                    continue
                node_id = str(node.get("id") or "")
                if not node_id:
                    continue
                # C3: guard against config: null producing TypeError.
                raw_config = node.get("config")
                node_config = dict(raw_config) if isinstance(raw_config, dict) else {}
                node_config.setdefault("model", model)
                node_config.setdefault("provider", provider)
                # M9: copy skills list to avoid shared mutable reference.
                if skills:
                    node_config.setdefault("skills", list(skills))
                # M5: propagate LSP bindings to graph nodes (defensive copy).
                if lsp_bindings is not None:
                    lsp_copy = (
                        list(lsp_bindings)
                        if isinstance(lsp_bindings, list)
                        else dict(lsp_bindings)
                    )
                    node_config.setdefault("lsp", lsp_copy)
                # M5: propagate actor-level options to graph nodes (#11223).
                if isinstance(options_raw, dict) and options_raw:
                    node_config.setdefault("options", dict(options_raw))
                nodes_map[node_id] = node_config

                # Create a reactive agent for each graph node.
                rc.agents[node_id] = AgentConfig(
                    name=node_id,
                    type=str(node.get("type") or "agent"),
                    config=node_config,
                )

            # m5: validate entry_node against nodes_map.
            if entry_node not in nodes_map:
                raise ConfigurationError(
                    f"v3 graph actor '{actor_name}': entry_node "
                    f"'{entry_node}' not found in route nodes "
                    f"{sorted(nodes_map.keys())}."
                )

            # C1: use "source"/"target" keys matching RouteConfig.to_graph_config().
            edge_entries: list[dict[str, Any]] = []
            for edge in edges_list:
                if not isinstance(edge, dict):
                    logger.warning(
                        "v3 graph actor %s: skipping non-dict edge entry: %r",
                        actor_name,
                        edge,
                    )
                    continue
                from_node = edge.get("from_node", "")
                to_node = edge.get("to_node", "")
                # m6: skip edges with empty source or target.
                if not from_node or not to_node:
                    logger.warning(
                        "v3 graph actor %s: skipping edge with missing "
                        "from_node=%r or to_node=%r",
                        actor_name,
                        from_node,
                        to_node,
                    )
                    continue
                edge_entries.append(
                    {
                        "source": from_node,
                        "target": to_node,
                        "condition": edge.get("condition"),
                    }
                )

            # m1: propagate exit_nodes into route metadata.
            exit_nodes = route_raw.get("exit_nodes", [])
            route_metadata: dict[str, Any] = {
                "v3_actor": actor_name,
                "skills": skills,
            }
            if exit_nodes:
                route_metadata["exit_nodes"] = exit_nodes

            rc.routes[f"{actor_name}_graph"] = RouteConfig(
                name=f"{actor_name}_graph",
                type=RouteType.GRAPH,
                nodes=nodes_map,
                edges=edge_entries,
                entry_point=entry_node,
                metadata=route_metadata,
            )

        return rc
