"""Built-in plan_subplan tool for CleverAgents strategy actors.

Provides the ``builtin/plan-subplan`` tool which strategy actors call
to emit ``SUBPLAN_SPAWN`` or ``SUBPLAN_PARALLEL_SPAWN`` decisions when
decomposing a plan into coordinated child plans.

## Tool Overview

| Attribute      | Value                                                 |
|----------------|-------------------------------------------------------|
| Tool name      | ``builtin/plan-subplan``                              |
| Capability     | ``writes=True``, ``checkpointable=False``             |
| Decision types | ``subplan_spawn`` / ``subplan_parallel_spawn``        |
| Namespace      | ``builtin/``                                          |

## Usage

```python
from cleveragents.tool.builtins import register_subplan_tool
from cleveragents.tool.registry import ToolRegistry

registry = ToolRegistry()
register_subplan_tool(registry)
```

With an injected ``DecisionService`` for persistence:

```python
from cleveragents.tool.builtins.subplan_tool import make_plan_subplan_spec

spec = make_plan_subplan_spec(decision_service=my_service)
registry.register(spec)
```

## Input Schema

Required fields: ``goal``, ``plan_id``, ``sequence_number``.

Optional fields and defaults:

- ``resource_scopes`` (list[str], default ``[]``) — paths the subplan targets
- ``project_ref`` (str, default ``None``) — alternative to resource_scopes
- ``parallel`` (bool, default ``False``) — emit parallel-spawn decision
- ``merge_strategy`` (str, default ``git_three_way``) — merge behaviour
- ``max_parallel`` (int 1-50, default ``5``) — max concurrent subplans
- ``dependencies`` (list[str], default ``[]``) — sibling ULIDs
- ``context_view`` (str, default ``None``) — actor context-view override
- ``parent_decision_id`` (str, default ``None``) — parent decision ULID

*At least one of ``resource_scopes`` (non-empty) or ``project_ref`` is required.

Based on:
    - docs/specification.md §Child Plan Spawning Mechanism
    - Forgejo issue #198 — feat(actor): add plan_subplan tool and decision emission
"""

from __future__ import annotations

import re
from collections.abc import Callable
from typing import Any, Protocol, runtime_checkable

from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    ValidationError,
    field_validator,
    model_validator,
)

from cleveragents.domain.models.core.decision import (
    ULID_PATTERN,
    Decision,
    DecisionType,
)
from cleveragents.domain.models.core.plan import SubplanMergeStrategy
from cleveragents.domain.models.core.tool import ToolCapability
from cleveragents.tool.runtime import ToolSpec

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

TOOL_NAME = "builtin/plan-subplan"

_DEFAULT_MERGE_STRATEGY = SubplanMergeStrategy.GIT_THREE_WAY
_DEFAULT_MAX_PARALLEL = 5
_VALID_CONTEXT_VIEWS: tuple[str, ...] = (
    "executor",
    "full",
    "reviewer",
    "strategist",
)
_ULID_RE = re.compile(ULID_PATTERN)


# ---------------------------------------------------------------------------
# Protocol for optional decision service injection
# ---------------------------------------------------------------------------


@runtime_checkable
class _DecisionRecorder(Protocol):
    """Protocol for recording decisions (subset of DecisionService API)."""

    def record_decision(self, decision: Decision) -> Decision: ...


# ---------------------------------------------------------------------------
# Input payload model
# ---------------------------------------------------------------------------


