"""Agent state persistence.

File-based state management: read/write agent state to $ANIMA_STATE directory.
Returns Result[T, E].

Layout (docs/INTERFACES.md Section F):
    $ANIMA_STATE/agents/<instance_id>/
        state.json    -- AgentStateData serialized as JSON
        result.json   -- AgentResult (only after terminal state)
        log.jsonl     -- Event log (one JSON line per event)
        mcp.sock      -- Unix domain socket for per-agent MCP (Phase 4)

Key insight: instance_id is the directory key per docs/INTERFACES.md Section F.
The instance_id uniquely identifies an agent instance, and directories are
organized by instance_id.
"""

# @invar:allow file_size: State management module with many related functions
# @invar:allow shell_result: Path getters and timestamp don't fail in normal operation
# @shell_orchestration: Path construction and log parsing helpers for state management

import errno
import json
import os
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Final

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

from anima.core.execution import ExecutionSessionState, ResolvedExecutionContract
from anima.core.lifecycle import LifecycleEvent, next_state
from anima.core.schemas import (
    AnimaError,
    AgentAlreadyExistsError,
    AgentNotFoundError,
    AgentResult,
    AgentState,
    AgentStateData,
    InternalError,
    InvalidInputError,
    JobSpec,
    LogEntry,
    NotFoundError,
    TokenUsage,
)


ApprovalResponse = dict[str, bool | str | None]


# =============================================================================
# Path Helpers
# =============================================================================


# @invar:allow shell_result: Path getter never fails for ANIMA_STATE root
def get_state_dir() -> Path:
    """Get the base state directory from $ANIMA_STATE or default.

    Creates the directory if it doesn't exist.

    Returns:
        Path to the state directory.
    """
    state_dir = os.environ.get("ANIMA_STATE")
    if state_dir:
        path = Path(state_dir)
    else:
        path = Path.home() / ".anima"

    # Create directory if not exists
    path.mkdir(parents=True, exist_ok=True)
    return path


# @invar:allow shell_result: Path getter never fails
# @shell_orchestration: Path construction helper for CLI proximity
def get_agent_dir(instance_id: str) -> Path:
    """Get the agent-specific directory by instance_id.

    Per docs/INTERFACES.md Section F, directories are organized by instance_id.
    This is the canonical path function.

    Args:
        instance_id: The instance identifier (e.g., "inst_abc123").

    Returns:
        Path to $ANIMA_STATE/agents/<instance_id>/
    """
    return get_state_dir() / "agents" / instance_id


# @invar:allow shell_result: Path getter never fails
# @shell_orchestration: Path construction helper
def get_approval_response_path(instance_id: str) -> Path:
    """Get path to approval response channel file for an agent.

    Source: user requirement for approval response queue/state channel.

    Args:
        instance_id: The instance identifier (directory key).

    Returns:
        Path to $ANIMA_STATE/agents/<instance_id>/approval_response.json
    """
    return get_agent_dir(instance_id) / "approval_response.json"


# @invar:allow shell_result: Path getter never fails
# @shell_orchestration: Path construction helper for log persistence seam
def get_agent_log_path(instance_id: str) -> Path:
    """Get path to per-agent log.jsonl file.

    Args:
        instance_id: The instance identifier (directory key).

    Returns:
        Path to $ANIMA_STATE/agents/<instance_id>/log.jsonl.
    """
    return get_agent_dir(instance_id) / "log.jsonl"


# @invar:allow shell_result: Path getter never fails
# @shell_orchestration: Snapshot path co-located with state adapter path helpers.
def get_contract_snapshot_path(instance_id: str) -> Path:
    """Get path to immutable execution-contract snapshot file.

    Args:
        instance_id: The instance identifier (directory key).

    Returns:
        Path to $ANIMA_STATE/agents/<instance_id>/contract.snapshot.json.
    """
    return get_agent_dir(instance_id) / "contract.snapshot.json"


# @invar:allow shell_result: Path getter never fails
# @shell_orchestration: Session projection path co-located with state adapter path helpers.
def get_session_projection_path(instance_id: str) -> Path:
    """Get path to mutable session attach projection file.

    Args:
        instance_id: The instance identifier (directory key).

    Returns:
        Path to $ANIMA_STATE/agents/<instance_id>/session.projection.json.
    """
    return get_agent_dir(instance_id) / "session.projection.json"


# =============================================================================
# Internal Helpers
# =============================================================================


# @shell_complexity: Atomic write with temp file cleanup cannot be simplified
def _atomic_write_json(file_path: Path, data: str) -> Result[None, InternalError]:
    """Write JSON data to file atomically using temp file + rename.

    Args:
        file_path: Target file path.
        data: JSON string to write.

    Returns:
        Success(None) or Failure(InternalError) on I/O error.
    """
    try:
        file_path.parent.mkdir(parents=True, exist_ok=True)
        tmp_file = file_path.with_suffix(".json.tmp")
        try:
            tmp_file.write_text(data)
            tmp_file.replace(file_path)
        except (OSError, FileNotFoundError):
            if tmp_file.exists():
                tmp_file.unlink()
            raise
        return Success(None)
    except (OSError, FileNotFoundError) as e:
        return Failure(InternalError(f"Failed to write file: {e}"))


# @invar:allow shell_result: Deterministic serializer is pure
# @shell_orchestration: Snapshot serialization format belongs to state adapter boundary.
def _contract_snapshot_payload(contract: ResolvedExecutionContract) -> tuple[str, str]:
    """Build deterministic payload and hash for a resolved contract snapshot."""
    contract_data = contract.model_dump(mode="json")
    stable_json = json.dumps(contract_data, sort_keys=True, separators=(",", ":"))
    import hashlib

    snapshot_hash = hashlib.sha256(stable_json.encode("utf-8")).hexdigest()
    payload = json.dumps(
        {
            "snapshot_hash": snapshot_hash,
            "contract": contract_data,
        },
        sort_keys=True,
        indent=2,
    )
    return (payload, snapshot_hash)


_SESSION_PROJECTION_VERSION_UNSET: Final[int] = -1


