"""Reactive executor ported from v2 ReactiveCleverAgentsApp (actor-first)."""

from __future__ import annotations

import asyncio
import logging
import re
from collections.abc import Callable
from pathlib import Path
from typing import Any

from rx.scheduler.eventloop import AsyncIOScheduler  # type: ignore[attr-defined]

from cleveragents.application.container import get_container
from cleveragents.core.exceptions import CleverAgentsException, UnsafeConfigurationError
from cleveragents.reactive.config_parser import ReactiveConfig, ReactiveConfigParser
from cleveragents.reactive.context_manager import ContextManager
from cleveragents.reactive.graph_executor import GraphExecutor
from cleveragents.reactive.route import RouteConfig, RouteType
from cleveragents.reactive.route_bridge import RouteBridge
from cleveragents.reactive.stream_router import (
    ReactiveStreamRouter,
    SimpleLLMAgent,
    SimpleToolAgent,
)

# Skill name format: namespace/short-name, ASCII alphanumeric + ._- only.
_SKILL_NAME_RE = re.compile(r"^[\w.-]{1,127}/[\w.-]{1,127}$", re.ASCII)
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")

logger = logging.getLogger(__name__)


class ReactiveCleverAgentsApp:
    """Minimal reactive executor supporting run_single_shot (actor-first)."""

    def __init__(
        self,
        config_files: list[Path] | None = None,
        verbose: int = 0,
        unsafe: bool = False,
        temperature_override: float | None = None,
        skill_names: list[str] | None = None,
    ):
        self.verbose = verbose
        self.unsafe = unsafe
        self.temperature_override = temperature_override
        self._skill_names: list[str] = list(
            dict.fromkeys(self._sanitize_skill_name(n) for n in (skill_names or []))
        )
        self._resolved_skill_tools: list[dict[str, Any]] = []

        self._configure_logging(verbose)
        self.stream_router = ReactiveStreamRouter()
        self.config_parser = ReactiveConfigParser()
        self.langgraph_bridge = None
        self.route_bridge = None
        self.config: ReactiveConfig | None = None

        if self._skill_names:
            self._resolve_skills()

        if config_files:
            self.load_configuration(config_files)
            if self.temperature_override is not None and self.config:
                self.config.global_context["_temperature_override"] = (
                    self.temperature_override
                )
                logger.info(
                    "Applied temperature override: %s", self.temperature_override
                )
            self._enforce_unsafe_flag()

    @property
    def skill_names(self) -> list[str]:
        """Return the list of skill names attached to this run."""
        return list(self._skill_names)

    @property
    def resolved_skill_tools(self) -> list[dict[str, Any]]:
        """Return the resolved skill tool configurations."""
        return list(self._resolved_skill_tools)

    @staticmethod
    def _sanitize_skill_name(name: str) -> str:
        """Validate format and strip control characters from a skill name.

        Raises:
            CleverAgentsException: If the name does not match the expected
                ``namespace/short-name`` pattern or exceeds 256 characters.
        """
        sanitized = _CONTROL_CHAR_RE.sub("", name)
        if not _SKILL_NAME_RE.match(sanitized):
            raise CleverAgentsException(
                f"Invalid skill name format: '{sanitized}'. "
                "Expected namespace/name (ASCII alphanumeric, ._- only, "
                "each segment max 127 chars)."
            )
        return sanitized

    def _resolve_skills(self) -> None:
        """Resolve skill names to tool entries via the DI-provided SkillService.

        Obtains the ``SkillService`` singleton from the application
        container, eliminating the previous cross-layer import from the
        CLI package and the TOCTOU between CLI validation and runtime
        resolution.

        Raises:
            CleverAgentsException: If any skill is not found or invalid.
        """
        service = get_container().skill_service()
        for name in self._skill_names:
            try:
                _skill, entries = service.resolve_tools(name)
                if not entries:
                    logger.warning("Skill '%s' resolved to zero tools", name)
                for entry in entries:
                    tool_dict: dict[str, Any] = {
                        "name": entry.name,
                        "source_skill": entry.source_skill,
                        "is_inline": entry.is_inline,
                        "operation": "identity",
                        "skill_source": name,
                    }
                    if entry.overrides:
                        tool_dict["overrides"] = dict(entry.overrides)
                    self._resolved_skill_tools.append(tool_dict)
            except KeyError as exc:
                raise CleverAgentsException(
                    f"Skill '{name}' not found in registry"
                ) from exc
            except ValueError as exc:
                raise CleverAgentsException(
                    f"Skill '{name}' resolution failed: {exc}"
                ) from exc
        if self._resolved_skill_tools:
            logger.info(
                "Resolved %d tools from %d skill(s)",
                len(self._resolved_skill_tools),
                len(self._skill_names),
            )

    def _configure_logging(self, verbose: int) -> None:
        if verbose == 0:
            level = logging.CRITICAL
        elif verbose == 1:
            level = logging.ERROR
        elif verbose == 2:
            level = logging.WARNING
        elif verbose == 3:
            level = logging.INFO
        else:
            level = logging.DEBUG
        root_logger = logging.getLogger()
        root_logger.setLevel(level)
        if not root_logger.handlers:
            handler = logging.StreamHandler()
            handler.setFormatter(logging.Formatter("[%(name)s] %(message)s"))
            root_logger.addHandler(handler)
        for handler in root_logger.handlers:
            handler.setLevel(level)

    def _enforce_unsafe_flag(self) -> None:
        if (
            self.config
            and self.config.global_context.get("unsafe", False)
            and not self.unsafe
        ):
            raise UnsafeConfigurationError(
                "This configuration requires the '--unsafe' flag because it may "
                "execute arbitrary code with full privileges."
            )

    def _register_agents_from_config(self) -> None:
        if not self.config:
            return

        def _make_agent_instance(name: str, agent_cfg: Any) -> Any:
            tools = agent_cfg.config.get("tools", []) if agent_cfg.config else []
            if self._resolved_skill_tools and tools:
                tools = list(tools) + self._resolved_skill_tools
            elif self._resolved_skill_tools and not tools:
                logger.debug(
                    "Skipping skill tool injection for agent '%s' "
                    "(agent has no base tools)",
                    name,
                )
            if tools:
                return SimpleToolAgent(tools, unsafe=self.unsafe)
            if agent_cfg.type == "llm":
                return SimpleLLMAgent(name, agent_cfg.config)
            return lambda msg: getattr(msg, "content", msg)

        def _register_aliases(name: str, agent: Any) -> None:
            if name.endswith("_actor"):
                alias = f"{name[:-6]}_agent"
            elif name.endswith("_agent"):
                alias = f"{name[:-6]}_actor"
            else:
                return
            if alias and alias not in self.stream_router.agents:
                self.stream_router.register_agent(alias, agent)

        for name, agent_cfg in self.config.agents.items():
            if name in self.stream_router.agents:
                continue
            agent_instance = _make_agent_instance(name, agent_cfg)
            self.stream_router.register_agent(name, agent_instance)
            _register_aliases(name, agent_instance)

        def _ensure_agent_registered(actor_name: str) -> None:
            if actor_name not in self.stream_router.agents:
                cfg_agent = self.config.agents.get(actor_name)  # type: ignore[union-attr]
                if cfg_agent:
                    agent_instance = _make_agent_instance(actor_name, cfg_agent)
                    self.stream_router.register_agent(actor_name, agent_instance)
                    _register_aliases(actor_name, agent_instance)

        for route in self.config.routes.values():
            if route.type == RouteType.STREAM:
                for actor_name in route.agents:
                    _ensure_agent_registered(actor_name)
                for op in route.operators:
                    params = op.get("params", {}) if isinstance(op, dict) else {}
                    actor_name = params.get("actor") or params.get("agent")
                    if actor_name:
                        _ensure_agent_registered(actor_name)

    def load_configuration(self, config_files: list[Path]) -> None:
        try:
            logger.info(
                "Loading reactive configuration from %d files", len(config_files)
            )
            self.config = self.config_parser.parse_files(config_files)
            self._register_agents_from_config()
            self._validate_routes()
            self._build_routes()
        except Exception as exc:  # pylint: disable=broad-exception-caught
            raise CleverAgentsException(f"Failed to load configuration: {exc}") from exc

    def _validate_routes(self) -> None:
        if not self.config:
            return
        for route_cfg in self.config.routes.values():
            if route_cfg.type == RouteType.STREAM and route_cfg.bridge is not None:
                raise CleverAgentsException(
                    f"Route '{route_cfg.name}' is stream type but has a bridge "
                    "configuration"
                )

    def _build_routes(self) -> None:
        if not self.config:
            return
        self._register_agents_from_config()
        for route in self.config.routes.values():
            if route.type == RouteType.STREAM:
                stream_cfg = self.stream_router._to_stream_config(route)
                for op in stream_cfg.operators:
                    params = op.get("params", {}) if isinstance(op, dict) else {}
                    actor_name = params.get("actor") or params.get("agent")
                    if actor_name and actor_name not in self.stream_router.agents:
                        self.stream_router.register_agent(
                            actor_name, lambda msg: getattr(msg, "content", msg)
                        )
                if stream_cfg.name not in self.stream_router.streams:
                    self.stream_router.create_stream(stream_cfg)
                if not stream_cfg.subscriptions:
                    self.stream_router.streams["__input__"].subscribe(
                        self.stream_router.streams[stream_cfg.name]
                    )
                if stream_cfg.name != "__output__":
                    self.stream_router.observables[stream_cfg.name].subscribe(
                        self.stream_router.streams["__output__"]
                    )
            elif route.type == RouteType.GRAPH:
                if self.route_bridge is None:
                    self.route_bridge = RouteBridge(
                        stream_router=self.stream_router,
                        agents=self.stream_router.agents,
                    )
                self.route_bridge.add_route(route)

        if self.config.merges:
            for merge in self.config.merges:
                sources = merge.get("sources", []) if isinstance(merge, dict) else []
                target = merge.get("target") if isinstance(merge, dict) else None
                if not target or target not in self.stream_router.streams:
                    continue
                for src in sources:
                    if src in self.stream_router.streams:
                        self.stream_router.streams[src].subscribe(
                            lambda msg, tgt=target: self.stream_router.streams[
                                tgt
                            ].on_next(msg)
                        )

    def _is_rxpy_stream_present(self) -> bool:
        if not self.config:
            return False
        return any(r.type == RouteType.STREAM for r in self.config.routes.values())

    def _get_graph_route(self) -> RouteConfig | None:
        """Return the first graph route config, or None."""
        if not self.config:
            return None
        for route in self.config.routes.values():
            if route.type == RouteType.GRAPH:
                return route
        return None

    # ------------------------------------------------------------------
    # Backward-compatible proxies into GraphExecutor.
    # Existing tests access these via ``app._xxx()``.
    # ------------------------------------------------------------------

    def _invoke_agent(self, agent: Any, message: str, context: dict[str, Any]) -> str:
        """Execute an agent and return the string result."""
        return GraphExecutor._invoke_agent(agent, message, context)

    def _parse_graph_topology(
        self, route: RouteConfig
    ) -> tuple[
        str | None,
        list[dict[str, Any]],
        dict[str, str],
        dict[str, list[dict[str, Any]]],
    ]:
        """Parse route nodes/edges into router node, rules, actor map, and edge map."""
        return GraphExecutor._parse_topology(route)

    def _initialize_graph_context(self) -> dict[str, Any]:
        """Ensure global context has required default keys for graph execution."""
        executor = GraphExecutor(self.stream_router.agents, self.config)
        return executor._initialize_context()

    def _match_router_rule(
        self, message: str, router_rules: list[dict[str, Any]]
    ) -> tuple[str | None, str, dict[str, Any] | None]:
        """Match message against router rules."""
        return GraphExecutor._match_router_rule(message, router_rules)

    def _follow_chained_edges(
        self,
        next_targets: list[str],
        current_message: str,
        context: dict[str, Any],
        node_actor_map: dict[str, str],
        agents: dict[str, Any],
        router_node: str | None,
        select_targets_fn: Callable[[str], list[str]],
    ) -> tuple[str, bool]:
        """Follow chained edges from a node."""
        return GraphExecutor._follow_chained_edges(
            next_targets,
            current_message,
            context,
            node_actor_map,
            agents,
            router_node,
            select_targets_fn,
        )

    def _execute_graph_route(self, prompt: str, route: RouteConfig) -> str:
        """Execute a graph route by following message_router rules iteratively."""
        executor = GraphExecutor(self.stream_router.agents, self.config)
        return executor.execute(prompt, route)

    async def run_single_shot(
        self,
        prompt: str,
        *,
        context_manager: ContextManager | None = None,
        allow_rxpy_in_run_mode: bool = False,
    ) -> str:
        if self._is_rxpy_stream_present() and not allow_rxpy_in_run_mode:
            raise CleverAgentsException(
                "RxPY stream routes are only supported in run mode unless allowed. "
                "Use --allow-rxpy-in-run-mode to bypass. "
                "Nested stream operators do not support completion detection; "
                "consider using LangGraph routes instead."
            )

        if context_manager and self.config and context_manager.global_context:
            self.config.global_context.update(context_manager.global_context)

        # Try graph route execution first
        graph_route = self._get_graph_route()
        if graph_route is not None:
            executor = GraphExecutor(self.stream_router.agents, self.config)
            output = executor.execute(prompt, graph_route)
            return GraphExecutor.strip_routing_prefixes(output)

        loop = asyncio.get_running_loop()
        scheduler = AsyncIOScheduler(loop=loop)
        self.stream_router.scheduler = scheduler

        result_container: list[str] = []
        error_container: list[str] = []

        def on_next(msg: Any) -> None:
            result_container.append(str(getattr(msg, "content", msg)))

        def on_error(err: Exception) -> None:
            error_container.append(str(err))

        self.stream_router.observables["__output__"].subscribe(on_next, on_error)
        self.stream_router.observables["__error__"].subscribe(on_error)

        self.stream_router.streams["__input__"].on_next(prompt)
        await asyncio.sleep(0)

        if error_container:
            raise CleverAgentsException("; ".join(error_container))

        if not result_container:
            return ""

        output = "\n".join(result_container)
        return GraphExecutor.strip_routing_prefixes_multiline(output)

    async def run_with_context(
        self,
        prompt: str,
        *,
        context_manager: ContextManager,
        allow_rxpy_in_run_mode: bool = False,
    ) -> str:
        context_manager.add_message("user", prompt)
        result = await self.run_single_shot(
            prompt,
            context_manager=context_manager,
            allow_rxpy_in_run_mode=allow_rxpy_in_run_mode,
        )
        context_manager.add_message("assistant", result)
        if self.config:
            context_manager.save_global_context(self.config.global_context)
        return result
