"""Interactive REPL for CleverAgents.

Provides a read-eval-print loop that dispatches user input to existing
CLI commands, with history, tab-completion, multi-line input, and
prompt context showing the active project/plan.
"""

from __future__ import annotations

import difflib
import os
import readline
import shlex
import subprocess
import sys
import time
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated

import typer
import yaml
from rich.console import Console

from cleveragents.cli.persona import PersonaConfig, PersonaRegistry
from cleveragents.tui.input.shell_exec import looks_dangerous

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_DEFAULT_HISTORY_DIR = Path.home() / ".cleveragents"
_DEFAULT_HISTORY_PATH = _DEFAULT_HISTORY_DIR / "history"
_MULTILINE_CONTINUATION = "\\"
_LAST_COMMAND_TOKEN = "!!"
_MAX_REFERENCE_FILES = 300
_SHELL_TIMEOUT_SECONDS = 30
_IGNORED_DIRS = {".git", ".venv", "node_modules", "__pycache__", ".mypy_cache"}
_REFERENCE_CACHE_TTL_SECONDS = 5.0
_reference_cache: dict[str, object] = {"cwd": None, "created_at": 0.0, "catalog": None}

# The subset of first-position CLI commands exposed inside the REPL.
# Mirrors the ``valid_cmds`` list in ``main.py`` minus the flags.
_REPL_COMMANDS: list[str] = [
    "version",
    "info",
    "diagnostics",
    "init",
    "project",
    "context",
    "plan",
    "actor",
    "action",
    "resource",
    "skill",
    "lsp",
    "cleanup",
    "config",
    "session",
    "tool",
    "validation",
    "auto-debug",
    "automation-profile",
    "invariant",
    "tell",
    "build",
    "apply",
    "context-load",
    "context-add",
    "tui",
]

_BUILTIN_COMMANDS: list[str] = [":help", ":exit", ":quit"]
_SLASH_COMMANDS: list[str] = [
    "/persona",
    "/persona list",
    "/persona set",
    "/persona create",
    "/persona pick",
    "/persona delete",
    "/persona export",
    "/persona import",
    "/session list",
    "/session new",
    "/session switch",
]

_console = Console()

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


@dataclass(slots=True)
class _ReplSessionState:
    name: str
    active_persona: str


def _compose_prompt(base_prompt: str, session_state: _ReplSessionState) -> str:
    """Compose prompt with session/persona status."""
    core = base_prompt.removesuffix("> ").rstrip()
    return f"{core} [{session_state.name}:{session_state.active_persona}]> "


def _get_prompt_context() -> str:
    """Return a prompt string reflecting the active project/plan.

    Reads ``CLEVERAGENTS_PROJECT`` and ``CLEVERAGENTS_PLAN`` from the
    environment.  If neither is set the prompt falls back to a plain
    ``agents``.
    """
    project = os.environ.get("CLEVERAGENTS_PROJECT", "")
    plan = os.environ.get("CLEVERAGENTS_PLAN", "")
    parts: list[str] = []
    if project:
        parts.append(project)
    if plan:
        parts.append(plan)
    ctx = "/".join(parts) if parts else ""
    if ctx:
        return f"agents ({ctx})> "
    return "agents> "


def _setup_history(history_path: Path) -> None:
    """Load readline history from *history_path* (creating dirs as needed)."""
    history_path.parent.mkdir(parents=True, exist_ok=True)
    if history_path.exists():
        readline.read_history_file(str(history_path))
    readline.set_history_length(10_000)


def _save_history(history_path: Path) -> None:
    """Persist the current readline history to *history_path*."""
    import contextlib

    with contextlib.suppress(OSError):
        readline.write_history_file(str(history_path))


def _setup_completer() -> None:
    """Install a tab-completer for known commands and built-ins."""
    all_tokens = sorted(set(_REPL_COMMANDS + _BUILTIN_COMMANDS + _SLASH_COMMANDS))

    def completer(text: str, state: int) -> str | None:
        matches = [t for t in all_tokens if t.startswith(text)]
        if state < len(matches):
            return matches[state]
        return None

    readline.set_completer(completer)
    readline.set_completer_delims(" \t\n")
    readline.parse_and_bind("tab: complete")