# @invar:allow shell_result: Session projection payload assembly is deterministic and side-effect free
def _session_projection_payload(
    *,
    version: int,
    session_state: ExecutionSessionState,
) -> str:
    """Build deterministic JSON payload for session projection persistence."""
    return json.dumps(
        {
            "version": version,
            "session_state": session_state.model_dump(mode="json"),
        },
        sort_keys=True,
        indent=2,
    )


# @invar:allow shell_result: Session projection path resolution is pure and deterministic
def _resolve_session_artifact_path(instance_id: str, path_value: str | None) -> Path:
    """Resolve relative or absolute socket/session artifact path values."""
    default_path = get_agent_dir(instance_id) / "mcp.sock"
    if path_value is None or not path_value.strip():
        return default_path

    candidate = Path(path_value)
    if candidate.is_absolute():
        return candidate

    if candidate.parts and candidate.parts[0] == "agents":
        return get_state_dir() / candidate

    return get_agent_dir(instance_id) / candidate


# @invar:allow shell_result: Session projection fallback synthesis is deterministic and side-effect free
def _session_state_from_mutable_state(
    state_data: AgentStateData,
) -> ExecutionSessionState:
    """Synthesize ephemeral session state when projection artifact is absent."""
    from anima.core.contracts import SessionTransportState

    socket_path = _resolve_session_artifact_path(
        state_data.instance_id,
        state_data.mcp_socket,
    )
    terminal_snapshot_ref: str | None = None
    if state_data.state in {AgentState.completed, AgentState.failed, AgentState.killed}:
        terminal_snapshot_ref = f"{state_data.state.value}:{state_data.updated_at}"

    return ExecutionSessionState(
        instance_id=state_data.instance_id,
        transport=SessionTransportState(
            attach_mode=None,
            stream_active=False,
            transport_connected=socket_path.exists(),
            socket_path=str(socket_path),
            pending_approval_prompt=(state_data.state == AgentState.awaiting_approval),
            terminal_snapshot_ref=terminal_snapshot_ref,
            last_seen_at=state_data.updated_at,
        ),
    )


# @shell_complexity: CAS semantics require branchy conflict and malformed-file handling.
def write_contract_snapshot(
    instance_id: str,
    contract: ResolvedExecutionContract,
    *,
    expected_hash: str | None = None,
) -> Result[str, InternalError | AgentNotFoundError | InvalidInputError]:
    """Persist immutable resolved execution contract with CAS support.

    Immutable snapshot semantics:
    - Snapshot may be written once for an instance.
    - Re-writing identical snapshot is a no-op success.
    - Re-writing different snapshot requires CAS via expected_hash.

    Args:
        instance_id: Agent instance identifier.
        contract: Resolved execution contract to persist.
        expected_hash: Optional compare-and-swap hash guard.

    Returns:
        Success(snapshot_hash) on write/no-op.
        Failure(AgentNotFoundError) if instance directory does not exist.
        Failure(InternalError) for CAS conflict or I/O errors.
        Failure(InvalidInputError) for malformed existing snapshot.
    """
    agent_dir = get_agent_dir(instance_id)
    if not agent_dir.exists():
        return Failure(AgentNotFoundError(f"Agent not found: {instance_id}"))

    snapshot_path = get_contract_snapshot_path(instance_id)
    payload, new_hash = _contract_snapshot_payload(contract)

    if snapshot_path.exists():
        try:
            existing_raw = snapshot_path.read_text()
            existing_data = json.loads(existing_raw)
            existing_hash = existing_data.get("snapshot_hash")
            if not isinstance(existing_hash, str) or len(existing_hash) == 0:
                return Failure(
                    InvalidInputError(
                        f"Invalid contract snapshot file for {instance_id}: missing snapshot_hash"
                    )
                )
            if expected_hash is not None and expected_hash != existing_hash:
                return Failure(
                    InternalError(
                        "Contract snapshot CAS conflict",
                        {
                            "instance_id": instance_id,
                            "expected_hash": expected_hash,
                            "actual_hash": existing_hash,
                        },
                    )
                )
            if existing_hash != new_hash and expected_hash is None:
                return Failure(
                    InternalError(
                        "Contract snapshot already exists with different immutable content",
                        {
                            "instance_id": instance_id,
                            "existing_hash": existing_hash,
                            "new_hash": new_hash,
                        },
                    )
                )
            if existing_hash == new_hash:
                return Success(existing_hash)
        except FileNotFoundError:
            pass
        except (json.JSONDecodeError, TypeError, ValueError) as error:
            return Failure(
                InvalidInputError(
                    f"Invalid contract snapshot file for {instance_id}: {error}"
                )
            )
        except OSError as error:
            return Failure(
                InternalError(
                    f"Failed reading contract snapshot for {instance_id}: {error}"
                )
            )

    write_result = _atomic_write_json(snapshot_path, payload)
    if isinstance(write_result, Failure):
        return write_result
    return Success(new_hash)


# @shell_complexity: Snapshot load validates compatibility payload shape and parsing errors.
def read_contract_snapshot(
    instance_id: str,
) -> Result[ResolvedExecutionContract, NotFoundError | InvalidInputError]:
    """Read persisted resolved execution contract snapshot.

    Args:
        instance_id: Agent instance identifier.

    Returns:
        Success(ResolvedExecutionContract) on valid snapshot.
        Failure(NotFoundError) when snapshot is absent.
        Failure(InvalidInputError) for malformed snapshot payload.
    """
    snapshot_path = get_contract_snapshot_path(instance_id)
    try:
        raw = snapshot_path.read_text()
        payload = json.loads(raw)
        if not isinstance(payload, dict) or "contract" not in payload:
            return Failure(
                InvalidInputError(
                    f"Invalid contract snapshot file for {instance_id}: missing contract"
                )
            )
        contract_data = payload["contract"]
        if not isinstance(contract_data, dict):
            return Failure(
                InvalidInputError(
                    f"Invalid contract snapshot file for {instance_id}: contract must be object"
                )
            )
        return Success(ResolvedExecutionContract(**contract_data))
    except FileNotFoundError:
        return Failure(
            NotFoundError(f"Contract snapshot not found for agent: {instance_id}")
        )
    except ValidationError as error:
        return Failure(
            InvalidInputError(
                f"Invalid contract snapshot file for {instance_id}: {error}"
            )
        )
    except (json.JSONDecodeError, TypeError, ValueError) as error:
        return Failure(
            InvalidInputError(
                f"Invalid contract snapshot file for {instance_id}: {error}"
            )
        )


