"""Session domain model and service contract for CleverAgents.

A **Session** is a persistent conversation thread tied to an orchestrator
actor. It maintains message history across plans and serves as the user's
natural-language interface.

## CLI Commands

Sessions are managed via the CLI:

| Command                 | Service Method         |
|------------------------|------------------------|
| ``agents session create`` | ``SessionService.create()``  |
| ``agents session list``   | ``SessionService.list()``    |
| ``agents session show``   | ``SessionService.get()``     |
| ``agents session delete`` | ``SessionService.delete()``  |
| ``agents session export`` | ``SessionService.export_session()``  |
| ``agents session import`` | ``SessionService.import_session()``  |
| ``agents session tell``   | ``SessionService.append_message()``  |

## Error Hierarchy

```
Exception
  -> SessionServiceError        (base)
       -> SessionNotFoundError  (session ID not found)
       -> SessionExportError    (export failures)
       -> SessionImportError    (import failures)
```

Based on ``docs/specification.md`` and ADR-004 (Pydantic Validation).
"""

from __future__ import annotations

import hashlib
import json
import re
from abc import ABC, abstractmethod
from collections import OrderedDict
from datetime import datetime
from enum import StrEnum
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from ulid import ULID

from cleveragents.domain.models.core.cost_budget import SessionCostBudget

# ULID is 26 characters, Crockford's base32
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"
_ULID_RE = re.compile(ULID_PATTERN)

# Actor name pattern: namespace/name (both lowercase alphanumeric with hyphens)
_ACTOR_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*/[a-z0-9][a-z0-9_-]*$")

# Export schema version
EXPORT_SCHEMA_VERSION = "1.0"


# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------


class MessageRole(StrEnum):
    """Role of a message in a session conversation.

    Follows standard chat-model conventions:
    - ``user``: Human input
    - ``assistant``: AI/agent response
    - ``system``: System-level instructions
    - ``tool``: Tool invocation result
    """

    USER = "user"
    ASSISTANT = "assistant"
    SYSTEM = "system"
    TOOL = "tool"


# ---------------------------------------------------------------------------
# SessionMessage
# ---------------------------------------------------------------------------


class SessionMessage(BaseModel):
    """A single message within a session conversation.

    Messages are ordered by ``sequence`` within their session. Each message
    has a unique ULID identifier, a role, and content.
    """

    message_id: str = Field(
        ...,
        description="Unique ULID identifier for this message",
        pattern=ULID_PATTERN,
    )
    role: MessageRole = Field(..., description="Role of the message sender")
    content: str = Field(
        ..., min_length=1, description="Message content (must not be whitespace-only)"
    )
    sequence: int = Field(..., ge=0, description="Ordering index within the session")
    timestamp: datetime = Field(
        default_factory=datetime.now, description="When the message was created"
    )
    metadata: dict[str, Any] = Field(
        default_factory=dict, description="Optional metadata for the message"
    )
    tool_call_id: str | None = Field(
        default=None,
        description="Tool call identifier (required when role is 'tool')",
    )

    @field_validator("content", mode="before")
    @classmethod
    def validate_content_not_whitespace(cls: type[SessionMessage], v: str) -> str:
        """Reject whitespace-only content before strip_whitespace runs."""
        if isinstance(v, str) and not v.strip():
            raise ValueError("Message content must not be whitespace-only")
        return v

    @model_validator(mode="after")
    def _validate_tool_call_id(self) -> SessionMessage:
        """Ensure tool_call_id is present when role is tool."""
        if self.role == MessageRole.TOOL and not self.tool_call_id:
            raise ValueError("tool_call_id is required when message role is 'tool'")
        return self

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


# ---------------------------------------------------------------------------
# SessionTokenUsage
# ---------------------------------------------------------------------------


