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

from __future__ import annotations

import importlib
import logging
import os
import re
import shlex
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any

import dotenv

logger = logging.getLogger(__name__)

dotenv.load_dotenv(Path.home() / ".icarus" / ".env")

# 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
_icarus_project = os.environ.get("ICARUS_LANGSMITH_PROJECT")
_original_langsmith_project = os.environ.get("LANGSMITH_PROJECT")
if _icarus_project:
    # Override LANGSMITH_PROJECT for agent traces
    os.environ["LANGSMITH_PROJECT"] = _icarus_project

from icarus.config.model_config import (  # noqa: E402  # Import after os.environ setup above
    ModelConfig,
    ModelConfigError,
    ModelSpec,
)

if TYPE_CHECKING:
    from langchain_core.language_models import BaseChatModel
    from langchain_core.runnables import RunnableConfig

DOCS_URL = "https://github.com/icarus-ai/icarus"
"""URL for ICARUS documentation."""

MODE_PREFIXES: dict[str, str] = {
    "bash": "!",
    "command": "/",
}
"""Maps each non-normal mode to its trigger character."""

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


# 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}


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 two locations and returns ALL that exist:
    1. project_root/.icarus/AGENTS.md
    2. project_root/AGENTS.md

    Both files will be loaded and combined if both exist.

    Args:
        project_root: Path to the project root directory.

    Returns:
        Existing AGENTS.md paths.

            Empty if neither file exists, one entry if only one is present, or
            two entries if both locations have the file.
    """
    candidates = [
        project_root / ".icarus" / "AGENTS.md",
        project_root / "AGENTS.md",
    ]
    paths: list[Path] = []
    for candidate in candidates:
        try:
            if candidate.exists():
                paths.append(candidate)
        except OSError:
            pass
    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 ICARUS.

    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.
        icarus_langchain_project: LangSmith project name for ICARUS
            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
    icarus_langchain_project: str | None  # For ICARUS 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
        # ICARUS_LANGSMITH_PROJECT: Project for ICARUS agent 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
        icarus_langchain_project = os.environ.get("ICARUS_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("ICARUS_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,
            icarus_langchain_project=icarus_langchain_project,
            user_langchain_project=user_langchain_project,
            project_root=project_root,
            shell_allow_list=shell_allow_list,
        )

    def reload_credentials(self) -> None:
        """Re-read credential env vars in-place.

        ``Settings.from_environment()`` creates a **new** object which breaks
        every module that imported ``settings`` via a bare name binding
        (``from icarus.config.app_config import settings``).  This method
        mutates the existing instance so all references stay valid and
        runtime state (``model_name``, ``model_provider``, etc.) is preserved.
        """
        self.openai_api_key = os.environ.get("OPENAI_API_KEY")
        self.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY")
        self.google_api_key = os.environ.get("GOOGLE_API_KEY")
        self.tavily_api_key = os.environ.get("TAVILY_API_KEY")
        self.google_cloud_project = os.environ.get("GOOGLE_CLOUD_PROJECT")

    @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_icarus_dir(self) -> Path:
        """Get the base user-level .icarus directory.

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

    @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 ~/.icarus/{agent_name}/AGENTS.md
        """
        return Path.home() / ".icarus" / agent_name / "AGENTS.md"

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

        Checks both `{project_root}/.icarus/AGENTS.md` and
        `{project_root}/AGENTS.md`, returning all that exist. If both are
        present, both are loaded and their instructions are combined, with
        `.icarus/AGENTS.md` first.

        Returns:
            Existing AGENTS.md paths.

                Empty if neither file exists or not in a project, one entry if
                only one is present, or two entries if both locations have the
                file.
        """
        if not self.project_root:
            return []
        return _find_project_agent_md(self.project_root)

    @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 ~/.icarus/{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 Path.home() / ".icarus" / 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 ~/.icarus/{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_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 ~/.icarus/{agent_name}/skills/
        """
        return self.get_agent_dir(agent_name) / "skills"

    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 ~/.icarus/{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}/.icarus/skills/, or None if not in a project
        """
        if not self.project_root:
            return None
        return self.project_root / ".icarus" / "skills"

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

        Returns:
            Path to {project_root}/.icarus/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

    @staticmethod
    def get_plugins_base_dir() -> Path:
        """Get the base directory for all plugins.

        Returns:
            Path to ~/.icarus/plugins/
        """
        return Path.home() / ".icarus" / "plugins"

    @staticmethod
    def get_plugin_skills_dir(plugin_name: str) -> Path:
        """Get the skills directory for a specific plugin.

        Args:
            plugin_name: Name of the plugin (registry name)

        Returns:
            Path to ~/.icarus/plugins/{plugin_name}/skills/
        """
        return Settings.get_plugins_base_dir() / plugin_name / "skills"

    @staticmethod
    def ensure_plugin_skills_dir(plugin_name: str) -> Path:
        """Ensure plugin skills directory exists and return its path.

        Args:
            plugin_name: Name of the plugin (registry name)

        Returns:
            Path to ~/.icarus/plugins/{plugin_name}/skills/
        """
        skills_dir = Settings.get_plugin_skills_dir(plugin_name)
        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., "icarus")

        Returns:
            Path to ~/.icarus/{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}/.icarus/agents/, or None if not in a project
        """
        if not self.project_root:
            return None
        return self.project_root / ".icarus" / "agents"

    @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.parent / "built_in_skills"


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


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.icarus_langchain_project` (from
    `ICARUS_LANGSMITH_PROJECT`), then `LANGSMITH_PROJECT` from the
    environment (note: this may already have been overridden at import
    time to match `ICARUS_LANGSMITH_PROJECT`), 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 (
        settings.icarus_langchain_project
        or 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 errors, 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  # Module-level cache requires global statement

    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

        project = Client().read_project(project_name=project_name)
    except (ImportError, 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=icarus"


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.parent / "assets" / "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, 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"

    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.
    """
    from icarus.config.model_config import KEYLESS_PROVIDERS, get_available_models

    config = ModelConfig.load()

    # Fast path: if default/recent is a cloud provider (not keyless), trust
    # it without hitting the network.  Only validate keyless specs (Ollama)
    # against a live server — avoids a 5-second timeout for cloud-only users.
    for candidate in (config.default_model, config.recent_model):
        if not candidate:
            continue
        provider = candidate.split(":", 1)[0] if ":" in candidate else ""
        if provider not in KEYLESS_PROVIDERS:
            return candidate

    # Keyless default/recent — validate against the running server.
    all_specs = {
        f"{p}:{m}" for p, models in get_available_models().items() for m in models
    }
    if config.default_model and config.default_model in all_specs:
        return config.default_model
    if config.recent_model and config.recent_model in all_specs:
        return config.recent_model

    if settings.has_openai:
        return "openai:gpt-5.2"
    if settings.has_anthropic:
        return "anthropic:claude-sonnet-4-5-20250929"
    if settings.has_google:
        return "google_genai:gemini-3.1-pro-preview"
    if settings.has_vertex_ai:
        return "google_vertexai:gemini-3.1-pro-preview"

    # Local Ollama — try if no cloud keys are set
    from icarus.config.ollama import discover_ollama_models, get_ollama_base_url

    ollama_models = discover_ollama_models(get_ollama_base_url())
    if ollama_models:
        return f"ollama:{ollama_models[0]['name']}"

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


_OPENROUTER_DEFAULT_HEADERS: dict[str, str] = {
    "HTTP-Referer": "https://github.com/icarus-ai/icarus",
    "X-Title": "ICARUS",
}
"""Default attribution headers sent with every OpenRouter request.

See https://openrouter.ai/docs/app-attribution for details.
"""

_SUPPORTS_MAX_RETRIES: frozenset[str] = frozenset({
    "anthropic", "openai", "google_vertexai", "google_genai",
    "groq", "mistralai", "fireworks", "together", "openrouter",
    "perplexity", "deepseek", "xai", "nvidia", "custom",
})
"""Providers whose LangChain classes accept ``max_retries``.

Bedrock Converse, Azure AI custom classes, and others reject unknown kwargs,
so we only inject ``max_retries=0`` for providers in this set.
"""


def _resolve_env_vars(params: dict[str, Any]) -> dict[str, Any]:
    """Resolve ``$VAR_NAME`` strings in *params* from ``os.environ``.

    Only top-level string values starting with ``$`` are resolved.  Missing
    env vars are left as the original ``$VAR_NAME`` string (the provider will
    raise its own error for missing required kwargs).

    Args:
        params: Flat dictionary of parameter key/value pairs.

    Returns:
        New dictionary with env-var references replaced by their values.
    """
    resolved: dict[str, Any] = {}
    for key, value in params.items():
        if isinstance(value, str) and value.startswith("$"):
            env_name = value[1:]
            resolved[key] = os.environ.get(env_name, value)
        else:
            resolved[key] = value
    return resolved


_ANTHROPIC_CLASS_PATH = "langchain_anthropic:ChatAnthropic"
"""Class path for Anthropic models hosted on Azure AI Foundry."""

_AZURE_AI_CLASS_PATH = (
    "langchain_azure_ai.chat_models:AzureAIChatCompletionsModel"
)
"""Class path for Azure AI Inference models."""


def _resolve_azure_endpoint(endpoint: str) -> tuple[str, str]:
    """Classify an Azure AI endpoint URL and return *(normalized_url, class_path)*.

    Azure AI Foundry hosts models from multiple vendors, each with its own
    API surface.  Users paste the endpoint URL from the Azure portal — this
    function detects the protocol and returns the correct LangChain class:

    * ``/anthropic/…``  → Anthropic Messages API → ``ChatAnthropic``
    * ``/models/…`` or bare URL → Azure AI Inference →
      ``AzureAIChatCompletionsModel``

    Strips query strings, trailing quotes, and known path suffixes that
    the respective SDKs append internally.
    """
    from urllib.parse import urlparse, urlunparse

    raw = endpoint.strip().rstrip("'\"")
    parsed = urlparse(raw)
    path = parsed.path.rstrip("/")

    # ── Anthropic protocol ───────────────────────────────────────────
    if "/anthropic" in path:
        # ChatAnthropic base_url should end at /anthropic
        idx = path.index("/anthropic")
        base_path = path[: idx + len("/anthropic")]
        url = urlunparse((parsed.scheme, parsed.netloc, base_path, "", "", ""))
        return url, _ANTHROPIC_CLASS_PATH

    # ── Azure AI Inference (default) ─────────────────────────────────
    for suffix in ("/chat/completions",):
        if path.endswith(suffix):
            path = path[: -len(suffix)]
            break

    if ".services.ai.azure.com" in (parsed.netloc or ""):
        if not path.endswith("/models"):
            path = path.rstrip("/") + "/models"

    url = urlunparse((parsed.scheme, parsed.netloc, path, "", "", ""))
    return url, _AZURE_AI_CLASS_PATH


def _get_provider_kwargs(
    provider: str, *, model_name: str | None = None
) -> tuple[dict[str, Any], str | None]:
    """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 providers with a ``class_path``, ``api_key`` is **not** injected from
    ``api_key_env`` — custom classes manage their own kwargs via ``params``.
    The ``api_key_env`` field still serves as a credential indicator for the TUI.

    ``$VAR_NAME`` strings in ``params`` values are resolved from ``os.environ``.

    For the `openrouter` provider, default attribution headers (`HTTP-Referer`
    and `X-Title`) are injected automatically. User-supplied `default_headers`
    in config take precedence.

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

    Returns:
        Tuple of (kwargs dict, per-model class_path override or None).
        The class_path override is set when Azure protocol auto-detection
        determines a different class than the provider default.
    """
    config = ModelConfig.load()
    result: dict[str, Any] = config.get_kwargs(provider, model_name=model_name)
    result = _resolve_env_vars(result)

    has_class_path = bool(config.get_class_path(provider)) if provider else False

    base_url = config.get_base_url(provider)
    if base_url:
        result["base_url"] = base_url

    # Only inject api_key for standard providers (not custom class_path
    # or endpoint-managed ones like azure_ai)
    if not has_class_path and provider != "azure_ai":
        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

    # Azure AI Foundry: inject credential and auto-detect API protocol.
    class_path_override: str | None = None
    if provider == "azure_ai":
        env_var = config.get_api_key_env(provider) or "AZURE_INFERENCE_CREDENTIAL"
        cred = os.environ.get(env_var)
        if cred:
            result.setdefault("credential", cred)

    if provider == "azure_ai" and "endpoint" in result:
        url, detected_class = _resolve_azure_endpoint(result["endpoint"])
        if detected_class == _ANTHROPIC_CLASS_PATH:
            # Anthropic on Azure: endpoint/credential → base_url/api_key
            result["base_url"] = url
            result["api_key"] = result.pop("credential", None)
            result.pop("endpoint", None)
        else:
            result["endpoint"] = url
        class_path_override = detected_class

    # Custom (OpenAI-compatible): convert endpoint → base_url for init_chat_model
    if provider == "custom" and "endpoint" in result:
        result["base_url"] = result.pop("endpoint")

    # Disable provider-level retries — our LangGraph RetryPolicy in builder.py
    # handles transient failures.  Provider retries delay permanent errors.
    if provider in _SUPPORTS_MAX_RETRIES:
        result.setdefault("max_retries", 0)

    if provider == "openrouter":
        user_headers = result.get("default_headers") or {}
        result["default_headers"] = {**_OPENROUTER_DEFAULT_HEADERS, **user_headers}

    return result, class_path_override


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.
    """
    from langchain_core.language_models import (
        BaseChatModel as _BaseChatModel,  # Runtime import; module level is typing only
    )

    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.
    """
    from langchain.chat_models import init_chat_model

    # Custom (OpenAI-compatible) → route through OpenAI provider for LangChain
    lc_provider = "openai" if provider == "custom" else provider

    try:
        if lc_provider:
            return init_chat_model(model_name, model_provider=lc_provider, **kwargs)
        return init_chat_model(model_name, **kwargs)
    except ImportError as e:
        package_map = {
            "anthropic": "langchain-anthropic",
            "openai": "langchain-openai",
            "google_genai": "langchain-google-genai",
            "google_vertexai": "langchain-google-vertexai",
        }
        package = package_map.get(lc_provider, f"langchain-{lc_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, class_path_override = _get_provider_kwargs(
        provider, model_name=model_name,
    )

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

    # Resolve class_path: per-model override > config
    config = ModelConfig.load()
    class_path = config.get_class_path(provider) if provider else None

    if class_path_override:
        class_path = class_path_override

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

    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,
    )