# @shell_complexity: CAS update with projection fallback and malformed-file handling.
def write_session_projection(
    instance_id: str,
    session_state: ExecutionSessionState,
    *,
    expected_version: int | None = None,
) -> Result[str, InternalError | AgentNotFoundError | InvalidInputError]:
    """Persist mutable session projection with optional CAS version guard.

    Semantics:
    - Projection updates are ephemeral and last-writer-wins by default.
    - When ``expected_version`` is provided, mismatches fail with CAS conflict.
    - Explicit ``None`` values inside ``session_state`` are written as nulls and
      overwrite previously populated values.

    Args:
        instance_id: Agent instance identifier.
        session_state: Ephemeral session projection to persist.
        expected_version: Optional compare-and-swap version guard.

    Returns:
        Success(version_token) on write success.
        Failure(AgentNotFoundError) if instance directory does not exist.
        Failure(InternalError) on CAS conflict or I/O errors.
        Failure(InvalidInputError) for malformed projection payload.
    """
    agent_dir = get_agent_dir(instance_id)
    if not agent_dir.exists():
        return Failure(AgentNotFoundError(f"Agent not found: {instance_id}"))

    projection_path = get_session_projection_path(instance_id)
    existing_version = 0

    if projection_path.exists():
        try:
            existing_raw = projection_path.read_text()
            existing_data = json.loads(existing_raw)
            version_value = existing_data.get("version")
            if not isinstance(version_value, int) or version_value < 0:
                return Failure(
                    InvalidInputError(
                        f"Invalid session projection file for {instance_id}: missing version"
                    )
                )
            existing_version = version_value
        except FileNotFoundError:
            existing_version = 0
        except (json.JSONDecodeError, TypeError, ValueError) as error:
            return Failure(
                InvalidInputError(
                    f"Invalid session projection file for {instance_id}: {error}"
                )
            )
        except OSError as error:
            return Failure(
                InternalError(
                    f"Failed reading session projection for {instance_id}: {error}"
                )
            )

    if expected_version is not None and existing_version != expected_version:
        return Failure(
            InternalError(
                "Session projection CAS conflict",
                {
                    "instance_id": instance_id,
                    "expected_version": expected_version,
                    "actual_version": existing_version,
                },
            )
        )

    next_version = existing_version + 1
    payload = _session_projection_payload(
        version=next_version,
        session_state=session_state,
    )
    write_result = _atomic_write_json(projection_path, payload)
    if isinstance(write_result, Failure):
        return write_result
    return Success(f"v{next_version}")


# @shell_complexity: Projection loader supports missing-file fallback and malformed payload branches.
def read_session_projection(
    instance_id: str,
) -> Result[ExecutionSessionState, AgentNotFoundError | InvalidInputError]:
    """Read session projection, with fallback synthesis from mutable state.

    If the projection file is missing (stale/cleaned transport artifact), this
    function falls back to current mutable state and inferred socket location.
    """
    projection_path = get_session_projection_path(instance_id)
    try:
        raw = projection_path.read_text()
        payload = json.loads(raw)
        if not isinstance(payload, dict):
            return Failure(
                InvalidInputError(
                    f"Invalid session projection file for {instance_id}: expected object"
                )
            )
        state_payload = payload.get("session_state")
        if not isinstance(state_payload, dict):
            return Failure(
                InvalidInputError(
                    f"Invalid session projection file for {instance_id}: missing session_state"
                )
            )

        transport_payload = state_payload.get("transport")
        if isinstance(transport_payload, dict):
            resolved_socket = _resolve_session_artifact_path(
                instance_id,
                transport_payload.get("socket_path")
                if isinstance(transport_payload.get("socket_path"), str)
                else None,
            )
            transport_payload["socket_path"] = str(resolved_socket)
            if transport_payload.get("transport_connected") is True:
                transport_payload["transport_connected"] = resolved_socket.exists()

        return Success(ExecutionSessionState(**state_payload))
    except FileNotFoundError:
        state_result = read_state(instance_id)
        if isinstance(state_result, Failure):
            return state_result
        return Success(_session_state_from_mutable_state(state_result.unwrap()))
    except ValidationError as error:
        return Failure(
            InvalidInputError(
                f"Invalid session projection file for {instance_id}: {error}"
            )
        )
    except (json.JSONDecodeError, TypeError, ValueError) as error:
        return Failure(
            InvalidInputError(
                f"Invalid session projection file for {instance_id}: {error}"
            )
        )


# =============================================================================
# Core API (Step: state-budget-ctl.state-persistence)
# All functions return Result[T, AnimaError] with instance_id
# =============================================================================


def write_state(
    instance_id: str,
    state_data: AgentStateData,
    event: str = "state_write",
    details: dict | None = None,
) -> Result[None, InternalError]:
    """Write state data as JSON to get_agent_dir(instance_id)/state.json.

    Creates the directory if needed. Uses atomic write (write to .tmp, rename).
    Also appends a log entry to log.jsonl.

    Args:
        instance_id: The instance identifier (directory key).
        state_data: The agent state data to write.
        event: Event name for logging (default: "state_write").
        details: Optional details for logging.

    Returns:
        Success(None) or Failure(InternalError) on I/O error.
    """
    state_file = get_agent_dir(instance_id) / "state.json"
    result = _atomic_write_json(state_file, state_data.model_dump_json(indent=2))
    if isinstance(result, Failure):
        return result

    # Append log entry (best effort, doesn't fail the write)
    log_details = details or {}
    log_details["state"] = state_data.state.value
    append_log(instance_id, event, log_details)

    return Success(None)