class SubplanPayload(BaseModel):
    """Validated and defaulted inputs for the plan_subplan tool.

    Enforces all acceptance-criteria constraints:
    - At least one of ``resource_scopes`` or ``project_ref`` must be provided.
    - ``max_parallel`` must be in [1, 50].
    - ``merge_strategy`` must be a valid ``SubplanMergeStrategy`` value.
    - ``goal`` must be non-empty.
    """

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

    goal: str = Field(..., min_length=1, description="Goal of the child subplan.")
    plan_id: str = Field(..., min_length=1, description="ULID of the spawning plan.")
    sequence_number: int = Field(
        ..., ge=0, description="Monotonic sequence number within the plan."
    )
    resource_scopes: list[str] = Field(
        default_factory=list,
        description="Paths or resource IDs the subplan targets.",
    )
    project_ref: str | None = Field(
        default=None,
        description="Project reference (alternative to resource_scopes).",
    )
    parallel: bool = Field(
        default=False,
        description="Emit SUBPLAN_PARALLEL_SPAWN instead of SUBPLAN_SPAWN.",
    )
    merge_strategy: SubplanMergeStrategy = Field(
        default=_DEFAULT_MERGE_STRATEGY,
        description="How to merge child-plan results into the parent.",
    )
    max_parallel: int = Field(
        default=_DEFAULT_MAX_PARALLEL,
        ge=1,
        le=50,
        description="Max concurrent subplans (only used when parallel=True).",
    )
    dependencies: list[str] = Field(
        default_factory=list,
        description="ULIDs of sibling subplans this one depends on.",
    )
    context_view: str | None = Field(
        default=None,
        description="Actor context-view override for the child plan.",
    )
    parent_decision_id: str | None = Field(
        default=None,
        description="ULID of the parent decision node.",
    )

    @field_validator("plan_id")
    @classmethod
    def _validate_plan_id(cls, v: str) -> str:
        if not _ULID_RE.match(v):
            raise ValueError(
                f"plan_id must be a valid ULID (26 uppercase chars), got {v!r}"
            )
        return v

    @field_validator("resource_scopes")
    @classmethod
    def _validate_resource_scopes(cls, v: list[str]) -> list[str]:
        for item in v:
            if not item or not item.strip():
                raise ValueError(
                    "resource_scopes must not contain empty or whitespace-only strings"
                )
        return v

    @field_validator("dependencies")
    @classmethod
    def _validate_dependencies(cls, v: list[str]) -> list[str]:
        for item in v:
            if not _ULID_RE.match(item):
                raise ValueError(
                    "Each dependency must be a valid ULID "
                    f"(26 uppercase chars), got {item!r}"
                )
        return v

    @field_validator("parent_decision_id")
    @classmethod
    def _validate_parent_decision_id(cls, v: str | None) -> str | None:
        if v is not None and not _ULID_RE.match(v):
            raise ValueError(
                f"parent_decision_id must be a valid ULID or None, got {v!r}"
            )
        return v

    @field_validator("context_view")
    @classmethod
    def _validate_context_view(cls, v: str | None) -> str | None:
        if v is not None and v not in _VALID_CONTEXT_VIEWS:
            raise ValueError(
                f"context_view must be one of {list(_VALID_CONTEXT_VIEWS)}, got {v!r}"
            )
        return v

    @model_validator(mode="after")
    def _require_scope_or_ref(self) -> SubplanPayload:
        has_scopes = bool(self.resource_scopes)
        has_ref = bool(self.project_ref and self.project_ref.strip())
        if not has_scopes and not has_ref:
            raise ValueError(
                "At least one of 'resource_scopes' (non-empty) or "
                "'project_ref' must be provided."
            )
        return self


# ---------------------------------------------------------------------------
# Rationale builder
# ---------------------------------------------------------------------------


def _build_rationale(payload: SubplanPayload) -> str:
    """Build a human-readable rationale explaining why the subplan was spawned.

    The text is surfaced in ``plan explain`` output and stored in the
    Decision record for audit purposes.
    """
    scope_desc = (
        ", ".join(payload.resource_scopes)
        if payload.resource_scopes
        else payload.project_ref or "(unspecified)"
    )
    mode = "parallel" if payload.parallel else "sequential"
    lines = [
        f"Spawning {mode} subplan to accomplish: {payload.goal}",
        f"Targeted resources: {scope_desc}",
        f"Merge strategy: {payload.merge_strategy.value}",
    ]
    if payload.parallel and payload.dependencies:
        dep_str = ", ".join(payload.dependencies)
        lines.append(f"Depends on subplans: {dep_str}")
    if payload.context_view:
        lines.append(f"Context view override: {payload.context_view}")
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Tool handler factory
# ---------------------------------------------------------------------------


def _make_handler(
    decision_service: _DecisionRecorder | None = None,
) -> Callable[[dict[str, Any]], dict[str, Any]]:
    """Return a tool handler optionally bound to a *decision_service*.

    When *decision_service* is ``None`` the handler validates and builds
    the decision but does not persist it.  Callers that need persistence
    (e.g. the full runtime) should inject a ``DecisionService`` instance.
    """

    def handler(inputs: dict[str, Any]) -> dict[str, Any]:
        # ------------------------------------------------------------------
        # Validate and apply defaults via SubplanPayload
        # ------------------------------------------------------------------
        try:
            payload = SubplanPayload(**inputs)
        except (ValidationError, ValueError) as exc:
            return {"success": False, "error": str(exc)}

        # ------------------------------------------------------------------
        # Determine decision type
        # ------------------------------------------------------------------
        decision_type = (
            DecisionType.SUBPLAN_PARALLEL_SPAWN
            if payload.parallel
            else DecisionType.SUBPLAN_SPAWN
        )

        # ------------------------------------------------------------------
        # Build rationale
        # ------------------------------------------------------------------
        rationale = _build_rationale(payload)

        # ------------------------------------------------------------------
        # Construct the Decision domain object
        # ------------------------------------------------------------------
        decision_kwargs: dict[str, Any] = {
            "plan_id": payload.plan_id,
            "sequence_number": payload.sequence_number,
            "decision_type": decision_type,
            "question": ("Should a subplan be spawned to accomplish this goal?"),
            "chosen_option": f"Spawn {decision_type.value} — {payload.goal}",
            "rationale": rationale,
        }
        if payload.parent_decision_id:
            decision_kwargs["parent_decision_id"] = payload.parent_decision_id

        decision = Decision(**decision_kwargs)

        # ------------------------------------------------------------------
        # Optionally persist via injected service
        # ------------------------------------------------------------------
        if decision_service is not None:
            decision_service.record_decision(decision)

        # ------------------------------------------------------------------
        # Return result dict (JSON-serialisable)
        # ------------------------------------------------------------------
        return {
            "success": True,
            "decision_id": decision.decision_id,
            "decision_type": str(decision_type.value),
            "goal": payload.goal,
            "resource_scopes": payload.resource_scopes,
            "project_ref": payload.project_ref,
            "parallel": payload.parallel,
            "merge_strategy": str(payload.merge_strategy.value),
            "max_parallel": payload.max_parallel,
            "dependencies": payload.dependencies,
            "context_view": payload.context_view,
            "parent_decision_id": payload.parent_decision_id,
            "rationale": rationale,
        }

    return handler


