"""Real LLM-backed actors for Strategize and Execute phases.

Replaces the local-only stub actors with implementations that resolve
the plan's configured actor names (e.g. ``openai/gpt-4``) to live
LangChain LLM instances via ``ProviderRegistry`` and invoke them for
strategy decomposition and code generation.
"""

from __future__ import annotations

import os
import re
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

import structlog
from ulid import ULID

from cleveragents.application.services.execute_phase_context_assembler import (
    ExecutePhaseContextAssembler,
)
from cleveragents.application.services.plan_executor import (
    ExecuteResult,
    StrategizeResult,
    StrategyDecision,
    StreamCallback,
)
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.acms.crp import AssembledContext
from cleveragents.domain.models.core.action import Action
from cleveragents.domain.models.core.plan import Plan, PlanInvariant
from cleveragents.tool.builtins.changeset import ChangeSet, ChangeSetEntry

if TYPE_CHECKING:
    from cleveragents.providers.registry import ProviderRegistry
    from cleveragents.tool.runner import ToolRunner


# ---------------------------------------------------------------------------
# Protocols — typed interfaces for injected dependencies
# ---------------------------------------------------------------------------


@runtime_checkable
class PlanLifecycleProtocol(Protocol):
    """Minimal interface of PlanLifecycleService required by LLM actors.

    Defines only the methods actually called by ``LLMStrategizeActor`` and
    ``LLMExecuteActor``, allowing any conforming object (including mocks) to
    be injected without importing the concrete ``PlanLifecycleService``.
    """

    def get_plan(self, plan_id: str) -> Plan:
        """Return the Plan for the given *plan_id*."""
        ...

    def get_action(self, action_name: str) -> Action:
        """Return the Action for the given *action_name*."""
        ...


logger = structlog.get_logger(__name__)

# Maximum characters of LLM response to log for debugging
_LOG_RESPONSE_CHARS = 500


def _parse_actor_name(actor_name: str) -> tuple[str, str]:
    """Split ``provider/model`` into *(provider_type, model_id)*.

    Examples:
        ``openai/gpt-4``     → ``("openai", "gpt-4")``
        ``anthropic/claude-3`` → ``("anthropic", "claude-3")``
        ``gpt-4``             → ``("openai", "gpt-4")``  (default provider)
    """
    if not actor_name:
        return ("openai", "gpt-4")
    parts = actor_name.split("/", 1)
    if len(parts) == 2:
        return (parts[0], parts[1])
    return ("openai", actor_name)