def read_state(
    instance_id: str,
) -> Result[AgentStateData, AgentNotFoundError | InvalidInputError]:
    """Read and parse get_agent_dir(instance_id)/state.json.

    Args:
        instance_id: The instance identifier (directory key).

    Returns:
        Success(AgentStateData) or Failure(AgentNotFoundError) if not found,
        Failure(InvalidInputError) if parse error.
    """
    state_file = get_agent_dir(instance_id) / "state.json"

    try:
        content = state_file.read_text()
        data = json.loads(content)
        return Success(AgentStateData(**data))
    except FileNotFoundError:
        return Failure(AgentNotFoundError(f"Agent not found: {instance_id}"))
    except ValidationError as e:
        return Failure(InvalidInputError(f"Invalid state file for {instance_id}: {e}"))
    except (json.JSONDecodeError, TypeError, ValueError) as e:
        return Failure(InvalidInputError(f"Invalid state file for {instance_id}: {e}"))


class ShellSessionProjectionPort:
    """Shell adapter implementing app.session_service projection port."""

    def read_state(self, instance_id: str) -> Result[AgentStateData, AnimaError]:
        """Load mutable state view for session projection."""
        return read_state(instance_id)

    def load_contract(
        self, instance_id: str
    ) -> Result[ResolvedExecutionContract, AnimaError]:
        """Load persisted contract snapshot or synthesize legacy fallback."""
        state_result = read_state(instance_id)
        if isinstance(state_result, Failure):
            return state_result

        from anima.shell.contract_compat import load_persisted_or_legacy_contract

        return Success(load_persisted_or_legacy_contract(state_result.unwrap()))

    def load_session_state(
        self, instance_id: str
    ) -> Result[ExecutionSessionState, AnimaError]:
        """Load ephemeral session transport state projection."""
        return read_session_projection(instance_id)


# @shell_complexity: Directory iteration with filtering cannot be simplified
def _iter_agent_dirs(
    state_filter: AgentState | None = None,
) -> Result[list[AgentStateData], InternalError]:
    """Iterate all agent directories and read state.json from each.

    Internal helper that yields successful reads.

    Args:
        state_filter: Optional state to filter by.

    Returns:
        Success(list of AgentStateData) or Failure on I/O error.
    """
    agents_dir = get_state_dir() / "agents"

    if not agents_dir.exists():
        return Success([])

    agents: list[AgentStateData] = []

    try:
        for agent_subdir in agents_dir.iterdir():
            if not agent_subdir.is_dir():
                continue

            result = read_state(agent_subdir.name)
            if isinstance(result, Success):
                agent_state = result.unwrap()
                if state_filter is None or agent_state.state == state_filter:
                    agents.append(agent_state)

        return Success(agents)
    except OSError as e:
        return Failure(InternalError(f"Failed to list agents: {e}"))


def list_agents(
    state_filter: AgentState | None = None,
) -> Result[list[AgentStateData], InternalError]:
    """Scan all agent subdirectories and read state.json from each.

    Args:
        state_filter: Optional state to filter by.

    Returns:
        Success(list of AgentStateData) or Failure on I/O error.
        Skips directories without valid state.json (logs warning, continues).
    """
    return _iter_agent_dirs(state_filter)


def delete_state(instance_id: str) -> Result[None, AgentNotFoundError | InternalError]:
    """Remove entire get_agent_dir(instance_id).

    Args:
        instance_id: The instance identifier (directory key).

    Returns:
        Success(None) or Failure(AgentNotFoundError) if not found.
    """
    agent_path = get_agent_dir(instance_id)

    try:
        if not agent_path.exists():
            return Failure(AgentNotFoundError(f"Agent not found: {instance_id}"))
        shutil.rmtree(agent_path)
        return Success(None)
    except OSError as e:
        return Failure(InternalError(f"Failed to delete agent state: {e}"))


def write_result(instance_id: str, result: AgentResult) -> Result[None, InternalError]:
    """Write result as JSON to get_agent_dir(instance_id)/result.json.

    Uses atomic write (write to .tmp, rename) to prevent partial reads.

    Args:
        instance_id: The instance identifier (directory key).
        result: The agent result to write.

    Returns:
        Success(None) or Failure on I/O error.
    """
    result_file = get_agent_dir(instance_id) / "result.json"
    return _atomic_write_json(result_file, result.model_dump_json(indent=2))


def write_approval_response(
    instance_id: str,
    approved: bool,
    note: str | None,
) -> Result[None, InternalError]:
    """Write approval response payload to per-agent state channel.

    Payload schema is strictly: {"approved": bool, "note": str | null}.

    Args:
        instance_id: The instance identifier (directory key).
        approved: Approval decision.
        note: Optional human note attached to decision.

    Returns:
        Success(None) or Failure(InternalError) on write error.
    """
    response_file = get_approval_response_path(instance_id)
    payload = {"approved": approved, "note": note}
    return _atomic_write_json(response_file, json.dumps(payload, indent=2))


# @shell_complexity: File I/O with multiple error paths and JSON validation branches
def read_approval_response(
    instance_id: str,
) -> Result[ApprovalResponse, NotFoundError | InvalidInputError]:
    """Read approval response payload from per-agent state channel.

    Expected schema: {"approved": bool, "note": str | null}.

    Args:
        instance_id: The instance identifier (directory key).

    Returns:
        Success(payload) when present and valid.
        Failure(NotFoundError) when channel file is absent.
        Failure(InvalidInputError) when payload is malformed.
    """
    response_file = get_approval_response_path(instance_id)

    try:
        raw_content = response_file.read_text()
        parsed = json.loads(raw_content)
    except FileNotFoundError:
        return Failure(
            NotFoundError(f"Approval response not found for agent: {instance_id}")
        )
    except json.JSONDecodeError as error:
        return Failure(
            InvalidInputError(
                f"Invalid approval response file for {instance_id}: {error}"
            )
        )
    except (TypeError, ValueError) as error:
        return Failure(
            InvalidInputError(
                f"Invalid approval response file for {instance_id}: {error}"
            )
        )

    if not isinstance(parsed, dict):
        return Failure(
            InvalidInputError(
                f"Invalid approval response payload for {instance_id}: expected object"
            )
        )

    approved_value = parsed.get("approved")
    note_value = parsed.get("note")

    if not isinstance(approved_value, bool):
        return Failure(
            InvalidInputError(
                f"Invalid approval response payload for {instance_id}: approved must be bool"
            )
        )

    if note_value is not None and not isinstance(note_value, str):
        return Failure(
            InvalidInputError(
                f"Invalid approval response payload for {instance_id}: note must be string or null"
            )
        )

    return Success({"approved": approved_value, "note": note_value})


