"""Plan execution context bridging plan metadata into the tool runtime.

Carries plan_id, decision_root_id, sandbox_root, automation_profile,
project resources, and resource bindings. Integrates with ChangeSetStore
for capturing tool-call mutations during plan execution.

## Key Classes

- ``PlanExecutionContext`` -- carries all metadata needed by the execute
  phase and delegates changeset operations to a ``ChangeSetStore``.
- ``RuntimeExecuteResult`` -- structured output from the runtime actor.
- ``RuntimeExecuteActor`` -- wraps ``ToolRunner`` to execute strategy
  decisions with full changeset capture.
"""

from __future__ import annotations

import time
from collections.abc import Callable
from typing import Any

import structlog
from pydantic import BaseModel, ConfigDict, Field
from ulid import ULID

from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core.change import (
    ChangeEntry,
    ChangeOperation,
    ChangeSetStore,
    InMemoryChangeSetStore,
    SpecChangeSet,
    ToolInvocation,
)
from cleveragents.tool.context import BoundResource
from cleveragents.tool.runner import ToolRunner

logger = structlog.get_logger(__name__)

# Type alias for streaming callbacks
StreamCallback = Callable[[str, dict[str, Any]], None]


# ---------------------------------------------------------------------------
# PlanExecutionContext
# ---------------------------------------------------------------------------


class PlanExecutionContext:
    """Execution context for the plan execute phase.

    Bridges plan metadata (plan_id, decision root, sandbox, automation
    profile, resources) into the tool runtime.  All changeset operations
    are delegated to the injected ``ChangeSetStore``.

    Parameters
    ----------
    plan_id:
        ULID of the plan being executed.  Must be non-empty.
    decision_root_id:
        ULID of the root decision from the strategize phase.
    sandbox_root:
        Filesystem path for sandboxed execution.
    automation_profile:
        Name of the automation profile governing this execution.
    project_resources:
        Mapping of project resource names to their metadata.
    resource_bindings:
        Resolved resource bindings keyed by slot name.
    changeset_store:
        Store implementation for persisting changesets.  Defaults
        to ``InMemoryChangeSetStore`` when not provided.
    """

    def __init__(
        self,
        *,
        plan_id: str,
        decision_root_id: str | None = None,
        sandbox_root: str | None = None,
        automation_profile: str | None = None,
        project_resources: dict[str, Any] | None = None,
        resource_bindings: dict[str, BoundResource] | None = None,
        changeset_store: ChangeSetStore | None = None,
        plan_env: str | None = None,
        project_env: str | None = None,
    ) -> None:
        if not plan_id:
            raise ValidationError("plan_id must not be empty")

        self._plan_id = plan_id
        self._decision_root_id = decision_root_id
        self._sandbox_root = sandbox_root
        self._automation_profile = automation_profile
        self._project_resources: dict[str, Any] = (
            project_resources if project_resources is not None else {}
        )
        self._resource_bindings: dict[str, BoundResource] = (
            resource_bindings if resource_bindings is not None else {}
        )
        self._plan_env = plan_env
        self._project_env = project_env
        self._changeset_store: ChangeSetStore = (
            changeset_store if changeset_store is not None else InMemoryChangeSetStore()
        )
        self._active_changeset_ids: list[str] = []
        self._logger = logger.bind(
            plan_id=plan_id,
            component="plan_execution_context",
        )
        self._logger.debug(
            "PlanExecutionContext created",
            decision_root_id=decision_root_id,
            sandbox_root=sandbox_root,
            automation_profile=automation_profile,
            resource_count=len(self._project_resources),
            binding_count=len(self._resource_bindings),
        )

    # -- Properties ----------------------------------------------------------

    @property
    def plan_id(self) -> str:
        """The plan ULID."""
        return self._plan_id

    @property
    def decision_root_id(self) -> str | None:
        """Root decision node ULID from strategize."""
        return self._decision_root_id

    @decision_root_id.setter
    def decision_root_id(self, value: str | None) -> None:
        self._decision_root_id = value

    @property
    def sandbox_root(self) -> str | None:
        """Sandbox filesystem root."""
        return self._sandbox_root

    @property
    def automation_profile(self) -> str | None:
        """Automation profile name."""
        return self._automation_profile

    @property
    def plan_env(self) -> str | None:
        """Plan-level execution environment override."""
        return self._plan_env

    @property
    def project_env(self) -> str | None:
        """Project-level execution environment override."""
        return self._project_env

    @property
    def project_resources(self) -> dict[str, Any]:
        """Project resources mapping."""
        return self._project_resources

    @property
    def resource_bindings(self) -> dict[str, BoundResource]:
        """Resolved resource bindings."""
        return self._resource_bindings

    @property
    def changeset_store(self) -> ChangeSetStore:
        """The underlying changeset store."""
        return self._changeset_store

    @property
    def active_changeset_ids(self) -> list[str]:
        """IDs of changesets started in this context."""
        return list(self._active_changeset_ids)

    # -- Changeset operations ------------------------------------------------

    def start_changeset(self) -> str:
        """Start a new changeset for this plan.

        Returns the changeset_id (ULID string).
        """
        changeset_id = self._changeset_store.start(self._plan_id)
        self._active_changeset_ids.append(changeset_id)
        self._logger.info("Changeset started", changeset_id=changeset_id)
        return changeset_id

    def record_change(self, entry: ChangeEntry) -> None:
        """Record a change entry into the most recent active changeset.

        Raises ``PlanError`` if no changeset has been started.
        """
        if not self._active_changeset_ids:
            raise PlanError("No active changeset. Call start_changeset() first.")
        changeset_id = self._active_changeset_ids[-1]
        self._changeset_store.record(changeset_id, entry)
        self._logger.debug(
            "Change recorded",
            changeset_id=changeset_id,
            entry_id=entry.entry_id,
            operation=entry.operation,
            path=entry.path,
        )

    def get_changeset(self, changeset_id: str) -> SpecChangeSet | None:
        """Retrieve a changeset by ID.

        Returns ``None`` if the changeset does not exist.
        """
        return self._changeset_store.get(changeset_id)

    def summarize(self) -> dict[str, Any]:
        """Return a summary of this execution context.

        Includes plan metadata, resource counts, and changeset summaries.
        """
        changeset_summaries: list[dict[str, Any]] = []
        for cs_id in self._active_changeset_ids:
            summary = self._changeset_store.summarize(cs_id)
            if summary:
                summary["changeset_id"] = cs_id
                changeset_summaries.append(summary)

        return {
            "plan_id": self._plan_id,
            "decision_root_id": self._decision_root_id,
            "sandbox_root": self._sandbox_root,
            "automation_profile": self._automation_profile,
            "project_resource_count": len(self._project_resources),
            "resource_binding_count": len(self._resource_bindings),
            "active_changeset_count": len(self._active_changeset_ids),
            "active_changeset_ids": list(self._active_changeset_ids),
            "changeset_summaries": changeset_summaries,
        }


