"""Subagent spawn helpers for ``ctl.*`` tools.

Extracted from ``shell/ctl.py`` to reduce file-size and isolate spawn concern.
"""

import json
from datetime import datetime, timezone
from pathlib import Path

from pydantic import ValidationError
from returns.result import Failure, Result, Success

from anima.app.control_service import (
    ApprovalRequest,
    DefaultControlService,
    SpawnPersonaPort,
    SpawnSubagentRejected,
    SpawnSubagentRequest,
)
from anima.app.launch_service import LaunchResult, LaunchSubagentRequest
from anima.core.execution import resolve_execution_contract
from anima.core.ids import generate_instance_id
from anima.core.schemas import (
    AgentState,
    AgentStateData,
    AnimaError,
    InternalError,
    InvalidInputError,
    JobSpec,
    MaxDepthExceededError,
    PersonaSpec,
    TokenUsage,
)
from anima.shell.agent_launcher import launch_agent_subprocess
from anima.shell.contract_compat import load_persisted_or_legacy_contract
from anima.shell.state import append_log, read_state, write_state


def _validate_persona_path(persona_ref: str) -> Result[Path, AnimaError]:
    """Validate persona path for security.

    Rejects absolute paths and path traversal attempts.

    Args:
        persona_ref: Path to validate.

    Returns:
        Success(Path) if valid, Failure(AnimaError) if invalid.
    """
    path = Path(persona_ref)

    if path.is_absolute():
        return Failure(InvalidInputError(f"Absolute paths not allowed: {persona_ref}"))
    if ".." in path.parts:
        return Failure(InvalidInputError(f"Path traversal not allowed: {persona_ref}"))

    return Success(path)


# @invar:allow shell_result: CLI helper for loading persona files
# @shell_complexity: Security validation with path checks and JSON parsing
def _load_persona_from_ref(persona_ref: str) -> Result[PersonaSpec, AnimaError]:
    """Load PersonaSpec from persona_ref (file path)."""
    path_result = _validate_persona_path(persona_ref)
    if isinstance(path_result, Failure):
        return path_result
    path = path_result.unwrap()

    if not path.exists():
        return Failure(InvalidInputError(f"Persona file not found: {persona_ref}"))

    try:
        content = path.read_text()
        data = json.loads(content)
        return Success(PersonaSpec(**data))
    except json.JSONDecodeError as exc:
        return Failure(InvalidInputError(f"Invalid JSON in persona file: {exc}"))
    except (OSError, ValidationError) as exc:
        return Failure(InternalError(f"Failed to load persona: {exc}"))


def _try_synthesize_inline_persona(
    persona_ref: str, *, parent_model: str | None = None
) -> Result[PersonaSpec, AnimaError]:
    """Synthesize an inline persona if persona_ref matches the inline id.

    For child agents inheriting from an inline parent, we synthesize a persona
    using the parent's model so the child runs on the same model.
    """
    from anima.core.persona_factory import build_inline_persona_fields
    from anima.shell.cli_utils import (
        INLINE_PERSONA_DESCRIPTION,
        INLINE_PERSONA_NAME,
        INLINE_PERSONA_PROMPT,
    )

    if persona_ref != INLINE_PERSONA_NAME:
        return Failure(InvalidInputError(f"Persona file not found: {persona_ref}"))

    effective_model = parent_model or "default"
    try:
        return Success(
            PersonaSpec(
                **build_inline_persona_fields(
                    model=effective_model,
                    system_prompt=INLINE_PERSONA_PROMPT,
                    persona_name=INLINE_PERSONA_NAME,
                    description=INLINE_PERSONA_DESCRIPTION,
                )
            )
        )
    except (ValidationError, ValueError) as exc:
        return Failure(InternalError(f"Failed to synthesize inline persona: {exc}"))


def _check_spawn_permission(
    parent_persona: PersonaSpec, child_persona_name: str
) -> Result[None, AnimaError]:
    """Check if parent persona can spawn child persona."""
    can_spawn = parent_persona.can_spawn

    if can_spawn is False:
        return Failure(
            InvalidInputError(
                "Agent not permitted to spawn: can_spawn is False",
                {"parent_persona": parent_persona.id},
            )
        )

    if isinstance(can_spawn, list) and child_persona_name not in can_spawn:
        return Failure(
            InvalidInputError(
                f"Agent not permitted to spawn '{child_persona_name}': not in allowlist",
                {"allowed": can_spawn, "requested": child_persona_name},
            )
        )

    return Success(None)