def clear_approval_response(instance_id: str) -> Result[None, InternalError]:
    """Clear approval response channel for a fresh request cycle.

    Args:
        instance_id: The instance identifier (directory key).

    Returns:
        Success(None) when removed or already absent.
        Failure(InternalError) on I/O failure.
    """
    response_file = get_approval_response_path(instance_id)

    try:
        response_file.unlink(missing_ok=True)
        return Success(None)
    except OSError as error:
        return Failure(
            InternalError(
                f"Failed to clear approval response for {instance_id}: {error}"
            )
        )


def read_result(
    instance_id: str,
) -> Result[AgentResult, NotFoundError | InvalidInputError]:
    """Read get_agent_dir(instance_id)/result.json.

    Args:
        instance_id: The instance identifier (directory key).

    Returns:
        Success(AgentResult) or Failure(NotFoundError) if file missing.
    """
    result_file = get_agent_dir(instance_id) / "result.json"

    try:
        content = result_file.read_text()
        data = json.loads(content)
        return Success(AgentResult(**data))
    except FileNotFoundError:
        return Failure(NotFoundError(f"Result not found for agent: {instance_id}"))
    except ValidationError as e:
        return Failure(InvalidInputError(f"Invalid result file for {instance_id}: {e}"))
    except (json.JSONDecodeError, TypeError, ValueError) as e:
        return Failure(InvalidInputError(f"Invalid result file for {instance_id}: {e}"))


# =============================================================================
# Logging API (Step: state-budget-ctl.state-logging)
# =============================================================================


def append_log(
    instance_id: str,
    event: str,
    details: dict | None = None,
) -> Result[None, InternalError]:
    """Append a log entry to get_agent_dir(instance_id)/log.jsonl.

    Creates the file if it doesn't exist. Each entry is a JSON line with
    ts (timestamp), event, and optional details.

    Args:
        instance_id: The instance identifier (directory key).
        event: Event name (e.g., "state_transition", "created").
        details: Optional details dict.

    Returns:
        Success(None) or Failure(InternalError) on I/O error.
    """
    log_file = get_agent_log_path(instance_id)

    try:
        # Create directory if needed
        log_file.parent.mkdir(parents=True, exist_ok=True)

        # Create log entry (uses 'ts' field per docs/INTERFACES.md)
        entry = LogEntry(
            ts=datetime.now(timezone.utc).isoformat(),
            event=event,
            details=details,
        )

        # Append JSONL line
        with log_file.open("a") as f:
            f.write(entry.model_dump_json() + "\n")

        return Success(None)
    except (OSError, FileNotFoundError) as e:
        return Failure(InternalError(f"Failed to append log: {e}"))
    except ValidationError as e:
        return Failure(InternalError(f"Failed to append log: {e}"))


# @invar:allow shell_result: Pure helper for JSONL parsing
# @shell_orchestration: JSONL string parsing helper
# @shell_complexity: JSONL parsing with error skipping cannot be simplified
def _parse_log_lines(content: str) -> list[dict]:
    """Parse JSONL content into list of dicts, skipping malformed lines.

    Args:
        content: Raw JSONL file content.

    Returns:
        List of parsed log entry dicts.
    """
    entries: list[dict] = []
    for line in content.splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            entry = json.loads(line)
            if isinstance(entry, dict):
                entries.append(entry)
        except json.JSONDecodeError:
            # Skip malformed lines
            continue
    return entries


def read_log(instance_id: str) -> Result[list[dict], InternalError]:
    """Read all log entries from get_agent_dir(instance_id)/log.jsonl.

    Returns list of dicts parsed from JSONL. Skips malformed lines.

    Args:
        instance_id: The instance identifier (directory key).

    Returns:
        Success(list of log entry dicts) or Failure(InternalError) on I/O error.
        Returns empty list if file doesn't exist.
        Skips lines that aren't valid JSON.
    """
    log_file = get_agent_log_path(instance_id)

    if not log_file.exists():
        return Success([])

    try:
        content = log_file.read_text()
        return Success(_parse_log_lines(content))
    except (OSError, FileNotFoundError) as e:
        return Failure(InternalError(f"Failed to read log: {e}"))


# =============================================================================
# Log Tailing (Step: logs-follow-seek.implement-seek-reader)
# =============================================================================


