"""Configuration, constants, and model creation for the CLI."""

import importlib
import json
import logging
import os
import re
import shlex
import sys
import tomllib
import uuid
from dataclasses import dataclass
from enum import StrEnum
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path
from types import MethodType
from typing import Any

import dotenv
import tomli_w
from rich.console import Console

from deepagents_cli._version import __version__

logger = logging.getLogger(__name__)

_VALID_AGENT_HARNESSES = frozenset({"rlmagents"})
_LEGACY_LANGSMITH_PROJECT_NAMES = frozenset({"aleph-rlm", "aleph_rlm"})

dotenv.load_dotenv()


def _normalize_langsmith_project_name(project_name: str | None) -> str | None:
    """Normalize legacy project naming to rlmagents defaults.

    Args:
        project_name: Raw project name from environment/settings.

    Returns:
        Normalized project name, or `None` when no value is available.
    """
    if project_name is None:
        return None
    normalized = project_name.strip()
    if not normalized:
        return None
    if normalized.lower() in _LEGACY_LANGSMITH_PROJECT_NAMES:
        return "rlmagents"
    return normalized


# CRITICAL: Override LANGSMITH_PROJECT to route agent traces to separate project
# LangSmith reads LANGSMITH_PROJECT at invocation time, so we override it here
# and preserve the user's original value for shell commands.
#
# Primary env var is `RLMAGENTS_LANGSMITH_PROJECT`.
_rlmagents_project = _normalize_langsmith_project_name(
    os.environ.get("RLMAGENTS_LANGSMITH_PROJECT")
)
_original_langsmith_project = os.environ.get("LANGSMITH_PROJECT")
if _rlmagents_project:
    # Override LANGSMITH_PROJECT for agent traces
    os.environ["LANGSMITH_PROJECT"] = _rlmagents_project
else:
    normalized_env_project = _normalize_langsmith_project_name(
        _original_langsmith_project
    )
    if normalized_env_project:
        os.environ["LANGSMITH_PROJECT"] = normalized_env_project

# E402: Now safe to import LangChain modules
from langchain.chat_models import init_chat_model  # noqa: E402
from langchain_core.language_models import (  # noqa: E402
    BaseChatModel,
    LanguageModelInput,
)
from langchain_core.messages import AIMessage  # noqa: E402
from langchain_core.runnables import RunnableConfig  # noqa: E402

from deepagents_cli.model_config import (  # noqa: E402
    ModelConfig,
    ModelConfigError,
    ModelSpec,
)

DOCS_URL = "https://github.com/Hmbown/rlmagents/tree/main/libs/cli"

# Color scheme
COLORS = {
    "primary": "#10b981",
    "primary_dev": "#f97316",
    "dim": "#6b7280",
    "user": "#ffffff",
    "agent": "#10b981",
    "thinking": "#34d399",
    "tool": "#fbbf24",
}


# Charset mode configuration
class CharsetMode(StrEnum):
    """Character set mode for TUI display."""

    UNICODE = "unicode"
    ASCII = "ascii"
    AUTO = "auto"


@dataclass(frozen=True)
class Glyphs:
    """Character glyphs for TUI display."""

    tool_prefix: str  # ⏺ vs (*)
    ellipsis: str  # … vs ...
    checkmark: str  # ✓ vs [OK]
    error: str  # ✗ vs [X]
    circle_empty: str  # ○ vs [ ]
    circle_filled: str  # ● vs [*]
    output_prefix: str  # ⎿ vs L
    spinner_frames: tuple[str, ...]  # Braille vs ASCII spinner
    pause: str  # ⏸ vs ||
    newline: str  # ⏎ vs \\n
    warning: str  # ⚠ vs [!]
    question: str  # ? vs [?]
    arrow_up: str  # up arrow vs ^
    arrow_down: str  # down arrow vs v
    bullet: str  # bullet vs -
    cursor: str  # cursor vs >

    # Box-drawing characters
    box_vertical: str  # │ vs |
    box_horizontal: str  # ─ vs -
    box_double_horizontal: str  # ═ vs =

    # Diff-specific
    gutter_bar: str  # ▌ vs |

    # Tree connectors (full prefixes for tree display)
    tree_branch: str  # "├── " vs "+-- "
    tree_last: str  # "└── " vs "`-- "
    tree_vertical: str  # "│   " vs "|   "


UNICODE_GLYPHS = Glyphs(
    tool_prefix="⏺",
    ellipsis="…",
    checkmark="✓",
    error="✗",
    circle_empty="○",
    circle_filled="●",
    output_prefix="⎿",
    spinner_frames=("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"),
    pause="⏸",
    newline="⏎",
    warning="⚠",
    question="?",
    arrow_up="↑",
    arrow_down="↓",
    bullet="•",
    cursor="›",  # noqa: RUF001
    # Box-drawing characters
    box_vertical="│",
    box_horizontal="─",
    box_double_horizontal="═",
    gutter_bar="▌",
    tree_branch="├── ",
    tree_last="└── ",
    tree_vertical="│   ",
)

ASCII_GLYPHS = Glyphs(
    tool_prefix="(*)",
    ellipsis="...",
    checkmark="[OK]",
    error="[X]",
    circle_empty="[ ]",
    circle_filled="[*]",
    output_prefix="L",
    spinner_frames=("(-)", "(\\)", "(|)", "(/)"),
    pause="||",
    newline="\\n",
    warning="[!]",
    question="[?]",
    arrow_up="^",
    arrow_down="v",
    bullet="-",
    cursor=">",
    # Box-drawing characters
    box_vertical="|",
    box_horizontal="-",
    box_double_horizontal="=",
    gutter_bar="|",
    tree_branch="+-- ",
    tree_last="`-- ",
    tree_vertical="|   ",
)

# Module-level cache for detected glyphs
_glyphs_cache: Glyphs | None = None

# Module-level cache for editable install detection
_editable_cache: bool | None = None

# Module-level cache for LangSmith project URL (None means "not yet fetched")
_langsmith_url_cache: tuple[str, str | None] | None = None


def _is_editable_install() -> bool:
    """Check if rlmagents is installed in editable mode.

    Uses PEP 610 direct_url.json metadata to detect editable installs.

    Returns:
        True if installed in editable mode, False otherwise.
    """
    global _editable_cache  # noqa: PLW0603
    if _editable_cache is not None:
        return _editable_cache

    for package_name in ("rlmagents", "rlmagents-cli"):
        try:
            dist = distribution(package_name)
            direct_url = dist.read_text("direct_url.json")
            if direct_url:
                data = json.loads(direct_url)
                _editable_cache = data.get("dir_info", {}).get("editable", False)
            else:
                _editable_cache = False
            break
        except (
            PackageNotFoundError,
            FileNotFoundError,
            json.JSONDecodeError,
            TypeError,
        ):
            _editable_cache = False

    return bool(_editable_cache)