def _read_multiline(initial: str, prompt_cont: str = "... ") -> str:
    """Read continuation lines when *initial* ends with a backslash."""
    lines = [initial.rstrip(_MULTILINE_CONTINUATION)]
    while True:
        try:
            cont = input(prompt_cont)
        except (EOFError, KeyboardInterrupt):
            break
        if cont.endswith(_MULTILINE_CONTINUATION):
            lines.append(cont.rstrip(_MULTILINE_CONTINUATION))
        else:
            lines.append(cont)
            break
    return " ".join(lines)


def _print_repl_help() -> None:
    """Display available REPL commands and built-ins."""
    _console.print("[bold]CleverAgents Interactive REPL[/bold]\n")
    _console.print("[underline]Built-in commands[/underline]")
    _console.print("  :help          Show this help message")
    _console.print("  :exit / :quit  Exit the REPL")
    _console.print("  !!             Repeat the last command")
    _console.print("")
    _console.print("[underline]Input modes[/underline]")
    _console.print("  @reference     Inline reference expansion and fuzzy matching")
    _console.print("  /command       Slash command mode")
    _console.print("  !shell cmd     Shell command passthrough")
    _console.print("  Full-screen UI: run [bold]agents tui[/bold]")
    _console.print("")
    _console.print("[underline]Slash commands[/underline]")
    for cmd in _SLASH_COMMANDS:
        _console.print(f"  {cmd}")
    _console.print("")
    _console.print("[underline]CLI commands[/underline]")
    for cmd in _REPL_COMMANDS:
        _console.print(f"  {cmd}")
    _console.print("\nType any CLI command without the leading 'agents' prefix.")
    _console.print("Use [bold]\\\\[/bold] at end of line for multi-line input.")


def _build_reference_catalog() -> dict[str, list[str]]:
    """Build lightweight catalog for @ reference expansion."""
    cwd = Path.cwd()
    now = time.time()
    cached_cwd = _reference_cache.get("cwd")
    cached_time = _reference_cache.get("created_at")
    cached_catalog = _reference_cache.get("catalog")
    if (
        isinstance(cached_cwd, Path)
        and cached_cwd == cwd
        and isinstance(cached_time, float)
        and now - cached_time < _REFERENCE_CACHE_TTL_SECONDS
        and isinstance(cached_catalog, dict)
    ):
        return cached_catalog

    files: list[str] = []
    for root, dirs, filenames in os.walk(cwd, followlinks=False):
        dirs[:] = [name for name in dirs if name not in _IGNORED_DIRS]
        root_path = Path(root)
        for filename in filenames:
            if len(files) >= _MAX_REFERENCE_FILES:
                break
            path = root_path / filename
            try:
                files.append(str(path.relative_to(cwd).as_posix()))
            except ValueError:
                files.append(path.as_posix())
        if len(files) >= _MAX_REFERENCE_FILES:
            break

    actors: set[str] = set()
    actor_examples = cwd / "examples" / "actors"
    if actor_examples.is_dir():
        for file in actor_examples.glob("*.y*ml"):
            actors.add(f"local/{file.stem}")

    tools: set[str] = set()
    tool_examples = cwd / "examples" / "tools"
    if tool_examples.is_dir():
        for file in tool_examples.glob("*.y*ml"):
            tools.add(f"local/{file.stem}")

    skills: set[str] = set()
    skill_examples = cwd / "examples" / "skills"
    if skill_examples.is_dir():
        for item in skill_examples.iterdir():
            if item.is_dir():
                skills.add(f"local/{item.name}")
            elif item.suffix in {".yaml", ".yml"}:
                skills.add(f"local/{item.stem}")

    project_env = os.environ.get("CLEVERAGENTS_PROJECT", "")
    plan_env = os.environ.get("CLEVERAGENTS_PLAN", "")

    catalog = {
        "file": sorted(files),
        "actor": sorted(actors),
        "tool": sorted(tools),
        "skill": sorted(skills),
        "project": [project_env] if project_env else [],
        "plan": [plan_env] if plan_env else [],
    }
    _reference_cache["cwd"] = cwd
    _reference_cache["created_at"] = now
    _reference_cache["catalog"] = catalog
    return catalog


def _best_fuzzy_match(query: str, options: list[str]) -> str | None:
    """Return the best match using prefix/substring/fuzzy ordering."""
    if not query or not options:
        return None
    for option in options:
        if option.startswith(query):
            return option
    for option in options:
        if query in option:
            return option
    matches = difflib.get_close_matches(query, options, n=1, cutoff=0.4)
    if matches:
        return matches[0]
    return None