class LogTailer:
    """Tail a log file for new entries, handling partial lines and truncation.

    Tracks file position to read only new content on each call to read_new().
    Handles:
    - Partial line buffering (lines without trailing newline)
    - File truncation (log rotation)
    - Missing files (appear later)
    """

    def __init__(self, path: Path) -> None:
        """Initialize the tailer.

        Args:
            path: Path to the log file to tail.
        """
        self.path = path
        self._position: int = 0
        self._partial_buffer: str = ""
        self._file_existed: bool = path.exists()
        self._file_identity: tuple[int, int] | None = None

        if path.exists():
            try:
                stat_result = path.stat()
                self._file_identity = (stat_result.st_dev, stat_result.st_ino)
            except OSError:
                self._file_identity = None
            # Open and seek to end
            with open(path, "rb") as f:
                f.seek(0, 2)  # Seek to end
                self._position = f.tell()

    def read_new(self) -> list[dict]:
        """Read new entries appended since last read.

        Returns:
            List of parsed log entry dicts from new content.
            Returns [] if no new content or file doesn't exist yet.
        """
        # Check for file appearance (was missing at init)
        if not self._file_existed:
            if not self.path.exists():
                # Still missing, no new content
                return []
            # File appeared! Read from beginning
            self._file_existed = True
            self._position = 0
            self._partial_buffer = ""

        if not self.path.exists():
            # File disappeared - treat as truncation
            self._position = 0
            self._partial_buffer = ""
            self._file_existed = False
            self._file_identity = None
            return []

        try:
            stat_result = self.path.stat()
            current_size = stat_result.st_size
        except OSError:
            return []

        current_identity = (stat_result.st_dev, stat_result.st_ino)
        if self._file_identity is not None and current_identity != self._file_identity:
            # File was replaced/recreated without shrinking. Reset and read from start.
            self._position = 0
            self._partial_buffer = ""
        self._file_identity = current_identity

        # Check for truncation (file size < saved position)
        if current_size < self._position:
            self._position = 0
            self._partial_buffer = ""

        # No new content
        if current_size == self._position:
            return []

        try:
            with open(self.path, "r") as f:
                f.seek(self._position)
                new_content = f.read()
                self._position = f.tell()
        except (OSError, IOError):
            return []

        # Combine with partial buffer from previous read
        content_to_parse = self._partial_buffer + new_content

        # Split on newline, keeping partial last line
        if not content_to_parse:
            return []

        lines = content_to_parse.split("\n")

        # If content doesn't end with newline, last element is partial
        if content_to_parse.endswith("\n"):
            complete_content = content_to_parse
            self._partial_buffer = ""
        else:
            complete_content = "\n".join(lines[:-1])
            self._partial_buffer = lines[-1]

        if not complete_content:
            return []

        # Parse complete JSONL lines
        return _parse_log_lines(complete_content)


# =============================================================================
# Agent state lifecycle API
# Typed errors align with AnimaError hierarchy.
# =============================================================================


# @shell_complexity: Initial state persistence now includes optional immutable snapshot write with rollback.
def create_agent_state(
    instance_id: str,
    persona_name: str,
    job: JobSpec,
    parent_id: str | None = None,
    trace_id: str | None = None,
    spec_id: str | None = None,
    # Spec source: contract-alignment-integration.fix-seam-proof-blockers (B1).
    # Transitional allowlist scope: runtime state persistence compatibility only.
    # Sunset criteria: remove in same migration as AgentStateData.spec_digest.
    spec_digest: str | None = None,
    model: str | None = None,
    resolved_contract: ResolvedExecutionContract | None = None,
) -> Result[AgentStateData, AgentAlreadyExistsError | InternalError]:
    """Create initial agent state file.

    Per docs/INTERFACES.md Section F, the directory is keyed by instance_id:
    $ANIMA_STATE/agents/<instance_id>/state.json

    This function enforces idempotency: if a directory for this instance_id
    already exists, it returns Failure(AgentAlreadyExistsError).

    Args:
        instance_id: Unique instance identifier. Used as the directory key.
        persona_name: Name of the persona.
        job: Job specification.
        parent_id: Optional parent agent instance ID for sub-agents.
        trace_id: End-to-end trace ID (inherited from parent, or generated).
        spec_id: Persona spec ID from PersonaSpec.
        spec_digest: Persona spec digest from PersonaSpec.
        model: Resolved model string from PersonaSpec.
        resolved_contract: Optional immutable resolved execution contract snapshot.

    Returns:
        Success with AgentStateData, or Failure(AgentAlreadyExistsError) if
        the instance directory already exists, or Failure(InternalError) on
        I/O failure.
    """
    from anima.core.ids import generate_trace_id

    # Generate trace_id if not provided (parent inheritance case)
    if trace_id is None:
        trace_id = generate_trace_id()

    state_dir = get_agent_dir(instance_id)
    state_file = state_dir / "state.json"

    # Idempotency check: fail if directory already exists
    if state_dir.exists():
        return Failure(
            AgentAlreadyExistsError(f"Agent instance already exists: {instance_id}")
        )

    now = datetime.now(timezone.utc).isoformat()

    state = AgentStateData(
        instance_id=instance_id,
        trace_id=trace_id,
        persona_name=persona_name,
        spec_id=spec_id,
        spec_digest=spec_digest,
        model=model,
        state=AgentState.pending,
        lifecycle=job.lifecycle,
        parent_id=parent_id,
        children=[],
        depth=0,
        max_depth=job.max_depth,
        pid=None,
        mcp_socket=None,
        created_at=now,
        updated_at=now,
        token_usage=TokenUsage(),
        tools_called=[],
        current_turn=0,
        max_turns=job.max_turns,
        budget_total=job.max_tokens,
        grace_budget=job.finalize_grace_budget,
        error=None,
        result_ref=None,
    )

    try:
        state_dir.mkdir(parents=True, exist_ok=False)
        state_file.write_text(state.model_dump_json(indent=2))
        if resolved_contract is not None:
            snapshot_result = write_contract_snapshot(instance_id, resolved_contract)
            if isinstance(snapshot_result, Failure):
                shutil.rmtree(state_dir, ignore_errors=True)
                return Failure(
                    InternalError(
                        f"Failed to persist contract snapshot: {snapshot_result.failure()}"
                    )
                )
        return Success(state)
    except OSError as e:
        return Failure(InternalError(f"Failed to create agent state: {e}"))


# @shell_complexity: Atomic write with JSON validation
def update_agent_state(
    instance_id: str,
    updates: dict,
) -> Result[AgentStateData, InternalError | AgentNotFoundError]:
    """Update agent state on disk.

    Uses atomic write (write to .tmp, rename) to prevent partial reads.

    Args:
        instance_id: The instance identifier.
        updates: Fields to update.

    Returns:
        Success with updated AgentStateData.
        Failure(AgentNotFoundError) if agent state is missing.
        Failure(InternalError) for read/parse/write failures.
    """
    state_file = get_agent_dir(instance_id) / "state.json"

    try:
        content = state_file.read_text()
        data = json.loads(content)
        data.update(updates)
        data["updated_at"] = datetime.now(timezone.utc).isoformat()

        state = AgentStateData(**data)
        write_result = _atomic_write_json(state_file, state.model_dump_json(indent=2))
        if isinstance(write_result, Failure):
            return write_result

        return Success(state)
    except FileNotFoundError:
        return Failure(AgentNotFoundError(f"Agent not found: {instance_id}"))
    except ValidationError as e:
        return Failure(InternalError(f"Failed to update agent state: {e}"))
    except (json.JSONDecodeError, TypeError, ValueError) as e:
        return Failure(
            InternalError(f"Invalid state file for agent {instance_id}: {e}")
        )
    except OSError as e:
        return Failure(InternalError(f"Failed to update agent state: {e}"))