def _detect_charset_mode() -> CharsetMode:
    """Auto-detect terminal charset capabilities.

    Returns:
        The detected CharsetMode based on environment and terminal encoding.
    """
    env_mode = os.environ.get("UI_CHARSET_MODE", "auto").lower()
    if env_mode == "unicode":
        return CharsetMode.UNICODE
    if env_mode == "ascii":
        return CharsetMode.ASCII

    # Auto: check stdout encoding and LANG
    encoding = getattr(sys.stdout, "encoding", "") or ""
    if "utf" in encoding.lower():
        return CharsetMode.UNICODE
    lang = os.environ.get("LANG", "") or os.environ.get("LC_ALL", "")
    if "utf" in lang.lower():
        return CharsetMode.UNICODE
    return CharsetMode.ASCII


def get_glyphs() -> Glyphs:
    """Get the glyph set for the current charset mode.

    Returns:
        The appropriate Glyphs instance based on charset mode detection.
    """
    global _glyphs_cache  # noqa: PLW0603
    if _glyphs_cache is not None:
        return _glyphs_cache

    mode = _detect_charset_mode()
    _glyphs_cache = ASCII_GLYPHS if mode == CharsetMode.ASCII else UNICODE_GLYPHS
    return _glyphs_cache


def reset_glyphs_cache() -> None:
    """Reset the glyphs cache (for testing)."""
    global _glyphs_cache  # noqa: PLW0603
    _glyphs_cache = None


# Text art banners (Unicode and ASCII variants)

_UNICODE_BANNER = f"""
╔══════════════════════════════╗
║          RLMAgents           ║
╚══════════════════════════════╝
                      v{__version__}
"""
_ASCII_BANNER = f"""
+------------------------------+
|          RLMAgents           |
+------------------------------+
                      v{__version__}
"""


def get_banner() -> str:
    """Get the appropriate banner for the current charset mode.

    Returns:
        The text art banner string (Unicode or ASCII based on charset mode).
        Includes "(local)" suffix when installed in editable mode.
    """
    if _detect_charset_mode() == CharsetMode.ASCII:
        banner = _ASCII_BANNER
    else:
        banner = _UNICODE_BANNER

    if _is_editable_install():
        banner = banner.replace(f"v{__version__}", f"v{__version__} (local)")

    return banner


# Interactive commands
COMMANDS = {
    "clear": "Clear screen and reset conversation",
    "help": "Show help information",
    "remember": "Review conversation and update memory/skills",
    "tokens": "Show token usage for current thread",
    "quit": "Exit the CLI",
    "exit": "Exit the CLI",
}


# Maximum argument length for display
MAX_ARG_LENGTH = 150

# Agent configuration
config: RunnableConfig = {"recursion_limit": 1000}

# Rich console instance
console = Console(highlight=False)


def _find_project_root(start_path: Path | None = None) -> Path | None:
    """Find the project root by looking for .git directory.

    Walks up the directory tree from start_path (or cwd) looking for a .git
    directory, which indicates the project root.

    Args:
        start_path: Directory to start searching from.
            Defaults to current working directory.

    Returns:
        Path to the project root if found, None otherwise.
    """
    current = Path(start_path or Path.cwd()).resolve()

    # Walk up the directory tree
    for parent in [current, *list(current.parents)]:
        git_dir = parent / ".git"
        if git_dir.exists():
            return parent

    return None


def _find_project_agent_md(project_root: Path) -> list[Path]:
    """Find project-specific AGENTS.md file(s).

    Checks locations and returns ALL that exist:
    1. project_root/.rlmagents/AGENTS.md
    2. project_root/AGENTS.md

    All files found are loaded and combined in that order.

    Args:
        project_root: Path to the project root directory.

    Returns:
        List of paths to project AGENTS.md files.
    """
    paths = []

    # Check .rlmagents/AGENTS.md (preferred)
    rlmagents_md = project_root / ".rlmagents" / "AGENTS.md"
    if rlmagents_md.exists():
        paths.append(rlmagents_md)

    # Check root AGENTS.md (fallback, but also include if both exist)
    root_md = project_root / "AGENTS.md"
    if root_md.exists():
        paths.append(root_md)

    return paths


def parse_shell_allow_list(allow_list_str: str | None) -> list[str] | None:
    """Parse shell allow-list from string.

    Args:
        allow_list_str: Comma-separated list of commands, or "recommended" for
            safe defaults.

            Can also include "recommended" in the list to merge with custom commands.

    Returns:
        List of allowed commands, or None if no allow-list configured.
    """
    if not allow_list_str:
        return None

    # Special value "recommended" uses our curated safe list
    if allow_list_str.strip().lower() == "recommended":
        return list(RECOMMENDED_SAFE_SHELL_COMMANDS)

    # Split by comma and strip whitespace
    commands = [cmd.strip() for cmd in allow_list_str.split(",") if cmd.strip()]

    # If "recommended" is in the list, merge with recommended commands
    result = []
    for cmd in commands:
        if cmd.lower() == "recommended":
            result.extend(RECOMMENDED_SAFE_SHELL_COMMANDS)
        else:
            result.append(cmd)

    # Remove duplicates while preserving order
    seen: set[str] = set()
    unique: list[str] = []
    for cmd in result:
        if cmd not in seen:
            seen.add(cmd)
            unique.append(cmd)
    return unique