# @invar:allow shell_result: Helper returns Result for consistency
# @shell_complexity: Rollback cleanup handles multiple failure modes
def _delete_child_state_sync(agent_id: str) -> Result[None, AnimaError]:
    """Delete child state file on rollback."""
    from anima.shell.state import delete_state

    result = delete_state(agent_id)
    if isinstance(result, Failure):
        return Failure(
            InternalError(f"Failed to delete child state: {result.failure()}")
        )
    return Success(None)


# @shell_complexity: Validation path intentionally short-circuits on missing refs and permission failures
def _load_parent_and_child_personas(
    *, parent_state: AgentStateData, persona_ref: str | None
) -> Result[tuple[PersonaSpec, PersonaSpec], AnimaError]:
    """Load parent/child personas and validate spawn permission."""
    if not parent_state.persona_ref:
        return Failure(
            InvalidInputError(
                "Parent agent missing persona_ref",
                {"instance_id": parent_state.instance_id},
            )
        )

    parent_persona_result = _load_persona_from_ref(parent_state.persona_ref)
    if isinstance(parent_persona_result, Failure):
        return parent_persona_result
    parent_persona = parent_persona_result.unwrap()

    child_persona_ref = persona_ref or parent_state.persona_ref
    child_persona_result = _load_persona_from_ref(child_persona_ref)
    if isinstance(child_persona_result, Failure):
        return child_persona_result
    child_persona = child_persona_result.unwrap()

    permission_result = _check_spawn_permission(parent_persona, child_persona.id)
    if isinstance(permission_result, Failure):
        return permission_result

    return Success((parent_persona, child_persona))


def _validate_spawn_overrides(
    *,
    parent_state: AgentStateData,
    max_turns: int | None,
    max_tokens: int | None,
) -> Result[None, AnimaError]:
    """Validate child override limits against parent limits."""
    if (
        parent_state.max_turns is not None
        and max_turns is not None
        and max_turns > parent_state.max_turns
    ):
        return Failure(
            InvalidInputError(
                "max_turns override exceeds parent max_turns",
                {
                    "requested": max_turns,
                    "parent_max_turns": parent_state.max_turns,
                },
            )
        )

    if (
        parent_state.budget_total is not None
        and max_tokens is not None
        and max_tokens > parent_state.budget_total
    ):
        return Failure(
            InvalidInputError(
                "max_tokens override exceeds parent budget_total",
                {
                    "requested": max_tokens,
                    "parent_max_tokens": parent_state.budget_total,
                },
            )
        )

    return Success(None)


# @invar:allow shell_result: Helper returns optional bundle path for shared-mode spawn wiring
# @shell_complexity: Shared mode requires log read, extraction, export, and graceful fallbacks
# @shell_orchestration: Shared-mode bundle export coordinates shell read/write adapters for spawn flow
def _export_spawn_context_bundle_if_shared(
    *,
    parent_id: str,
    child_instance_id: str,
    context_mode: str,
) -> str | None:
    """Export child context bundle from persisted parent logs for shared mode."""
    if context_mode != "shared":
        return None

    from anima.shell.context_bundle import (
        export_context_bundle,
        extract_raw_tail_from_logs,
    )
    from anima.shell.state import get_agent_dir, read_log

    log_result = read_log(parent_id)
    if isinstance(log_result, Failure):
        return None

    raw_tail = extract_raw_tail_from_logs(log_result.unwrap())
    if not raw_tail:
        return None

    bundle_id = f"ctx_{child_instance_id}"
    bundle_path = get_agent_dir(child_instance_id) / "context_bundle.json"
    export_result = export_context_bundle(
        bundle_id=bundle_id,
        inherited_from=parent_id,
        raw_tail=raw_tail,
        output_path=bundle_path,
    )
    if isinstance(export_result, Success):
        return str(bundle_path)
    return None


