"""
Ported from v2 reactive.stream_router with minimal adaptations to actor-first
semantics. Provider/model flags are not used; actors are resolved by name.
"""

from __future__ import annotations

import contextlib
import logging
from collections.abc import Callable
from enum import Enum
from typing import Any, ClassVar

import rx
from pydantic import BaseModel, Field
from rx import operators as ops
from rx.core import Observable as ObservableType  # type: ignore[attr-defined]
from rx.scheduler.eventloop import AsyncIOScheduler  # type: ignore[attr-defined]
from rx.subject.behaviorsubject import BehaviorSubject  # type: ignore[attr-defined]
from rx.subject.subject import Subject  # type: ignore[attr-defined]

from cleveragents.application.services.prompt_sanitizer import PromptSanitizer
from cleveragents.core.exceptions import StreamRoutingError
from cleveragents.providers.registry import get_provider_registry

# Module-level sanitizer for prompt boundary markers (mechanism 2)
_SANITIZER = PromptSanitizer()

try:
    from jinja2.sandbox import SandboxedEnvironment
except Exception:  # pragma: no cover - optional runtime dependency guard
    SandboxedEnvironment = None

try:
    from langchain_core.messages import HumanMessage, SystemMessage
except Exception:  # pragma: no cover - optional runtime dependency guard
    HumanMessage = None
    SystemMessage = None


class StreamType(Enum):
    """Types of streams in the reactive system."""

    HOT = "hot"
    COLD = "cold"
    REPLAY = "replay"


class StreamMessage(BaseModel):
    """Message container for reactive streams."""

    content: Any
    metadata: dict[str, Any] = Field(default_factory=dict)
    source_stream: str | None = None
    timestamp: float | None = None

    def copy_with(self, **kwargs: Any) -> StreamMessage:
        new_metadata = kwargs.get("metadata")
        if new_metadata is None:
            new_metadata = {}
            for key, value in self.metadata.items():
                if key == "context":
                    new_metadata[key] = value
                else:
                    new_metadata[key] = (
                        value.copy() if isinstance(value, dict) else value
                    )
        data = {
            "content": self.content,
            "metadata": new_metadata,
            "source_stream": self.source_stream,
            "timestamp": self.timestamp,
        }
        data.update(kwargs)
        return StreamMessage(**data)


class StreamConfig(BaseModel):  # pylint: disable=too-many-instance-attributes
    """Configuration for a reactive stream."""

    name: str
    type: StreamType = StreamType.COLD
    operators: list[dict[str, Any]] = Field(default_factory=list)
    subscriptions: list[str] = Field(default_factory=list)
    publications: list[str] = Field(default_factory=list)
    agents: list[str] = Field(default_factory=list)
    initial_value: Any | None = None
    buffer_size: int = 1
    template_config: dict[str, Any] | None = None


logger_sr = logging.getLogger(__name__)