@dataclass
class Settings:
    """Global settings and environment detection for rlmagents.

    This class is initialized once at startup and provides access to:
    - Available models and API keys
    - Current project information
    - Tool availability (e.g., Tavily)
    - File system paths

    Attributes:
        openai_api_key: OpenAI API key if available.
        anthropic_api_key: Anthropic API key if available.
        google_api_key: Google API key if available.
        tavily_api_key: Tavily API key if available.
        google_cloud_project: Google Cloud project ID for VertexAI
            authentication.
        rlmagents_langsmith_project: LangSmith project name for rlmagents
            agent tracing.
        user_langchain_project: Original LANGSMITH_PROJECT from environment
            (for user code).
        model_name: Currently active model name (set after model creation).
        model_provider: Provider identifier (e.g., openai, anthropic, google_genai).
        model_context_limit: Maximum input token count from the model profile.
        project_root: Current project root directory (if in a git project).
        shell_allow_list: List of shell commands that don't require approval.
    """

    # API keys
    openai_api_key: str | None
    anthropic_api_key: str | None
    google_api_key: str | None
    tavily_api_key: str | None

    # Google Cloud configuration (for VertexAI)
    google_cloud_project: str | None

    # LangSmith configuration
    rlmagents_langsmith_project: str | None  # For rlmagents agent tracing
    user_langchain_project: str | None  # Original LANGSMITH_PROJECT for user code

    # Model configuration
    model_name: str | None = None  # Currently active model name
    model_provider: str | None = None  # Provider name (see PROVIDER_API_KEY_ENV)
    model_context_limit: int | None = None  # Max input tokens from model profile

    # Project information
    project_root: Path | None = None

    # Shell command allow-list for auto-approval
    shell_allow_list: list[str] | None = None

    @classmethod
    def from_environment(cls, *, start_path: Path | None = None) -> "Settings":
        """Create settings by detecting the current environment.

        Args:
            start_path: Directory to start project detection from (defaults to cwd)

        Returns:
            Settings instance with detected configuration
        """
        # Detect API keys
        openai_key = os.environ.get("OPENAI_API_KEY")
        anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
        google_key = os.environ.get("GOOGLE_API_KEY")
        tavily_key = os.environ.get("TAVILY_API_KEY")
        google_cloud_project = os.environ.get("GOOGLE_CLOUD_PROJECT")

        # Detect LangSmith configuration
        # RLMAGENTS_LANGSMITH_PROJECT: Primary project for rlmagents tracing
        # user_langchain_project: User's ORIGINAL LANGSMITH_PROJECT (before override)
        # Note: LANGSMITH_PROJECT was already overridden at module import time (above)
        # so we use the saved original value, not the current os.environ value
        rlmagents_langsmith_project = _normalize_langsmith_project_name(
            os.environ.get("RLMAGENTS_LANGSMITH_PROJECT")
        )
        user_langchain_project = _original_langsmith_project  # Use saved original!

        # Detect project
        project_root = _find_project_root(start_path)

        # Parse shell command allow-list from environment
        # Format: comma-separated list of commands (e.g., "ls,cat,grep,pwd")
        # Special value "recommended" uses RECOMMENDED_SAFE_SHELL_COMMANDS
        shell_allow_list_str = os.environ.get("RLMAGENTS_SHELL_ALLOW_LIST")
        shell_allow_list = parse_shell_allow_list(shell_allow_list_str)

        return cls(
            openai_api_key=openai_key,
            anthropic_api_key=anthropic_key,
            google_api_key=google_key,
            tavily_api_key=tavily_key,
            google_cloud_project=google_cloud_project,
            rlmagents_langsmith_project=rlmagents_langsmith_project,
            user_langchain_project=user_langchain_project,
            project_root=project_root,
            shell_allow_list=shell_allow_list,
        )

    @property
    def has_openai(self) -> bool:
        """Check if OpenAI API key is configured."""
        return self.openai_api_key is not None

    @property
    def has_anthropic(self) -> bool:
        """Check if Anthropic API key is configured."""
        return self.anthropic_api_key is not None

    @property
    def has_google(self) -> bool:
        """Check if Google API key is configured."""
        return self.google_api_key is not None

    @property
    def has_vertex_ai(self) -> bool:
        """Check if VertexAI is available (Google Cloud project set, no API key).

        VertexAI uses Application Default Credentials (ADC) for authentication,
        so if GOOGLE_CLOUD_PROJECT is set and GOOGLE_API_KEY is not, we assume
        VertexAI.
        """
        return self.google_cloud_project is not None and self.google_api_key is None

    @property
    def has_tavily(self) -> bool:
        """Check if Tavily API key is configured."""
        return self.tavily_api_key is not None

    @property
    def user_rlmagents_dir(self) -> Path:
        """Get the primary user-level agent directory.

        Returns:
            Path to `~/.rlmagents`.
        """
        return Path.home() / ".rlmagents"

    @staticmethod
    def get_user_agent_md_path(agent_name: str) -> Path:
        """Get user-level AGENTS.md path for a specific agent.

        Returns path regardless of whether the file exists.

        Args:
            agent_name: Name of the agent

        Returns:
            Path to `~/.rlmagents/{agent_name}/AGENTS.md`.
        """
        return Path.home() / ".rlmagents" / agent_name / "AGENTS.md"

    @classmethod
    def get_user_agent_md_paths(cls, agent_name: str) -> list[Path]:
        """Get user AGENTS.md paths.

        Args:
            agent_name: Name of the agent.

        Returns:
            List containing only the primary path.
        """
        return [cls.get_user_agent_md_path(agent_name)]

    def get_project_agent_md_path(self) -> Path | None:
        """Get project-level AGENTS.md path.

        Returns:
            `project_root/.rlmagents/AGENTS.md`, or `None` when not in a project.
        """
        if not self.project_root:
            return None
        return self.project_root / ".rlmagents" / "AGENTS.md"

    def get_project_agent_md_paths(self) -> list[Path]:
        """Get project AGENTS.md paths.

        Returns:
            List containing the primary path, or empty list outside a project.
        """
        if not self.project_root:
            return []
        return [self.project_root / ".rlmagents" / "AGENTS.md"]

    @staticmethod
    def _is_valid_agent_name(agent_name: str) -> bool:
        """Validate to prevent invalid filesystem paths and security issues.

        Returns:
            True if the agent name is valid, False otherwise.
        """
        if not agent_name or not agent_name.strip():
            return False
        # Allow only alphanumeric, hyphens, underscores, and whitespace
        return bool(re.match(r"^[a-zA-Z0-9_\-\s]+$", agent_name))

    def get_agent_dir(self, agent_name: str) -> Path:
        """Get the global agent directory path.

        Args:
            agent_name: Name of the agent

        Returns:
            Path to `~/.rlmagents/{agent_name}`.

        Raises:
            ValueError: If the agent name contains invalid characters.
        """
        if not self._is_valid_agent_name(agent_name):
            msg = (
                f"Invalid agent name: {agent_name!r}. Agent names can only "
                "contain letters, numbers, hyphens, underscores, and spaces."
            )
            raise ValueError(msg)
        return self.user_rlmagents_dir / agent_name

    def ensure_agent_dir(self, agent_name: str) -> Path:
        """Ensure the global agent directory exists and return its path.

        Args:
            agent_name: Name of the agent

        Returns:
            Path to `~/.rlmagents/{agent_name}`.

        Raises:
            ValueError: If the agent name contains invalid characters.
        """
        if not self._is_valid_agent_name(agent_name):
            msg = (
                f"Invalid agent name: {agent_name!r}. Agent names can only "
                "contain letters, numbers, hyphens, underscores, and spaces."
            )
            raise ValueError(msg)
        agent_dir = self.get_agent_dir(agent_name)
        agent_dir.mkdir(parents=True, exist_ok=True)
        return agent_dir

    def get_agent_settings_path(self, agent_name: str) -> Path:
        """Get per-agent settings file path.

        Args:
            agent_name: Name of the agent.

        Returns:
            Path to `~/.rlmagents/{agent_name}/settings.toml`.
        """
        return self.get_agent_dir(agent_name) / "settings.toml"

    def load_agent_settings(self, agent_name: str) -> dict[str, Any]:
        """Load per-agent settings from TOML.

        Args:
            agent_name: Name of the agent.

        Returns:
            Parsed settings dictionary, or empty dict on missing/invalid file.
        """
        settings_path = self.get_agent_settings_path(agent_name)
        if not settings_path.exists():
            return {}
        try:
            with settings_path.open("rb") as f:
                data = tomllib.load(f)
        except (OSError, tomllib.TOMLDecodeError):
            logger.warning(
                "Failed to parse agent settings at %s",
                settings_path,
                exc_info=True,
            )
            return {}
        if isinstance(data, dict):
            return data
        return {}

    def get_agent_harness(self, agent_name: str) -> str | None:
        """Get persisted harness for an agent, if configured.

        Args:
            agent_name: Name of the agent.

        Returns:
            Harness name (`rlmagents`) if valid, otherwise None.
        """
        harness = self.load_agent_settings(agent_name).get("harness")
        if isinstance(harness, str) and harness in _VALID_AGENT_HARNESSES:
            return harness
        return None

    def save_agent_harness(self, agent_name: str, harness: str) -> bool:
        """Persist harness selection for an agent.

        Args:
            agent_name: Name of the agent.
            harness: Harness identifier (`rlmagents`).

        Returns:
            True if saved successfully, False if write fails.

        Raises:
            ValueError: If the harness value is unsupported.
        """
        if harness not in _VALID_AGENT_HARNESSES:
            msg = f"Invalid harness: {harness!r}. Supported values are 'rlmagents'."
            raise ValueError(msg)

        settings_data = self.load_agent_settings(agent_name)
        settings_data["harness"] = harness

        settings_path = self.get_agent_settings_path(agent_name)
        settings_path.parent.mkdir(parents=True, exist_ok=True)
        try:
            settings_path.write_text(tomli_w.dumps(settings_data), encoding="utf-8")
        except OSError:
            logger.warning(
                "Failed to write agent settings at %s",
                settings_path,
                exc_info=True,
            )
            return False

        return True

    def get_user_skills_dir(self, agent_name: str) -> Path:
        """Get user-level skills directory path for a specific agent.

        Args:
            agent_name: Name of the agent

        Returns:
            Path to `~/.rlmagents/{agent_name}/skills/`.
        """
        return self.get_agent_dir(agent_name) / "skills"

    def get_user_skills_dirs(self, agent_name: str) -> list[Path]:
        """Get user skills directories.

        Args:
            agent_name: Name of the agent.

        Returns:
            List containing the primary path.
        """
        return [self.get_user_skills_dir(agent_name)]

    def ensure_user_skills_dir(self, agent_name: str) -> Path:
        """Ensure user-level skills directory exists and return its path.

        Args:
            agent_name: Name of the agent

        Returns:
            Path to `~/.rlmagents/{agent_name}/skills/`.
        """
        skills_dir = self.get_user_skills_dir(agent_name)
        skills_dir.mkdir(parents=True, exist_ok=True)
        return skills_dir

    def get_project_skills_dir(self) -> Path | None:
        """Get project-level skills directory path.

        Returns:
            Path to `{project_root}/.rlmagents/skills/`, or `None` if not in a project.
        """
        if not self.project_root:
            return None
        return self.project_root / ".rlmagents" / "skills"

    def get_project_skills_dirs(self) -> list[Path]:
        """Get project skills directories.

        Returns:
            List containing the primary path, or empty list outside a project.
        """
        primary = self.get_project_skills_dir()
        if primary is None:
            return []
        return [primary]

    def ensure_project_skills_dir(self) -> Path | None:
        """Ensure project-level skills directory exists and return its path.

        Returns:
            Path to `{project_root}/.rlmagents/skills/`, or `None` if not in a project.
        """
        if not self.project_root:
            return None
        skills_dir = self.get_project_skills_dir()
        if skills_dir is None:
            return None
        skills_dir.mkdir(parents=True, exist_ok=True)
        return skills_dir

    def get_user_agents_dir(self, agent_name: str) -> Path:
        """Get user-level agents directory path for custom subagent definitions.

        Args:
            agent_name: Name of the CLI agent (e.g., "rlmagents")

        Returns:
            Path to `~/.rlmagents/{agent_name}/agents/`.
        """
        return self.get_agent_dir(agent_name) / "agents"

    def get_project_agents_dir(self) -> Path | None:
        """Get project-level agents directory path for custom subagent definitions.

        Returns:
            Path to `{project_root}/.rlmagents/agents/`, or `None` if not in a project.
        """
        if not self.project_root:
            return None
        return self.project_root / ".rlmagents" / "agents"

    def get_user_agents_dirs(self, agent_name: str) -> list[Path]:
        """Get user agent-definition directories.

        Args:
            agent_name: Name of the CLI agent.

        Returns:
            List containing the primary path.
        """
        return [self.get_user_agents_dir(agent_name)]

    def get_project_agents_dirs(self) -> list[Path]:
        """Get project agent-definition directories.

        Returns:
            List containing the primary path, or empty list outside a project.
        """
        primary = self.get_project_agents_dir()
        if primary is None:
            return []
        return [primary]

    def list_agent_dirs(self) -> dict[str, Path]:
        """List user agent directories from the primary location.

        Returns:
            Mapping of agent name to resolved directory path.
        """
        discovered: dict[str, Path] = {}
        base = self.user_rlmagents_dir
        if not base.exists():
            return discovered
        for path in sorted(base.iterdir()):
            if path.is_dir():
                discovered[path.name] = path
        return discovered

    @property
    def user_agents_dir(self) -> Path:
        """Get the base user-level `.agents` directory (`~/.agents`).

        Returns:
            Path to `~/.agents`
        """
        return Path.home() / ".agents"

    def get_user_agent_skills_dir(self) -> Path:
        """Get user-level `~/.agents/skills/` directory.

        This is a generic alias path for skills that is tool-agnostic.

        Returns:
            Path to `~/.agents/skills/`
        """
        return self.user_agents_dir / "skills"

    def get_project_agent_skills_dir(self) -> Path | None:
        """Get project-level `.agents/skills/` directory.

        This is a generic alias path for skills that is tool-agnostic.

        Returns:
            Path to `{project_root}/.agents/skills/`, or `None` if not in a project
        """
        if not self.project_root:
            return None
        return self.project_root / ".agents" / "skills"

    @staticmethod
    def get_built_in_skills_dir() -> Path:
        """Get the directory containing built-in skills that ship with the CLI.

        Returns:
            Path to the `built_in_skills/` directory within the package.
        """
        return Path(__file__).parent / "built_in_skills"


