"""LLM-powered Strategy Actor for the plan ``strategize`` phase.

Receives a plan's definition of done, available resources, and project context,
then uses an LLM to produce a detailed execution strategy as a hierarchical
action tree with dependencies, resource requirements, complexity estimates, and
risk scores.

Falls back to :class:`StrategizeStubActor` when no LLM provider is configured,
ensuring graceful degradation in environments without AI credentials.

Based on ``docs/specification.md`` §Strategize Phase.

.. note:: Known limitations (future work)

    The spec (§Strategize) envisions the strategy actor producing
    ``resource_selection``, ``subplan_spawn``, ``subplan_parallel_spawn``,
    and ``invariant_enforced`` Decision objects.  This implementation
    currently produces only ``strategy_choice`` and ``prompt_definition``
    types.  Invariant records are returned as plain dicts (placeholder
    pending the Invariant Reconciliation Actor).  These gaps will be
    addressed as the respective subsystems land.

Forgejo: #828
"""

from __future__ import annotations

import time
from typing import TYPE_CHECKING, Any

import structlog
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import ValidationError as PydanticValidationError
from ulid import ULID

from cleveragents.application.services.plan_executor import (
    StrategizeResult,
    StrategyDecision,
    StreamCallback,
)
from cleveragents.application.services.strategy_models import (
    StrategyAction,
    StrategyTree,
)
from cleveragents.application.services.strategy_parsing import (
    _DEFAULT_DESCRIPTION,
    _MAX_ACTIONS,
    _MAX_JSON_PARSE_RETRIES,
    parse_strategy_response,
)
from cleveragents.application.services.strategy_prompt import (
    _MAX_CONTEXT_CHARS,
    _MAX_DOD_CHARS,
    _MAX_INVARIANTS,
    _MAX_RESOURCES,
    STRATEGY_SYSTEM_PROMPT,
    _sanitize_xml_content,
    _truncate_at_word,
    build_strategy_prompt,
)
from cleveragents.application.services.strategy_resolution import (
    AcmsPipeline,
    LifecycleService,
    _parse_actor_name,
    resolve_strategy_actor,
    validate_no_cycles,
)
from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core.decision import Decision, DecisionType
from cleveragents.domain.models.core.plan import PlanInvariant

if TYPE_CHECKING:
    from cleveragents.providers.registry import ProviderRegistry

logger = structlog.get_logger(__name__)

# Re-export public API so existing imports from this module keep working.
# Re-exported constants (prefixed with _) are used by tests.
__all__ = [
    "_DEFAULT_DESCRIPTION",
    "_MAX_ACTIONS",
    "_MAX_CONTEXT_CHARS",
    "_MAX_DOD_CHARS",
    "_MAX_INVARIANTS",
    "_MAX_JSON_PARSE_RETRIES",
    "_MAX_RESOURCES",
    "StrategyAction",
    "StrategyActor",
    "StrategyTree",
    "_parse_actor_name",
    "_sanitize_xml_content",
    "_truncate_at_word",
    "build_strategy_prompt",
    "parse_strategy_response",
    "resolve_strategy_actor",
    "validate_no_cycles",
]