def _find_reference_candidates(
    query: str, options: list[str], limit: int = 5
) -> list[str]:
    """Return top fuzzy candidates for preview/display."""
    if not query:
        return options[:limit]
    prefix = [value for value in options if value.startswith(query)]
    contains = [value for value in options if query in value and value not in prefix]
    fuzzy = [
        value
        for value in difflib.get_close_matches(query, options, n=limit, cutoff=0.4)
        if value not in prefix and value not in contains
    ]
    return (prefix + contains + fuzzy)[:limit]


def _expand_references(
    line: str, *, catalog: dict[str, list[str]] | None = None
) -> str:
    """Expand inline @ tokens to canonical references when possible."""
    active_catalog = catalog or _build_reference_catalog()
    tokens = line.split()
    expanded_tokens: list[str] = []

    for token in tokens:
        if token.startswith("\\@"):
            expanded_tokens.append(token[1:])
            continue
        if not token.startswith("@") or len(token) == 1:
            expanded_tokens.append(token)
            continue

        raw = token[1:]
        category = ""
        query = raw
        if ":" in raw:
            maybe_category, rest = raw.split(":", 1)
            if maybe_category in active_catalog:
                category = maybe_category
                query = rest

        if category:
            candidate = _best_fuzzy_match(query, active_catalog.get(category, []))
            if candidate:
                expanded_tokens.append(f"@{category}:{candidate}")
                continue
            expanded_tokens.append(token)
            continue

        best_category = ""
        best_value = ""
        for candidate_category, values in active_catalog.items():
            candidate = _best_fuzzy_match(query, values)
            if candidate:
                best_category = candidate_category
                best_value = candidate
                break
        if best_category:
            expanded_tokens.append(f"@{best_category}:{best_value}")
        else:
            expanded_tokens.append(token)

    return " ".join(expanded_tokens)


def _reference_suggestions(
    line: str, *, catalog: dict[str, list[str]] | None = None
) -> list[str]:
    """Return lightweight suggestion hints for unresolved @ tokens."""
    active_catalog = catalog or _build_reference_catalog()
    for token in line.split():
        if not token.startswith("@") or token.startswith("\\@"):
            continue
        raw = token[1:]
        if not raw:
            continue
        if ":" in raw:
            category, query = raw.split(":", 1)
            if category in active_catalog:
                candidates = _find_reference_candidates(
                    query, active_catalog[category], limit=5
                )
                return [f"@{category}:{value}" for value in candidates]
        else:
            merged: list[str] = []
            for category, values in active_catalog.items():
                for value in _find_reference_candidates(raw, values, limit=2):
                    merged.append(f"@{category}:{value}")
            return merged[:5]
    return []


def _run_shell_command(command_text: str) -> int:
    """Execute a shell command with minimal safeguards.

    Threat model:
    - Shell mode is intended for trusted local development workflows.
    - It is not a sandbox and must never be exposed as a remote execution surface.
    - Dangerous patterns require explicit user confirmation before execution.
    """
    if not command_text.strip():
        _console.print("[yellow]Shell mode requires a command after '!'.[/yellow]")
        return 2
    if os.environ.get("CLEVERAGENTS_DISABLE_SHELL_MODE", "").strip() in {"1", "true"}:
        _console.print("[yellow]Shell mode is disabled by configuration.[/yellow]")
        return 2
    if looks_dangerous(command_text):
        answer = input("Dangerous command detected. Continue? [y/N]: ").strip().lower()
        if answer not in {"y", "yes"}:
            _console.print("[yellow]Blocked dangerous shell command.[/yellow]")
            return 1
    try:
        proc = subprocess.run(
            command_text,
            shell=True,
            text=True,
            capture_output=True,
            timeout=_SHELL_TIMEOUT_SECONDS,
        )  # nosec B602
    except subprocess.TimeoutExpired:
        _console.print(
            f"[red]Shell command timed out after {_SHELL_TIMEOUT_SECONDS}s.[/red]"
        )
        return 124
    if proc.stdout:
        _console.print(proc.stdout.rstrip("\n"))
    if proc.stderr:
        _console.print(f"[red]{proc.stderr.rstrip()}[/red]")
    return proc.returncode


def _print_persona_list(
    registry: PersonaRegistry,
    sessions: dict[str, _ReplSessionState],
    current_session: str,
) -> None:
    personas = registry.list_personas()
    if not personas:
        _console.print("[yellow]No personas found.[/yellow]")
        return
    active = sessions[current_session].active_persona
    _console.print("[bold]Personas[/bold]")
    for persona in personas:
        marker = "*" if persona.name == active else " "
        _console.print(f" {marker} {persona.name} -> {persona.actor}")