def _persist_child_and_parent_spawn_state(
    *,
    child_state: AgentStateData,
    parent_state: AgentStateData,
    child_instance_id: str,
    child_persona_name: str,
    now: str,
) -> Result[None, AnimaError]:
    """Persist child state and parent linkage with rollback on parent write failure."""
    write_child_result = write_state(
        child_state.instance_id,
        child_state,
        "created",
        {"parent_id": parent_state.instance_id},
    )
    if isinstance(write_child_result, Failure):
        return Failure(
            InternalError(
                f"Failed to create child state: {write_child_result.failure()}",
                {"child_instance_id": child_instance_id},
            )
        )

    if child_instance_id not in parent_state.children:
        parent_state.children.append(child_instance_id)
        parent_state.updated_at = now

    write_parent_result = write_state(
        parent_state.instance_id,
        parent_state,
        "child_spawned",
        {"child_id": child_instance_id, "persona": child_persona_name},
    )
    if isinstance(write_parent_result, Failure):
        _delete_child_state_sync(child_state.instance_id)
        return Failure(
            InternalError(
                f"Failed to update parent state: {write_parent_result.failure()}",
                {"child_instance_id": child_instance_id},
            )
        )

    return Success(None)


def _launch_child_subprocess_with_audit(
    *,
    parent_instance_id: str,
    child_instance_id: str,
    persona_ref: str,
    task: str,
    child_persona: PersonaSpec,
    child_job: JobSpec,
    trace_id: str,
) -> Result[None, AnimaError]:
    """Launch child and append failure audit log if launch fails."""
    launch_result = launch_agent_subprocess(
        instance_id=child_instance_id,
        task=task,
        persona=child_persona,
        job=child_job,
        trace_id=trace_id,
    )
    if isinstance(launch_result, Failure):
        append_log(
            parent_instance_id,
            "spawn_failed",
            {
                "child_id": child_instance_id,
                "persona_ref": persona_ref,
                "error": str(launch_result.failure()),
            },
        )
        return Failure(launch_result.failure())

    return Success(None)


class _SpawnContractStatePort:
    """Read mutable state and resolved contract for ctl_spawn control service."""

    def read_state(self, instance_id: str):
        return read_state(instance_id)

    def load_contract(self, instance_id: str):
        state_result = read_state(instance_id)
        if isinstance(state_result, Failure):
            return state_result
        return Success(load_persisted_or_legacy_contract(state_result.unwrap()))


class _SpawnPersonaPort(SpawnPersonaPort):
    """Persona loader/permission adapter for ctl_spawn control service.

    Accepts an optional parent_model so that inline persona synthesis can
    inherit the parent's model when no persona file exists.

    When mcp_servers is provided, attempts to resolve spec_id via larva
    MCP server before falling back to inline synthesis.
    """

    def __init__(
        self,
        *,
        parent_model: str | None = None,
        mcp_servers: list[object] | None = None,
        pre_resolved: PersonaSpec | None = None,
    ) -> None:
        self._parent_model = parent_model
        self._mcp_servers = mcp_servers
        self._pre_resolved = pre_resolved

    def load_persona(self, persona_ref: str):
        # Return pre-resolved persona if available (from larva MCP pre-resolve)
        if self._pre_resolved is not None:
            return Success(self._pre_resolved)

        result = _load_persona_from_ref(persona_ref)
        if isinstance(result, Failure):
            error = result.failure()
            # Only try fallbacks for "file not found" errors, not
            # security rejections (absolute paths, path traversal)
            if isinstance(error, InvalidInputError) and "not found" in error.message:
                return _try_synthesize_inline_persona(
                    persona_ref, parent_model=self._parent_model
                )
        return result

    def validate_parent_spawn_permission(
        self,
        *,
        parent_persona: PersonaSpec,
        child_persona_name: str,
    ):
        return _check_spawn_permission(parent_persona, child_persona_name)


class _NoopApprovalPort:
    """Unused approval adapter required by DefaultControlService constructor."""

    def write_approval_response(
        self, instance_id: str, approved: bool, note: str | None = None
    ):
        return Success(None)


class _NoopLifecyclePort:
    """Unused lifecycle adapter required by DefaultControlService constructor."""

    def approve(self, instance_id: str):
        return Success(None)

    def reject(self, instance_id: str, reason: str | None = None):
        return Success(None)

    def resume(self, instance_id: str):
        return Success(None)

    def kill(self, instance_id: str, reason: str | None = None):
        return Success(None)