# Global settings instance (initialized once)
settings = Settings.from_environment()


class SessionState:
    """Mutable session state shared across the app, adapter, and agent.

    Tracks runtime flags like auto-approve that can be toggled during a
    session via keybindings or the HITL approval menu's "Auto-approve all"
    option.

    The `auto_approve` flag controls whether tool calls (shell execution, file
    writes/edits, web search, URL fetch) require user confirmation before running.
    """

    def __init__(self, auto_approve: bool = False, no_splash: bool = False) -> None:
        """Initialize session state with optional flags.

        Args:
            auto_approve: Whether to auto-approve tool calls without
                prompting.

                Can be toggled at runtime via Shift+Tab or the HITL
                approval menu.
            no_splash: Whether to skip displaying the splash screen on startup.
        """
        self.auto_approve = auto_approve
        self.no_splash = no_splash
        self.exit_hint_until: float | None = None
        self.exit_hint_handle = None
        self.thread_id = str(uuid.uuid4())

    def toggle_auto_approve(self) -> bool:
        """Toggle auto-approve and return the new state.

        Called by the Shift+Tab keybinding in the Textual app.

        When auto-approve is on, all tool calls execute without prompting.

        Returns:
            The new `auto_approve` state after toggling.
        """
        self.auto_approve = not self.auto_approve
        return self.auto_approve