class SessionTokenUsage(BaseModel):
    """Token usage tracking for a session.

    Accumulates input/output token counts and estimated cost across all
    messages and plan executions within the session.
    """

    input_tokens: int = Field(
        default=0, ge=0, description="Total input tokens consumed"
    )
    output_tokens: int = Field(
        default=0, ge=0, description="Total output tokens generated"
    )
    estimated_cost: float = Field(
        default=0.0, ge=0.0, description="Estimated total cost (USD)"
    )

    model_config = ConfigDict(
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# Session
# ---------------------------------------------------------------------------


class LinkedPlan(BaseModel):
    """A plan linked to a session, with phase and state information.

    Used in ``Session.as_cli_dict()`` to produce spec-compliant
    ``linked_plans`` objects containing ``plan_id``, ``phase``, and
    ``state``.
    """

    plan_id: str = Field(..., description="ULID of the linked plan")
    phase: str = Field(..., description="Current phase of the plan (e.g. 'execute')")
    state: str = Field(
        ..., description="Current processing state of the plan (e.g. 'complete')"
    )

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


class Session(BaseModel):
    """Domain model for a Session.

    A Session is a persistent conversation thread tied to an orchestrator
    actor.  It maintains message history across plans and serves as the
    user's natural-language interface.

    Sessions are identified by a ULID and may optionally be bound to a
    specific actor via ``actor_name``.
    """

    session_id: str = Field(
        ...,
        description="Unique ULID identifier for this session",
        pattern=ULID_PATTERN,
    )
    name: str | None = Field(
        default=None,
        description="Optional human-readable name for this session",
    )
    actor_name: str | None = Field(
        default=None,
        description="Namespaced actor reference (namespace/name format)",
    )
    namespace: str = Field(
        default="local",
        description="Namespace for session ownership",
    )
    automation: str | None = Field(
        default=None,
        description="Automation profile name associated with this session",
    )
    messages: list[SessionMessage] = Field(
        default_factory=list,
        description="Ordered list of messages in this session",
    )
    linked_plan_ids: list[str] = Field(
        default_factory=list,
        description="ULID references to linked plans (flat list for persistence)",
    )
    linked_plans: list[LinkedPlan] = Field(
        default_factory=list,
        description="Linked plans with phase and state details",
    )
    token_usage: SessionTokenUsage = Field(
        default_factory=SessionTokenUsage,
        description="Accumulated token usage for this session",
    )
    created_at: datetime = Field(
        default_factory=datetime.now,
        description="When the session was created",
    )
    updated_at: datetime = Field(
        default_factory=datetime.now,
        description="When the session was last modified",
    )
    metadata: dict[str, Any] = Field(
        default_factory=dict,
        description="Arbitrary session metadata",
    )
    cost_budget: SessionCostBudget | None = Field(
        default=None,
        description="Per-session cost budget (None = no budget tracking).",
    )

    # -- Validators ---------------------------------------------------------

    @field_validator("actor_name")
    @classmethod
    def validate_actor_name(cls: type[Session], v: str | None) -> str | None:
        """Validate actor name matches namespace/name pattern."""
        if v is None:
            return None
        if not _ACTOR_NAME_PATTERN.match(v):
            raise ValueError(
                "actor_name must match 'namespace/name' pattern "
                "(lowercase alphanumeric with hyphens/underscores)"
            )
        return v

    @model_validator(mode="after")
    def _validate_message_ordering(self) -> Session:
        """Ensure messages are ordered by sequence."""
        if len(self.messages) > 1:
            sequences = [m.sequence for m in self.messages]
            if sequences != sorted(sequences):
                raise ValueError("Messages must be ordered by sequence")
        return self

    # -- Properties ---------------------------------------------------------

    @property
    def message_count(self) -> int:
        """Return the number of messages in this session."""
        return len(self.messages)

    @property
    def last_message(self) -> SessionMessage | None:
        """Return the most recent message, or None if empty."""
        if not self.messages:
            return None
        return self.messages[-1]

    @property
    def is_empty(self) -> bool:
        """Return True if the session has no messages."""
        return len(self.messages) == 0

    # -- Methods ------------------------------------------------------------

    def append_message(
        self,
        role: MessageRole,
        content: str,
        metadata: dict[str, Any] | None = None,
        tool_call_id: str | None = None,
    ) -> SessionMessage:
        """Append a new message to the session.

        Auto-generates a ULID for the message, sets the sequence number
        based on existing messages, and updates the session timestamp.

        Args:
            role: The message role.
            content: The message content.
            metadata: Optional metadata dict.
            tool_call_id: Required when role is 'tool'.

        Returns:
            The newly created SessionMessage.
        """
        next_sequence = self.messages[-1].sequence + 1 if self.messages else 0
        message = SessionMessage(
            message_id=str(ULID()),
            role=role,
            content=content,
            sequence=next_sequence,
            timestamp=datetime.now(),
            metadata=metadata or {},
            tool_call_id=tool_call_id,
        )
        self.messages.append(message)
        self.updated_at = datetime.now()
        return message

    def get_messages(
        self, limit: int | None = None, offset: int = 0
    ) -> list[SessionMessage]:
        """Return messages with optional pagination.

        Args:
            limit: Maximum number of messages to return (None = all).
            offset: Number of messages to skip from the beginning.

        Returns:
            A slice of the messages list.
        """
        if limit is None:
            return list(self.messages[offset:])
        return list(self.messages[offset : offset + limit])

    def link_plan(self, plan_id: str) -> None:
        """Link a plan to this session (deduplicated).

        Args:
            plan_id: The ULID of the plan to link.
        """
        if plan_id not in self.linked_plan_ids:
            self.linked_plan_ids.append(plan_id)

    def as_cli_dict(self) -> OrderedDict[str, Any]:
        """Return a stable-ordered dictionary for CLI rendering.

        Matches the output fields from ``agents session show`` per spec:

        - ``session_summary``: ``id``, ``actor``, ``messages``, ``created``,
          ``updated``, ``automation``
        - ``recent_messages`` (last 5): ``role``, ``text``
        - ``linked_plans``: list of ``{plan_id, phase, state}`` objects
        - ``token_usage``: ``input_tokens``, ``output_tokens``,
          ``estimated_cost`` (formatted string e.g. ``"$0.0184"``)
        """
        result: OrderedDict[str, Any] = OrderedDict()

        # session_summary block — spec-compliant field names
        session_summary: OrderedDict[str, Any] = OrderedDict()
        session_summary["id"] = self.session_id
        if self.actor_name:
            session_summary["actor"] = self.actor_name
        session_summary["messages"] = self.message_count
        session_summary["created"] = self.created_at.isoformat()
        session_summary["updated"] = self.updated_at.isoformat()
        if self.automation is not None:
            session_summary["automation"] = self.automation
        result["session_summary"] = session_summary

        # recent_messages — use "text" key per spec
        if self.messages:
            recent = self.messages[-5:]
            result["recent_messages"] = [
                {"role": m.role.value, "text": m.content} for m in recent
            ]

        # linked_plans — list of {plan_id, phase, state} objects per spec
        if self.linked_plans:
            result["linked_plans"] = [
                {"plan_id": lp.plan_id, "phase": lp.phase, "state": lp.state}
                for lp in self.linked_plans
            ]
        elif self.linked_plan_ids:
            # Fallback: if only flat IDs are available, emit minimal objects
            result["linked_plans"] = [
                {"plan_id": pid, "phase": "", "state": ""}
                for pid in self.linked_plan_ids
            ]

        # token_usage — estimated_cost as formatted string per spec
        cost = self.token_usage.estimated_cost
        result["token_usage"] = {
            "input_tokens": self.token_usage.input_tokens,
            "output_tokens": self.token_usage.output_tokens,
            "estimated_cost": f"${cost:.4f}",
        }

        if self.metadata:
            result["metadata"] = dict(self.metadata)
        if self.cost_budget is not None:
            util = self.cost_budget.utilization()
            util_str = f"{util * 100:.0f}%" if util is not None else "N/A"
            result["cost_budget"] = {
                "total_cost": self.cost_budget.total_cost,
                "max_cost_usd": self.cost_budget.max_cost_usd,
                "utilization": util_str,
                "remaining": self.cost_budget.remaining(),
            }
        return result

    def as_export_dict(self) -> dict[str, Any]:
        """Return a JSON-serializable dict for session export.

        Includes:

        - ``schema_version``, ``session_id``, ``actor_name``, ``namespace``
        - ``messages`` (full history), ``linked_plan_ids``
        - ``token_usage``, ``metadata``
        - ``created_at``, ``updated_at``, ``checksum`` (SHA-256)
        """
        messages_data = [
            {
                "message_id": m.message_id,
                "role": m.role.value,
                "content": m.content,
                "sequence": m.sequence,
                "timestamp": m.timestamp.isoformat(),
                "metadata": m.metadata,
                "tool_call_id": m.tool_call_id,
            }
            for m in self.messages
        ]
        export: dict[str, Any] = {
            "schema_version": EXPORT_SCHEMA_VERSION,
            "session_id": self.session_id,
            "actor_name": self.actor_name,
            "namespace": self.namespace,
            "messages": messages_data,
            "linked_plan_ids": list(self.linked_plan_ids),
            "token_usage": {
                "input_tokens": self.token_usage.input_tokens,
                "output_tokens": self.token_usage.output_tokens,
                "estimated_cost": self.token_usage.estimated_cost,
            },
            "metadata": dict(self.metadata),
            "created_at": self.created_at.isoformat(),
            "updated_at": self.updated_at.isoformat(),
        }
        # Compute checksum over the canonical JSON (excluding checksum itself)
        canonical = json.dumps(export, sort_keys=True, default=str)
        export["checksum"] = hashlib.sha256(canonical.encode()).hexdigest()
        return export

    def as_export_markdown(self) -> str:
        """Return a human-readable Markdown transcript of the session.

        This is a **lossy** export intended for sharing and documentation.
        It does not contain enough information to fully restore a session via
        ``import_session`` — use :meth:`as_export_dict` for that.

        The output format is::

            # Session: <session_id>

            **Actor:** <actor_name>
            **Namespace:** <namespace>
            **Created:** <created_at>
            **Updated:** <updated_at>

            ---

            ## Messages

            ### [<sequence>] <ROLE> — <timestamp>

            <content>

            ---
        """
        lines: list[str] = []
        lines.append(f"# Session: {self.session_id}")
        lines.append("")
        lines.append(f"**Actor:** {self.actor_name or '(none)'}")
        lines.append(f"**Namespace:** {self.namespace}")
        lines.append(f"**Created:** {self.created_at.isoformat()}")
        lines.append(f"**Updated:** {self.updated_at.isoformat()}")
        if self.linked_plan_ids:
            plan_refs = ", ".join(self.linked_plan_ids)
            lines.append(f"**Linked Plans:** {plan_refs}")
        lines.append("")
        lines.append("---")
        lines.append("")
        lines.append("## Messages")
        lines.append("")
        for msg in self.messages:
            ts = msg.timestamp.strftime("%Y-%m-%d %H:%M:%S")
            lines.append(f"### [{msg.sequence}] {msg.role.value.upper()} — {ts}")
            lines.append("")
            lines.append(msg.content)
            lines.append("")
            lines.append("---")
            lines.append("")
        if not self.messages:
            lines.append("*(no messages)*")
            lines.append("")
        return "\n".join(lines)

    def as_export_plain_text(self) -> str:
        """Return a plain text transcript of the session.

        This is a **lossy** export intended for sharing and reading in plain
        text editors.  It does not contain enough information to fully restore
        a session via ``import_session`` -- use :meth:`as_export_dict` for that.

        The output format is::

            Session: <session_id>
            Actor: <actor_name>
            Namespace: <namespace>
            Created: <created_at>
            Updated: <updated_at>

            ----------------------------------------

            [<sequence>] <ROLE> (<timestamp>):
            <content>

            ----------------------------------------
        """
        separator = "-" * 40
        lines: list[str] = []
        lines.append(f"Session: {self.session_id}")
        lines.append(f"Actor: {self.actor_name or '(none)'}")
        lines.append(f"Namespace: {self.namespace}")
        lines.append(f"Created: {self.created_at.isoformat()}")
        lines.append(f"Updated: {self.updated_at.isoformat()}")
        if self.linked_plan_ids:
            plan_refs = ", ".join(self.linked_plan_ids)
            lines.append(f"Linked Plans: {plan_refs}")
        lines.append("")
        lines.append(separator)
        lines.append("")
        if not self.messages:
            lines.append("(no messages)")
            lines.append("")
        else:
            for msg in self.messages:
                ts = msg.timestamp.strftime("%Y-%m-%d %H:%M:%S")
                lines.append(f"[{msg.sequence}] {msg.role.value.upper()} ({ts}):")
                lines.append(msg.content)
                lines.append("")
                lines.append(separator)
                lines.append("")
        return "\n".join(lines)

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
        use_enum_values=False,
    )


# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------


class SessionServiceError(Exception):
    """Base error for session service operations."""


class SessionNotFoundError(SessionServiceError):
    """Raised when a session ID is not found."""


class SessionExportError(SessionServiceError):
    """Raised when session export fails."""


class SessionImportError(SessionServiceError):
    """Raised when session import fails (schema mismatch, corrupt data)."""


# ---------------------------------------------------------------------------
# Service Contract (Abstract)
# ---------------------------------------------------------------------------


class SessionService(ABC):
    """Abstract interface for session management.

    Implementations handle persistence, ULID generation, and lifecycle
    management for
    [Session][cleveragents.domain.models.core.session.Session] objects.

    The service contract backs the following CLI commands:

    | Command                 | Service Method       |
    |------------------------|---------------------|
    | ``agents session create`` | ``create()``          |
    | ``agents session list``   | ``list()``            |
    | ``agents session show``   | ``get()``             |
    | ``agents session delete`` | ``delete()``          |
    | ``agents session export`` | ``export_session()``  |
    | ``agents session import`` | ``import_session()``  |
    | ``agents session tell``   | ``append_message()``  |
    """

    @abstractmethod
    def create(self, actor_name: str | None = None) -> Session:
        """Create a new session.

        Args:
            actor_name: Optional namespaced actor to bind to the session.

        Returns:
            A newly created Session with a fresh ULID.
        """

    @abstractmethod
    def get(self, session_id: str) -> Session:
        """Retrieve a session by ID.

        Args:
            session_id: The ULID of the session to retrieve.

        Returns:
            The Session with the given ID.

        Raises:
            SessionNotFoundError: If no session with the given ID exists.
        """

    @abstractmethod
    def list(self) -> list[Session]:
        """List all sessions.

        Returns:
            A list of all sessions, ordered by creation time.
        """

    @abstractmethod
    def delete(self, session_id: str) -> None:
        """Delete a session by ID.

        Args:
            session_id: The ULID of the session to delete.

        Raises:
            SessionNotFoundError: If no session with the given ID exists.
        """

    @abstractmethod
    def append_message(
        self,
        session_id: str,
        role: MessageRole,
        content: str,
        metadata: dict[str, Any] | None = None,
    ) -> SessionMessage:
        """Append a message to a session.

        Args:
            session_id: The ULID of the target session.
            role: The message role.
            content: The message content.
            metadata: Optional metadata dict.

        Returns:
            The newly created SessionMessage.

        Raises:
            SessionNotFoundError: If no session with the given ID exists.
        """

    @abstractmethod
    def export_session(self, session_id: str) -> dict[str, Any]:
        """Export a session as a JSON-serializable dict.

        Args:
            session_id: The ULID of the session to export.

        Returns:
            A dict containing the full session data with checksum.

        Raises:
            SessionNotFoundError: If no session with the given ID exists.
            SessionExportError: If export fails.
        """

    @abstractmethod
    def import_session(self, data: dict[str, Any]) -> Session:
        """Import a session from an export dict.

        Args:
            data: A dict previously produced by export_session.

        Returns:
            The imported Session object.

        Raises:
            SessionImportError: If import fails (bad schema, corrupt data).
        """

    @abstractmethod
    def update_token_usage(
        self,
        session_id: str,
        input_tokens: int,
        output_tokens: int,
        cost: float,
    ) -> None:
        """Update token usage counters for a session.

        Args:
            session_id: The ULID of the session to update.
            input_tokens: Number of input tokens to add.
            output_tokens: Number of output tokens to add.
            cost: Estimated cost to add (USD).

        Raises:
            SessionNotFoundError: If no session with the given ID exists.
        """