_LOG_RESPONSE_CHARS = 500
_QUESTION_DESC_MAX_CHARS = 100
_DEFAULT_ACTOR_NAME = "openai/gpt-4"
_LLM_MAX_RETRIES = 2
_LLM_RETRY_BASE_DELAY = 1.0
_ULID_ALPHABET = frozenset("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
_ULID_LEN = 26


# ---------------------------------------------------------------------------
# Strategy Actor
# ---------------------------------------------------------------------------


class StrategyActor:
    """LLM-powered Strategy Actor for the ``strategize`` phase.

    Receives a plan's definition of done, available resources, and context,
    then uses an LLM to produce a detailed execution strategy as a
    hierarchical action tree with dependencies.

    Falls back to :class:`StrategizeStubActor` when:
    - No ``provider_registry`` is supplied (``None``)
    - The LLM invocation fails after retries

    Each action in the strategy tree includes:
    - description: concrete step to execute
    - resource_requirements: needed resources
    - estimated_complexity: low / medium / high
    - risk_score: 0.0-1.0
    - depends_on: dependency links (validated for cycles)

    The strategy output produces ``Decision`` objects of type
    ``strategy_choice``.

    .. note::

        This actor supersedes :class:`LLMStrategizeActor`
        (``llm_actors.py``) by adding hierarchical action trees,
        dependency graph validation, risk scoring, and ACMS context
        integration.  ``LLMStrategizeActor`` remains available for
        callers that only need flat decision lists.
    """

    def __init__(
        self,
        provider_registry: ProviderRegistry | None = None,
        lifecycle_service: LifecycleService | None = None,
        acms_pipeline: AcmsPipeline | None = None,
    ) -> None:
        """Initialize the Strategy Actor.

        Args:
            provider_registry: Optional provider registry for LLM access.
                When ``None``, the actor falls back to stub behaviour.
            lifecycle_service: Optional lifecycle service for plan/action
                resolution.
            acms_pipeline: Optional ACMS pipeline for code analysis context.
        """
        self._registry = provider_registry
        self._lifecycle = lifecycle_service
        self._acms_pipeline = acms_pipeline
        self._logger = logger.bind(actor="strategy_actor")

    @property
    def has_llm(self) -> bool:
        """Return ``True`` if an LLM provider registry is configured."""
        return self._registry is not None

    def execute(
        self,
        plan_id: str,
        definition_of_done: str | None,
        invariants: list[PlanInvariant] | None = None,
        stream_callback: StreamCallback | None = None,
        *,
        resources: list[str] | None = None,
        project_context: str | None = None,
    ) -> StrategizeResult:
        """Run the strategize phase, producing a hierarchical strategy.

        When an LLM provider is configured, constructs a prompt from the
        plan context and invokes the LLM.  Invariants are included in the
        prompt so the LLM can factor constraints into its strategy.
        Falls back to simple bullet-point parsing when no LLM is available.

        Args:
            plan_id: The plan ULID.
            definition_of_done: Completion criteria text.
            invariants: Optional plan invariants.
            stream_callback: Optional streaming event callback.
            resources: Optional list of available resource descriptions.
            project_context: Optional project context string.

        Returns:
            A ``StrategizeResult`` with decisions and invariant records.

        Raises:
            ValidationError: If ``plan_id`` is empty.
            PlanError: If the strategy tree contains circular dependencies.
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")
        if len(plan_id) != _ULID_LEN or not all(c in _ULID_ALPHABET for c in plan_id):
            raise ValidationError("plan_id must be a valid ULID")

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

        dod = definition_of_done or "Complete the plan objectives"

        # Try LLM-powered strategy generation
        used_llm = False
        if self._registry is not None:
            try:
                strategy_tree = self._execute_with_llm(
                    plan_id=plan_id,
                    definition_of_done=dod,
                    resources=resources,
                    project_context=project_context,
                    invariants=invariants,
                )
                used_llm = True
            except (PlanError, ValidationError):
                raise
            except PydanticValidationError:
                # Pydantic model validation failures indicate a
                # programming error in the parser, not an LLM issue.
                # Re-raise so it is not silently swallowed.
                raise
            except Exception as exc:
                # Broad catch for any LLM provider error — OpenAI,
                # Anthropic, httpx, and other third-party libraries
                # raise diverse exception types that cannot be
                # exhaustively enumerated.  Recovery logic: fall back
                # to deterministic stub strategy.
                # Individual retry attempts are already logged by
                # _invoke_llm_with_retry; log only the summary here.
                self._logger.warning(
                    "LLM strategy generation failed after %d attempt(s), "
                    "falling back to stub: %s",
                    _LLM_MAX_RETRIES + 1,
                    exc,
                    plan_id=plan_id,
                )
                strategy_tree = self._execute_stub(dod)
        else:
            self._logger.info(
                "No LLM provider configured, using stub strategy",
                plan_id=plan_id,
            )
            strategy_tree = self._execute_stub(dod)

        # Convert strategy tree to decisions
        decisions = self._tree_to_decisions(strategy_tree)

        # Build invariant records
        invariant_records = self._build_invariant_records(invariants)

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

        result = StrategizeResult(
            decision_root_id=strategy_tree.root_id,
            decisions=decisions,
            invariant_records=invariant_records,
        )

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

        self._logger.info(
            "Strategy generation completed",
            plan_id=plan_id,
            decision_count=len(decisions),
            used_llm=used_llm,
        )
        return result

    def build_decisions(
        self,
        plan_id: str,
        strategy_tree: StrategyTree,
    ) -> list[Decision]:
        """Convert a strategy tree into formal Decision domain objects.

        Each action in the tree becomes a ``Decision``.  The first action becomes
        the ``prompt_definition`` root decision.  Actions with ``parallel_group``
        become ``subplan_parallel_spawn`` decisions, actions that represent child
        plans become ``subplan_spawn`` decisions, and regular strategy decisions
        become ``strategy_choice`` decisions.

        ``downstream_decision_ids`` is populated from the strategy tree's
        dependency edges.

        .. note::

            This method is **not** called by :meth:`execute` or by
            ``PlanExecutor.run_strategize()`` today.  It is a
            forward-looking API that will be integrated once the
            ``PlanExecutor`` wires full Decision persistence into the
            strategize pipeline.  Until then it is exercised only by
            tests.

        Args:
            plan_id: The plan ULID.
            strategy_tree: The strategy tree to convert.

        Returns:
            List of ``Decision`` objects.

        Raises:
            ValidationError: If ``plan_id`` is empty.
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")
        if len(plan_id) != _ULID_LEN or not all(c in _ULID_ALPHABET for c in plan_id):
            raise ValidationError("plan_id must be a valid ULID")

        pre_decision_ids: list[str] = [str(ULID()) for _ in strategy_tree.actions]

        action_to_decision: dict[str, str] = {}
        for idx, action in enumerate(strategy_tree.actions):
            action_to_decision[action.action_id] = pre_decision_ids[idx]

        downstream_actions: dict[str, list[str]] = {}
        for action_id, dep_id in strategy_tree.dependency_edges:
            downstream_actions.setdefault(dep_id, []).append(action_id)

        # Build a mapping of action_id -> children action_ids for hierarchical analysis
        children_by_parent: dict[str, list[StrategyAction]] = {}
        for action in strategy_tree.actions:
            if action.parent_id is not None:
                children_by_parent.setdefault(action.parent_id, []).append(action)

        decisions: list[Decision] = []
        for idx, action in enumerate(strategy_tree.actions):
            parent_decision_id: str | None = None
            if idx > 0:
                raw_parent = action.parent_id or ""
                resolved = action_to_decision.get(raw_parent)
                if resolved is not None:
                    parent_decision_id = resolved
                else:
                    parent_decision_id = (
                        pre_decision_ids[0] if pre_decision_ids else None
                    )
                    if raw_parent:
                        logger.warning(
                            "Unresolvable parent_id '%s' for action at "
                            "index %d — falling back to root decision",
                            raw_parent,
                            idx,
                        )
                    elif action.parent_id is not None:
                        logger.warning(
                            "Empty-string parent_id for action at "
                            "index %d — falling back to root decision",
                            idx,
                        )

            ds_action_ids = downstream_actions.get(action.action_id, [])
            ds_decision_ids = [
                action_to_decision[a] for a in ds_action_ids if a in action_to_decision
            ]

            decision_type = self._determine_decision_type(idx, action)

            # NOTE: context_snapshot and actor_reasoning use default
            # empty values.  Full context snapshots (hot_context_hash,
            # hot_context_ref, relevant_resources, actor_state_ref per
            # spec §Decision Record Structure) will be populated once
            # the ACMS context snapshot pipeline is integrated into the
            # strategize flow.
            decision = Decision(
                decision_id=pre_decision_ids[idx],
                plan_id=plan_id,
                parent_decision_id=parent_decision_id,
                sequence_number=idx,
                decision_type=decision_type,
                question=(
                    "How to achieve: "
                    f"{_truncate_at_word(action.description, _QUESTION_DESC_MAX_CHARS)}"
                ),
                chosen_option=action.description,
                alternatives_considered=[],
                confidence_score=1.0 - action.risk_score,
                rationale=(
                    f"Complexity: {action.estimated_complexity}, "
                    f"Risk: {action.risk_score:.2f}"
                ),
                downstream_decision_ids=ds_decision_ids,
            )
            decisions.append(decision)
        return decisions

    # -- Private helpers --

    @staticmethod
    def _determine_decision_type(idx: int, action: StrategyAction) -> DecisionType:
        """Determine decision type for an action based on hierarchy and parallelization.

        Returns:
            DecisionType.PROMPT_DEFINITION for root action (idx == 0)
            DecisionType.SUBPLAN_PARALLEL_SPAWN for parallel groups
            DecisionType.SUBPLAN_SPAWN for explicit hierarchical subplans
            DecisionType.STRATEGY_CHOICE for regular strategy decisions
        """
        if idx == 0:
            return DecisionType.PROMPT_DEFINITION
        if action.parallel_group is not None:
            return DecisionType.SUBPLAN_PARALLEL_SPAWN
        if action.parent_step_explicit:
            return DecisionType.SUBPLAN_SPAWN
        return DecisionType.STRATEGY_CHOICE

    def _execute_with_llm(
        self,
        plan_id: str,
        definition_of_done: str,
        resources: list[str] | None = None,
        project_context: str | None = None,
        invariants: list[PlanInvariant] | None = None,
    ) -> StrategyTree:
        """Invoke the LLM and build a strategy tree from its response.

        Retries transient LLM failures up to ``_LLM_MAX_RETRIES`` times
        with exponential back-off before giving up.
        """
        if self._registry is None:
            raise PlanError("_execute_with_llm called without provider registry")

        # Resolve actor name if lifecycle is available
        actor_name = _DEFAULT_ACTOR_NAME
        if self._lifecycle is not None:
            try:
                plan = self._lifecycle.get_plan(plan_id)
                action = self._lifecycle.get_action(plan.action_name)
                actor_name = action.strategy_actor or _DEFAULT_ACTOR_NAME
            except (KeyError, ValueError, AttributeError, RuntimeError):
                self._logger.debug(
                    "Could not resolve actor from plan, using default",
                    plan_id=plan_id,
                    exc_info=True,
                )

        provider_type, model_id = _parse_actor_name(actor_name)

        self._logger.info(
            "Resolving LLM for strategy generation",
            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)

        # Gather ACMS context if pipeline available
        acms_context: str | None = None
        if self._acms_pipeline is not None:
            try:
                acms_result = self._acms_pipeline.get_context_summary()
                acms_context = str(acms_result) if acms_result else None
            except (RuntimeError, ConnectionError, TimeoutError, ValueError, OSError):
                # ACMS pipeline failures are explicitly non-fatal;
                # strategy generation proceeds without context enrichment.
                # Catches transient/environmental errors (network, timeout, resource).
                # Programming errors (AttributeError, TypeError, etc.) propagate.
                self._logger.debug(
                    "ACMS context retrieval failed (non-fatal)",
                    exc_info=True,
                )

        prompt = build_strategy_prompt(
            definition_of_done=definition_of_done,
            resources=resources,
            project_context=project_context,
            acms_context=acms_context,
            invariants=invariants,
        )

        messages = [
            SystemMessage(content=STRATEGY_SYSTEM_PROMPT),
            HumanMessage(content=prompt),
        ]

        # Retry loop for transient LLM failures
        content = self._invoke_llm_with_retry(llm, messages, plan_id)

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

        raw_actions = parse_strategy_response(content)
        return self._build_tree(raw_actions)

    def _invoke_llm_with_retry(
        self,
        llm: Any,
        messages: list[SystemMessage | HumanMessage],
        plan_id: str,
    ) -> str:
        """Invoke the LLM with retry logic for transient failures.

        Retries up to ``_LLM_MAX_RETRIES`` times with exponential
        back-off.  Returns the parsed content string.
        """
        last_exc: Exception = RuntimeError("LLM invocation failed after all retries")
        for attempt in range(_LLM_MAX_RETRIES + 1):
            try:
                response = llm.invoke(messages)
                return self._extract_content(response)
            except (PlanError, ValidationError):
                raise
            except Exception as exc:
                last_exc = exc
                if attempt < _LLM_MAX_RETRIES:
                    delay = _LLM_RETRY_BASE_DELAY * (2**attempt)
                    self._logger.warning(
                        "LLM invocation failed (attempt %d/%d), retrying in %.1fs: %s",
                        attempt + 1,
                        _LLM_MAX_RETRIES + 1,
                        delay,
                        exc,
                        plan_id=plan_id,
                    )
                    time.sleep(delay)

        # All retries exhausted — re-raise last exception
        raise last_exc

    @staticmethod
    def _extract_content(response: Any) -> str:
        """Extract text content from an LLM response object.

        Handles responses with ``.content``, ``.text``, or falls
        back to ``str()``.  Also handles ``list[MessageContent]``
        responses from some LangChain providers.
        """
        raw_content = getattr(response, "content", None)
        if raw_content is None:
            raw_content = getattr(response, "text", None)
        if raw_content is None:
            raw_content = str(response)
        if isinstance(raw_content, list):
            return " ".join(str(chunk) for chunk in raw_content)
        return str(raw_content)

    def _execute_stub(self, definition_of_done: str) -> StrategyTree:
        """Produce a strategy tree from bullet-point parsing (no LLM).

        Delegates to :func:`parse_strategy_response` which handles
        both JSON and numbered/bulleted-list formats, providing the
        same robust parsing used for LLM output.
        """
        raw_actions = parse_strategy_response(definition_of_done)
        return self._build_tree(raw_actions)

    def _build_tree(self, raw_actions: list[dict[str, Any]]) -> StrategyTree:
        """Build a StrategyTree from parsed action dicts.

        Assigns ULIDs, resolves dependency references (step numbers to
        action_ids), and validates the dependency graph for cycles.
        """
        # Guard: an empty action list produces a tree whose root_id
        # does not correspond to any action.  Downstream consumers
        # (_tree_to_decisions, build_decisions) handle this correctly
        # by returning empty lists, but callers that look up the root
        # action by root_id should be aware of this edge case.
        root_id = str(ULID())
        id_map: dict[int, str] = {}  # step_number -> action_id
        index_keys: list[tuple[int, str]] = []

        for idx, raw_action in enumerate(raw_actions):
            action_id = root_id if idx == 0 else str(ULID())
            raw_step = raw_action.get("step")
            try:
                step_key = int(raw_step) if raw_step is not None else idx + 1
            except (TypeError, ValueError):
                step_key = idx + 1
            if step_key in id_map:
                self._logger.debug(
                    "Duplicate step_key %d at index %d, using unique fallback",
                    step_key,
                    idx,
                )
                step_key = -(1_000_000 + idx)
            id_map[step_key] = action_id
            index_keys.append((step_key, action_id))

        # Second pass: resolve deps and build actions using cached keys.
        # Structural ``parent_id`` resolved from explicit parent_step field
        # or inferred from dependency graph (backward compatibility).
        actions: list[StrategyAction] = []
        dependency_edges: list[tuple[str, str]] = []
        for idx, raw in enumerate(raw_actions):
            step_key, action_id = index_keys[idx]

            raw_deps = raw.get("depends_on", [])
            resolved_deps: list[str] = []
            for dep_step in raw_deps:
                try:
                    dep_num = int(dep_step)
                except (TypeError, ValueError):
                    continue
                dep_id = id_map.get(dep_num)
                if dep_id is None:
                    self._logger.warning(
                        "Unresolvable dependency: step %d references "
                        "non-existent step %d — dependency dropped",
                        step_key,
                        dep_num,
                    )
                    continue
                if dep_id != action_id:
                    resolved_deps.append(dep_id)
                    dependency_edges.append((action_id, dep_id))

            # Resolve parent_id: prefer explicit parent_step over dependency inference
            parent_id: str | None = None
            parent_step_explicit = False
            if idx > 0:
                raw_parent_step = raw.get("parent_step")
                if raw_parent_step is not None:
                    try:
                        parent_step_num = int(raw_parent_step)
                        parent_id = id_map.get(parent_step_num)
                        if parent_id is not None:
                            parent_step_explicit = True
                        else:
                            self._logger.warning(
                                "Unresolvable parent_step: step %d references "
                                "non-existent step %d — using dependency fallback",
                                step_key,
                                parent_step_num,
                            )
                    except (TypeError, ValueError):
                        self._logger.debug(
                            "Invalid parent_step value for step %d: %s",
                            step_key,
                            raw_parent_step,
                        )
                # Fallback to dependency-based parent inference if no parent_step
                if parent_id is None:
                    parent_id = resolved_deps[0] if resolved_deps else root_id

            # Extract parallel_group if specified
            parallel_group: str | None = raw.get("parallel_group")

            actions.append(
                StrategyAction(
                    action_id=action_id,
                    description=raw.get("description", _DEFAULT_DESCRIPTION),
                    resource_requirements=raw.get("resource_requirements", []),
                    estimated_complexity=raw.get("estimated_complexity", "medium"),
                    risk_score=raw.get("risk_score", 0.3),
                    depends_on=resolved_deps,
                    parent_id=parent_id,
                    parent_step_explicit=parent_step_explicit,
                    parallel_group=parallel_group,
                    sequence=idx,
                )
            )

        validate_no_cycles(dependency_edges)

        return StrategyTree(
            root_id=root_id,
            actions=actions,
            dependency_edges=dependency_edges,
        )

    @staticmethod
    def _tree_to_decisions(tree: StrategyTree) -> list[StrategyDecision]:
        """Convert a StrategyTree into StrategyDecision objects."""
        decisions: list[StrategyDecision] = []
        for action in tree.actions:
            decisions.append(
                StrategyDecision(
                    decision_id=action.action_id,
                    step_text=action.description,
                    sequence=action.sequence,
                    parent_id=action.parent_id,
                )
            )
        return decisions

    @staticmethod
    def _build_invariant_records(
        invariants: list[PlanInvariant] | None,
    ) -> list[dict[str, Any]]:
        """Build invariant record dicts from plan invariants.

        .. note::

            Invariants are currently marked as unconditionally enforced.
            Full reconciliation by the Invariant Reconciliation Actor
            (spec §Invariant) is not yet implemented — this is a
            placeholder that will be replaced when that actor lands.
        """
        records: list[dict[str, Any]] = []
        for inv in invariants or []:
            records.append(
                {
                    "text": inv.text,
                    "source": inv.source.value,
                    "enforced": True,
                    "enforcement_note": (
                        "strategy_actor: accepted (placeholder — "
                        "full reconciliation pending Invariant "
                        "Reconciliation Actor)"
                    ),
                }
            )
        return records