class LLMStrategizeActor:
    """Strategize actor that uses a real LLM to decompose tasks.

    Resolves the plan's ``strategy_actor`` name to a live LLM via
    ``ProviderRegistry.create_llm()`` and asks it to break the
    ``definition_of_done`` into discrete implementation steps, returning
    them as a list of ``StrategyDecision`` objects.
    """

    def __init__(
        self,
        provider_registry: ProviderRegistry | None,
        lifecycle_service: PlanLifecycleProtocol,
    ) -> None:
        if provider_registry is None:
            raise ValidationError("provider_registry must not be None")
        if lifecycle_service is None:
            raise ValidationError("lifecycle_service must not be None")
        self._registry = provider_registry
        self._lifecycle: PlanLifecycleProtocol = lifecycle_service
        self._logger = logger.bind(actor="llm_strategize")

    def execute(
        self,
        plan_id: str,
        definition_of_done: str | None,
        invariants: list[PlanInvariant] | None = None,
        stream_callback: StreamCallback | None = None,
    ) -> StrategizeResult:
        """Invoke the LLM to decompose *definition_of_done* into steps."""
        if not plan_id:
            raise ValidationError("plan_id must not be empty")

        if stream_callback is not None:
            stream_callback(
                "strategize_started", {"plan_id": plan_id, "phase": "strategize"}
            )

        # Resolve actor name from plan → action
        plan = self._lifecycle.get_plan(plan_id)
        action = self._lifecycle.get_action(plan.action_name)
        actor_name = action.strategy_actor or "openai/gpt-4"
        provider_type, model_id = _parse_actor_name(actor_name)

        self._logger.info(
            "Resolving LLM for strategize",
            plan_id=plan_id,
            actor_name=actor_name,
            provider=provider_type,
            model=model_id,
        )

        llm = self._registry.create_llm(provider_type=provider_type, model_id=model_id)

        dod = definition_of_done or "Complete the plan objectives"
        prompt = (
            "You are an expert software architect. Analyze the following task "
            "and break it into concrete, sequential implementation steps.\n\n"
            f"Task:\n{dod}\n\n"
            "Return ONLY a numbered list (1., 2., …) of steps. "
            "Each step must be a single, actionable change. "
            "Do not include commentary before or after the list."
        )

        from langchain_core.messages import HumanMessage

        # TODO(#650): Wire actor-configured response_format into provider calls
        # when structured-output enforcement is implemented in runtime execution.
        response = llm.invoke([HumanMessage(content=prompt)])
        content = response.content if hasattr(response, "content") else str(response)

        self._logger.debug(
            "LLM strategize response",
            plan_id=plan_id,
            response_preview=content[:_LOG_RESPONSE_CHARS],
        )

        decisions = self._parse_decisions(content)

        root_id = str(ULID())
        result_decisions: list[StrategyDecision] = []
        for idx, step_text in enumerate(decisions):
            result_decisions.append(
                StrategyDecision(
                    decision_id=str(ULID()) if idx > 0 else root_id,
                    step_text=step_text,
                    sequence=idx,
                    parent_id=root_id if idx > 0 else None,
                )
            )

        invariant_records: list[dict[str, Any]] = []
        for inv in invariants or []:
            invariant_records.append(
                {
                    "text": inv.text,
                    "source": inv.source.value,
                    "enforced": True,
                    "enforcement_note": "llm: accepted without reconciliation",
                }
            )

        if stream_callback is not None:
            stream_callback(
                "strategize_decisions",
                {
                    "plan_id": plan_id,
                    "decision_count": len(result_decisions),
                    "root_id": root_id,
                },
            )

        result = StrategizeResult(
            decision_root_id=root_id,
            decisions=result_decisions,
            invariant_records=invariant_records,
        )

        if stream_callback is not None:
            stream_callback(
                "strategize_complete",
                {"plan_id": plan_id, "decision_count": len(result_decisions)},
            )

        self._logger.info(
            "LLM strategize completed",
            plan_id=plan_id,
            decision_count=len(result_decisions),
        )
        return result

    @staticmethod
    def _parse_decisions(llm_output: str) -> list[str]:
        """Parse numbered steps from LLM response text."""
        lines = llm_output.strip().splitlines()
        steps: list[str] = []
        for line in lines:
            cleaned = line.strip()
            if not cleaned:
                continue
            # Strip numbered prefix (e.g. "1.", "2)", "1 -")
            match = re.match(r"^\d+[\.\)\-\:]\s*", cleaned)
            if match:
                cleaned = cleaned[match.end() :].strip()
            # Strip bullet prefixes
            for prefix in ("-", "*", "•"):
                if cleaned.startswith(prefix):
                    cleaned = cleaned[len(prefix) :].strip()
                    break
            if cleaned:
                steps.append(cleaned)
        return steps if steps else ["Complete the plan objectives"]