# ---------------------------------------------------------------------------
# RuntimeExecuteResult
# ---------------------------------------------------------------------------


class RuntimeExecuteResult(BaseModel):
    """Structured output from the runtime execute actor.

    Extends the stub ``ExecuteResult`` concept with runtime-specific
    metadata such as tool call count, sandbox references, processed
    decision IDs, and wall-clock execution duration.
    """

    changeset_id: str = Field(
        ...,
        min_length=1,
        description="ULID of the produced ChangeSet",
    )
    tool_call_count: int = Field(
        default=0,
        ge=0,
        description="Number of tool calls executed",
    )
    sandbox_refs: list[str] = Field(
        default_factory=list,
        description="Sandbox reference paths used",
    )
    decision_ids_processed: list[str] = Field(
        default_factory=list,
        description="Decision node IDs that were processed",
    )
    execution_duration_ms: float = Field(
        default=0.0,
        ge=0.0,
        description="Wall-clock execution time in milliseconds",
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# RuntimeExecuteActor
# ---------------------------------------------------------------------------


class RuntimeExecuteActor:
    """Execute actor that dispatches decisions through ``ToolRunner``.

    Wraps the tool-calling runtime to execute strategy decisions with
    full changeset capture via ``PlanExecutionContext``.

    Parameters
    ----------
    tool_runner:
        The ``ToolRunner`` for discovering and executing tools.
    execution_context:
        The ``PlanExecutionContext`` carrying plan metadata and
        changeset store.
    """

    def __init__(
        self,
        *,
        tool_runner: ToolRunner,
        execution_context: PlanExecutionContext,
    ) -> None:
        if tool_runner is None:
            raise ValidationError("tool_runner must not be None")
        if execution_context is None:
            raise ValidationError("execution_context must not be None")

        self._tool_runner = tool_runner
        self._execution_context = execution_context
        self._logger = logger.bind(
            plan_id=execution_context.plan_id,
            component="runtime_execute_actor",
        )

    @property
    def tool_runner(self) -> ToolRunner:
        """The underlying tool runner."""
        return self._tool_runner

    @property
    def execution_context(self) -> PlanExecutionContext:
        """The execution context."""
        return self._execution_context

    def execute(
        self,
        decisions: list[Any],
        stream_callback: StreamCallback | None = None,
    ) -> RuntimeExecuteResult:
        """Execute a list of strategy decisions through the tool runtime.

        For each decision, discovers available tools and records a stub
        invocation into the changeset store.  In later milestones, this
        will dispatch real tool calls based on the decision content.

        Args:
            decisions: Strategy decisions (StrategyDecision instances).
            stream_callback: Optional callback for streaming status.

        Returns:
            A ``RuntimeExecuteResult`` with changeset and execution data.
        """
        start_time = time.monotonic()
        plan_id = self._execution_context.plan_id

        if stream_callback is not None:
            stream_callback(
                "runtime_execute_started",
                {"plan_id": plan_id, "decision_count": len(decisions)},
            )

        # Start a changeset for this execution run
        changeset_id = self._execution_context.start_changeset()

        tool_call_count = 0
        decision_ids_processed: list[str] = []
        sandbox_refs: list[str] = []

        if self._execution_context.sandbox_root is not None:
            sandbox_refs.append(self._execution_context.sandbox_root)

        for decision in decisions:
            decision_id = getattr(decision, "decision_id", str(ULID()))
            step_text = getattr(decision, "step_text", "unknown step")
            sequence = getattr(decision, "sequence", 0)

            if stream_callback is not None:
                stream_callback(
                    "runtime_execute_step",
                    {
                        "plan_id": plan_id,
                        "decision_id": decision_id,
                        "step": step_text,
                        "sequence": sequence,
                    },
                )

            # Discover available tools
            available_tools = self._tool_runner.discover()
            tool_call_count += len(available_tools)

            # Record a stub invocation entry for each decision
            invocation = ToolInvocation(
                plan_id=plan_id,
                tool_name="stub/execute-step",
                arguments={"step_text": step_text, "sequence": sequence},
                result={"status": "stub_executed", "tools_found": len(available_tools)},
                success=True,
                duration_ms=0.0,
                sandbox_path=self._execution_context.sandbox_root,
            )

            # Record a change entry for traceability
            entry = ChangeEntry(
                plan_id=plan_id,
                resource_id=decision_id,
                tool_name="stub/execute-step",
                operation=ChangeOperation.MODIFY,
                path=f"decisions/{decision_id}",
            )
            self._execution_context.record_change(entry)
            decision_ids_processed.append(decision_id)

            self._logger.debug(
                "Decision processed",
                decision_id=decision_id,
                step_text=step_text,
                invocation_id=invocation.invocation_id,
            )

        elapsed_ms = (time.monotonic() - start_time) * 1000.0

        if stream_callback is not None:
            stream_callback(
                "runtime_execute_complete",
                {
                    "plan_id": plan_id,
                    "changeset_id": changeset_id,
                    "tool_call_count": tool_call_count,
                    "decision_count": len(decision_ids_processed),
                    "duration_ms": elapsed_ms,
                },
            )

        self._logger.info(
            "Runtime execution completed",
            changeset_id=changeset_id,
            tool_call_count=tool_call_count,
            decisions_processed=len(decision_ids_processed),
            duration_ms=elapsed_ms,
        )

        return RuntimeExecuteResult(
            changeset_id=changeset_id,
            tool_call_count=tool_call_count,
            sandbox_refs=sandbox_refs,
            decision_ids_processed=decision_ids_processed,
            execution_duration_ms=elapsed_ms,
        )