def write_agent_pid(
    instance_id: str, pid: int
) -> Result[None, InternalError | AgentNotFoundError]:
    """Write PID to agent state.

    Args:
        instance_id: The instance identifier.
        pid: Process ID.

    Returns:
        Success(None), Failure(AgentNotFoundError), or Failure(InternalError).
    """
    result = update_agent_state(instance_id, {"pid": pid})
    if isinstance(result, Failure):
        return result
    return Success(None)


# @shell_complexity: State machine validation with multiple error paths and log assembly
def transition_state(
    instance_id: str,
    event: LifecycleEvent,
    error: str | None = None,
    result_ref: str | None = None,
) -> Result[AgentStateData, AnimaError]:
    """Transition agent state by applying a lifecycle event.

    Reads current state, validates transition via lifecycle.next_state(),
    and writes updated state with logging.

    Per docs/INTERFACES.md Section H.1, only valid state transitions are allowed.
    Terminal states (completed, failed, killed) have no outgoing transitions.

    Args:
        instance_id: The instance identifier (directory key).
        event: The lifecycle event to apply (validates transition).
        error: Optional error message (for failed/killed states).
        result_ref: Optional result reference (for completed state).

    Returns:
        Success with updated AgentStateData on valid transition.
        Failure(AnimaError) for invalid transition, agent not found, or I/O errors.

    Examples:
        Valid transition (pending -> running):

        >>> from anima.core.lifecycle import LifecycleEvent
        >>> # Assuming agent 'inst_123' exists in pending state
        >>> result = transition_state('inst_123', LifecycleEvent.start)
        >>> # result is Success(AgentStateData(state=running))

        Invalid transition (terminal state has no outgoing edges):

        >>> result = transition_state('inst_completed', LifecycleEvent.start)
        >>> # result is Failure("Invalid transition: cannot apply 'start'...")
    """
    # Read current state
    read_result = read_state(instance_id)
    if isinstance(read_result, Failure):
        return read_result

    state_data = read_result.unwrap()
    current_state = state_data.state

    # Validate and compute next state
    try:
        new_state = next_state(current_state, event)
    except InvalidInputError as e:
        return Failure(
            InvalidInputError(
                f"Invalid transition: cannot apply '{event.value}' in state '{current_state.value}': {e}"
            )
        )

    # Build updates
    updates: dict = {"state": new_state.value}
    if error is not None:
        updates["error"] = error
    if result_ref is not None:
        updates["result_ref"] = result_ref

    result = update_agent_state(instance_id, updates)
    if isinstance(result, Failure):
        return result

    # Append log entry for the state transition
    log_details = {"from": current_state.value, "to": new_state.value}
    if error is not None:
        log_details["error"] = error
    if result_ref is not None:
        log_details["result_ref"] = result_ref
    append_log(instance_id, event.value, log_details)

    return result


# @invar:allow shell_result: Shell I/O operation (filesystem delete)
# @shell_complexity: Security validation with path traversal checks and error branches
def delete_agent_state(instance_id: str) -> Result[None, InternalError]:
    """Delete agent state directory with safety validation.

    Security: Validates that the path being deleted is a descendant of the
    agents directory to prevent path traversal attacks. This is defense-in-depth
    validation that supplements CLI-level validation.

    Args:
        instance_id: The instance identifier (directory key).

    Returns:
        Success(None) or Failure(InternalError).
    """
    from anima.core.ids import validate_instance_id

    # Defense-in-depth: validate instance_id structure
    try:
        validate_instance_id(instance_id)
    except Exception as e:
        return Failure(InternalError(f"Invalid instance_id: {e}"))

    agent_path = get_agent_dir(instance_id)

    # Security: verify path is under agents directory
    try:
        agents_dir = get_state_dir() / "agents"

        # Resolve both paths to handle symlinks and .. components
        resolved_agent_path = agent_path.resolve()
        resolved_agents_dir = agents_dir.resolve()

        # Check that agent_path is a descendant of agents_dir
        # Use relative_to with try/except to check ancestry
        try:
            resolved_agent_path.relative_to(resolved_agents_dir)
        except ValueError:
            return Failure(
                InternalError(
                    f"Invalid agent path: {instance_id} resolves outside agents directory"
                )
            )

        # Additional check: ensure we're not deleting the agents directory itself
        if resolved_agent_path == resolved_agents_dir:
            return Failure(
                InternalError("Invalid operation: cannot delete agents directory")
            )
    except OSError as e:
        return Failure(InternalError(f"Failed to resolve agent path: {e}"))

    try:
        if agent_path.exists():
            shutil.rmtree(agent_path)
        return Success(None)
    except OSError as e:
        return Failure(InternalError(f"Failed to delete agent state: {e}"))


# @shell_complexity: Agent search with prefix matching and error handling
# @invar:allow shell_result: Returns Result for consistency
def _find_agents_by_prefix(
    prefix: str,
) -> Result[list[AgentStateData], AgentNotFoundError | InternalError]:
    """Find all agents whose instance_id starts with prefix.

    Internal helper that returns all matches. Keys on directory names
    (instance_id), which provides fast prefix resolution.

    Args:
        prefix: The instance ID prefix to search for.

    Returns:
        Success with list of matching AgentStateData,
        Failure(AgentNotFoundError) when no agent directory exists,
        or Failure(InternalError) on I/O failure.
    """
    agents_dir = get_state_dir() / "agents"

    if not agents_dir.exists():
        return Failure(AgentNotFoundError(f"Agent not found: {prefix}"))

    matches: list[AgentStateData] = []

    try:
        for agent_subdir in agents_dir.iterdir():
            if not agent_subdir.is_dir():
                continue

            # Match by directory name (instance_id)
            if agent_subdir.name.startswith(prefix):
                result = read_state(agent_subdir.name)
                if isinstance(result, Success):
                    matches.append(result.unwrap())

        if not matches:
            return Failure(AgentNotFoundError(f"Agent not found: {prefix}"))

        return Success(matches)
    except OSError as e:
        return Failure(InternalError(f"Failed to search agents: {e}"))