def _parse_persona_create(tokens: list[str]) -> PersonaConfig:
    if len(tokens) < 4:
        raise ValueError("Usage: /persona create <name> --actor <namespace/name>")
    name = tokens[2]
    actor = ""
    description = ""
    icon = ""
    greeting = ""
    cycle_order = 0
    idx = 3
    while idx < len(tokens):
        current = tokens[idx]
        if current == "--actor" and idx + 1 < len(tokens):
            actor = tokens[idx + 1]
            idx += 2
            continue
        if current == "--description" and idx + 1 < len(tokens):
            description = tokens[idx + 1]
            idx += 2
            continue
        if current == "--icon" and idx + 1 < len(tokens):
            icon = tokens[idx + 1]
            idx += 2
            continue
        if current == "--greeting" and idx + 1 < len(tokens):
            greeting = tokens[idx + 1]
            idx += 2
            continue
        if current == "--cycle-order" and idx + 1 < len(tokens):
            try:
                cycle_order = int(tokens[idx + 1])
            except ValueError as exc:
                raise ValueError("--cycle-order must be an integer") from exc
            idx += 2
            continue
        idx += 1
    if not actor:
        raise ValueError("Missing --actor for /persona create")
    return PersonaConfig(
        name=name,
        actor=actor,
        description=description,
        icon=icon,
        greeting=greeting,
        cycle_order=cycle_order,
    )