SHELL_TOOL_NAMES: frozenset[str] = frozenset({"bash", "shell", "execute"})
"""Tool names recognized as shell/command-execution tools.

Only `'execute'` is registered by the SDK and CLI backends in practice.
`'bash'` and `'shell'` are legacy names carried over and kept as
backwards-compatible aliases.
"""

DANGEROUS_SHELL_PATTERNS = (
    "$(",  # Command substitution
    "`",  # Backtick command substitution
    "$'",  # ANSI-C quoting (can encode dangerous chars via escape sequences)
    "\n",  # Newline (command injection)
    "\r",  # Carriage return (command injection)
    "\t",  # Tab (can be used for injection in some shells)
    "<(",  # Process substitution (input)
    ">(",  # Process substitution (output)
    "<<<",  # Here-string
    "<<",  # Here-doc (can embed commands)
    ">>",  # Append redirect
    ">",  # Output redirect
    "<",  # Input redirect
    "${",  # Variable expansion with braces (can run commands via ${var:-$(cmd)})
)

# Recommended safe shell commands for non-interactive mode.
# These commands are primarily read-only and do not modify the filesystem
# when used without shell redirection operators (which the dangerous-patterns
# check blocks).
#
# EXCLUDED (dangerous - listed on GTFOBins/LOOBins or can modify system):
# - All shells: bash, sh, zsh, fish, dash, ksh, csh, tcsh, etc.
# - Editors: vim, vi, nano, emacs, ed, etc. (can spawn shells)
# - Interpreters: python, perl, ruby, node, php, lua, awk, gawk, etc.
# - Package managers: pip, npm, gem, apt, yum, brew, etc.
# - Compilers: gcc, cc, make, cmake, etc.
# - Network tools: curl, wget, nc, ssh, scp, ftp, telnet, etc.
# - Archivers with shell escape: tar, zip, 7z, etc.
# - System modifiers: chmod, chown, chattr, mv, rm, cp, dd, etc.
# - Privilege tools: sudo, su, doas, pkexec, etc.
# - Process tools: env, xargs, find (with -exec), etc.
# - Git (can run hooks), docker, kubectl, etc.
#
# SAFE commands included below are primarily readers/formatters. File write and
# injection are prevented by the dangerous-patterns check that blocks redirects,
# command substitution, and other shell metacharacters.
RECOMMENDED_SAFE_SHELL_COMMANDS = (
    # Directory listing
    "ls",
    "dir",
    # File content viewing (read-only)
    "cat",
    "head",
    "tail",
    # Text searching (read-only)
    "grep",
    "wc",
    "strings",
    # Text processing (read-only, no shell execution)
    "cut",
    "tr",
    "diff",
    "md5sum",
    "sha256sum",
    # Path utilities
    "pwd",
    "which",
    # System info (read-only)
    "uname",
    "hostname",
    "whoami",
    "id",
    "groups",
    "uptime",
    "nproc",
    "lscpu",
    "lsmem",
    # Process viewing (read-only)
    "ps",
)


def contains_dangerous_patterns(command: str) -> bool:
    """Check if a command contains dangerous shell patterns.

    These patterns can be used to bypass allow-list validation by embedding
    arbitrary commands within seemingly safe commands. The check includes
    both literal substring patterns (redirects, substitution operators, etc.)
    and regex patterns for bare variable expansion (`$VAR`) and the background
    operator (`&`).

    Args:
        command: The shell command to check.

    Returns:
        True if dangerous patterns are found, False otherwise.
    """
    if any(pattern in command for pattern in DANGEROUS_SHELL_PATTERNS):
        return True

    # Bare variable expansion ($VAR without braces) can leak sensitive paths.
    # We already block ${ and $( above; this catches plain $HOME, $IFS, etc.
    if re.search(r"\$[A-Za-z_]", command):
        return True

    # Standalone & (background execution) changes the execution model and
    # should not be allowed.  We check for & that is NOT part of &&.
    return bool(re.search(r"(?<![&])&(?![&])", command))


def is_shell_command_allowed(command: str, allow_list: list[str] | None) -> bool:
    """Check if a shell command is in the allow-list.

    The allow-list matches against the first token of the command (the executable name).
    This allows read-only commands like ls, cat, grep, etc. to be auto-approved.

    SECURITY: This function rejects commands containing dangerous shell patterns
    (command substitution, redirects, process substitution, etc.) BEFORE parsing,
    to prevent injection attacks that could bypass the allow-list.

    Args:
        command: The full shell command to check
        allow_list: List of allowed command names (e.g., ["ls", "cat", "grep"])

    Returns:
        True if the command is allowed, False otherwise.
    """
    if not allow_list or not command or not command.strip():
        return False

    # SECURITY: Check for dangerous patterns BEFORE any parsing
    # This prevents injection attacks like: ls "$(rm -rf /)"
    if contains_dangerous_patterns(command):
        return False

    allow_set = set(allow_list)

    # Extract the first command token
    # Handle pipes and other shell operators by checking each command in the pipeline
    # Split by compound operators first (&&, ||), then single-char operators (|, ;).
    # Note: standalone & (background) is blocked by contains_dangerous_patterns above.
    segments = re.split(r"&&|\|\||[|;]", command)

    # Track if we found at least one valid command
    found_command = False

    for raw_segment in segments:
        segment = raw_segment.strip()
        if not segment:
            continue

        try:
            # Try to parse as shell command to extract the executable name
            tokens = shlex.split(segment)
            if tokens:
                found_command = True
                cmd_name = tokens[0]
                # Check if this command is in the allow set
                if cmd_name not in allow_set:
                    return False
        except ValueError:
            # If we can't parse it, be conservative and require approval
            return False

    # All segments are allowed (and we found at least one command)
    return found_command