class LLMExecuteActor:
    """Execute actor that uses a real LLM to generate code changes.

    Resolves the plan's ``execution_actor`` name to a live LLM via
    ``ProviderRegistry.create_llm()`` and asks it to produce file
    changes for the strategy decisions, returning a ``ChangeSet``.
    """

    def __init__(
        self,
        provider_registry: ProviderRegistry | None,
        lifecycle_service: PlanLifecycleProtocol,
        context_assembler: ExecutePhaseContextAssembler | None = None,
        tier_service: Any | None = None,
        project_repository: Any | None = None,
        resource_registry: Any | None = None,
    ) -> None:
        if provider_registry is None:
            raise ValidationError("provider_registry must not be None")
        if lifecycle_service is None:
            raise ValidationError("lifecycle_service must not be None")
        self._registry = provider_registry
        self._lifecycle: PlanLifecycleProtocol = lifecycle_service
        self._context_assembler = context_assembler
        self._tier_service = tier_service
        self._project_repository = project_repository
        self._resource_registry = resource_registry
        self._logger = logger.bind(actor="llm_execute")

    @staticmethod
    def _format_context_for_prompt(assembled: AssembledContext) -> str:
        """Render assembled ACMS context into a deterministic prompt section."""
        lines = [
            "ACMS Execute-Phase Context:",
            f"- total_tokens: {assembled.total_tokens}",
            f"- budget_used: {assembled.budget_used:.4f}",
            f"- context_hash: {assembled.context_hash}",
            f"- strategies_used: {', '.join(assembled.strategies_used) or '(none)'}",
        ]
        if assembled.preamble:
            lines.append(f"- preamble: {assembled.preamble}")
        lines.append("- fragments:")
        for idx, fragment in enumerate(assembled.fragments, start=1):
            lines.append(
                "  "
                f"{idx}. {fragment.provenance.resource_uri} "
                f"({fragment.token_count} tokens, depth={fragment.detail_depth})"
            )
            lines.append(
                f"     location: {fragment.provenance.location or '(unknown)'}"
            )
            lines.append("     content:")
            lines.append(f"{fragment.content}")
        return "\n".join(lines)

    def execute(
        self,
        plan_id: str,
        decisions: list[StrategyDecision],
        tool_runner: ToolRunner | None = None,
        sandbox_root: str | None = None,
        stream_callback: StreamCallback | None = None,
        *,
        read_only: bool = False,
    ) -> ExecuteResult:
        """Invoke the LLM to generate file changes for the given decisions."""
        if not plan_id:
            raise ValidationError("plan_id must not be empty")

        if stream_callback is not None:
            stream_callback("execute_started", {"plan_id": plan_id, "phase": "execute"})

        # Resolve actor name from plan → action
        plan = self._lifecycle.get_plan(plan_id)
        action = self._lifecycle.get_action(plan.action_name)
        actor_name = action.execution_actor or "openai/gpt-4"
        provider_type, model_id = _parse_actor_name(actor_name)

        self._logger.info(
            "Resolving LLM for execute",
            plan_id=plan_id,
            actor_name=actor_name,
            provider=provider_type,
            model=model_id,
        )

        llm = self._registry.create_llm(provider_type=provider_type, model_id=model_id)

        # Hydrate context tiers from linked project resources before
        # assembling context.  Without this, ContextTierService starts
        # empty and the LLM receives zero file context (bug #1028).
        if (
            self._tier_service is not None
            and self._project_repository is not None
            and self._resource_registry is not None
        ):
            try:
                # Lazy import to avoid pulling heavy dependencies at module
                # level — the same pattern used for langchain_core.messages
                # elsewhere in this file.  A top-level import here breaks
                # the M1 E2E test which loads this module without a full
                # container.
                from cleveragents.application.services.context_tier_hydrator import (
                    hydrate_tiers_for_plan,
                )

                project_names = [
                    pl.project_name for pl in getattr(plan, "project_links", [])
                ]
                if project_names:
                    hydrate_tiers_for_plan(
                        tier_service=self._tier_service,
                        project_names=project_names,
                        project_repository=self._project_repository,
                        resource_registry=self._resource_registry,
                    )
            except Exception as _hydration_exc:
                self._logger.warning(
                    "context_hydration_failed",
                    error=str(_hydration_exc),
                    plan_id=plan_id,
                )

        assembled_context: AssembledContext | None = None
        if self._context_assembler is not None:
            try:
                assembled_context = self._context_assembler.assemble(plan)
            except Exception as _asm_err:
                self._logger.warning(
                    "execute_context_assembly_failed",
                    plan_id=plan_id,
                    error=str(_asm_err),
                )

        # Build a prompt summarising the decisions
        steps_text = "\n".join(f"{d.sequence + 1}. {d.step_text}" for d in decisions)
        context_section = ""
        if assembled_context is not None:
            context_section = (
                "\n\n"
                f"{self._format_context_for_prompt(assembled_context)}"
                "\n\nUse this assembled context instead of requesting full raw files."
            )
        prompt = (
            "You are an expert software engineer. "
            "Implement the following steps by producing file changes.\n\n"
            f"Steps:\n{steps_text}\n\n"
            f"{context_section}"
            "For each file you create or modify, output a block:\n"
            "FILE: <path>\n```\n<full file content>\n```\n\n"
            "Only output file blocks. Do not add commentary."
        )

        from langchain_core.messages import HumanMessage

        # TODO(#650): Wire actor-configured response_format into provider calls
        # when structured-output enforcement is implemented in runtime execution.
        response = llm.invoke([HumanMessage(content=prompt)])
        content = response.content if hasattr(response, "content") else str(response)

        self._logger.debug(
            "LLM execute response",
            plan_id=plan_id,
            response_preview=content[:_LOG_RESPONSE_CHARS],
        )

        # Stream per-decision progress
        for decision in decisions:
            if stream_callback is not None:
                stream_callback(
                    "execute_step",
                    {
                        "plan_id": plan_id,
                        "decision_id": decision.decision_id,
                        "step": decision.step_text,
                        "sequence": decision.sequence,
                    },
                )

        # Parse LLM output into changeset entries
        entries = self._parse_file_blocks(content, plan_id)
        changeset_id = str(ULID())
        changeset = ChangeSet(plan_id=plan_id, entries=entries)

        # Write generated files to sandbox when available
        if sandbox_root is not None and not read_only:
            self._write_to_sandbox(entries, sandbox_root, content)

        sandbox_refs: list[str] = []
        if sandbox_root is not None:
            sandbox_refs.append(sandbox_root)

        if stream_callback is not None:
            stream_callback(
                "execute_complete",
                {
                    "plan_id": plan_id,
                    "changeset_id": changeset_id,
                    "tool_calls_count": len(entries),
                },
            )

        self._logger.info(
            "LLM execute completed",
            plan_id=plan_id,
            changeset_id=changeset_id,
            entry_count=len(entries),
        )

        return ExecuteResult(
            changeset_id=changeset_id,
            changeset=changeset,
            tool_calls_count=len(entries),
            sandbox_refs=sandbox_refs,
        )

    @staticmethod
    def _parse_file_blocks(llm_output: str, plan_id: str) -> list[ChangeSetEntry]:
        """Extract ``FILE: <path>`` + fenced code blocks from LLM output."""
        entries: list[ChangeSetEntry] = []
        # Pattern: FILE: <path> followed by a fenced code block
        pattern = re.compile(
            r"FILE:\s*(.+?)\s*\n```[^\n]*\n(.*?)```",
            re.DOTALL,
        )
        for match in pattern.finditer(llm_output):
            path = match.group(1).strip()
            entries.append(
                ChangeSetEntry(
                    operation="create",
                    path=path,
                    resource_id=plan_id,
                    tool_name="llm_execute",
                    timestamp=datetime.now(tz=UTC),
                    metadata={"source": "llm", "plan_id": plan_id},
                )
            )
        return entries

    @staticmethod
    def _write_to_sandbox(
        entries: list[ChangeSetEntry],
        sandbox_root: str,
        llm_output: str,
    ) -> None:
        """Write generated file contents to the sandbox directory."""

        pattern = re.compile(
            r"FILE:\s*(.+?)\s*\n```[^\n]*\n(.*?)```",
            re.DOTALL,
        )
        for match in pattern.finditer(llm_output):
            path = match.group(1).strip()
            content = match.group(2)
            full_path = os.path.normpath(os.path.join(sandbox_root, path))
            # Path traversal guard: reject paths escaping sandbox
            if not full_path.startswith(sandbox_root + os.sep):
                logger.warning(
                    "Rejected path traversal in LLM output",
                    path=path,
                    resolved=full_path,
                )
                continue
            os.makedirs(os.path.dirname(full_path), exist_ok=True)
            try:
                with open(full_path, "w") as fh:
                    fh.write(content)
            except OSError:
                logger.warning(
                    "Failed to write generated file to sandbox",
                    path=full_path,
                    exc_info=True,
                )