def _handle_slash_command(
    slash_text: str,
    *,
    registry: PersonaRegistry,
    sessions: dict[str, _ReplSessionState],
    current_session: str,
) -> tuple[int, str]:
    """Handle slash commands and return (exit_code, current_session)."""
    try:
        tokens = shlex.split(slash_text)
    except ValueError as exc:
        _console.print(f"[red]Parse error:[/red] {exc}")
        return 2, current_session
    if not tokens:
        _console.print("[yellow]Empty slash command.[/yellow]")
        return 2, current_session

    if tokens[0] == "persona":
        subcommands = {"list", "set", "create", "pick", "delete", "export", "import"}
        if len(tokens) == 2 and tokens[1] not in subcommands:
            tokens = ["persona", "set", tokens[1]]
        if len(tokens) == 1 or (len(tokens) > 1 and tokens[1] == "list"):
            _print_persona_list(registry, sessions, current_session)
            return 0, current_session
        if tokens[1] == "set":
            if len(tokens) < 3:
                _console.print("[red]Usage:[/red] /persona set <name>")
                return 2, current_session
            target = tokens[2]
            persona = registry.get_persona(target)
            if persona is None:
                _console.print(f"[red]Persona not found:[/red] {target}")
                return 1, current_session
            sessions[current_session].active_persona = persona.name
            registry.set_active_persona_name(persona.name)
            _console.print(f"[green]Active persona:[/green] {persona.name}")
            return 0, current_session
        if tokens[1] == "create":
            try:
                persona = _parse_persona_create(tokens)
            except ValueError as exc:
                _console.print(f"[red]{exc}[/red]")
                return 2, current_session
            registry.save_persona(persona)
            _console.print(f"[green]Created persona:[/green] {persona.name}")
            return 0, current_session
        if tokens[1] == "pick":
            personas = registry.list_personas()
            if not personas:
                _console.print("[yellow]No personas available.[/yellow]")
                return 1, current_session
            if len(tokens) > 2:
                query = tokens[2]
                names = [persona.name for persona in personas]
                narrowed = _find_reference_candidates(query, names, limit=len(names))
                personas = [persona for persona in personas if persona.name in narrowed]
                if not personas:
                    _console.print(f"[yellow]No personas matched '{query}'.[/yellow]")
                    return 1, current_session
            _console.print("[bold]Persona picker[/bold]")
            for idx, persona in enumerate(personas, start=1):
                _console.print(f" {idx}. {persona.name} ({persona.actor})")
            selection = input("Select persona number: ").strip()
            try:
                selected_idx = int(selection)
            except ValueError:
                _console.print("[red]Invalid selection.[/red]")
                return 2, current_session
            if selected_idx < 1 or selected_idx > len(personas):
                _console.print("[red]Selection out of range.[/red]")
                return 2, current_session
            selected = personas[selected_idx - 1]
            sessions[current_session].active_persona = selected.name
            registry.set_active_persona_name(selected.name)
            _console.print(f"[green]Active persona:[/green] {selected.name}")
            return 0, current_session
        if tokens[1] == "delete":
            if len(tokens) < 3:
                _console.print("[red]Usage:[/red] /persona delete <name>")
                return 2, current_session
            target = tokens[2]
            if target == "default":
                _console.print("[red]Cannot delete default persona.[/red]")
                return 1, current_session
            deleted = registry.delete_persona(target)
            if not deleted:
                _console.print(f"[red]Persona not found:[/red] {target}")
                return 1, current_session
            reset_any = False
            for state in sessions.values():
                if state.active_persona == target:
                    state.active_persona = "default"
                    reset_any = True
            if reset_any:
                registry.set_active_persona_name("default")
            _console.print(f"[green]Deleted persona:[/green] {target}")
            return 0, current_session
        if tokens[1] == "export":
            if len(tokens) < 3:
                _console.print("[red]Usage:[/red] /persona export <name> [path]")
                return 2, current_session
            target = tokens[2]
            persona = registry.get_persona(target)
            if persona is None:
                _console.print(f"[red]Persona not found:[/red] {target}")
                return 1, current_session
            output_path = Path(tokens[3]) if len(tokens) > 3 else Path(f"{target}.yaml")
            try:
                safe_output_path = registry.resolve_export_path(output_path)
            except ValueError as exc:
                _console.print(f"[red]{exc}[/red]")
                return 1, current_session
            payload = persona.model_dump(mode="json", exclude_none=True)
            safe_output_path.write_text(
                yaml.safe_dump(payload, sort_keys=False, default_flow_style=False),
                encoding="utf-8",
            )
            _console.print(f"[green]Exported persona:[/green] {safe_output_path}")
            return 0, current_session
        if tokens[1] == "import":
            if len(tokens) < 3:
                _console.print("[red]Usage:[/red] /persona import <path>")
                return 2, current_session
            input_path = Path(tokens[2])
            try:
                safe_input_path = registry.resolve_import_path(input_path)
            except ValueError as exc:
                _console.print(f"[red]{exc}[/red]")
                return 1, current_session
            if not safe_input_path.exists():
                _console.print(f"[red]File not found:[/red] {safe_input_path}")
                return 1, current_session

            raw = yaml.safe_load(safe_input_path.read_text(encoding="utf-8")) or {}
            if not isinstance(raw, dict):
                _console.print("[red]Invalid persona YAML.[/red]")
                return 1, current_session
            try:
                persona = PersonaConfig.model_validate(raw)
            except Exception as exc:
                _console.print(f"[red]Persona validation failed:[/red] {exc}")
                return 1, current_session
            registry.save_persona(persona)
            _console.print(f"[green]Imported persona:[/green] {persona.name}")
            return 0, current_session
        _console.print(f"[red]Unknown persona command:[/red] {' '.join(tokens)}")
        return 2, current_session

    if tokens[0] == "session":
        if len(tokens) == 1 or (len(tokens) > 1 and tokens[1] == "list"):
            _console.print("[bold]REPL Sessions[/bold]")
            for name, state in sessions.items():
                marker = "*" if name == current_session else " "
                _console.print(f" {marker} {name} (persona={state.active_persona})")
            return 0, current_session
        if tokens[1] == "new":
            if len(tokens) < 3:
                _console.print("[red]Usage:[/red] /session new <name>")
                return 2, current_session
            new_name = tokens[2]
            if new_name in sessions:
                _console.print(f"[red]Session already exists:[/red] {new_name}")
                return 1, current_session
            sessions[new_name] = _ReplSessionState(
                name=new_name,
                active_persona=sessions[current_session].active_persona,
            )
            _console.print(f"[green]Created session:[/green] {new_name}")
            return 0, current_session
        if tokens[1] == "switch":
            if len(tokens) < 3:
                _console.print("[red]Usage:[/red] /session switch <name>")
                return 2, current_session
            target_session = tokens[2]
            if target_session not in sessions:
                _console.print(f"[red]Session not found:[/red] {target_session}")
                return 1, current_session
            _console.print(f"[green]Switched session:[/green] {target_session}")
            return 0, target_session
        _console.print(f"[red]Unknown session command:[/red] {' '.join(tokens)}")
        return 2, current_session

    _console.print(f"[red]Unknown slash command:[/red] /{' '.join(tokens)}")
    return 2, current_session