# ---------------------------------------------------------------------------
# Public factory
# ---------------------------------------------------------------------------


def make_plan_subplan_spec(
    decision_service: _DecisionRecorder | None = None,
) -> ToolSpec:
    """Build a ``ToolSpec`` for the ``builtin/plan-subplan`` tool.

    Args:
        decision_service: Optional service that implements
            ``record_decision``.  When supplied the handler persists each
            emitted decision immediately.  When ``None`` the decision is
            built and returned but not stored.

    Returns:
        A ready-to-register :class:`~cleveragents.tool.runtime.ToolSpec`.
    """
    return ToolSpec(
        name=TOOL_NAME,
        description=(
            "Emit a SUBPLAN_SPAWN (or SUBPLAN_PARALLEL_SPAWN when "
            "parallel=True) decision for the current strategy actor. "
            "Validates the subplan payload and records the decision."
        ),
        input_schema={
            "type": "object",
            "properties": {
                "goal": {"type": "string", "minLength": 1},
                "plan_id": {"type": "string", "minLength": 1},
                "sequence_number": {"type": "integer", "minimum": 0},
                "resource_scopes": {
                    "type": "array",
                    "items": {"type": "string"},
                    "default": [],
                },
                "project_ref": {"type": ["string", "null"]},
                "parallel": {"type": "boolean", "default": False},
                "merge_strategy": {
                    "type": "string",
                    "enum": [s.value for s in SubplanMergeStrategy],
                    "default": _DEFAULT_MERGE_STRATEGY.value,
                },
                "max_parallel": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 50,
                    "default": _DEFAULT_MAX_PARALLEL,
                },
                "dependencies": {
                    "type": "array",
                    "items": {"type": "string"},
                    "default": [],
                },
                "context_view": {
                    "type": ["string", "null"],
                    "enum": [*_VALID_CONTEXT_VIEWS, None],  # type: ignore[list-item]
                },
                "parent_decision_id": {"type": ["string", "null"]},
            },
            "required": ["goal", "plan_id", "sequence_number"],
        },
        output_schema={
            "type": "object",
            "properties": {
                "success": {"type": "boolean"},
                "decision_id": {"type": "string"},
                "decision_type": {"type": "string"},
                "goal": {"type": "string"},
                "resource_scopes": {"type": "array"},
                "project_ref": {"type": ["string", "null"]},
                "parallel": {"type": "boolean"},
                "merge_strategy": {"type": "string"},
                "max_parallel": {"type": "integer"},
                "dependencies": {"type": "array"},
                "context_view": {"type": ["string", "null"]},
                "parent_decision_id": {"type": ["string", "null"]},
                "rationale": {"type": "string"},
            },
        },
        capabilities=ToolCapability(
            read_only=False,
            writes=True,
            checkpointable=False,
            side_effects=["spawn_subplan"],
        ),
        handler=_make_handler(decision_service),
        source="builtin",
    )


# ---------------------------------------------------------------------------
# Default spec (no service injection)
# ---------------------------------------------------------------------------

#: Ready-to-register spec with no decision persistence.
#: Use :func:`make_plan_subplan_spec` when you need decision recording.
PLAN_SUBPLAN_SPEC: ToolSpec = make_plan_subplan_spec()

#: All subplan tools (currently one) for bulk registration.
ALL_SUBPLAN_TOOLS: list[ToolSpec] = [PLAN_SUBPLAN_SPEC]


__all__ = [
    "ALL_SUBPLAN_TOOLS",
    "PLAN_SUBPLAN_SPEC",
    "TOOL_NAME",
    "SubplanPayload",
    "make_plan_subplan_spec",
]