class _SpawnLaunchPort:
    """Launch adapter preserving ctl_spawn persistence and logging semantics."""

    def launch_subagent(
        self,
        request: LaunchSubagentRequest,
    ) -> Result[LaunchResult, AnimaError]:
        parent_state_result = read_state(request.parent_instance_id)
        if isinstance(parent_state_result, Failure):
            return parent_state_result
        parent_state = parent_state_result.unwrap()

        context_bundle_ref = request.context_bundle_ref
        if (
            request.child_job.context_mode.value == "shared"
            and context_bundle_ref is None
        ):
            context_bundle_ref = _export_spawn_context_bundle_if_shared(
                parent_id=request.parent_instance_id,
                child_instance_id=request.instance_id,
                context_mode="shared",
            )

        child_job = request.child_job
        if context_bundle_ref is not None:
            child_job = request.child_job.model_copy(
                update={"context_bundle_ref": context_bundle_ref}
            )

        now = datetime.now(timezone.utc).isoformat()
        child_state = AgentStateData(
            instance_id=request.instance_id,
            trace_id=request.trace_id,
            persona_name=request.child_persona.id,
            persona_ref=request.child_persona_ref,
            spec_id=request.child_persona.id,
            spec_digest=request.child_persona.spec_digest,
            state=AgentState.pending,
            lifecycle=child_job.lifecycle,
            parent_id=request.parent_instance_id,
            children=[],
            depth=parent_state.depth + 1,
            max_depth=child_job.max_depth,
            pid=None,
            mcp_socket=None,
            created_at=now,
            updated_at=now,
            token_usage=TokenUsage(),
            tools_called=[],
            current_turn=0,
            max_turns=child_job.max_turns,
            budget_total=child_job.max_tokens,
            grace_budget=child_job.finalize_grace_budget,
            error=None,
            result_ref=None,
        )

        persist_result = _persist_child_and_parent_spawn_state(
            child_state=child_state,
            parent_state=parent_state,
            child_instance_id=request.instance_id,
            child_persona_name=request.child_persona.id,
            now=now,
        )
        if isinstance(persist_result, Failure):
            return persist_result

        append_log(
            request.parent_instance_id,
            "spawn",
            {
                "child_id": request.instance_id,
                "persona_ref": request.child_persona_ref,
                "task": request.task[:100],
            },
        )

        launch_result = _launch_child_subprocess_with_audit(
            parent_instance_id=request.parent_instance_id,
            child_instance_id=request.instance_id,
            persona_ref=request.child_persona_ref,
            task=request.task,
            child_persona=request.child_persona,
            child_job=child_job,
            trace_id=request.trace_id,
        )
        if isinstance(launch_result, Failure):
            return launch_result

        return Success(
            LaunchResult(
                instance_id=request.instance_id,
                trace_id=request.trace_id,
                resolved_contract=request.resolved_contract,
                initial_state=child_state,
            )
        )


