"""Subplan service for building and spawning child plans.

Coordinates the spawning of child plans from ``DecisionService`` spawn entries
and ``SubplanConfig``.  The service validates resource scopes, merge strategy
presence, and ``max_parallel`` bounds before creating child plans.

Spawn metadata (spawn decision ID, parent/root plan IDs, execution mode) is
persisted alongside each child plan for status output and provenance tracking.

Design decisions:
    - Dependency injection: ``DecisionService`` and ``UnitOfWork`` are
      injected via the constructor (consistent with existing service patterns).
    - Stateless: All spawn state is derived from ``Decision`` entries and
      ``SubplanConfig`` on the parent ``Plan``.
    - Fail-fast: Validation errors are raised immediately before any plan
      creation occurs.

Based on:
    - docs/specification.md L18170-L18295 (subplan spawning)
    - ADR-006 (Plan Lifecycle)
    - Forgejo issue #197
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from ulid import ULID

from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.decision import Decision, DecisionType
from cleveragents.domain.models.core.plan import (
    ExecutionMode,
    NamespacedName,
    Plan,
    PlanIdentity,
    PlanPhase,
    PlanTimestamps,
    ProcessingState,
    SubplanConfig,
    SubplanStatus,
)

if TYPE_CHECKING:
    from cleveragents.application.services.decision_service import DecisionService

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Value objects
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class SpawnMetadata:
    """Metadata for a spawned subplan.

    Persisted alongside each child plan for status output and provenance.

    Attributes:
        spawn_decision_id: ULID of the decision that triggered the spawn.
        parent_plan_id: ULID of the parent plan.
        root_plan_id: ULID of the root plan in the hierarchy.
        execution_mode: How the subplan should be executed.
    """

    spawn_decision_id: str
    parent_plan_id: str
    root_plan_id: str
    execution_mode: str


@dataclass(frozen=True)
class SpawnEntry:
    """A single spawn request derived from a decision.

    Attributes:
        decision: The decision that triggered this spawn.
        action_name: Namespaced action name for the child plan.
        target_resources: Resource IDs the child plan operates on.
        description: Description/prompt for the child plan.
    """

    decision: Decision
    action_name: str
    target_resources: list[str] = field(default_factory=list)
    description: str = ""


@dataclass(frozen=True)
class SpawnValidationResult:
    """Result of validating a spawn request.

    Attributes:
        valid: Whether the spawn request passed all validation checks.
        errors: List of validation error messages.
    """

    valid: bool
    errors: list[str] = field(default_factory=list)


@dataclass(frozen=True)
class SpawnResult:
    """Result of spawning child plans.

    Attributes:
        spawned_statuses: Status objects for each spawned child plan.
        metadata: Spawn metadata for each child plan (keyed by subplan_id).
        total_spawned: Number of child plans spawned.
        execution_mode: The execution mode from the SubplanConfig.
        child_plans: Actual child Plan domain objects created during spawn.
    """

    spawned_statuses: list[SubplanStatus]
    metadata: dict[str, SpawnMetadata] = field(default_factory=dict)
    total_spawned: int = 0
    execution_mode: str = ExecutionMode.SEQUENTIAL
    child_plans: list[Plan] = field(default_factory=list)


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------


class SpawnValidationError(ValidationError):
    """Raised when spawn validation fails.

    Attributes:
        validation_errors: List of individual validation failure messages.
    """

    def __init__(self, validation_errors: list[str]) -> None:
        self.validation_errors: list[str] = validation_errors
        errors_str: str = "; ".join(validation_errors)
        super().__init__(f"Spawn validation failed: {errors_str}")


# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------


class SubplanService:
    """Service for spawning child plans from decision spawn entries.

    Coordinates the creation of child plans by:

    1. Extracting spawn entries from ``DecisionService`` for a parent plan.
    2. Validating each entry (resource scopes, merge strategy, parallelism).
    3. Building ``SubplanStatus`` objects for the parent plan.
    4. Persisting ``SpawnMetadata`` for status queries.

    Args:
        decision_service: Service for querying decision records.

    Raises:
        ValueError: If *decision_service* is ``None``.
    """

    def __init__(
        self,
        decision_service: DecisionService,
    ) -> None:
        if decision_service is None:
            raise ValueError("decision_service must not be None")
        self._decision_service: DecisionService = decision_service

    @property
    def decision_service(self) -> DecisionService:
        """The injected decision service."""
        return self._decision_service

    # ------------------------------------------------------------------
    # spawn
    # ------------------------------------------------------------------

    def spawn(
        self,
        parent_plan: Plan,
        config: SubplanConfig,
        spawn_entries: list[SpawnEntry],
        *,
        available_resources: frozenset[str] | None = None,
    ) -> SpawnResult:
        """Build child plan statuses from spawn entries.

        Validates the spawn request, then creates ``SubplanStatus`` objects
        and ``SpawnMetadata`` for each entry.  The caller is responsible for
        persisting the updated parent plan.

        Args:
            parent_plan: The parent plan that will own the child plans.
            config: Subplan execution configuration.
            spawn_entries: Entries describing which child plans to create.
            available_resources: Optional set of known resource IDs for
                scope validation.  When ``None``, resource scope checks
                are skipped.

        Returns:
            A :class:`SpawnResult` with statuses and metadata.

        Raises:
            ValueError: If *parent_plan*, *config*, or *spawn_entries* is
                ``None``.
            ValueError: If *spawn_entries* is empty.
            SpawnValidationError: If validation fails.
        """
        if parent_plan is None:
            raise ValueError("parent_plan must not be None")
        if config is None:
            raise ValueError("config must not be None")
        if spawn_entries is None:
            raise ValueError("spawn_entries must not be None")
        if not spawn_entries:
            raise ValueError("spawn_entries must not be empty")

        # Validate before spawning
        validation: SpawnValidationResult = self.validate_spawn(
            config=config,
            spawn_entries=spawn_entries,
            available_resources=available_resources,
        )
        if not validation.valid:
            raise SpawnValidationError(validation.errors)

        # Build statuses, metadata, and real child Plan objects
        parent_id: str = parent_plan.identity.plan_id
        root_id: str = parent_plan.identity.root_plan_id or parent_id
        mode: str = config.execution_mode.value

        statuses: list[SubplanStatus] = []
        metadata: dict[str, SpawnMetadata] = {}
        child_plans: list[Plan] = []

        for entry in spawn_entries:
            # Generate a unique ULID for each child plan
            subplan_id: str = str(ULID())

            status: SubplanStatus = SubplanStatus(
                subplan_id=subplan_id,
                action_name=entry.action_name,
                target_resources=list(entry.target_resources),
            )
            statuses.append(status)

            meta: SpawnMetadata = SpawnMetadata(
                spawn_decision_id=entry.decision.decision_id,
                parent_plan_id=parent_id,
                root_plan_id=root_id,
                execution_mode=mode,
            )
            metadata[subplan_id] = meta

            # Create a real child Plan domain object
            child_plan: Plan = Plan(
                identity=PlanIdentity(
                    plan_id=subplan_id,
                    parent_plan_id=parent_id,
                    root_plan_id=root_id,
                ),
                namespaced_name=NamespacedName(
                    server=parent_plan.namespaced_name.server,
                    namespace=parent_plan.namespaced_name.namespace,
                    name=f"subplan-{subplan_id[:8]}",
                ),
                description=entry.description or f"Child plan for {entry.action_name}",
                definition_of_done=parent_plan.definition_of_done,
                action_name=entry.action_name,
                phase=PlanPhase.STRATEGIZE,
                processing_state=ProcessingState.QUEUED,
                strategy_actor=parent_plan.strategy_actor,
                execution_actor=parent_plan.execution_actor,
                project_links=list(parent_plan.project_links),
                timestamps=PlanTimestamps(),
                created_by=parent_plan.created_by,
                reusable=parent_plan.reusable,
                read_only=parent_plan.read_only,
            )
            child_plans.append(child_plan)

        # Attach subplan statuses to the parent plan for lifecycle tracking
        parent_plan.subplan_statuses = list(parent_plan.subplan_statuses) + statuses

        logger.info(
            "spawned_subplans",
            extra={
                "parent_plan_id": parent_id,
                "root_plan_id": root_id,
                "count": len(statuses),
                "execution_mode": mode,
            },
        )

        return SpawnResult(
            spawned_statuses=statuses,
            metadata=metadata,
            total_spawned=len(statuses),
            execution_mode=mode,
            child_plans=child_plans,
        )

    # ------------------------------------------------------------------
    # validate_spawn
    # ------------------------------------------------------------------

    def validate_spawn(
        self,
        config: SubplanConfig,
        spawn_entries: list[SpawnEntry],
        *,
        available_resources: frozenset[str] | None = None,
    ) -> SpawnValidationResult:
        """Validate a spawn request before execution.

        Checks:
            1. Resource scopes are resolved (all referenced resources exist).
            2. Merge strategy is defined (not None/missing).
            3. ``max_parallel`` bounds are respected (number of entries does
               not exceed ``max_parallel`` when using PARALLEL mode).

        Args:
            config: Subplan execution configuration.
            spawn_entries: Entries describing which child plans to create.
            available_resources: Optional set of known resource IDs.  When
                provided, each entry's ``target_resources`` are checked
                against this set.

        Returns:
            A :class:`SpawnValidationResult` indicating success or listing
            errors.

        Raises:
            ValueError: If *config* or *spawn_entries* is ``None``.
        """
        if config is None:
            raise ValueError("config must not be None")
        if spawn_entries is None:
            raise ValueError("spawn_entries must not be None")

        errors: list[str] = []

        # 1. Resource scope validation
        if available_resources is not None:
            for entry in spawn_entries:
                for resource_id in entry.target_resources:
                    if resource_id not in available_resources:
                        errors.append(
                            f"Unresolved resource scope: resource '{resource_id}' "
                            f"not found for spawn decision "
                            f"'{entry.decision.decision_id}'"
                        )

        # 2. Merge strategy validation
        if config.merge_strategy is None:
            errors.append("Merge strategy must be defined in SubplanConfig")

        # 3. max_parallel bounds validation
        if config.execution_mode == ExecutionMode.PARALLEL:
            entry_count: int = len(spawn_entries)
            if entry_count > config.max_parallel:
                errors.append(
                    f"Number of spawn entries ({entry_count}) exceeds "
                    f"max_parallel bound ({config.max_parallel})"
                )

        # 4. Each entry must have a valid action_name
        for entry in spawn_entries:
            if not entry.action_name or not entry.action_name.strip():
                errors.append(
                    f"Spawn entry for decision "
                    f"'{entry.decision.decision_id}' has empty action_name"
                )

        # 5. Each entry must reference a spawn-type decision
        for entry in spawn_entries:
            if entry.decision.decision_type not in (
                DecisionType.SUBPLAN_SPAWN,
                DecisionType.SUBPLAN_PARALLEL_SPAWN,
            ):
                errors.append(
                    f"Decision '{entry.decision.decision_id}' has type "
                    f"'{entry.decision.decision_type}' but must be "
                    f"'{DecisionType.SUBPLAN_SPAWN}' or "
                    f"'{DecisionType.SUBPLAN_PARALLEL_SPAWN}'"
                )

        valid: bool = len(errors) == 0
        return SpawnValidationResult(valid=valid, errors=errors)

    # ------------------------------------------------------------------
    # get_spawn_decisions
    # ------------------------------------------------------------------

    def get_spawn_decisions(self, plan_id: str) -> list[Decision]:
        """Retrieve spawn-type decisions for a plan.

        Queries the ``DecisionService`` for both ``subplan_spawn`` and
        ``subplan_parallel_spawn`` decision types.

        Args:
            plan_id: ULID of the plan to query.

        Returns:
            List of spawn decisions ordered by sequence number.

        Raises:
            ValueError: If *plan_id* is empty.
        """
        if not plan_id or not plan_id.strip():
            raise ValueError("plan_id must not be empty")

        spawn_decisions: list[Decision] = self._decision_service.list_by_type(
            plan_id, DecisionType.SUBPLAN_SPAWN.value
        )
        parallel_decisions: list[Decision] = self._decision_service.list_by_type(
            plan_id, DecisionType.SUBPLAN_PARALLEL_SPAWN.value
        )

        all_decisions: list[Decision] = spawn_decisions + parallel_decisions
        all_decisions.sort(key=lambda d: d.sequence_number)
        return all_decisions

    # ------------------------------------------------------------------
    # build_spawn_entries
    # ------------------------------------------------------------------

    def build_spawn_entries(
        self,
        decisions: list[Decision],
        default_action_name: str = "local/subplan-action",
    ) -> list[SpawnEntry]:
        """Build spawn entries from decisions.

        Converts decision objects into ``SpawnEntry`` objects suitable
        for passing to :meth:`spawn`.

        Args:
            decisions: Spawn-type decisions.
            default_action_name: Action name to use when the decision's
                ``chosen_option`` does not specify one.

        Returns:
            List of spawn entries.

        Raises:
            ValueError: If *decisions* is ``None``.
        """
        if decisions is None:
            raise ValueError("decisions must not be None")

        entries: list[SpawnEntry] = []
        for decision in decisions:
            # Use chosen_option as action_name if it looks like a
            # namespaced action reference; otherwise use default
            action_name: str = default_action_name
            if "/" in decision.chosen_option:
                action_name = decision.chosen_option

            # Extract target resources from context snapshot
            target_resources: list[str] = [
                ref.resource_id for ref in decision.context_snapshot.relevant_resources
            ]

            entry: SpawnEntry = SpawnEntry(
                decision=decision,
                action_name=action_name,
                target_resources=target_resources,
                description=decision.question,
            )
            entries.append(entry)

        return entries


__all__: list[str] = [
    "SpawnEntry",
    "SpawnMetadata",
    "SpawnResult",
    "SpawnValidationError",
    "SpawnValidationResult",
    "SubplanService",
]