def get_langsmith_project_name() -> str | None:
    """Resolve the LangSmith project name if tracing is configured.

    Checks for the required API key and tracing environment variables.
    When both are present, resolves the project name with priority:
    `settings.rlmagents_langsmith_project` (from
    `RLMAGENTS_LANGSMITH_PROJECT`), then `LANGSMITH_PROJECT` from the
    environment (note: this may already have been overridden at import
    time to match the rlmagents project var), then `'default'`.

    Returns:
        Project name string when LangSmith tracing is active, None otherwise.
    """
    langsmith_key = os.environ.get("LANGSMITH_API_KEY") or os.environ.get(
        "LANGCHAIN_API_KEY"
    )
    langsmith_tracing = os.environ.get("LANGSMITH_TRACING") or os.environ.get(
        "LANGCHAIN_TRACING_V2"
    )
    if not (langsmith_key and langsmith_tracing):
        return None

    return (
        _normalize_langsmith_project_name(settings.rlmagents_langsmith_project)
        or _normalize_langsmith_project_name(os.environ.get("LANGSMITH_PROJECT"))
        or "default"
    )


def fetch_langsmith_project_url(project_name: str) -> str | None:
    """Fetch the LangSmith project URL via the LangSmith client.

    Results are cached at module level so repeated calls do not make additional
    network requests. Failed lookups are also cached to avoid retries.

    This is a blocking network call on the first invocation. In async
    contexts, run it in a thread (e.g. via `asyncio.to_thread`).

    Returns None (with a debug log) on any expected failure: missing
    `langsmith` package, network/auth issues, unknown project names, invalid
    project names, or client initialization issues.

    Args:
        project_name: LangSmith project name to look up.

    Returns:
        Project URL string if found, None otherwise.
    """
    global _langsmith_url_cache  # noqa: PLW0603

    if _langsmith_url_cache is not None:
        cached_name, cached_url = _langsmith_url_cache
        if cached_name == project_name:
            return cached_url
        # Different project name — fall through to fetch.

    try:
        from langsmith import Client
        from langsmith.utils import LangSmithError
    except ImportError:
        logger.debug(
            "Could not fetch LangSmith project URL for '%s'",
            project_name,
            exc_info=True,
        )
        _langsmith_url_cache = (project_name, None)
        return None

    try:
        project = Client().read_project(project_name=project_name)
    except (LangSmithError, OSError, ValueError, RuntimeError):
        logger.debug(
            "Could not fetch LangSmith project URL for '%s'",
            project_name,
            exc_info=True,
        )
        _langsmith_url_cache = (project_name, None)
        return None
    else:
        url = project.url or None
        _langsmith_url_cache = (project_name, url)
        return url


def build_langsmith_thread_url(thread_id: str) -> str | None:
    """Build a full LangSmith thread URL if tracing is configured.

    Combines `get_langsmith_project_name` and `fetch_langsmith_project_url`
    into a single convenience helper.

    Args:
        thread_id: Thread identifier to build the URL for.

    Returns:
        Full thread URL string, or `None` if unavailable (LangSmith is not
            configured or the project URL cannot be resolved.)
    """
    project_name = get_langsmith_project_name()
    if not project_name:
        return None

    project_url = fetch_langsmith_project_url(project_name)
    if not project_url:
        return None

    return f"{project_url.rstrip('/')}/t/{thread_id}?utm_source=rlmagents"


def reset_langsmith_url_cache() -> None:
    """Reset the LangSmith URL cache (for testing)."""
    global _langsmith_url_cache  # noqa: PLW0603
    _langsmith_url_cache = None


def get_default_coding_instructions() -> str:
    """Get the default coding agent instructions.

    These are the immutable base instructions that cannot be modified by the agent.
    Long-term memory (AGENTS.md) is handled separately by the middleware.

    Returns:
        The default agent instructions as a string.
    """
    default_prompt_path = Path(__file__).parent / "default_agent_prompt.md"
    return default_prompt_path.read_text()


def detect_provider(model_name: str) -> str | None:
    """Auto-detect provider from model name.

    Intentionally duplicates a subset of LangChain's
    `_attempt_infer_model_provider` because we need to resolve the provider
    **before** calling `init_chat_model` in order to:

    1. Build provider-specific kwargs (API base URLs, headers, etc.) that are
       passed *into* `init_chat_model`.
    2. Validate credentials early to surface user-friendly errors.

    Args:
        model_name: Model name to detect provider from.

    Returns:
        Provider name (openai, anthropic, deepseek, google_genai, google_vertexai) or
            `None` if the provider cannot be determined from the name alone.
    """
    model_lower = model_name.lower()

    if model_lower.startswith(("gpt-", "o1", "o3", "o4", "chatgpt")):
        return "openai"

    if model_lower.startswith("claude"):
        if not settings.has_anthropic and settings.has_vertex_ai:
            return "google_vertexai"
        return "anthropic"

    if model_lower.startswith("gemini"):
        if settings.has_vertex_ai and not settings.has_google:
            return "google_vertexai"
        return "google_genai"

    if model_lower.startswith("deepseek"):
        return "deepseek"

    return None


def _get_default_model_spec() -> str:
    """Get default model specification based on available credentials.

    Checks in order:

    1. `[models].default` in config file (user's intentional preference).
    2. `[models].recent` in config file (last `/model` switch).
    3. Auto-detection based on available API credentials.

    Returns:
        Model specification in provider:model format.

    Raises:
        ModelConfigError: If no credentials are configured.
    """
    config = ModelConfig.load()
    if config.default_model:
        return config.default_model

    if config.recent_model:
        return config.recent_model

    if settings.has_openai:
        model = os.environ.get("OPENAI_MODEL", "gpt-5.2")
        return f"openai:{model}"
    if settings.has_anthropic:
        model = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5-20250929")
        return f"anthropic:{model}"
    if settings.has_google:
        model = os.environ.get("GOOGLE_MODEL", "gemini-3-pro-preview")
        return f"google_genai:{model}"
    if settings.has_vertex_ai:
        model = os.environ.get("VERTEX_AI_MODEL", "gemini-3-pro-preview")
        return f"google_vertexai:{model}"
    if os.environ.get("DEEPSEEK_API_KEY"):
        model = os.environ.get("DEEPSEEK_MODEL", "deepseek-chat")
        return f"deepseek:{model}"

    msg = (
        "No credentials configured. Please set one of: "
        "ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, DEEPSEEK_API_KEY, "
        "or GOOGLE_CLOUD_PROJECT"
    )
    raise ModelConfigError(msg)