def dispatch_command(argv: Sequence[str]) -> int:
    """Invoke the main Typer CLI with the given argument vector.

    Returns the integer exit code (0 for success).
    """
    from cleveragents.cli.main import _register_subcommands
    from cleveragents.cli.main import app as root_app

    _register_subcommands()
    try:
        root_app(list(argv), standalone_mode=False)
    except SystemExit as exc:
        return int(exc.code) if exc.code is not None else 0
    except KeyboardInterrupt:
        return 130
    except Exception as exc:
        _console.print(f"[red]Error:[/red] {exc}")
        return 1
    return 0


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def run_repl(
    *,
    no_history: bool = False,
    history_path: Path = _DEFAULT_HISTORY_PATH,
) -> int:
    """Run the interactive REPL loop.

    Parameters
    ----------
    no_history:
        When ``True`` readline history is neither loaded nor saved.
    history_path:
        Filesystem path used for persisting history between sessions.

    Returns
    -------
    int
        Exit code (always 0 for a clean exit).
    """
    use_history = not no_history

    if use_history:
        _setup_history(history_path)
    _setup_completer()
    persona_registry = PersonaRegistry()
    default_persona = persona_registry.ensure_default_persona()
    active_persona = persona_registry.get_active_persona_name() or default_persona.name
    sessions: dict[str, _ReplSessionState] = {
        "default": _ReplSessionState(name="default", active_persona=active_persona)
    }
    current_session = "default"

    _console.print(
        "[bold green]CleverAgents REPL[/bold green] — "
        "type [bold]:help[/bold] for commands, "
        "[bold]:exit[/bold] to quit.\n"
    )

    last_command: str = ""
    exit_code = 0

    while True:
        prompt = _compose_prompt(_get_prompt_context(), sessions[current_session])
        try:
            line = input(prompt)
        except EOFError:
            # Ctrl+D → clean exit
            _console.print("")
            break
        except KeyboardInterrupt:
            # Ctrl+C → cancel current line, continue
            _console.print("")
            continue

        line = line.strip()
        if not line:
            continue

        # Multi-line continuation
        if line.endswith(_MULTILINE_CONTINUATION):
            line = _read_multiline(line)

        # !! — repeat last command
        if line == _LAST_COMMAND_TOKEN:
            if not last_command:
                _console.print("[yellow]No previous command.[/yellow]")
                continue
            line = last_command
            _console.print(f"[dim]{line}[/dim]")

        # Built-in commands
        if line in (":exit", ":quit"):
            break
        if line == ":help":
            _print_repl_help()
            continue

        # Shell mode
        if line.startswith("!"):
            last_command = line
            exit_code = _run_shell_command(line[1:].strip())
            continue

        # Slash command mode
        if line.startswith("/"):
            last_command = line
            exit_code, current_session = _handle_slash_command(
                line[1:],
                registry=persona_registry,
                sessions=sessions,
                current_session=current_session,
            )
            continue

        # Normal mode @ references
        reference_catalog = _build_reference_catalog() if "@" in line else None
        expanded_line = _expand_references(line, catalog=reference_catalog)
        if expanded_line != line:
            _console.print(f"[dim]{expanded_line}[/dim]")
        elif "@" in line:
            hints = _reference_suggestions(line, catalog=reference_catalog)
            if hints:
                _console.print("[dim]Reference suggestions:[/dim] " + ", ".join(hints))
        line = expanded_line

        # Tokenise and dispatch
        try:
            argv = shlex.split(line)
        except ValueError as exc:
            _console.print(f"[red]Parse error:[/red] {exc}")
            continue

        if not argv:
            continue

        last_command = line
        exit_code = dispatch_command(argv)

    if use_history:
        _save_history(history_path)

    return exit_code


# ---------------------------------------------------------------------------
# Typer entry-point (registered as ``agents repl`` in main.py)
# ---------------------------------------------------------------------------

_repl_app = typer.Typer(help="Interactive REPL for CleverAgents.")


@_repl_app.callback(invoke_without_command=True)
def repl_callback(
    no_history: Annotated[
        bool,
        typer.Option(
            "--no-history",
            help="Disable readline history loading and saving.",
        ),
    ] = False,
    history_path: Annotated[
        Path,
        typer.Option(
            "--history-path",
            help="Custom path for the history file.",
        ),
    ] = _DEFAULT_HISTORY_PATH,
) -> None:
    """Start an interactive REPL session."""
    # Only run the REPL when invoked directly (not during help generation)
    if sys.stdout.isatty() or os.environ.get("CLEVERAGENTS_FORCE_REPL"):
        raise SystemExit(run_repl(no_history=no_history, history_path=history_path))