# @shell_complexity: Multi-step spawn orchestration with permission/depth checks
# @invar:allow function_size: Subagent spawning orchestrates multiple state operations
async def ctl_spawn(
    instance_id: str,
    persona_ref: str | None,
    task: str,
    context_mode: str = "isolated",
    lifecycle: str | None = None,
    max_turns: int | None = None,
    max_tokens: int | None = None,
    mcp_servers: list[object] | None = None,
) -> Result[str, AnimaError]:
    """Spawn a child subagent.

    When context_mode='shared', exports parent conversation history (interact_message
    and response log entries) as a ContextBundle file for the child agent.

    Args:
        instance_id: Parent agent's instance ID.
        persona_ref: Path to child persona file (relative to cwd).
            If None, child inherits the parent's persona_ref.
            **Standalone mode:** Only file paths are supported. Treated as a relative
            path from the current working directory. Security validation rejects
            absolute paths and path traversal attempts (e.g., `../secrets.json`).
            **Nervus integration (not yet implemented):** Will support spec_id
            resolution via a persona registry. For now, use file paths only.
        task: Task string for the child agent.
        context_mode: Context sharing mode ('shared' or 'isolated').
            - 'shared': Parent conversation history exported to child.
            - 'isolated': Child starts with no conversation context.
        lifecycle: Optional child lifecycle override. If None, inherits parent lifecycle.
        max_turns: Optional child max_turns override. If None, inherits parent max_turns.
        max_tokens: Optional child max_tokens override. If None, inherits parent budget.

    Returns:
        Success with child instance_id, or Failure with error.

    Semantics:
        - docs/INTERFACES.md states persona_ref can be "file path or spec_id"
        - Current implementation (standalone mode) only supports file paths
        - spec_id resolution via larva MCP when available (pass mcp_servers)
        - Falls back to inline synthesis when both file and larva resolution fail

    Steps and behavior are preserved from prior ``shell/ctl.py`` implementation.
    """
    from anima.core.schemas import ContextMode, LifecycleType

    # Strip @ prefix from persona_ref — LLMs sometimes add it (e.g., "@python-executor")
    if persona_ref and persona_ref.startswith("@"):
        persona_ref = persona_ref[1:]

    try:
        context_mode_type = ContextMode(context_mode)
    except ValueError:
        return Failure(
            InvalidInputError(
                f"Invalid context_mode '{context_mode}'. Must be one of: {[cm.value for cm in ContextMode]}",
                {"context_mode": context_mode},
            )
        )

    lifecycle_type: LifecycleType | None = None
    if lifecycle is not None:
        try:
            lifecycle_type = LifecycleType(lifecycle)
        except ValueError:
            return Failure(
                InvalidInputError(
                    f"Invalid lifecycle '{lifecycle}'. Must be one of: {[lt.value for lt in LifecycleType]}",
                    {"lifecycle": lifecycle},
                )
            )

    # Read parent model for inline persona synthesis fallback
    parent_state_result = read_state(instance_id)
    parent_model = (
        parent_state_result.unwrap().model
        if isinstance(parent_state_result, Success)
        else None
    )

    # Pre-resolve spec_id via larva MCP (async context has live MCP connections).
    # Store resolved PersonaSpec directly on the port to bypass file path validation.
    # Handles both explicit persona_ref AND inherited parent persona_ref.
    pre_resolved_persona: PersonaSpec | None = None
    effective_ref = persona_ref
    if effective_ref is None and isinstance(parent_state_result, Success):
        effective_ref = parent_state_result.unwrap().persona_ref
    if (
        effective_ref
        and not Path(effective_ref).exists()
        and "/" not in effective_ref
        and mcp_servers
    ):
        from anima.shell.larva_persona_resolver import resolve_persona_via_larva

        larva_result = await resolve_persona_via_larva(effective_ref, mcp_servers)
        if isinstance(larva_result, Success):
            pre_resolved_persona = larva_result.unwrap()

    control_service = DefaultControlService(
        contract_state_port=_SpawnContractStatePort(),
        approval_port=_NoopApprovalPort(),
        lifecycle_port=_NoopLifecyclePort(),
        launch_port=_SpawnLaunchPort(),
        spawn_persona_port=_SpawnPersonaPort(
            parent_model=parent_model,
            mcp_servers=mcp_servers,
            pre_resolved=pre_resolved_persona,
        ),
        contract_resolver=resolve_execution_contract,
        generate_instance_id=generate_instance_id,
    )
    spawn_result = control_service.spawn_subagent(
        SpawnSubagentRequest(
            parent_instance_id=instance_id,
            task=task,
            persona_ref=persona_ref,
            context_mode=context_mode_type,
            lifecycle=lifecycle_type,
            max_turns=max_turns,
            max_tokens=max_tokens,
        )
    )
    if isinstance(spawn_result, Failure):
        return spawn_result

    outcome = spawn_result.unwrap()
    if isinstance(outcome, SpawnSubagentRejected):
        return Failure(
            InvalidInputError(
                outcome.reason.message,
                {
                    "code": outcome.reason.code,
                    "user_action": outcome.reason.user_action,
                    "retryable": outcome.reason.retryable,
                    "details": outcome.reason.details,
                },
            )
        )
    return Success(outcome.launch_result.instance_id)


# Backward compatibility alias
ctl_spawn_subagent = ctl_spawn