def _get_provider_kwargs(
    provider: str, *, model_name: str | None = None
) -> dict[str, Any]:
    """Get provider-specific kwargs from the config file.

    Reads `base_url`, `api_key_env`, and the `params` table from the user's
    `config.toml` for the given provider.

    When `model_name` is provided, per-model overrides from the `params`
    sub-table are shallow-merged on top.

    For `deepseek`, applies environment fallbacks when config values are absent:
    - `base_url`: `DEEPSEEK_BASE_URL` or `https://api.deepseek.com`
    - `api_key`: `DEEPSEEK_API_KEY`

    Args:
        provider: Provider name (e.g., openai, anthropic, fireworks, ollama).
        model_name: Optional model name for per-model overrides.

    Returns:
        Dictionary of provider-specific kwargs.
    """
    config = ModelConfig.load()
    result: dict[str, Any] = config.get_kwargs(provider, model_name=model_name)
    base_url = config.get_base_url(provider)
    if base_url:
        result["base_url"] = base_url
    elif provider == "deepseek":
        result["base_url"] = os.environ.get(
            "DEEPSEEK_BASE_URL", "https://api.deepseek.com"
        )
    api_key_env = config.get_api_key_env(provider)
    if api_key_env:
        api_key = os.environ.get(api_key_env)
        if api_key:
            result["api_key"] = api_key
    elif provider == "deepseek":
        deepseek_key = os.environ.get("DEEPSEEK_API_KEY")
        if deepseek_key:
            result["api_key"] = deepseek_key
    return result


def _patch_deepseek_reasoner_payload(
    model: BaseChatModel,
    *,
    provider: str,
    model_name: str,
) -> None:
    """Patch DeepSeek reasoner payload serialization for tool-call loops.

    DeepSeek reasoner requires `reasoning_content` on prior assistant tool-call
    messages. Current LangChain OpenAI message serialization can drop this field,
    causing request failures on follow-up turns. This patch re-injects the field
    from `AIMessage.additional_kwargs`.

    Args:
        model: Instantiated chat model.
        provider: Resolved provider name.
        model_name: Resolved model name.
    """
    if provider != "deepseek" or "reasoner" not in model_name.lower():
        return

    if getattr(model, "_rlmagents_deepseek_reasoner_patch", False):
        return

    get_request_payload = getattr(model, "_get_request_payload", None)
    convert_input = getattr(model, "_convert_input", None)
    if not callable(get_request_payload) or not callable(convert_input):
        logger.warning(
            "Could not patch DeepSeek reasoner payload path for %s; "
            "missing payload conversion methods",
            model_name,
        )
        return

    original_get_request_payload = get_request_payload

    def _patched_get_request_payload(
        self: BaseChatModel,  # noqa: ARG001 - required for MethodType binding
        input_: LanguageModelInput,
        *,
        stop: list[str] | None = None,
        **kwargs: Any,
    ) -> dict[str, Any]:
        payload = original_get_request_payload(input_, stop=stop, **kwargs)
        payload_messages = payload.get("messages")
        if not isinstance(payload_messages, list):
            return payload

        try:
            messages = convert_input(input_).to_messages()
        except Exception:
            logger.debug(
                "Failed to inspect messages for DeepSeek reasoning passthrough",
                exc_info=True,
            )
            return payload

        for lc_message, payload_message in zip(
            messages, payload_messages, strict=False
        ):
            if not isinstance(lc_message, AIMessage):
                continue
            if not isinstance(payload_message, dict):
                continue
            if payload_message.get("role") != "assistant":
                continue
            if "tool_calls" not in payload_message:
                continue
            reasoning_content = lc_message.additional_kwargs.get("reasoning_content")
            if isinstance(reasoning_content, str) and reasoning_content:
                payload_message["reasoning_content"] = reasoning_content

        return payload

    model._get_request_payload = MethodType(  # type: ignore[attr-defined]
        _patched_get_request_payload, model
    )
    model._rlmagents_deepseek_reasoner_patch = True  # type: ignore[attr-defined]


def _create_model_from_class(
    class_path: str,
    model_name: str,
    provider: str,
    kwargs: dict[str, Any],
) -> BaseChatModel:
    """Import and instantiate a custom `BaseChatModel` class.

    Args:
        class_path: Fully-qualified class in `module.path:ClassName` format.
        model_name: Model identifier to pass as `model` kwarg.
        provider: Provider name (for error messages).
        kwargs: Additional keyword arguments for the constructor.

    Returns:
        Instantiated `BaseChatModel`.

    Raises:
        ModelConfigError: If the class cannot be imported, is not a
            `BaseChatModel` subclass, or fails to instantiate.
    """
    if ":" not in class_path:
        msg = (
            f"Invalid class_path '{class_path}' for provider '{provider}': "
            "must be in module.path:ClassName format"
        )
        raise ModelConfigError(msg)

    module_path, class_name = class_path.rsplit(":", 1)

    try:
        module = importlib.import_module(module_path)
    except ImportError as e:
        msg = f"Could not import module '{module_path}' for provider '{provider}': {e}"
        raise ModelConfigError(msg) from e

    cls = getattr(module, class_name, None)
    if cls is None:
        msg = (
            f"Class '{class_name}' not found in module '{module_path}' "
            f"for provider '{provider}'"
        )
        raise ModelConfigError(msg)

    if not (isinstance(cls, type) and issubclass(cls, BaseChatModel)):
        msg = (
            f"'{class_path}' is not a BaseChatModel subclass (got {type(cls).__name__})"
        )
        raise ModelConfigError(msg)

    try:
        return cls(model=model_name, **kwargs)
    except Exception as e:
        msg = f"Failed to instantiate '{class_path}' for '{provider}:{model_name}': {e}"
        raise ModelConfigError(msg) from e