class SimpleToolAgent:
    """Executes tool operations from agent config.

    Uses **named operations** only: Set ``"operation": "<name>"`` in the
    tool dict to use a registered safe operation.

    **SEC1**: Arbitrary code execution via ``code`` blocks has been fully
    removed.  Only named operations from the ``_SAFE_OPERATIONS`` registry
    are allowed.  Any tool dict containing a ``"code"`` key is rejected
    unconditionally with a :class:`StreamRoutingError`.
    """

    # Registry of safe, named operations.
    # Each function takes (content, metadata, context) and returns a value.
    _SAFE_OPERATIONS: ClassVar[dict[str, Callable[..., Any]]] = {
        "identity": lambda content, meta, ctx: content,
        "uppercase": lambda content, meta, ctx: str(content).upper() if content else "",
        "lowercase": lambda content, meta, ctx: str(content).lower() if content else "",
        "strip": lambda content, meta, ctx: str(content).strip() if content else "",
        "extract_content": lambda content, meta, ctx: (
            content.get("content", content) if isinstance(content, dict) else content
        ),
        "to_string": lambda content, meta, ctx: str(content) if content else "",
    }

    def __init__(
        self,
        tools: list[dict[str, Any]] | None = None,
        *,
        unsafe: bool = False,
    ):
        self.tools = tools or []
        # NOTE: The ``unsafe`` parameter is accepted for API compatibility
        # (callers such as ``ReactiveApplication`` pass it), but code-block
        # execution has been fully removed as part of SEC1.
        self._unsafe = unsafe

    @classmethod
    def register_operation(cls, name: str, fn: Callable[..., Any]) -> None:
        """Register a named safe operation.

        Args:
            name: Operation name (must be alphanumeric + underscores).
            fn: Callable(content, metadata, context) -> result.
        """
        if not name.replace("_", "").isalnum():
            raise ValueError(
                f"Operation name must be alphanumeric with underscores: {name}"
            )
        cls._SAFE_OPERATIONS[name] = fn

    def process(
        self,
        content: Any,
        metadata: dict[str, Any] | None = None,
        context: dict[str, Any] | None = None,
    ) -> Any:  # type: ignore[override]
        meta = metadata or {}
        ctx = context if context is not None else {}
        if not self.tools:
            return content
        tool = self.tools[0] if isinstance(self.tools, list) else None
        if not isinstance(tool, dict):
            return content

        # SEC1: Only named operations are allowed in safe mode.
        if "operation" in tool:
            operation_name = tool["operation"]
            fn = self._SAFE_OPERATIONS.get(operation_name)
            if fn is None:
                logging.getLogger(__name__).warning(
                    "Unknown operation '%s'; falling back to identity",
                    operation_name,
                )
                return content
            try:
                return fn(content, meta, ctx)
            except Exception:
                return ""

        # SEC1: code blocks are unconditionally rejected (exec removed).
        if "code" in tool:
            raise StreamRoutingError(
                "SEC1: Raw 'code' blocks are not supported. "
                "Use 'operation' with a registered named function instead."
            )

        return content

    def process_message_sync(
        self, content: Any, metadata: dict[str, Any] | None = None
    ) -> Any:
        return self.process(content, metadata)


class SimpleLLMAgent:
    """LLM-backed agent for actor-first configs."""

    def __init__(self, name: str, config: dict[str, Any] | None = None):
        self.name = name
        self.config = config or {}
        self.system_prompt = self.config.get("system_prompt", "") or ""
        self._llm = None
        self._template_env = (
            SandboxedEnvironment() if SandboxedEnvironment is not None else None
        )

    def _render_prompt(self, template: str, context: dict[str, Any]) -> str:
        if not template:
            return ""
        if self._template_env is None:
            return template
        try:
            return self._template_env.from_string(template).render(
                context=context, **context
            )
        except Exception:
            return template

    def _resolve_llm(self) -> Any:
        if self._llm is not None:
            return self._llm
        provider = self.config.get("provider")
        model = self.config.get("model")
        temperature = self.config.get("temperature")
        max_tokens = self.config.get("max_tokens")
        max_retries = self.config.get("max_retries")
        llm_kwargs: dict[str, Any] = {}
        if temperature is not None:
            llm_kwargs["temperature"] = temperature
        if max_tokens is not None:
            llm_kwargs["max_tokens"] = max_tokens
        if max_retries is not None:
            llm_kwargs["max_retries"] = max_retries
        registry = get_provider_registry()
        self._llm = registry.create_llm(
            provider_type=provider, model_id=model, **llm_kwargs
        )
        return self._llm

    def process(
        self,
        content: Any,
        metadata: dict[str, Any] | None = None,
        context: dict[str, Any] | None = None,
    ) -> Any:  # type: ignore[override]
        prompt = content if isinstance(content, str) else str(content or "")
        ctx = context or {}
        rendered_system = self._render_prompt(self.system_prompt, ctx)

        # Mechanism 2: augment system prompt with boundary instructions
        # and wrap user content with boundary markers
        if rendered_system:
            rendered_system = _SANITIZER.augment_system_prompt(rendered_system)
        prompt = _SANITIZER.wrap_user_content(prompt)

        llm = self._resolve_llm()
        if HumanMessage is None or SystemMessage is None:
            raise StreamRoutingError(
                "LangChain messages not available for LLM execution"
            )
        messages = []
        if rendered_system:
            messages.append(SystemMessage(content=rendered_system))
        messages.append(HumanMessage(content=prompt))
        response = llm.invoke(messages)
        return getattr(response, "content", str(response))

    def process_message_sync(
        self, content: Any, metadata: dict[str, Any] | None = None
    ) -> Any:
        return self.process(content, metadata)


