"""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
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, NamedTuple, 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.application.services.strategy_resolution import _is_known_provider
from cleveragents.config.settings import Settings, get_settings
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.application.services.context_tiers import ContextTierService
    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*."""
        ...

    def commit_plan(self, plan: Plan) -> None:
        """Commit the plan by persisting its state and artifacts."""
        ...

    def resolve_actor_provider_model(self, actor_name: str) -> str | None:
        """Resolve a namespaced actor name to ``provider/model`` format."""
        ...

    def resolve_actor_options(self, actor_name: str) -> dict[str, Any] | None:
        """Resolve a namespaced actor name to its opaque options dict."""
        ...


# ---------------------------------------------------------------------------
# Internal data structures
# ---------------------------------------------------------------------------


class _FileBlock(NamedTuple):
    """A parsed file block with path and content from LLM output.

    Used to eliminate double-parsing: _parse_file_blocks returns these
    alongside ChangeSetEntry objects, and _write_to_sandbox consumes
    them directly without re-running regex patterns.
    """

    path: str
    content: str
    operation: str = "create"


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 *actor_name* into *(provider_type, model_id)*.

    When the first slash-separated segment is a known
    :class:`ProviderType` value the input is treated as
    ``provider/model``.  Otherwise it is treated as a namespace/name
    reference and the entire string is returned as the model with the
    default provider.  Callers SHOULD pre-resolve namespace/name
    references via the actor registry before calling this function.

    Examples:
        ``openai/gpt-4``       → ``("openai", "gpt-4")``
        ``anthropic/claude-3`` → ``("anthropic", "claude-3")``
        ``gpt-4``              → ``("openai", "gpt-4")``
        ``local/my-actor``     → ``("openai", "local/my-actor")``
    """
    if not actor_name:
        return ("openai", "gpt-4")
    parts = actor_name.split("/", 1)
    if len(parts) == 2:
        provider, model = parts[0], parts[1]
        if _is_known_provider(provider):
            return (provider, model)
        if not provider:
            return ("openai", model)
        return ("openai", actor_name)
    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"

        # Resolve actor options BEFORE the name is resolved to
        # provider/model (because the original namespace/name is
        # needed for options lookup).
        actor_options = self._lifecycle.resolve_actor_options(actor_name) or {}

        # Pre-resolve namespace/name to provider/model via actor registry
        resolved = self._lifecycle.resolve_actor_provider_model(actor_name)
        if resolved:
            actor_name = resolved

        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,
        )

        from cleveragents.actor.config import build_llm_kwargs_from_options

        llm_kwargs = build_llm_kwargs_from_options(actor_options, logger=self._logger)
        llm = self._registry.create_llm(
            provider_type=provider_type, model_id=model_id, **llm_kwargs
        )

        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

        # (#650): response_format wiring deferred until structured-output
        # enforcement is implemented in runtime execution. Issue #9025.
        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: ContextTierService | None = None,
        project_repository: Any | None = None,
        resource_registry: Any | None = None,
        settings: Settings | 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._settings = settings or get_settings()
        self._logger = logger.bind(actor="llm_execute")

    def _provider_supports_configurable(self, provider_type: str) -> bool:
        """Determine if a provider supports configurable parameters in invoke().

        Some LangChain providers don't support the config parameter with
        configurable.max_tokens. This method returns True for providers
        that do support it and False for those that don't.

        Args:
            provider_type: The provider type (e.g., "openai", "anthropic")

        Returns:
            bool: True if the provider supports configurable parameters
        """
        # Providers known to support configurable parameters
        supported_providers = {
            "openai",
            "azure",
            "openrouter",
            "together",
        }

        # Providers known to NOT support configurable parameters
        unsupported_providers = {
            "anthropic",
            "google",
            "gemini",
            "cohere",
            "groq",
        }

        if provider_type in supported_providers:
            return True
        if provider_type in unsupported_providers:
            return False

        # For unknown providers, default to not supporting configurable
        # to avoid breaking them
        return False

    @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"

        # Resolve actor options BEFORE the name is resolved to
        # provider/model (because the original namespace/name is
        # needed for options lookup).
        actor_options = self._lifecycle.resolve_actor_options(actor_name) or {}

        # Pre-resolve namespace/name to provider/model via actor registry
        resolved = self._lifecycle.resolve_actor_provider_model(actor_name)
        if resolved:
            actor_name = resolved

        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,
        )

        try:
            from cleveragents.actor.config import build_llm_kwargs_from_options

            llm_kwargs = build_llm_kwargs_from_options(
                actor_options, logger=self._logger
            )
            llm = self._registry.create_llm(
                provider_type=provider_type, model_id=model_id, **llm_kwargs
            )
        except ValueError as exc:
            self._logger.warning(
                "LLM provider unavailable for execute, falling back to no-op: %s",
                exc,
                plan_id=plan_id,
            )
            # Return an empty result so the CLI can continue gracefully
            # instead of crashing with an unhandled ValueError.
            return ExecuteResult(
                changeset_id="",
                changeset=ChangeSet(plan_id=plan_id, entries=[]),
                sandbox_refs=[],
                tool_calls_count=0,
                decision_ids_processed=[d.decision_id for d in decisions],
                execution_duration_ms=0.0,
            )

        assembled_context: AssembledContext | None = None
        if self._context_assembler is not None:
            try:
                assembled_context = self._context_assembler.assemble(plan)
            except (RuntimeError, ConnectionError, OSError) 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"
            ">>>>>>>> CLEVERAGENTS_FILE_START >>>>>>>>\n"
            "<full file content>\n"
            ">>>>>>>> CLEVERAGENTS_FILE_END >>>>>>>>\n\n"
            "Only output file blocks. Do not add commentary."
        )

        from langchain_core.messages import HumanMessage

        # (#650): response_format wiring deferred until structured-output
        # enforcement is implemented in runtime execution. Issue #9025.
        # Consider increasing max_tokens for longer responses.

        # Use max_tokens value from settings for all providers.
        # - Configurable providers: pass via config["configurable"]
        # - Non-configurable providers (e.g. anthropic, google): set directly
        #   on the LLM instance to avoid silently ignoring the token limit (M1)
        max_tokens = self._settings.llm_max_tokens
        _start_ns = time.monotonic_ns()
        if self._provider_supports_configurable(provider_type):
            response = llm.invoke(
                [HumanMessage(content=prompt)],
                config={"configurable": {"max_tokens": max_tokens}},
            )
        else:
            # Set max_tokens on the LLM instance for providers that don't
            # support the config["configurable"] approach (M1 fix).
            # ChatAnthropic and similar providers accept max_tokens via
            # model_kwargs which must be set before invoke().
            llm.max_tokens = max_tokens  # type: ignore[attr-defined]
            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 and file blocks (single pass).
        # H2: content stored in entry.metadata["content"] for downstream consumers.
        # H3: blocks passed directly to _write_to_sandbox -- no re-parsing needed.
        entries, blocks = 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(blocks, sandbox_root)

        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),
        )

        _duration_ms = (time.monotonic_ns() - _start_ns) / 1_000_000

        return ExecuteResult(
            changeset_id=changeset_id,
            changeset=changeset,
            tool_calls_count=len(entries),
            sandbox_refs=sandbox_refs,
            decision_ids_processed=[d.decision_id for d in decisions],
            execution_duration_ms=_duration_ms,
        )

    @staticmethod
    def _parse_file_blocks(
        llm_output: str, plan_id: str
    ) -> tuple[list[ChangeSetEntry], list[_FileBlock]]:
        """Extract ``FILE: <path>`` + content between delimiter markers.

        Parses the LLM output **once** with three regex patterns and returns
        both the changeset entries (with content stored in metadata) and the
        raw file blocks for direct use by callers -- eliminating the double-
        parse problem where _write_to_sandbox independently re-ran the same
        three patterns against the same output.

        Supports escaped delimiters via negative lookbehind so that
        backslash-prefixed occurrences (e.g. \\<CAFS>) inside file body
        are treated as literal text, not block boundaries.  Also supports the
        legacy ``<<<<<<< CLEVERAGENTS_FILE_START >>>>>>>`` markers for
        backward compatibility with existing LLM output, plus the newer
        ``>>>>>>>> CLEVERAGENTS_FILE_START >>>>>>>>`` markers.

        Returns:
            A ``(entries, blocks)`` tuple where *entries* is a list of
            :class:`ChangeSetEntry` objects (each with ``metadata["content"]``
            set to the file body) and *blocks* is a list of :class:`_FileBlock`
            objects with the path, content, and operation for each parsed file.
        """
        entries: list[ChangeSetEntry] = []
        blocks: list[_FileBlock] = []
        _seen_paths: set[str] = set()

        # Short-form pattern: <CAFS> / </CAFE>
        cafs_pat = re.compile(
            r"FILE:\s*(.+?)\s*\n(?<!\\)<CAFS>\n(.*?)\n(?<!\\)</CAFE>",
            re.DOTALL,
        )
        # Non-conflicting marker pattern
        # (>>>>>>>> prefix, avoids git merge conflicts).
        # NOTE: Must match the prompt delimiter exactly (8 trailing > characters).
        _NEW_START = ">>>>>>>> CLEVERAGENTS_FILE_START >>>>>>>>"
        _NEW_END = ">>>>>>>> CLEVERAGENTS_FILE_END >>>>>>>>"
        new_pat = re.compile(
            rf"FILE:\s*(.+?)\s*\n(?<!\\){re.escape(_NEW_START)}\n(.*?)\n(?<!\\){re.escape(_NEW_END)}",
            re.DOTALL,
        )
        # Legacy-marker pattern.
        _LEGACY_START = "<<<<<<< CLEVERAGENTS_FILE_START >>>>>>>"
        _LEGACY_END = "<<<<<<< CLEVERAGENTS_FILE_END >>>>>>>"
        legacy_pat = re.compile(
            rf"FILE:\s*(.+?)\s*\n(?<!\\){re.escape(_LEGACY_START)}\n(.*?)\n(?<!\\){re.escape(_LEGACY_END)}",
            re.DOTALL,
        )

        for pattern in (cafs_pat, new_pat, legacy_pat):
            for match in pattern.finditer(llm_output):
                path = match.group(1).strip()
                file_content = match.group(2)
                if path not in _seen_paths:
                    _seen_paths.add(path)
                    entry = 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,
                            "content": file_content,
                        },
                    )
                    entries.append(entry)
                    blocks.append(
                        _FileBlock(
                            path=path,
                            content=file_content,
                            operation="create",
                        ),
                    )

        return entries, blocks

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

        Uses semantic path containment via os.path.relpath instead of
        string prefix matching (str.startswith). String prefix matching
        is vulnerable to sibling-directory prefix-collision attacks where
        /tmp/sandbox would incorrectly match /tmp/sandboxmalicious/file.

        See issue #7478 -- startswith bypass in path containment checks.

        Unlike the previous implementation, this method consumes pre-parsed
        _FileBlock objects produced by _parse_file_blocks rather than
        re-running regex patterns against the raw LLM output.
        """
        for block in blocks:
            path = block.path
            file_content = block.content
            full_path = os.path.normpath(os.path.join(sandbox_root, path))
            # Path traversal guard: reject paths escaping sandbox.
            # m2 fix: os.path.relpath raises ValueError on Windows when src
            # and sandbox_root are on different drives — catch it gracefully.
            try:
                rel = os.path.relpath(full_path, sandbox_root)
            except ValueError:
                rel = "..invalid"
            if rel.startswith(".." + os.sep) or rel == "..":
                logger.warning(
                    "Rejected path traversal in LLM output",
                    path=path,
                    resolved=full_path,
                )
                continue
            try:
                # C2 fix: guard against empty dirname (flat filenames) and catch
                # OSError from makedirs (e.g. permission denied) in the same
                # handler as the open() call.
                dir_path = os.path.dirname(full_path)
                if dir_path:
                    os.makedirs(dir_path, exist_ok=True)
                with open(full_path, "w") as fh:
                    fh.write(file_content)
                    logger.debug(
                        "Wrote generated file to sandbox",
                        path=full_path,
                        content_length=len(file_content),
                    )
            except OSError:
                logger.warning(
                    "Failed to write generated file to sandbox",
                    path=full_path,
                    exc_info=True,
                )