# @shell_complexity: Ambiguous prefix matching with multiple results
def find_agent_by_prefix(
    prefix: str,
) -> Result[AgentStateData, AgentNotFoundError | InternalError]:
    """Find agent by instance ID prefix.

    Searches for agents whose instance_id starts with the given prefix.
    Returns exactly one match, or failure if 0 or >1 matches.

    Args:
        prefix: The instance ID prefix to search for.

    Returns:
        Success with AgentStateData,
        Failure(AgentNotFoundError) when no unambiguous match exists,
        or Failure(InternalError) on search failure.

    >>> # Test requires state directory setup
    """
    result = _find_agents_by_prefix(prefix)
    if isinstance(result, Failure):
        return result

    matches = result.unwrap()

    if len(matches) == 0:
        return Failure(AgentNotFoundError(f"Agent not found: {prefix}"))
    elif len(matches) == 1:
        return Success(matches[0])
    else:
        # Multiple matches - ambiguous prefix
        ids = [m.instance_id for m in matches]
        return Failure(
            AgentNotFoundError(
                f"Ambiguous prefix '{prefix}' matches {len(matches)} agents: {', '.join(ids[:3])}{'...' if len(ids) > 3 else ''}"
            )
        )


# @shell_complexity: PID liveness check requires multiple OS error code branches
def is_agent_process_alive(instance_id: str) -> Result[bool, AnimaError]:
    """Check if the agent's process is alive.

    Uses os.kill(pid, 0) semantics to check process liveness without sending
    a signal. Returns False if the agent is not in running state, has no PID,
    or the process is dead.

    Args:
        instance_id: The instance identifier (directory key).

    Returns:
        Success(True) if the process is alive.
        Success(False) if the process is dead or agent is not running.
        Failure(AgentNotFoundError) if agent not found.
        Failure(InvalidInputError) if state file is malformed.
        Failure(InternalError) on I/O error.

    >>> # Non-existent agent
    >>> result = is_agent_process_alive('nonexistent')
    >>> isinstance(result, Failure)
    True

    >>> # Agent without PID (pending state)
    >>> # is_agent_process_alive returns False for non-running states
    """
    # Read current state
    read_result = read_state(instance_id)
    if isinstance(read_result, Failure):
        return read_result

    state_data = read_result.unwrap()

    # Check state: only running agents can be alive
    if state_data.state != AgentState.running:
        return Success(False)

    # Check PID: must exist for running agent
    pid = state_data.pid
    if pid is None:
        return Success(False)

    # Check process liveness via os.kill(pid, 0)
    try:
        os.kill(pid, 0)
        return Success(True)
    except OSError as e:
        if e.errno == errno.ESRCH:
            # No such process - process is dead
            return Success(False)
        elif e.errno == errno.EPERM:
            # Permission denied - process exists but we can't signal it
            # This means the process is alive (just owned by another user)
            return Success(True)
        else:
            # Unexpected error - treat as internal error
            return Failure(
                InternalError(f"Failed to check process liveness for pid {pid}: {e}")
            )


def reap_stale_agent(instance_id: str) -> Result[AgentStateData, AnimaError]:
    """Reap a stale agent by transitioning running->failed with stale-state error.

    Transitions an agent from running to failed state with an error message
    indicating stale state (dead process). Logs the event.

    Args:
        instance_id: The instance identifier (directory key).

    Returns:
        Success with updated AgentStateData (state=failed).
        Failure(AgentNotFoundError) if agent not found.
        Failure(InvalidInputError) if agent is not in running state.
        Failure(InternalError) on state transition or I/O error.

    >>> # Non-existent agent
    >>> result = reap_stale_agent('nonexistent')
    >>> isinstance(result, Failure)
    True
    """
    # Read current state
    read_result = read_state(instance_id)
    if isinstance(read_result, Failure):
        return read_result

    state_data = read_result.unwrap()

    # Validate state: can only reap running agents
    if state_data.state != AgentState.running:
        return Failure(
            InvalidInputError(
                f"Cannot reap agent in state '{state_data.state.value}': agent must be running"
            )
        )

    # Transition running -> failed with stale-state error
    error_msg = "Stale agent state: process terminated unexpectedly"
    result = transition_state(
        instance_id,
        LifecycleEvent.crash,
        error=error_msg,
    )

    if isinstance(result, Failure):
        return result

    # Log the reap event
    append_log(
        instance_id,
        "agent_reaped",
        {"reason": "stale_state", "pid": state_data.pid},
    )

    return result


# @invar:allow shell_result: returns updated list (orchestration), not I/O result
# @shell_orchestration: orchestrates is_agent_process_alive and reap_stale_agent calls
# @shell_complexity: 5 branches for defensive error handling (alive check failure, reap failure)
def check_and_reap_stale_agents(
    agents: list[AgentStateData],
) -> list[AgentStateData]:
    """Check running agents for stale PIDs and reap them.

    For each agent in 'running' state, check if PID is alive. If dead,
    transition to 'failed' state. Return updated agent list.

    Args:
        agents: List of agents to check.

    Returns:
        Updated list with reaped agents now in 'failed' state.
    """
    updated_agents: list[AgentStateData] = []

    for agent in agents:
        if agent.state != AgentState.running:
            updated_agents.append(agent)
            continue

        alive_result = is_agent_process_alive(agent.instance_id)

        if isinstance(alive_result, Failure):
            updated_agents.append(agent)
            continue

        is_alive = alive_result.unwrap()

        if is_alive:
            updated_agents.append(agent)
        else:
            reap_result = reap_stale_agent(agent.instance_id)

            if isinstance(reap_result, Success):
                updated_agents.append(reap_result.unwrap())
            else:
                updated_agents.append(agent)

    return updated_agents