class ReactiveStreamRouter:  # pylint: disable=too-many-instance-attributes
    """RxPy-based stream router for actor orchestration."""

    # SEC1: Named transform function registry (replaces eval-based transforms).
    # All functions take a single value and return the transformed value.
    _TRANSFORM_REGISTRY: ClassVar[dict[str, Callable[..., Any]]] = {
        "identity": lambda x: x,
        "uppercase": lambda x: str(x).upper() if x else "",
        "lowercase": lambda x: str(x).lower() if x else "",
        "strip": lambda x: str(x).strip() if x else "",
        "to_string": lambda x: str(x) if x is not None else "",
        "extract_content": lambda x: x.get("content", x) if isinstance(x, dict) else x,
    }

    @classmethod
    def register_transform(cls, name: str, fn: Callable[..., Any]) -> None:
        """Register a named transform function for use in stream configs.

        Args:
            name: Transform name (alphanumeric + underscores).
            fn: Callable that takes a single value and returns the result.
        """
        if not name.replace("_", "").isalnum():
            raise ValueError(
                f"Transform name must be alphanumeric with underscores: {name}"
            )
        cls._TRANSFORM_REGISTRY[name] = fn

    def __init__(self, scheduler: AsyncIOScheduler | None = None):
        self.logger = logging.getLogger(__name__)
        self.scheduler = scheduler
        self.streams: dict[str, Any] = {}
        self.stream_configs: dict[str, StreamConfig] = {}
        self.observables: dict[str, ObservableType] = {}
        self.agents: dict[str, Any] = {}
        self.subscriptions: list[Any] = []
        self._langgraph_bridge: Any | None = None
        self._create_builtin_streams()

    def _to_stream_config(self, route_config: Any) -> StreamConfig:
        """Convert RouteConfig (or dict) to StreamConfig (actor-first)."""

        def _get(obj: Any, key: str, default: Any = None) -> Any:
            if hasattr(obj, key):
                return getattr(obj, key)
            if isinstance(obj, dict):
                return obj.get(key, default)
            return default

        return StreamConfig(
            name=_get(route_config, "name"),
            type=_get(route_config, "stream_type", StreamType.COLD),
            operators=_get(route_config, "operators", []) or [],
            subscriptions=_get(route_config, "subscriptions", []) or [],
            publications=_get(route_config, "publications", []) or [],
            agents=_get(route_config, "agents", []) or [],
            initial_value=_get(route_config, "initial_value"),
            buffer_size=_get(route_config, "buffer_size", 1),
            template_config=_get(route_config, "template_config"),
        )

    def _create_builtin_streams(self) -> None:
        input_stream = Subject()
        self.streams["__input__"] = input_stream
        self.observables["__input__"] = input_stream
        self.stream_configs["__input__"] = StreamConfig(
            name="__input__", type=StreamType.COLD
        )

        output_stream = Subject()
        self.streams["__output__"] = output_stream
        self.observables["__output__"] = output_stream
        self.stream_configs["__output__"] = StreamConfig(
            name="__output__", type=StreamType.COLD
        )

        error_stream = Subject()
        self.streams["__error__"] = error_stream
        self.observables["__error__"] = error_stream
        self.stream_configs["__error__"] = StreamConfig(
            name="__error__", type=StreamType.COLD
        )

    def register_agent(self, name: str, agent: Any) -> None:
        self.agents[name] = agent
        self.logger.debug("Registered actor: %s", name)

    def create_stream(self, config: StreamConfig | str) -> Any:
        if isinstance(config, str):
            config = StreamConfig(name=config)

        if config.name in self.streams:
            raise StreamRoutingError(f"Stream '{config.name}' already exists")

        if config.type == StreamType.HOT:
            stream: Any = BehaviorSubject(config.initial_value)
        elif config.type == StreamType.REPLAY:
            from rx.subject.replaysubject import (
                ReplaySubject,  # type: ignore[attr-defined]
            )

            stream = ReplaySubject(buffer_size=config.buffer_size)
        else:
            stream = Subject()

        self.streams[config.name] = stream
        self.stream_configs[config.name] = config
        observable = self._build_observable(config.name, config.operators)
        self.observables[config.name] = observable
        self._setup_subscriptions(config)
        self.logger.info("Created stream: %s (%s)", config.name, config.type.value)
        return stream

    def _build_observable(
        self, stream_name: str, operator_configs: list[dict[str, Any]]
    ) -> ObservableType:
        base_stream = self.streams[stream_name]
        observable: ObservableType = base_stream
        for op_config in operator_configs:
            operator = self._create_operator(op_config)
            observable = observable.pipe(operator)
        return observable

    def _create_operator(self, config: dict[str, Any]) -> Any:  # pylint: disable=too-many-branches,too-many-statements
        op_type = config["type"]
        params = config.get("params", {})

        if op_type in [
            "graph_execute",
            "state_update",
            "state_checkpoint",
            "langgraph_node",
            "conditional_route",
        ]:
            if self._langgraph_bridge is not None:
                factory_name = f"_operator_{op_type}"
                if hasattr(self._langgraph_bridge, factory_name):
                    factory = getattr(self._langgraph_bridge, factory_name)
                    return factory(params)
            raise StreamRoutingError(
                f"LangGraph operator '{op_type}' requires LangGraph bridge"
            )

        if op_type == "map":
            actor_name = params.get("actor") or params.get("agent")
            if actor_name:
                agent = self.agents.get(actor_name)
                if not agent:
                    raise StreamRoutingError(f"Agent '{actor_name}' not found")
                return ops.map(self._create_agent_mapper(agent))
            if "function" in params:
                func_name = params["function"]
                return ops.map(getattr(self, f"_builtin_{func_name}", lambda x: x))
            if "transform" in params:
                transform = params["transform"]
                return ops.map(lambda x: self._apply_transform(x, transform))
            raise StreamRoutingError(
                "Map operator requires 'actor'/'agent', 'function', or "
                "'transform' parameter"
            )

        if op_type == "filter":
            if "condition" in params:
                condition = params["condition"]
                return ops.filter(lambda x: self._evaluate_condition(x, condition))
            raise StreamRoutingError("Filter operator requires 'condition' parameter")

        if op_type == "transform":
            if "fn" in params:
                fn_str = params["fn"]
                # SEC1: Only named transforms from the registry are allowed.
                # Arbitrary eval of Python expressions has been removed per
                # the NO BACKWARDS COMPATIBILITY rule.
                registry = ReactiveStreamRouter._TRANSFORM_REGISTRY
                transform_fn = registry.get(fn_str)
                if transform_fn is not None:
                    return ops.map(
                        lambda msg, _fn=transform_fn: (
                            _fn(msg.content) if hasattr(msg, "content") else _fn(msg)
                        )
                    )
                # Reject unregistered fn strings -- no eval fallback.
                raise StreamRoutingError(
                    f"SEC1: Unknown transform '{fn_str}'. "
                    f"Only registered named transforms are allowed. "
                    f"Use ReactiveStreamRouter.register_transform() to add "
                    f"custom transforms."
                )
            if "type" in params:
                transform = params
                return ops.map(lambda x: self._apply_transform(x, transform))
            raise StreamRoutingError(
                "Transform operator requires 'fn' or 'type' parameter"
            )

        if op_type in {"switch", "conditional_route"}:
            cases = params.get("cases", [])
            default_stream = params.get("default")
            default_operators = params.get("default_operators", [])

            def switch_mapper(msg: StreamMessage) -> ObservableType:
                for case in cases:
                    condition = case.get("condition", {})
                    target_stream = case.get("target")
                    case_operators = case.get("operators", [])

                    if self._evaluate_condition(msg, condition):
                        if case_operators:
                            result = rx.just(msg)
                            for op_config in case_operators:
                                operator = self._create_operator(op_config)
                                if operator:
                                    result = result.pipe(operator)
                            return result
                        if target_stream and target_stream in self.streams:
                            target = self.streams[target_stream]
                            return rx.just(msg).pipe(
                                ops.flat_map(lambda _m, tgt=target: tgt)
                            )

                if default_operators:
                    result = rx.just(msg)
                    for op_config in default_operators:
                        operator = self._create_operator(op_config)
                        if operator:
                            result = result.pipe(operator)
                    return result
                if default_stream and default_stream in self.streams:
                    target = self.streams[default_stream]
                    return rx.just(msg).pipe(ops.flat_map(lambda _m: target))
                return rx.just(msg)

            return ops.flat_map(switch_mapper)

        if op_type == "debounce":
            duration = params.get("duration", 1.0)
            debounce_time = min(duration, 0.05)
            return ops.debounce(debounce_time)

        if op_type == "throttle":
            duration = params.get("duration", 0.2)
            return ops.throttle_first(duration)

        if op_type == "delay":
            duration = params.get("duration", 0.1)
            return ops.delay(duration)

        if op_type == "buffer":
            if "count" in params:
                count = params["count"]
                timeout = params.get("timeout", 0.1)
                return ops.buffer_with_time_or_count(timeout, count)
            raise StreamRoutingError("Buffer operator requires 'count'")

        if op_type == "window":
            if "count" in params:
                count = params["count"]
                return ops.window_with_count(count)
            raise StreamRoutingError("Window operator requires 'count'")

        if op_type == "take":
            return ops.take(params.get("count", 1))

        if op_type == "skip":
            return ops.skip(params.get("count", 1))

        if op_type == "merge":
            streams = params.get("streams", [])
            observables = [self.streams[s] for s in streams if s in self.streams]
            return ops.merge(*observables)

        if op_type == "accumulate":
            accumulator_config = params.get("accumulator", {})
            return ops.scan(
                lambda acc, msg: self._apply_accumulator(acc, msg, accumulator_config)
            )

        raise StreamRoutingError(f"Unknown operator type: {op_type}")

    def _apply_transform(self, message: Any, transform: dict[str, Any]) -> Any:
        t_type = transform.get("type")
        if t_type == "replace":
            target = transform.get("target")
            value = transform.get("value")
            if isinstance(message, dict) and target in message:
                message[target] = value
            return message
        if t_type == "extract_field":
            field = transform.get("field")
            if isinstance(message, dict):
                return message.get(field)
        return message

    def _evaluate_condition(self, message: Any, condition: dict[str, Any]) -> bool:
        if not condition:
            return True
        if "equals" in condition:
            return getattr(message, "content", message) == condition["equals"]
        if "field" in condition:
            field = condition["field"]
            if isinstance(message, dict):
                return message.get(field) is not None
        return False

    def _create_agent_mapper(self, agent: Any) -> Callable[[Any], StreamMessage]:
        def mapper(msg: Any) -> StreamMessage:
            content = getattr(msg, "content", msg)
            metadata = getattr(msg, "metadata", {}) if hasattr(msg, "metadata") else {}
            try:
                if hasattr(agent, "process_message_sync"):
                    result = agent.process_message_sync(content, metadata)
                elif hasattr(agent, "process"):
                    result = agent.process(content, metadata)  # type: ignore[attr-defined]
                else:
                    result = content
            except Exception:  # pylint: disable=broad-except
                result = ""
            return StreamMessage(
                content=str(result),
                metadata={"processed_by": agent.__class__.__name__, **metadata},
            )

        return mapper

    def _apply_accumulator(self, acc: Any, msg: Any, config: dict[str, Any]) -> Any:
        if acc is None:
            acc = [] if config.get("init") == "list" else 0
        if config.get("op") == "sum":
            return acc + (msg if isinstance(msg, int | float) else 0)
        if config.get("op") == "append" and isinstance(acc, list):
            acc.append(msg)
            return acc
        return acc

    def _setup_subscriptions(self, config: StreamConfig) -> None:
        for source in config.subscriptions:
            if source not in self.streams:
                continue
            source_stream = self.streams[source]
            subscription = source_stream.subscribe(self.streams[config.name])
            self.subscriptions.append(subscription)

    def send_message(
        self, stream_name: str, message: Any, metadata: dict[str, Any] | None = None
    ) -> None:
        if stream_name not in self.streams:
            raise StreamRoutingError(f"Stream '{stream_name}' not found")
        meta = metadata or {}
        import time

        msg = StreamMessage(content=message, metadata=meta, timestamp=time.time())
        self.streams[stream_name].on_next(msg)

    def split_stream(
        self, source_stream: str, conditions: list[dict[str, Any]]
    ) -> None:
        """Split a stream based on conditions."""
        if source_stream not in self.streams:
            raise StreamRoutingError(f"Stream '{source_stream}' not found")

        source = self.streams[source_stream]

        def splitter(msg: Any) -> None:
            for cond in conditions:
                target = cond.get("target")
                condition = cond.get("condition", {})

                if (
                    target
                    and target in self.streams
                    and self._evaluate_condition(msg, condition)
                ):
                    self.streams[target].on_next(msg)

        subscription = source.subscribe(splitter)
        self.subscriptions.append(subscription)

    def merge_streams(self, source_streams: list[str], target_stream: str) -> None:
        """Merge multiple streams into a single target stream."""
        # Create target stream if it doesn't exist
        if target_stream not in self.streams:
            self.create_stream(StreamConfig(name=target_stream))

        target = self.streams[target_stream]

        for source_name in source_streams:
            if source_name in self.streams:
                subscription = self.streams[source_name].subscribe(target)
                self.subscriptions.append(subscription)

    def subscribe_to_output(self, observer: Callable[[Any], None]) -> Any:
        """Subscribe to the output stream."""
        return self.streams["__output__"].subscribe(observer)

    def subscribe_to_error(self, observer: Callable[[Any], None]) -> Any:
        """Subscribe to the error stream."""
        return self.streams["__error__"].subscribe(observer)

    def dispose(self) -> None:
        """Dispose of all streams and subscriptions."""
        # Dispose subscriptions first
        for subscription in self.subscriptions[:]:
            with contextlib.suppress(Exception):
                if hasattr(subscription, "dispose"):
                    subscription.dispose()

        # Clear subscriptions
        self.subscriptions.clear()

        # Dispose streams
        for stream_name in list(self.streams.keys()):
            stream = self.streams[stream_name]
            with contextlib.suppress(Exception):
                if hasattr(stream, "dispose"):
                    stream.dispose()

        # Clear all dictionaries
        self.streams.clear()
        self.observables.clear()
        self.stream_configs.clear()
        self.agents.clear()