def _create_model_via_init(
    model_name: str,
    provider: str,
    kwargs: dict[str, Any],
) -> BaseChatModel:
    """Create a model using langchain's `init_chat_model`.

    Args:
        model_name: Model identifier.
        provider: Provider name (may be empty for auto-detection).
        kwargs: Additional keyword arguments.

    Returns:
        Instantiated `BaseChatModel`.

    Raises:
        ModelConfigError: On import, value, or runtime errors.
    """
    try:
        if provider:
            return init_chat_model(model_name, model_provider=provider, **kwargs)
        return init_chat_model(model_name, **kwargs)
    except ImportError as e:
        package_map = {
            "anthropic": "langchain-anthropic",
            "deepseek": "langchain-deepseek",
            "openai": "langchain-openai",
            "google_genai": "langchain-google-genai",
            "google_vertexai": "langchain-google-vertexai",
        }
        package = package_map.get(provider, f"langchain-{provider}")
        msg = (
            f"Missing package for provider '{provider}'. Install: pip install {package}"
        )
        raise ModelConfigError(msg) from e
    except (ValueError, TypeError) as e:
        spec = f"{provider}:{model_name}" if provider else model_name
        msg = f"Invalid model configuration for '{spec}': {e}"
        raise ModelConfigError(msg) from e
    except Exception as e:  # provider SDK auth/network errors
        spec = f"{provider}:{model_name}" if provider else model_name
        msg = f"Failed to initialize model '{spec}': {e}"
        raise ModelConfigError(msg) from e


@dataclass(frozen=True)
class ModelResult:
    """Result of creating a chat model, bundling the model with its metadata.

    This separates model creation from settings mutation so callers can decide
    when to commit the metadata to global settings.

    Attributes:
        model: The instantiated chat model.
        model_name: Resolved model name.
        provider: Resolved provider name.
        context_limit: Max input tokens from the model profile, or `None`.
    """

    model: BaseChatModel
    model_name: str
    provider: str
    context_limit: int | None = None

    def apply_to_settings(self) -> None:
        """Commit this result's metadata to global `settings`."""
        settings.model_name = self.model_name
        settings.model_provider = self.provider
        settings.model_context_limit = self.context_limit


def create_model(
    model_spec: str | None = None,
    *,
    extra_kwargs: dict[str, Any] | None = None,
) -> ModelResult:
    """Create a chat model.

    Uses `init_chat_model` for standard providers, or imports a custom
    `BaseChatModel` subclass when the provider has a `class_path` in config.

    Supports `provider:model` format (e.g., `'anthropic:claude-sonnet-4-5'`)
    for explicit provider selection, or bare model names for auto-detection.

    Args:
        model_spec: Model specification in `provider:model` format (e.g.,
            `'anthropic:claude-sonnet-4-5'`, `'openai:gpt-4o'`) or just the model
            name for auto-detection (e.g., `'claude-sonnet-4-5'`).

                If not provided, uses environment-based defaults.
        extra_kwargs: Additional kwargs to pass to the model constructor.

            These take highest priority, overriding values from the config file.

    Returns:
        A `ModelResult` containing the model and its metadata.

    Raises:
        ModelConfigError: If provider cannot be determined from the model name,
            required provider package is not installed, or no credentials are
            configured.

    Examples:
        >>> model = create_model("anthropic:claude-sonnet-4-5")
        >>> model = create_model("openai:gpt-4o")
        >>> model = create_model("gpt-4o")  # Auto-detects openai
        >>> model = create_model()  # Uses environment defaults
    """
    if not model_spec:
        model_spec = _get_default_model_spec()

    # Parse provider:model syntax
    provider: str
    model_name: str
    parsed = ModelSpec.try_parse(model_spec)
    if parsed:
        # Explicit provider:model (e.g., "anthropic:claude-sonnet-4-5")
        provider, model_name = parsed.provider, parsed.model
    elif ":" in model_spec:
        # Contains colon but ModelSpec rejected it (empty provider or model)
        _, _, after = model_spec.partition(":")
        if after:
            # Leading colon (e.g., ":claude-opus-4-6") — treat as bare model name
            model_name = after
            provider = detect_provider(model_name) or ""
        else:
            msg = (
                f"Invalid model spec '{model_spec}': model name is required "
                "(e.g., 'anthropic:claude-sonnet-4-5' or 'claude-sonnet-4-5')"
            )
            raise ModelConfigError(msg)
    else:
        # Bare model name — auto-detect provider or let init_chat_model infer
        model_name = model_spec
        provider = detect_provider(model_spec) or ""

    # Provider-specific kwargs (with per-model overrides)
    kwargs = _get_provider_kwargs(provider, model_name=model_name)

    # CLI --model-params take highest priority
    if extra_kwargs:
        kwargs.update(extra_kwargs)

    # Check if this provider uses a custom BaseChatModel class
    config = ModelConfig.load()
    class_path = config.get_class_path(provider) if provider else None

    if class_path:
        model = _create_model_from_class(class_path, model_name, provider, kwargs)
    else:
        model = _create_model_via_init(model_name, provider, kwargs)

    _patch_deepseek_reasoner_payload(model, provider=provider, model_name=model_name)

    resolved_provider = provider or getattr(model, "_model_provider", provider)

    # Extract context limit from model profile (if available)
    context_limit: int | None = None
    profile = getattr(model, "profile", None)
    if isinstance(profile, dict) and isinstance(profile.get("max_input_tokens"), int):
        context_limit = profile["max_input_tokens"]

    return ModelResult(
        model=model,
        model_name=model_name,
        provider=resolved_provider,
        context_limit=context_limit,
    )


def validate_model_capabilities(model: BaseChatModel, model_name: str) -> None:
    """Validate that the model has required capabilities for `rlmagents`.

    Checks the model's profile (if available) to ensure it supports tool calling, which
    is required for agent functionality. Issues warnings for models without profiles or
    with limited context windows.

    Args:
        model: The instantiated model to validate.
        model_name: Model name for error/warning messages.

    Note:
        This validation is best-effort. Models without profiles will pass with
        a warning. Exits via sys.exit(1) if model profile explicitly indicates
        tool_calling=False.
    """
    profile = getattr(model, "profile", None)

    if profile is None:
        # Model doesn't have profile data - warn but allow
        console.print(
            f"[dim][yellow]Note:[/yellow] No capability profile for "
            f"'{model_name}'. Cannot verify tool calling support.[/dim]"
        )
        return

    if not isinstance(profile, dict):
        return

    # Check required capability: tool_calling
    tool_calling = profile.get("tool_calling")
    if tool_calling is False:
        console.print(
            f"[bold red]Error:[/bold red] Model '{model_name}' "
            "does not support tool calling."
        )
        console.print(
            "\nRLMAgents requires tool calling for agent functionality. "
            "Please choose a model that supports tool calling."
        )
        console.print("\nSee MODELS.md for supported models.")
        sys.exit(1)

    # Warn about potentially limited context (< 8k tokens)
    max_input_tokens = profile.get("max_input_tokens")
    if max_input_tokens and max_input_tokens < 8000:
        console.print(
            f"[dim][yellow]Warning:[/yellow] Model '{model_name}' has limited context "
            f"({max_input_tokens:,} tokens). Agent performance may be affected.[/dim]"
        )
