"""
⚛️ AgentNova — CLI
Command-line interface for AgentNova.

Status: Alpha

Written by VTSTech — https://www.vts-tech.org
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import time
from pathlib import Path
from typing import Optional

from .agent import Agent
from .agent_mode import AgentMode
from .orchestrator import Orchestrator, AgentCard
from .tools import make_builtin_registry
from .backends import get_backend, get_default_backend, OllamaBackend
from .config import get_config, AGENTNOVA_BACKEND, OLLAMA_BASE_URL, BITNET_BASE_URL
from .model_discovery import match_models, get_models
from .shared_args import add_agent_args
from .colors import (
    Color, c, dim, bold, cyan, green, yellow, red, magenta, blue,
    bright_cyan, bright_green, bright_yellow, bright_magenta, bright_red,
    visible_len, pad_colored, is_color_enabled
)


# ============================================================================
# ASCII Banner
# ============================================================================

BANNER_ATOM_BRAILLE = """
\x1b[96m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠿⠛⢷⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\x1b[0m  \x1b[95;1mAgentNova\x1b[0m
\x1b[96m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡿⠃⠀⠀⠀⠙⣷⡀⠀⠀⢀⣀⠀⠀⠀⠀⠀\x1b[0m  \x1b[2mAutonomous Agents with Local LLMs\x1b[0m
\x1b[96m⠀⠀⠀⣀⣀⣀⣀⣀⠀⢀⣿⠃⠀⠀⠀⠀⠀⠸⣷⠀⣰⣿⣿⣿⣆⣀⠀⠀\x1b[0m
\x1b[96m⠀⣰⡿⠛⠉⠉⠉⠛⠻⣿⣷⣤⣀⠀⠀⠀⣀⣤⣿⡿⠿⣿⣿⣿⠏⠛⣷⡄\x1b[0m  \x1b[2mStatus:\x1b[0m \x1b[33mAlpha\x1b[0m
\x1b[96m⠀⣿⣇⣀⠀⠀⠀⠀⢀⣿⠅⠉⢛⣿⣶⣿⡋⠉⠘⣿⠀⠀⠉⠀⠀⠀⢸⡇\x1b[0m  \x1b[2mhttps://www.vts-tech.org\x1b[0m
\x1b[96m⢸⣿⣿⣿⣧⠀⠀⠀⢸⣟⣠⣾⠟⠋⠀⠙⠻⣶⣄⣿⡄⠀⠀⠀⠀⢀⣾⠃\x1b[0m
\x1b[96m⠘⢿⣿⣿⣏⠀⠀⢀⣼⡿⠋⣠⣶⣿⣿⣿⣦⡌⠙⣿⣧⡀⠀⠀⣠⣾⠋⠀\x1b[0m
\x1b[96m⠀⠀⠀⠈⢻⣦⣴⠟⣹⡇⢰⣿⣿⣿⣿⣿⣿⣿⡄⢸⡟⠻⣦⣴⠟⠁⠀⠀\x1b[0m
\x1b[96m⠀⠀⠀⢀⣴⡟⢿⣦⣿⡗⠸⣿⣿⣿⣿⣿⣿⣿⠃⢸⣇⣴⡿⢿⣦⡀⠀⠀\x1b[0m
\x1b[96m⠀⠀⢠⣾⠋⠀⠀⠙⢿⣧⣄⠙⢿⣿⣿⣿⠿⠃⣠⣿⡟⠁⠀⠀⠙⣷⡄⠀\x1b[0m
\x1b[96m⠀⢠⣿⠁⠀⠀⠀⠀⢸⣯⠛⢷⣦⣀⠀⣠⣴⡿⠋⣿⠃⠀⠀⠀⠀⠘⣿⡄\x1b[0m
\x1b[96m⠀⣾⡇⠀⠀⠀⠀⠀⠈⣿⠀⢀⣩⣿⣿⣿⣅⡀⢠⣿⠀⠀⠀⠀⠀⠀⢸⡇\x1b[0m
\x1b[96m⠀⠹⣷⣄⣀⣀⣀⣠⣤⣿⡿⠟⠋⠁⠀⠈⠙⠻⣿⣷⣤⣄⣀⣀⣀⣠⣾⠇\x1b[0m
\x1b[96m⠀⠀⠈⠉⠛⠛⠛⠉⠉⠘⣿⡀⠀⠀⠀⢀⣴⣶⣿⣄⠈⠉⠙⠛⠛⠋⠁⠀\x1b[0m
\x1b[96m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣷⡀⠀⠀⢸⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀\x1b[0m
\x1b[96m⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⠿⣦⣤⣶⠟⠛⠛⠁⠀⠀⠀⠀⠀⠀⠀⠀\x1b[0m
"""

BANNER_ATOM_PLAIN = """
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠿⠛⢷⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀  AgentNova
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡿⠃⠀⠀⠀⠙⣷⡀⠀⠀⢀⣀⠀⠀⠀⠀⠀  Autonomous Agents with Local LLMs
⠀⠀⠀⣀⣀⣀⣀⣀⠀⢀⣿⠃⠀⠀⠀⠀⠀⠸⣷⠀⣰⣿⣿⣿⣆⣀⠀⠀
⠀⣰⡿⠛⠉⠉⠉⠛⠻⣿⣷⣤⣀⠀⠀⠀⣀⣤⣿⡿⠿⣿⣿⣿⠏⠛⣷⡄  Status: Alpha
⠀⣿⣇⣀⠀⠀⠀⠀⢀⣿⠅⠉⢛⣿⣶⣿⡋⠉⠘⣿⠀⠀⠉⠀⠀⠀⢸⡇  https://www.vts-tech.org
⢸⣿⣿⣿⣧⠀⠀⠀⢸⣟⣠⣾⠟⠋⠀⠙⠻⣶⣄⣿⡄⠀⠀⠀⠀⢀⣾⠃
⠘⢿⣿⣿⣏⠀⠀⢀⣼⡿⠋⣠⣶⣿⣿⣿⣦⡌⠙⣿⣧⡀⠀⠀⣠⣾⠋⠀
⠀⠀⠀⠈⢻⣦⣴⠟⣹⡇⢰⣿⣿⣿⣿⣿⣿⣿⡄⢸⡟⠻⣦⣴⠟⠁⠀⠀
⠀⠀⠀⢀⣴⡟⢿⣦⣿⡗⠸⣿⣿⣿⣿⣿⣿⣿⠃⢸⣇⣴⡿⢿⣦⡀⠀⠀
⠀⠀⢠⣾⠋⠀⠀⠙⢿⣧⣄⠙⢿⣿⣿⣿⠿⠃⣠⣿⡟⠁⠀⠀⠙⣷⡄⠀
⠀⢠⣿⠁⠀⠀⠀⠀⢸⣯⠛⢷⣦⣀⠀⣠⣴⡿⠋⣿⠃⠀⠀⠀⠀⠘⣿⡄
⠀⣾⡇⠀⠀⠀⠀⠀⠈⣿⠀⢀⣩⣿⣿⣿⣅⡀⢠⣿⠀⠀⠀⠀⠀⠀⢸⡇
⠀⠹⣷⣄⣀⣀⣀⣠⣤⣿⡿⠟⠋⠁⠀⠈⠙⠻⣿⣷⣤⣄⣀⣀⣀⣠⣾⠇
⠀⠀⠈⠉⠛⠛⠛⠉⠉⠘⣿⡀⠀⠀⠀⢀⣴⣶⣿⣄⠈⠉⠙⠛⠛⠋⠁⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣷⡀⠀⠀⢸⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⠿⣦⣤⣶⠟⠛⠛⠁⠀⠀⠀⠀⠀⠀⠀⠀
"""


def print_banner() -> None:
    """Print the AgentNova ASCII banner."""
    from . import __version__, __status__
    # Convert 0.3.3 to R03.3 format for display
    parts = __version__.split('.')
    display_version = f"R{int(parts[1]):02d}.{parts[2]}" if len(parts) >= 2 else __version__    
    version_str = f"{display_version} [{__status__}]"
    if is_color_enabled():
        # Replace ANSI-colored "Status: Alpha" with version
        banner = BANNER_ATOM_BRAILLE.replace("\x1b[2mStatus:\x1b[0m \x1b[33mAlpha\x1b[0m", f"\x1b[2m{version_str}\x1b[0m")
        print(banner)
    else:
        banner = BANNER_ATOM_PLAIN.replace("Status: Alpha", version_str)
        print(banner)


# ============================================================================
# Model Matching
# ============================================================================

def resolve_model_pattern(
    pattern: str,
    backend_name: str = "ollama",
    allow_multiple: bool = False,
) -> str | list[str]:
    """
    Resolve a model pattern to actual model name(s).
    
    Shows helpful output when multiple models match.
    
    Parameters
    ----------
    pattern : str
        Model name or pattern (e.g., "qwen", "g", ":0.5b")
    backend_name : str
        Backend to use for model discovery
    allow_multiple : bool
        If True, return all matches; if False, return first match
    
    Returns
    -------
    str or list[str]
        Resolved model name(s), or empty list if no matches
    """
    backend = get_backend(backend_name)
    matches = match_models(pattern, backend=backend)
    
    if not matches:
        print(f"{red('Error:')} No models found matching '{pattern}'")
        available = get_models(client=backend)
        if available:
            print(f"\n{dim('Available models:')}")
            for m in sorted(available)[:10]:
                print(f"  {cyan(m)}")
            if len(available) > 10:
                print(f"  {dim(f'... and {len(available) - 10} more')}")
        return [] if allow_multiple else ""
    
    # If allow_multiple, always return a list
    if allow_multiple:
        return matches
    
    # Single match - return as string
    if len(matches) == 1:
        return matches[0]
    
    # Multiple matches - show and use first
    print(f"{yellow('Multiple models match')} '{pattern}':")
    for i, m in enumerate(matches[:5]):
        marker = green("→") if i == 0 else " "
        print(f"  {marker} {cyan(m)}")
    if len(matches) > 5:
        print(f"    {dim(f'... and {len(matches) - 5} more')}")
    print(f"{dim('Using first match:')} {cyan(matches[0])}")
    return matches[0]


# ============================================================================
# CLI Commands
# ============================================================================


def create_parser() -> argparse.ArgumentParser:
    """Create the argument parser."""
    parser = argparse.ArgumentParser(
        prog="agentnova",
        description="⚛️ AgentNova - Autonomous agents with local LLMs (Alpha)",
    )

    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # Agent command
    agent_parser = subparsers.add_parser("agent", help="Autonomous agent mode")
    add_agent_args(agent_parser, tools_default="calculator,shell,write_file")

    # Chat command
    chat_parser = subparsers.add_parser("chat", help="Interactive chat mode")
    add_agent_args(chat_parser, tools_default="")

    # Config command
    config_parser = subparsers.add_parser("config", help="Show current configuration")
    config_parser.add_argument("--urls", action="store_true", help="Show only URLs")

    # Models command
    models_parser = subparsers.add_parser("models", help="List available models")
    models_parser.add_argument("--backend", choices=["ollama", "bitnet", "llama-server"], default=None, help="Backend to use")
    models_parser.add_argument("--api", choices=["openre", "openai"], default=None, dest="api_mode",
                           help="API mode for tool support testing (default: test both)")
    models_parser.add_argument("--tool-support", action="store_true", help="Test tool calling support (skips already-cached models)")
    models_parser.add_argument("--no-cache", action="store_true", help="Ignore cached results and re-test all models")
    models_parser.add_argument("--acp", action="store_true", help="Enable ACP logging to Agent Control Panel")
    models_parser.add_argument("--acp-url", default=None, help="ACP server URL (default: from config)")

    # Modelfile command
    modelfile_parser = subparsers.add_parser("modelfile", help="Show model's Modelfile info")
    modelfile_parser.add_argument("-m", "--model", default=None, help="Model to inspect")
    modelfile_parser.add_argument("--backend", choices=["ollama", "bitnet", "llama-server"], default=None, help="Backend to use")

    # Run command
    run_parser = subparsers.add_parser("run", help="Run a single prompt")
    run_parser.add_argument("prompt", help="The prompt to process")
    add_agent_args(run_parser, tools_default="calculator")
    run_parser.add_argument("--stream", action="store_true", help="Stream output")
    run_parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
    run_parser.add_argument("-q", "--quiet", action="store_true", help="Suppress header and summary")

    # Sessions command
    sessions_parser = subparsers.add_parser("sessions", help="List and manage saved sessions")
    sessions_parser.add_argument(
        "--delete",
        metavar="SESSION_ID",
        default=None,
        help="Delete a specific session by ID",
    )

    # Skills command
    subparsers.add_parser("skills", help="List available skills")
    
    # Soul command
    soul_parser = subparsers.add_parser("soul", help="Inspect a Soul Spec package")
    soul_parser.add_argument("path", help="Path to soul package directory or soul.json")
    soul_parser.add_argument("--level", type=int, default=2, choices=[1, 2, 3],
                            help="Progressive disclosure level (1=quick, 2=full, 3=deep)")
    soul_parser.add_argument("--validate", action="store_true", help="Run validation checks")
    soul_parser.add_argument("--prompt", action="store_true", help="Show generated system prompt")

    # Test command
    test_parser = subparsers.add_parser("test", help="Run diagnostic tests")
    test_parser.add_argument("test_id", nargs="?", default="all", 
                             help="Test to run: 00, 01, 02, ... 11, or 'all' (default: all)")
    test_parser.add_argument("-m", "--model", default=None, 
                             help="Model to test (supports patterns: 'qwen', 'g', ':0.5b')")
    test_parser.add_argument("--backend", choices=["ollama", "bitnet", "llama-server"], default=None, help="Backend to use")
    test_parser.add_argument("--api", choices=["openre", "openai"], default="openre", dest="api_mode",
                           help="API mode: 'openre' (OpenResponses) or 'openai' (Chat-Completions)")
    test_parser.add_argument("--debug", action="store_true", help="Enable debug output")
    test_parser.add_argument("--list", action="store_true", help="List available tests")
    test_parser.add_argument("--acp", action="store_true", help="Enable ACP logging to Agent Control Panel")
    test_parser.add_argument("--acp-url", default=None, help="ACP server URL (default: http://localhost:8766)")
    test_parser.add_argument("--use-mf-sys", action="store_true", dest="use_modelfile_system",
                             help="Use the model's Modelfile system prompt instead of custom test prompts")
    test_parser.add_argument("--num-ctx", type=int, default=None, dest="num_ctx",
                           help="Context window size in tokens (Ollama default is 2048)")
    test_parser.add_argument("--num-predict", type=int, default=None, dest="num_predict",
                           help="Maximum tokens to generate (default: model-specific)")
    test_parser.add_argument("--temp", "--temperature", type=float, default=None, dest="temperature",
                           help="Sampling temperature 0.0-2.0 (default: model-specific)")
    test_parser.add_argument("--top-p", type=float, default=None, dest="top_p",
                           help="Nucleus sampling probability 0.0-1.0 (default: model-specific)")
    test_parser.add_argument("--timeout", type=int, default=None,
                           help="Request timeout in seconds (default: 120)")
    test_parser.add_argument("--warmup", action="store_true",
                           help="Send warmup request before testing (avoids cold start timeout)")
    test_parser.add_argument("--force-react", action="store_true", help="Force ReAct mode for tool calling")
    test_parser.add_argument("--soul", default=None, help="Path to Soul Spec package (disabled by default)")
    test_parser.add_argument("--soul-level", type=int, default=2, choices=[1, 2, 3],
                           help="Soul progressive disclosure level (1=quick, 2=full, 3=deep)")
    test_parser.add_argument("--tools-only", action="store_true", dest="tools_only",
                           help="Only run Phase 1 (direct tool tests, no model)")
    test_parser.add_argument("--model-only", action="store_true", dest="model_only",
                           help="Only run Phase 2 (model tool calling tests)")

    # Turbo command
    turbo_parser = subparsers.add_parser("turbo", help="TurboQuant server management (start/stop/list Ollama models)")
    turbo_sub = turbo_parser.add_subparsers(dest="turbo_command", help="TurboQuant subcommand")

    # turbo list
    turbo_list_parser = turbo_sub.add_parser("list", help="List Ollama models available for TurboQuant")
    turbo_list_parser.add_argument("--all", action="store_true", help="Show all models, including missing blobs")
    turbo_list_parser.add_argument("--ollama-dir", default=None, help="Override Ollama models directory")

    # turbo start
    turbo_start_parser = turbo_sub.add_parser("start", help="Start TurboQuant server with an Ollama model")
    turbo_start_parser.add_argument("model", help="Ollama model name (e.g. qwen2.5:7b) or path to GGUF file")
    turbo_start_parser.add_argument("--server", default=None, help="Path to llama-server binary (env: TURBOQUANT_SERVER_PATH)")
    turbo_start_parser.add_argument("--port", type=int, default=None, help=f"Server port (default: {os.environ.get('TURBOQUANT_PORT', '8764')})")
    turbo_start_parser.add_argument("--ctx", type=int, default=None, help=f"Context window (default: {os.environ.get('TURBOQUANT_CTX', '8192')})")
    turbo_start_parser.add_argument("--turbo-k", default=None, choices=["q8_0", "q4_0", "turbo2", "turbo3", "turbo4", "f16"], help="K cache type (default: auto-detected)")
    turbo_start_parser.add_argument("--turbo-v", default=None, choices=["q8_0", "q4_0", "turbo2", "turbo3", "turbo4", "f16"], help="V cache type (default: auto-detected)")
    turbo_start_parser.add_argument("--flash-attn", action="store_true", help="Enable flash attention (-fa)")
    turbo_start_parser.add_argument("--sparsity", type=float, default=0.0, help="Sparse V decoding threshold (0.0=off)")
    turbo_start_parser.add_argument("--threads", type=int, default=0, help="CPU thread count (0=auto)")
    turbo_start_parser.add_argument("--no-wait", action="store_true", help="Don't wait for server to be ready")
    turbo_start_parser.add_argument("--timeout", type=int, default=120, help="Max seconds to wait for readiness (default: 120)")
    turbo_start_parser.add_argument("--", dest="extra_args", nargs="*", help="Extra arguments to pass to llama-server")

    # turbo stop
    turbo_stop_parser = turbo_sub.add_parser("stop", help="Stop the running TurboQuant server")
    turbo_stop_parser.add_argument("--force", action="store_true", help="Force kill (SIGKILL)")

    # turbo status
    turbo_sub.add_parser("status", help="Show TurboQuant server status")

    # Tools command
    subparsers.add_parser("tools", help="List available tools")

    # Update command
    subparsers.add_parser("update", help="Update AgentNova to the latest version from GitHub")

    # Version command
    subparsers.add_parser("version", help="Show version information")

    return parser


def _make_confirm_callback(args: argparse.Namespace):
    """
    Build a confirm_dangerous callback from CLI --confirm flag.
    
    When --confirm is set, the user is prompted (y/n) before any
    dangerous tool (shell, write_file, edit_file) executes.
    Returns None if --confirm is not set (no confirmation needed).
    """
    if not getattr(args, 'confirm_dangerous', False):
        return None

    from .colors import yellow, dim, green, red

    def _confirm(tool_name: str, args_dict: dict) -> bool:
        # Format the args for display
        arg_str = "  ".join(f"{k}={v}" for k, v in args_dict.items())
        # Truncate very long values (e.g. file content)
        if len(arg_str) > 200:
            arg_str = arg_str[:200] + "..."
        print(f"\n{yellow('⚠')}  Dangerous tool: {yellow(tool_name)}")
        print(f"{dim('  ' + arg_str)}")
        try:
            choice = input(f"  {dim('Execute?')} [y/N] ").strip().lower()
        except (EOFError, KeyboardInterrupt):
            print(f"  {red('Blocked.')}")
            return False
        if choice == "y":
            print(f"  {green('Allowed.')}")
            return True
        else:
            print(f"  {red('Blocked.')}")
            return False

    return _confirm


def _init_acp(args: argparse.Namespace, config, agent_name: str = "AgentNova") -> tuple:
    """
    Initialize ACP plugin if requested.
    
    Returns:
        tuple: (acp_plugin or None, should_stop bool)
    """
    if not getattr(args, 'acp', False):
        return None, False
    
    try:
        from .acp_plugin import ACPPlugin
        acp_url = getattr(args, 'acp_url', None) or config.acp_base_url
        acp = ACPPlugin(
            base_url=acp_url,
            agent_name=agent_name,
            model_name=getattr(args, 'model', None) or config.default_model,
            debug=getattr(args, 'debug', False),
        )
        # Bootstrap ACP connection
        bootstrap_result = acp.bootstrap()
        if bootstrap_result.get("stop_flag"):
            print(f"{red('Error:')} ACP STOP flag is set: {bootstrap_result.get('warnings')}")
            return None, True
        return acp, False
    except ImportError:
        print(f"{yellow('Warning:')} ACP plugin not available, continuing without ACP logging")
        return None, False
    except Exception as e:
        print(f"{yellow('Warning:')} Failed to connect to ACP: {e}")
        return None, False


def _load_skills_prompt(args: argparse.Namespace) -> str | None:
    """
    Load skills specified via --skills flag and return the system prompt addition.
    
    Uses lazy import to avoid circular dependencies.
    
    Args:
        args: Parsed CLI arguments (must have 'skills' attribute)
        
    Returns:
        System prompt addition string, or None if no skills specified
    """
    skills_str = getattr(args, 'skills', None)
    if not skills_str:
        return None
    
    try:
        from .skills import SkillLoader, SkillRegistry
    except ImportError:
        print(f"{yellow('Warning:')} Skills module not available, skipping --skills")
        return None
    
    loader = SkillLoader()
    registry = SkillRegistry()
    skill_names = [s.strip() for s in skills_str.split(",") if s.strip()]
    
    loaded = []
    failed = []
    for name in skill_names:
        try:
            skill = loader.load(name)
            registry.add(skill)
            loaded.append(name)
        except FileNotFoundError:
            failed.append(name)
            print(f"{yellow('Warning:')} Skill '{name}' not found (run 'agentnova skills' to list available)")
        except Exception as e:
            failed.append(name)
            print(f"{yellow('Warning:')} Failed to load skill '{name}': {e}")
    
    if loaded:
        prompt = registry.to_system_prompt_addition()
        if prompt:
            return prompt
    
    return None


def _build_agent(args: argparse.Namespace, config) -> Agent:
    """Build an Agent from parsed CLI args and config.

    Centralises the ~20-parameter Agent construction that was previously
    duplicated in cmd_run, cmd_chat, and cmd_agent.  Every new CLI flag
    only needs to be added here (and in add_agent_args).
    """
    backend_name = args.backend or config.backend
    api_mode = getattr(args, "api_mode", "openre")
    timeout = getattr(args, "timeout", None)

    # When --backend bitnet is used without --model, discover the actual
    # model name from the server via list_models() (/props endpoint).
    # This ensures correct family config resolution (stop tokens, prompt
    # format) instead of falling back to generic "bitnet" with no family.
    backend = None
    if args.model:
        model = args.model
    elif backend_name == "bitnet":
        backend = get_backend(backend_name, timeout=timeout, api_mode=api_mode)
        discovered = backend.list_models()
        if discovered and discovered[0].get("name") and discovered[0]["name"] != "bitnet":
            model = discovered[0]["name"]
            if os.environ.get("AGENTNOVA_DEBUG"):
                print(f"  [bitnet] Discovered model: {model}")
        else:
            model = "bitnet"
    else:
        model = config.default_model

    if backend is None:
        backend = get_backend(backend_name, timeout=timeout, api_mode=api_mode)

    # Build tools
    if args.tools:
        all_tools = make_builtin_registry()
        tool_names = [t.strip() for t in args.tools.split(",")]
        tools = all_tools.subset(tool_names)
    else:
        tools = None

    # Resolve --response-format CLI arg to Agent parameter
    # "json" → {"type": "json_object"}, "text" → None (default)
    cli_rf = getattr(args, "response_format", "text")
    if cli_rf == "json":
        response_format = {"type": "json_object"}
    else:
        response_format = None

    # Load skills if requested
    skills_prompt = _load_skills_prompt(args)

    return Agent(
        model=model,
        tools=tools,
        backend=backend,
        force_react=args.force_react,
        debug=args.debug,
        soul=getattr(args, "soul", None),
        soul_level=getattr(args, "soul_level", 2),
        num_ctx=(
            getattr(args, "num_ctx", None)
            if getattr(args, "num_ctx", None) is not None
            else config.num_ctx
        ),
        temperature=getattr(args, "temperature", None),
        top_p=getattr(args, "top_p", None),
        num_predict=getattr(args, "num_predict", None),
        skills_prompt=skills_prompt,
        retry_on_error=not getattr(args, "no_retry", False),
        max_tool_retries=getattr(args, "max_tool_retries", None) or config.max_tool_retries,
        confirm_dangerous=_make_confirm_callback(args),
        response_format=response_format,
        session_id=getattr(args, "session", None),
    )


def _print_session_header(agent: Agent, args: argparse.Namespace, config, label: str) -> None:
    """Print the common header shown by chat and agent modes."""
    model = agent.model
    backend_name = args.backend or config.backend
    api_mode = getattr(args, "api_mode", "openre")
    timeout = getattr(args, "timeout", None)

    print_banner()
    print(f"{bright_magenta(label)} — {cyan(model)}")
    print(f"{dim('Backend:')} {backend_name} ({dim(agent.backend.base_url)})")
    print(f"{dim('API Mode:')} {yellow(api_mode)}")
    if agent.soul:
        print(f"{dim('Soul:')} {green(agent.soul.display_name)} v{agent.soul.version}")
    if agent.num_ctx:
        ctx_display = f"{agent.num_ctx // 1024}K" if agent.num_ctx >= 1024 else str(agent.num_ctx)
        print(f"{dim('Context:')} {yellow(ctx_display)}")
    if timeout:
        print(f"{dim('Timeout:')} {yellow(str(timeout) + 's')}")
    acp = getattr(args, '_acp', None)
    if acp:
        print(f"{dim('ACP:')} {green('✓ Connected')} ({acp.base_url})")
    if agent._response_format:
        print(f"{dim('Output:')} {yellow('JSON mode')}")
    if getattr(agent, '_is_persistent', False) and hasattr(agent.memory, 'session_id'):
        print(f"{dim('Session:')} {green(agent.memory.session_id)}")
    print(f"{dim('Status:')} {yellow('Alpha')}")


def _print_run_header(agent: Agent, args: argparse.Namespace, config) -> None:
    """Print a concise info line for the run command."""
    backend_name = args.backend or config.backend
    api_mode = getattr(args, "api_mode", "openre")

    # Resolve effective generation params
    eff_temp = agent._temperature if agent._temperature is not None else agent.model_config.default_temperature
    eff_top_p = agent._top_p if agent._top_p is not None else agent.model_config.default_top_p
    eff_max_tokens = agent._num_predict if agent._num_predict is not None else agent.model_config.default_max_tokens

    parts = [
        f"{cyan(agent.model)}",
        f"{dim('backend=')}{backend_name}",
    ]

    if agent.num_ctx:
        ctx_str = f"{agent.num_ctx // 1024}K" if agent.num_ctx >= 1024 else str(agent.num_ctx)
        parts.append(f"{dim('ctx=')}{yellow(ctx_str)}")

    parts.append(f"{dim('api=')}{api_mode}")

    # Generation params
    params = []
    params.append(f"temp={eff_temp}")
    params.append(f"top_p={eff_top_p}")
    params.append(f"max_tokens={eff_max_tokens}")

    if agent.soul:
        parts.append(f"{dim('soul=')}{green(agent.soul.display_name)}")

    if agent.tools and len(agent.tools) > 0:
        tool_names = ", ".join(agent.tools.names())
        parts.append(f"{dim('tools=[')}{tool_names}{dim(']')}")

    # Print on two lines: main info + params
    line1 = "  ".join(parts)
    line2 = dim("params:") + " " + ", ".join(params)
    print(f"{dim('─') * 60}")
    print(f"  {line1}")
    print(f"  {line2}")
    print(f"{dim('─') * 60}")


def _print_run_summary(result, agent: Agent) -> None:
    """Print a summary line after run completes."""
    steps = result.iterations
    tokens = result.total_tokens
    ms = result.total_ms

    parts = []
    if steps > 0:
        parts.append(f"steps={steps}")
    if tokens > 0:
        parts.append(f"tokens={tokens}")
    if ms > 0:
        parts.append(f"{ms:.0f}ms")

    if parts:
        print(f"  {dim('Completed:')} {', '.join(parts)}")


def cmd_run(args: argparse.Namespace) -> int:
    """Execute the run command."""
    config = get_config()

    # Initialize ACP if requested
    acp, should_stop = _init_acp(args, config, "AgentNova-Run")
    if should_stop:
        return 1

    agent = _build_agent(args, config)

    # Print run info header
    if not getattr(args, 'quiet', False):
        _print_run_header(agent, args, config)

    result = agent.run(args.prompt, stream=getattr(args, "stream", False))
    print(result.final_answer)

    # Print run summary (unless quiet)
    if not getattr(args, 'quiet', False):
        _print_run_summary(result, agent)

    # Ensure persistent memory is flushed and closed
    if getattr(agent, '_is_persistent', False) and hasattr(agent.memory, 'close'):
        agent.memory.close()

    # Log to ACP
    if acp:
        acp.log_chat("assistant", result.final_answer)
        acp.a2a_unregister()

    return 0


def cmd_chat(args: argparse.Namespace) -> int:
    """Execute the chat command."""
    config = get_config()

    # Initialize ACP if requested
    acp, should_stop = _init_acp(args, config, "AgentNova-Chat")
    if should_stop:
        return 1

    agent = _build_agent(args, config)

    _print_session_header(agent, args, config, "Chat Mode")
    print("Type '/quit' to exit, '/help' for commands\n")

    while True:
        try:
            user_input = input(f"{dim('You:')} ").strip()
        except (EOFError, KeyboardInterrupt):
            # Ensure persistent memory is flushed and closed
            if getattr(agent, '_is_persistent', False) and hasattr(agent.memory, 'close'):
                agent.memory.close()
            print("\n👋 Goodbye!")
            break

        if not user_input:
            continue

        if user_input == "/quit":
            if acp:
                acp.log_chat("user", "/quit")
                acp.a2a_unregister()
            # Ensure persistent memory is flushed and closed
            if getattr(agent, '_is_persistent', False) and hasattr(agent.memory, 'close'):
                agent.memory.close()
            print(bright_cyan("👋 Goodbye!"))
            break

        if user_input == "/help":
            print(f"Commands: {cyan('/quit')}, {cyan('/help')}, {cyan('/clear')}, {cyan('/status')}")
            continue

        if user_input == "/clear":
            agent.clear_memory()
            print(green("Memory cleared."))
            continue

        if user_input == "/status":
            print(f"Model: {cyan(agent.model)}")
            print(f"Tool support: {green(agent._tool_support.value) if hasattr(agent._tool_support, 'value') else agent._tool_support}")
            print(f"Memory turns: {yellow(str(len(agent.memory)))}")
            continue

        # Log user message to ACP
        if acp:
            acp.log_chat("user", user_input)

        result = agent.run(user_input)
        print(f"\n{bright_green('Agent Nova')}: {result.final_answer}\n")

        # Log assistant response to ACP
        if acp:
            acp.log_chat("assistant", result.final_answer)

    return 0


def cmd_agent(args: argparse.Namespace) -> int:
    """Execute the agent command."""
    config = get_config()

    # Initialize ACP if requested
    acp, should_stop = _init_acp(args, config, "AgentNova-Agent")
    if should_stop:
        return 1

    agent = _build_agent(args, config)
    agent_mode = AgentMode(agent, verbose=True)

    _print_session_header(agent, args, config, "Agent Mode")
    print("Give the agent a goal to accomplish autonomously.")
    print(f"Commands: {cyan('/status')}, {cyan('/pause')}, {cyan('/resume')}, {cyan('/stop')}, {cyan('/quit')}\n")

    while True:
        try:
            user_input = input("Goal: ").strip()
        except (EOFError, KeyboardInterrupt):
            # Ensure persistent memory is flushed and closed
            if getattr(agent, '_is_persistent', False) and hasattr(agent.memory, 'close'):
                agent.memory.close()
            print("\n👋 Goodbye!")
            break

        if not user_input:
            continue

        if user_input.startswith("/"):
            cmd = user_input.split()[0]

            if cmd == "/quit":
                if acp:
                    acp.log_chat("user", "/quit")
                    acp.a2a_unregister()
                # Ensure persistent memory is flushed and closed
                if getattr(agent, '_is_persistent', False) and hasattr(agent.memory, 'close'):
                    agent.memory.close()
                print(bright_cyan("👋 Goodbye!"))
                break
            elif cmd == "/status":
                status = agent_mode.get_status()
                print(f"State: {cyan(status['state'])}")
                if "goal" in status and status["goal"]:
                    print(f"Goal: {bright_yellow(status['goal'])}")
                    if "progress_percent" in status:
                        pct = status['progress_percent']
                        pct_str = green(f"{pct:.0f}%") if pct >= 50 else yellow(f"{pct:.0f}%")
                        print(f"Progress: {pct_str}")
                continue
            elif cmd == "/pause":
                success, msg = agent_mode.pause()
                print(yellow(msg) if success else red(msg))
                continue
            elif cmd == "/resume":
                success, msg = agent_mode.resume()
                print(green(msg) if success else red(msg))
                continue
            elif cmd == "/stop":
                success, msg = agent_mode.stop(rollback=True)
                print(red(msg))
                continue

        # Log goal to ACP
        if acp:
            acp.log_chat("user", f"Goal: {user_input}")

        success, result = agent_mode.run_task(user_input)
        icon = bright_green("✅") if success else bright_red("❌")
        print(f"\n{icon} {result}\n")

        # Log result to ACP
        if acp:
            acp.log_chat("assistant", f"Result: {result}")

    return 0


def _get_cache_dir() -> Path:
    """Get the cache directory for AgentNova."""
    # Use platform-appropriate cache directory
    if os.name == "nt":
        # Windows: %LOCALAPPDATA%\agentnova\cache
        base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))
        cache_dir = Path(base) / "agentnova" / "cache"
    else:
        # Unix: ~/.cache/agentnova
        base = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
        cache_dir = Path(base) / "agentnova"
    
    cache_dir.mkdir(parents=True, exist_ok=True)
    return cache_dir


def _load_tool_cache() -> dict:
    """Load cached tool support results."""
    cache_file = _get_cache_dir() / "tool_support.json"
    if cache_file.exists():
        try:
            with open(cache_file, "r") as f:
                data = json.load(f)
                # Validate it's a dict
                if isinstance(data, dict):
                    return data
                # Corrupted - not a dict
                if os.environ.get("AGENTNOVA_DEBUG"):
                    print(f"Warning: Cache file corrupted (not a dict), ignoring", file=sys.stderr)
                return {}
        except json.JSONDecodeError as e:
            # Corrupted JSON - warn in debug mode
            if os.environ.get("AGENTNOVA_DEBUG"):
                print(f"Warning: Cache file has invalid JSON: {e}", file=sys.stderr)
            # Try to remove corrupted file
            try:
                cache_file.unlink()
            except Exception:
                pass
            return {}
        except IOError as e:
            if os.environ.get("AGENTNOVA_DEBUG"):
                print(f"Warning: Could not read cache file: {e}", file=sys.stderr)
    return {}


def _save_tool_cache(cache: dict) -> None:
    """Save tool support results to cache using atomic writes."""
    import tempfile
    
    cache_dir = _get_cache_dir()
    cache_file = cache_dir / "tool_support.json"
    
    try:
        # Write to a temp file first, then rename for atomicity
        # This prevents partial writes if the process is interrupted
        fd, temp_path = tempfile.mkstemp(
            dir=str(cache_dir),
            prefix=".tool_support_",
            suffix=".json.tmp"
        )
        
        try:
            with os.fdopen(fd, 'w') as f:
                json.dump(cache, f, indent=2)
                f.flush()
                os.fsync(f.fileno())
            
            # Atomic rename (on POSIX systems)
            os.replace(temp_path, str(cache_file))
        except Exception:
            # Clean up temp file on error
            if os.path.exists(temp_path):
                os.unlink(temp_path)
            raise
            
    except IOError as e:
        # Log the error but don't fail - cache is optional
        print(f"Warning: Could not save tool cache: {e}", file=sys.stderr)


def _tool_status(status: str) -> str:
    """Format a tool support status with color."""
    if status == "native":
        return bright_green("✓ native")
    elif status == "react":
        return yellow("○ react")
    elif status == "none":
        return red("✗ none")
    elif status == "error":
        return red("✗ error")
    return dim("? untested")


def cmd_models(args: argparse.Namespace) -> int:
    """Execute the models command."""
    from .core.tool_cache import cache_tool_support, get_cached_tool_support
    from .core.types import ToolSupportLevel, ApiMode
    
    config = get_config()
    backend_name = args.backend or config.backend
    api_mode_arg = getattr(args, 'api_mode', None)  # None = both modes

    # Which modes to test?  --api openai → only openai; otherwise both
    modes_to_test = [api_mode_arg] if api_mode_arg else ["openre", "openai"]
    # Always display both columns
    modes_display = ["openre", "openai"]

    backend = get_backend(backend_name, api_mode="openre")  # default for list_models etc.

    if not isinstance(backend, OllamaBackend):
        print(f"Models command works best with Ollama backend (current: {backend_name})")

    if not backend.is_running():
        print(f"❌ {backend_name.capitalize()} is not running at {backend.base_url}")
        if backend_name == "ollama":
            print("   Start with: ollama serve")
            print(f"   Or set OLLAMA_BASE_URL to your remote server")
        return 1

    models = backend.list_models()

    if not models:
        print("No models found.")
        if backend_name == "ollama":
            print("Pull one with: ollama pull qwen2.5:0.5b")
        return 0

    # Initialize ACP plugin if requested
    acp, _ = _init_acp(args, config, "AgentNova-Models")

    # Column widths
    NAME_W = 36
    SIZE_W = 8
    CTX_W = 12
    TOOLS_W = 12  # fits "✓ native"
    FAMILY_W = 12
    sep_len = 4 + NAME_W + SIZE_W + CTX_W + TOOLS_W + TOOLS_W + FAMILY_W + 10

    print()
    print(f"{bright_cyan('⚛ AgentNova')} - Available Models")
    print(dim(f"  Backend: {backend.base_url}"))
    if args.tool_support:
        mode_label = ", ".join(modes_to_test)
        print(dim(f"  Testing: {mode_label}"))
    if acp:
        print(f"  {dim('ACP:')} {green('✓ Connected')} ({acp.base_url})")
    print(dim("-" * sep_len))
    print(f"  {'Name':<{NAME_W}} {'Size':>{SIZE_W}}  {'Context':>{CTX_W}}  {'openre':>{TOOLS_W}}  {'openai':>{TOOLS_W}}  {'Family':<{FAMILY_W}}")
    print(dim("-" * sep_len))

    for m in models:
        name = m.get("name", "unknown")
        size = m.get("size", 0)
        size_gb = size / (1024**3) if size else 0
        family = m.get("details", {}).get("family", "unknown")
        
        # Get both runtime and max context
        runtime_ctx = backend.get_model_runtime_context(name)
        max_ctx = backend.get_model_max_context(name, family=family)
        
        # Format context size — show max context as plain int
        ctx_str = str(max_ctx)
        
        # Fixed columns
        name_col = pad_colored(cyan(name), NAME_W)
        size_col = f"{size_gb:>6.2f} GB"
        ctx_col = pad_colored(dim(ctx_str), CTX_W, 'right')

        if isinstance(backend, OllamaBackend):
            results = {}  # mode -> status string

            if args.tool_support:
                # Test each requested mode, skipping cached results
                modes_label = " + ".join(modes_to_test)
                print(f"  {dim('Testing:')} {cyan(name)} [{dim(modes_label)}]...", end="", flush=True)
                for mode in modes_to_test:
                    # Skip models that are already cached (unless --no-cache)
                    if not args.no_cache:
                        cached = get_cached_tool_support(name, api_mode=mode)
                        if cached is not None:
                            results[mode] = cached.value
                            continue
                    backend.api_mode = ApiMode(mode)
                    try:
                        support = backend.test_tool_support(name, family=family, force_test=True)
                        cache_tool_support(name, support, family=family, api_mode=mode)
                        results[mode] = support.value
                    except Exception as e:
                        cache_tool_support(name, ToolSupportLevel.NONE, family=family,
                                           error=str(e)[:100], api_mode=mode)
                        results[mode] = "error"

                # Fill untested display modes from cache
                for mode in modes_display:
                    if mode not in results and not args.no_cache:
                        cached = get_cached_tool_support(name, api_mode=mode)
                        if cached is not None:
                            results[mode] = cached.value

                # Overwrite the "Testing..." line with the final row
                tool_re = pad_colored(_tool_status(results.get("openre")), TOOLS_W, 'right')
                tool_ai = pad_colored(_tool_status(results.get("openai")), TOOLS_W, 'right')
                print(f"\r  {name_col} {size_col}  {ctx_col}  {tool_re}  {tool_ai}  {dim('(' + family + ')')}")

                # Log per-model test result to ACP
                if acp:
                    acp.model_name = name
                    re_status = results.get('openre', '?')
                    ai_status = results.get('openai', '?')
                    acp.log_chat("user", f"Testing tool support...")
                    acp.log_chat("assistant", f"openre={re_status} openai={ai_status} | {size_gb:.2f} GB | ctx {max_ctx}")
            else:
                # Read from cache for both display modes
                for mode in modes_display:
                    if not args.no_cache:
                        cached = get_cached_tool_support(name, api_mode=mode)
                        if cached is not None:
                            results[mode] = cached.value
                # Format missing modes as untested
                tool_re = pad_colored(_tool_status(results.get("openre")), TOOLS_W, 'right')
                tool_ai = pad_colored(_tool_status(results.get("openai")), TOOLS_W, 'right')
                print(f"  {name_col} {size_col}  {ctx_col}  {tool_re}  {tool_ai}  {dim('(' + family + ')')}")
        else:
            tool_na_re = pad_colored(dim("? n/a"), TOOLS_W, 'right')
            tool_na_ai = pad_colored(dim("? n/a"), TOOLS_W, 'right')
            print(f"  {name_col} {size_col}  {dim(pad_colored(ctx_str, CTX_W, 'right'))}  {tool_na_re}  {tool_na_ai}  {dim('(' + family + ')')}")

    print(dim("-" * sep_len))
    print(f"Total: {bright_green(str(len(models)))} models")
    
    # Show legend
    print(f"\n{dim('Legend:')} {bright_green('✓ native')} (API tools) | {yellow('○ react')} (text parsing) | {red('✗ none')} (no tools) | {dim('? untested')}")
    print(f"{dim('Context:')} Max context window from model API")
    print(f"{dim('Tool support columns show openre (OpenResponses) and openai (Chat-Completions) results.')}")
    print(f"{dim('Use')} {cyan('--tool-support')} {dim('to test both API modes.')} {cyan('--tool-support --api openai')} {dim('to test only Chat-Completions.')}")

    # Log summary to ACP and clean up
    if acp:
        if args.tool_support:
            acp.log_chat("assistant", f"Tool-support scan complete: {len(models)} models tested")
        acp.a2a_unregister()

    return 0


def cmd_tools(args: argparse.Namespace) -> int:
    """Execute the tools command."""
    tools = make_builtin_registry()

    print()
    print(f"{bright_cyan('⚛ AgentNova')} - Available Tools")
    print(dim("-" * 60))

    for tool in tools.all():
        params = ", ".join(p.name for p in tool.params)
        print(f"  {cyan(tool.name):<29} {tool.description[:40]}")
        if params:
            print(f"    {dim('Parameters:')} {yellow(params)}")

    print(dim("-" * 60))
    print(f"Total: {bright_green(str(len(tools.all())))} tools")

    return 0


def cmd_test(args: argparse.Namespace) -> int:
    """Execute the test command."""
    # Available tests
    TESTS = {
        "00": {
            "name": "Basic Agent",
            "desc": "Simple conversation without tools",
            "module": "agentnova.examples.00_basic_agent",
        },
        "01": {
            "name": "Quick Diagnostic",
            "desc": "5-question math reasoning test",
            "module": "agentnova.examples.01_quick_diagnostic",
        },
        "02": {
            "name": "Tool Tests",
            "desc": "Calculator, shell, datetime tools",
            "module": "agentnova.examples.02_tool_test",
        },
        "03": {
            "name": "Reasoning Test",
            "desc": "Multi-step reasoning challenges",
            "module": "agentnova.examples.03_reasoning_test",
        },
        "04": {
            "name": "GSM8K Benchmark",
            "desc": "Grade school math problems",
            "module": "agentnova.examples.04_gsm8k_benchmark",
        },
        "05": {
            "name": "Common Sense",
            "desc": "Everyday knowledge and reasoning (25 questions)",
            "module": "agentnova.examples.05_common_sense",
        },
        "06": {
            "name": "Causal Reasoning",
            "desc": "Cause and effect understanding (25 questions)",
            "module": "agentnova.examples.06_causal_reasoning",
        },
        "07": {
            "name": "Logical Deduction",
            "desc": "Syllogisms and logic puzzles (25 questions)",
            "module": "agentnova.examples.07_logical_deduction",
        },
        "08": {
            "name": "Reading Comprehension",
            "desc": "Text understanding and inference (25 questions)",
            "module": "agentnova.examples.08_reading_comprehension",
        },
        "09": {
            "name": "General Knowledge",
            "desc": "Geography, science, and facts (25 questions)",
            "module": "agentnova.examples.09_general_knowledge",
        },
        "10": {
            "name": "Implicit Reasoning",
            "desc": "Understanding implied meanings (25 questions)",
            "module": "agentnova.examples.10_implicit_reasoning",
        },
        "11": {
            "name": "Analogical Reasoning",
            "desc": "Pattern and relationship mapping (25 questions)",
            "module": "agentnova.examples.11_analogical_reasoning",
        },
    }
    
    # List tests
    if args.list:
        print(f"\n{bright_cyan('⚛ AgentNova')} - Available Tests")
        print(dim("-" * 50))
        for tid, info in TESTS.items():
            print(f"  {cyan(tid)}  {info['name']:<20} {dim(info['desc'])}")
        print(dim("-" * 50))
        print(f"\n  Usage: {cyan('agentnova test 01')} or {cyan('agentnova test all')}")
        return 0
    
    # Determine which tests to run
    test_id = args.test_id.lower()
    if test_id == "all":
        tests_to_run = list(TESTS.keys())
    elif test_id in TESTS:
        tests_to_run = [test_id]
    else:
        print(f"{red('Error:')} Unknown test '{test_id}'")
        print(f"  Run {cyan('agentnova test --list')} to see available tests")
        return 1
    
    # Check backend
    config = get_config()
    backend_name = args.backend or config.backend
    api_mode = getattr(args, 'api_mode', 'openre')
    timeout = getattr(args, 'timeout', None)
    backend = get_backend(backend_name, timeout=timeout, api_mode=api_mode)
    
    if not backend.is_running():
        print(f"{red('Error:')} {backend_name.capitalize()} not running at {backend.base_url}")
        if backend_name == "ollama":
            print(f"  Start with: {cyan('ollama serve')}")
            print(f"  Or set OLLAMA_BASE_URL to your remote server")
        return 1
    
    # Initialize ACP plugin if requested
    acp = None
    if args.acp:
        try:
            from .acp_plugin import ACPPlugin
            acp_url = args.acp_url or config.acp_base_url
            acp = ACPPlugin(
                base_url=acp_url,
                agent_name="AgentNova-Test",
                model_name=args.model or config.default_model,
                debug=args.debug,
            )
            # Bootstrap ACP connection
            bootstrap_result = acp.bootstrap()
            if bootstrap_result.get("stop_flag"):
                print(f"{red('Error:')} ACP STOP flag is set: {bootstrap_result.get('warnings')}")
                return 1
            acp_enabled = True
        except ImportError:
            print(f"{yellow('Warning:')} ACP plugin not available, continuing without ACP logging")
            acp = None
        except Exception as e:
            print(f"{yellow('Warning:')} Failed to connect to ACP: {e}")
            acp = None
    
    # Set environment for tests
    if args.debug:
        os.environ["AGENTNOVA_DEBUG"] = "1"
    if args.backend:
        os.environ["AGENTNOVA_BACKEND"] = args.backend
    if getattr(args, 'num_ctx', None):
        os.environ["AGENTNOVA_NUM_CTX"] = str(args.num_ctx)
    
    # Reload config to pick up new env vars
    config = get_config(reload=True)
    
    # Resolve model pattern to actual model(s)
    model_pattern = args.model

    # BitNet model discovery: when --backend bitnet without --model,
    # discover the actual model name from the server via list_models().
    # This avoids using the generic "bitnet-b1.58-2b-4t" placeholder and
    # ensures correct family config resolution (stop tokens, prompt format).
    # Mirrors the same logic in _build_agent().
    if not model_pattern and backend_name == "bitnet":
        try:
            discovered = backend.list_models()
            if (discovered and discovered[0].get("name")
                    and discovered[0]["name"] not in ("bitnet", "default")):
                model_pattern = discovered[0]["name"]
                if args.debug:
                    print(f"  [bitnet] Discovered model: {model_pattern}")
        except Exception:
            pass  # Fall through to config.default_model

    if model_pattern:
        models_to_test = resolve_model_pattern(model_pattern, backend_name, allow_multiple=True)
        if not models_to_test:
            return 1  # Error already printed
    else:
        models_to_test = [config.default_model]
    
    # Run tests
    print_banner()
    print(f"{bright_magenta('Test Runner')} — {len(tests_to_run)} test(s), {len(models_to_test)} model(s)")
    print(f"{dim('Backend:')} {backend_name} ({backend.base_url})")
    if len(models_to_test) == 1:
        print(f"{dim('Model:')} {cyan(models_to_test[0])}")
    else:
        print(f"{dim('Models:')} {cyan(str(len(models_to_test)))} matching '{model_pattern}'")
    num_ctx_val = getattr(args, 'num_ctx', None) if getattr(args, 'num_ctx', None) is not None else config.num_ctx
    if num_ctx_val:
        ctx_display = f"{num_ctx_val // 1024}K" if num_ctx_val >= 1024 else str(num_ctx_val)
        print(f"{dim('Context:')} {yellow(ctx_display)}")
    if acp:
        print(f"{dim('ACP:')} {green('✓ Connected')} ({acp.base_url})")
    print()
    
    # Track results per model
    all_results = {}  # model -> {test_id -> result}
    
    for model in models_to_test:
        model_results = {}
        print(f"\n{dim('═' * 50)}")
        print(f"{bright_magenta('Model:')} {cyan(model)}")
        print(dim("═" * 50))
        
        # Set model env var for this run
        os.environ["AGENTNOVA_MODEL"] = model
        
        for tid in tests_to_run:
            info = TESTS[tid]
            print(f"\n{dim('─' * 50)}")
            print(f"{cyan(f'[{tid}]')} {bright_magenta(info['name'])}")
            print(f"{dim(info['desc'])}")
            print(dim("─" * 50))
            
            # Log test start to ACP
            if acp:
                acp.log_chat("user", f"[{model}] Starting test: {info['name']}")
            
            try:
                # Import and run the test module
                import importlib
                module = importlib.import_module(info["module"])
                
                # Build argv for test modules (they have their own argparse)
                test_argv = ["-m", model]
                if args.debug:
                    test_argv.append("--debug")
                if args.backend:
                    test_argv.extend(["--backend", args.backend])
                if getattr(args, 'api_mode', 'openre') != 'openre':
                    test_argv.extend(["--api", args.api_mode])
                if getattr(args, 'force_react', False):
                    test_argv.append("--force-react")
                if getattr(args, 'use_modelfile_system', False):
                    test_argv.append("--use-mf-sys")
                if getattr(args, 'soul', None):
                    test_argv.extend(["--soul", args.soul])
                    test_argv.extend(["--soul-level", str(getattr(args, 'soul_level', 2))])
                if getattr(args, 'timeout', None):
                    test_argv.extend(["--timeout", str(args.timeout)])
                if getattr(args, 'warmup', False):
                    test_argv.append("--warmup")
                if getattr(args, 'num_ctx', None) is not None:
                    test_argv.extend(["--num-ctx", str(args.num_ctx)])
                if getattr(args, 'num_predict', None) is not None:
                    test_argv.extend(["--num-predict", str(args.num_predict)])
                if getattr(args, 'temperature', None) is not None:
                    test_argv.extend(["--temp", str(args.temperature)])
                if getattr(args, 'top_p', None) is not None:
                    test_argv.extend(["--top-p", str(args.top_p)])
                if getattr(args, 'tools_only', False):
                    test_argv.append("--tools-only")
                if getattr(args, 'model_only', False):
                    test_argv.append("--model-only")
                
                # Override sys.argv for the test module's argparse
                old_argv = sys.argv
                sys.argv = ["test"] + test_argv
                
                try:
                    result = module.main()
                finally:
                    sys.argv = old_argv
                
                # Handle both old-style exit code and new-style result dict
                if isinstance(result, dict):
                    # New-style: granular results
                    passed = result.get("passed", 0)
                    total = result.get("total", 1)
                    time_s = result.get("time", 0)
                    exit_code = result.get("exit_code", 0)
                    model_results[tid] = {
                        "passed": exit_code == 0,
                        "exit_code": exit_code,
                        "granular": f"{passed}/{total}",
                        "time": time_s,
                    }
                else:
                    # Old-style: just exit code
                    exit_code = result if result is not None else 1
                    model_results[tid] = {"passed": exit_code == 0, "exit_code": exit_code}
                
                # Log test result to ACP
                if acp:
                    status = "passed" if exit_code == 0 else "failed"
                    granular = model_results[tid].get("granular", "")
                    acp.log_chat("assistant", f"[{model}] Test {info['name']}: {status} {granular}")
                
            except ImportError as e:
                print(f"{red('Error:')} Could not import test module: {e}")
                model_results[tid] = {"passed": False, "error": str(e)}
                if acp:
                    acp.log_chat("assistant", f"[{model}] Test {info['name']}: import error - {e}")
            except Exception as e:
                print(f"{red('Error:')} {e}")
                model_results[tid] = {"passed": False, "error": str(e)}
                if acp:
                    acp.log_chat("assistant", f"[{model}] Test {info['name']}: error - {e}")
        
        all_results[model] = model_results
    
    # Summary
    print(f"\n{dim('=' * 50)}")
    print(f"{bright_magenta('Test Summary')}")
    print(dim("=" * 50))
    
    # If multiple models, show per-model summary
    if len(models_to_test) > 1:
        print(f"\n{bright_magenta('Results by Model:')}")
        for model in models_to_test:
            model_results = all_results.get(model, {})
            passed = sum(1 for r in model_results.values() if r.get("passed"))
            total = len(model_results)
            granular_sum = ""
            # Sum up granular scores if available
            total_score = 0
            total_possible = 0
            total_time = 0
            for r in model_results.values():
                if "granular" in r:
                    try:
                        parts = r["granular"].split("/")
                        total_score += int(parts[0])
                        total_possible += int(parts[1])
                    except (ValueError, IndexError):
                        pass
                total_time += r.get("time", 0)
            
            if total_possible > 0:
                granular_sum = f"  {cyan(f'{total_score}/{total_possible}')}"
            time_str = f"  {dim(f'({total_time:.1f}s)')}" if total_time else ""
            status = bright_green("✓") if passed == total else red("✗")
            print(f"  {status} {cyan(model):<30} {passed}/{total}{granular_sum}{time_str}")
    else:
        # Single model - show per-test breakdown
        model = models_to_test[0]
        model_results = all_results.get(model, {})
        
        for tid, result in model_results.items():
            status = bright_green("✓ PASS") if result.get("passed") else red("✗ FAIL")
            granular = result.get("granular", "")
            time_s = result.get("time", 0)
            
            # Show granular results if available
            if granular:
                time_str = f" ({time_s:.1f}s)" if time_s else ""
                print(f"  [{tid}] {TESTS[tid]['name']:<20} {status}  {cyan(granular)}{time_str}")
            else:
                print(f"  [{tid}] {TESTS[tid]['name']:<20} {status}")
        
        print(dim("-" * 50))
        passed = sum(1 for r in model_results.values() if r.get("passed"))
        total = len(model_results)
        pct = 100 * passed // total if total > 0 else 0
        print(f"  {bright_green(str(passed))}/{total} tests passed ({pct}%)")
    
    # Log final summary to ACP and unregister (don't shutdown the server!)
    if acp:
        total_passed = sum(
            1 for model_results in all_results.values() 
            for r in model_results.values() if r.get("passed")
        )
        total_tests = sum(len(mr) for mr in all_results.values())
        acp.log_chat("assistant", f"All tests complete: {total_passed}/{total_tests} passed")
        # Only unregister from A2A, don't shutdown the ACP server
        acp.a2a_unregister()
        acp._log("Test complete, unregistered from A2A (ACP server remains running)")
    
    # Return success only if all tests passed
    total_passed = sum(
        1 for model_results in all_results.values() 
        for r in model_results.values() if r.get("passed")
    )
    total_tests = sum(len(mr) for mr in all_results.values())
    return 0 if total_passed == total_tests else 1


def cmd_version(args: argparse.Namespace) -> int:
    """Show version information."""
    from . import __version__, __status__, __author__

    print_banner()
    print(f"   {dim('Version:')} {bright_green(__version__)}")
    print(f"   {dim('Status:')}  {yellow(__status__)}")
    print(f"   {dim('Author:')}  {cyan(__author__)}")
    print(f"   {dim('Repo:')}    {dim('https://github.com/VTSTech/AgentNova')}")
    print()

    return 0


def cmd_turbo(args: argparse.Namespace) -> int:
    """TurboQuant server management commands."""
    from .turbo import (
        start_server, stop_server, get_status,
        print_model_list, print_status,
        TURBOQUANT_SERVER_PATH,
    )
    from .backends.ollama_registry import discover_models

    turbo_cmd = getattr(args, "turbo_command", None)

    if not turbo_cmd:
        # No subcommand — show status or help
        state = get_status()
        if state:
            print_status(state)
        else:
            print(bold(bright_cyan("TURBOQUANT")) + dim(" — server management"))
            print()
            print(f"  {bold('Usage:')}")
            print(f"    agentnova turbo list             List Ollama models")
            print(f"    agentnova turbo start <model>     Start TurboQuant server")
            print(f"    agentnova turbo stop              Stop TurboQuant server")
            print(f"    agentnova turbo status            Show server status")
            print()
            print(f"  {dim('No server running.')} Run {cyan('agentnova turbo list')} to see available models.")
            print()
        return 0

    if turbo_cmd == "list":
        from .backends.ollama_registry import OllamaModel

        ollama_dir = Path(args.ollama_dir) if args.ollama_dir else None
        only_existing = not getattr(args, "all", False)
        config = get_config()

        # Try API-based discovery first (same source as `agentnova models`)
        api_models = None
        api_url = None
        backend_name = getattr(args, "backend", None) or config.backend
        if backend_name == "ollama":
            try:
                backend = get_backend("ollama", api_mode="openre")
                api_models = backend.list_models()
                if api_models:
                    api_url = backend.base_url
            except Exception:
                api_models = None

        if api_models is not None:
            # Got models from API — merge with local GGUF metadata
            local_models = discover_models(ollama_dir=ollama_dir, only_existing=False)

            # Build lookup by both short name ("repo:tag") and full name ("library/repo:tag")
            # discover_models uses short names; the API returns full names with library prefix
            local_lookup: dict[str, OllamaModel] = {}
            for m in local_models:
                local_lookup[m.name] = m
                # Derive full name from manifest path: .../registry.ollama.ai/<library>/<repo>/<tag>
                if m.manifest_path != Path("") and m.manifest_path.parent.parent.name != "registry.ollama.ai":
                    library = m.manifest_path.parent.parent.name
                    full_name = f"{library}/{m.name}"
                    local_lookup[full_name] = m

            models: list = []
            for api_m in api_models:
                name = api_m.get("name", "")
                if not name:
                    continue
                if name in local_lookup:
                    models.append(local_lookup[name])
                else:
                    # Not pulled locally — create minimal OllamaModel from API data
                    repo, tag = name, "latest"
                    if ":" in name:
                        repo, tag = name.rsplit(":", 1)
                    models.append(OllamaModel(
                        name=name,
                        repo=repo,
                        tag=tag,
                        blob_path=Path(""),
                        size_bytes=api_m.get("size", 0),
                        weight_quant="not pulled",
                        manifest_path=Path(""),
                        model_digest="",
                    ))
            models.sort(key=lambda m: m.name)
            print_model_list(models, source="api", backend_url=api_url)
        else:
            # API unreachable — fall back to filesystem discovery
            models = discover_models(ollama_dir=ollama_dir, only_existing=only_existing)
            print_model_list(models, source="local")
        return 0

    elif turbo_cmd == "start":
        try:
            state = start_server(
                model_name=args.model,
                server_path=args.server,
                port=args.port,
                ctx=args.ctx,
                cache_type_k=args.turbo_k,
                cache_type_v=args.turbo_v,
                flash_attn=getattr(args, "flash_attn", False),
                sparsity=getattr(args, "sparsity", 0.0),
                num_threads=getattr(args, "threads", 0),
                wait_ready=not getattr(args, "no_wait", False),
                ready_timeout=getattr(args, "timeout", 120),
                extra_args=getattr(args, "extra_args", None),
            )
            # Show how to use
            print(dim("  Use with AgentNova:"))
            _cmd1 = f"agentnova run --backend llama-server --model {args.model} \"<prompt>\""
            _cmd2 = f"OLLAMA_BASE_URL=http://localhost:{state.port} agentnova run \"<prompt>\""
            print(f"    {cyan(_cmd1)}")
            print(f"    {cyan(_cmd2)}")
            print()
            return 0
        except FileNotFoundError as e:
            print(bright_red(f"Error: {e}"))
            return 1
        except RuntimeError as e:
            print(bright_red(f"Error: {e}"))
            return 1
        except ValueError as e:
            print(bright_red(f"Error: {e}"))
            return 1

    elif turbo_cmd == "stop":
        stopped = stop_server(force=getattr(args, "force", False))
        if not stopped:
            print(yellow("No TurboQuant server is running."))
            print()
        return 0

    elif turbo_cmd == "status":
        state = get_status()
        if state:
            print_status(state)
        else:
            print(yellow("No TurboQuant server is running."))
            print()
            print(dim("  Start one with:"))
            print(f"    {cyan('agentnova turbo list')}        # see available models")
            print(f"    {cyan('agentnova turbo start <model>')}  # start server")
            print()
        return 0

    return 1


def cmd_config(args: argparse.Namespace) -> int:
    """Show current configuration."""
    from .config import (
        OLLAMA_BASE_URL, BITNET_BASE_URL, ACP_BASE_URL,
        AGENTNOVA_BACKEND, DEFAULT_MODEL, NUM_CTX,
    )

    if args.urls:
        print(f"OLLAMA_BASE_URL={OLLAMA_BASE_URL}")
        print(f"BITNET_BASE_URL={BITNET_BASE_URL}")
        print(f"ACP_BASE_URL={ACP_BASE_URL}")
    else:
        print()
        print(f"{bright_cyan('⚛ AgentNova')} - Configuration")
        print(dim("-" * 40))
        print(f"  {dim('Backend:')}       {green(AGENTNOVA_BACKEND)}")
        print(f"  {dim('Default Model:')} {cyan(DEFAULT_MODEL)}")
        if NUM_CTX and NUM_CTX > 0:
            ctx_display = f"{NUM_CTX // 1024}K" if NUM_CTX >= 1024 else str(NUM_CTX)
            print(f"  {dim('Context Window:')} {yellow(ctx_display)} (num_ctx)")
        print()
        print(f"  {yellow('URLs:')}")
        print(f"    {dim('Ollama:')} {OLLAMA_BASE_URL}")
        print(f"    {dim('BitNet:')} {BITNET_BASE_URL}")
        print(f"    {dim('ACP:')}    {ACP_BASE_URL}")
        print(dim("-" * 40))
        print(f"\n{yellow('Environment variables:')}")
        print(f"  {dim('OLLAMA_BASE_URL')}    - Ollama server URL")
        print(f"  {dim('BITNET_BASE_URL')}    - BitNet server URL")
        print(f"  {dim('BITNET_TUNNEL')}      - BitNet tunnel URL")
        print(f"  {dim('ACP_BASE_URL')}       - ACP server URL")
        print(f"  {dim('AGENTNOVA_BACKEND')}  - Default backend (ollama/bitnet)")
        print(f"  {dim('AGENTNOVA_MODEL')}    - Default model")
        print(f"  {dim('OLLAMA_NUM_CTX')}     - Context window size (default: 2048)")

    return 0


def cmd_modelfile(args: argparse.Namespace) -> int:
    """Show model's Modelfile system prompt and other info."""
    from .backends import OllamaBackend

    config = get_config()
    backend_name = args.backend or config.backend
    backend = get_backend(backend_name)

    if not isinstance(backend, OllamaBackend):
        print(f"{red('Error:')} Modelfile command requires Ollama backend")
        return 1

    if not backend.is_running():
        print(f"{red('✗')}  Ollama is not running. Start it with: {cyan('ollama serve')}")
        return 1

    model = args.model or config.default_model
    print(bold(f"\n⚛️ AgentNova Modelfile") + dim(" · Written by VTSTech · https://www.vts-tech.org"))
    print()

    try:
        info = backend.get_model_info(model)
    except Exception as e:
        print(f"{red('✗')}  Could not get info for model '{model}': {e}")
        return 1

    # Display model information
    print(bold(f"Model: {model}"))
    print(dim("─" * 70))
    print()

    # System prompt from Modelfile
    system_prompt = info.get("system")
    if system_prompt:
        print(cyan("SYSTEM PROMPT (from Modelfile):"))
        print()
        print(system_prompt)
        print()
    else:
        print(dim("(No SYSTEM prompt defined in Modelfile)"))
        print()

    # Template
    template = info.get("template")
    if template:
        print(cyan("TEMPLATE:"))
        print()
        print(template)
        print()

    # Parameters
    params = info.get("parameters", "")
    if params:
        print(cyan("PARAMETERS:"))
        print()
        for line in params.strip().split("\n"):
            if line.strip():
                print(dim(f"  {line.strip()}"))
        print()

    # Details
    details = info.get("details", {})
    if details:
        print(cyan("DETAILS:"))
        print()
        for key, value in details.items():
            print(dim(f"  {key}: {value}"))
        print()

    return 0


def cmd_skills(args: argparse.Namespace) -> int:
    """List available skills."""
    from .skills import SkillLoader

    loader = SkillLoader()
    skills = loader.list_skills()

    print(bold(f"\n⚛️ AgentNova Skills") + dim(" · Written by VTSTech · https://www.vts-tech.org"))

    if not skills:
        print(yellow("  No skills found."))
        print(dim("  Skills are loaded from agentnova/skills/*/SKILL.md"))
        return 0

    print(bold(f"{'Skill':<20} Description"))
    print(dim("─" * 70))

    for name in skills:
        try:
            skill = loader.load(name)
            desc = skill.description[:60] + "..." if len(skill.description) > 60 else skill.description
            print(f"  {magenta(name):<26} {desc}")

            # Show resources
            resources = []
            if skill.scripts_dir:
                scripts = list(skill.scripts_dir.glob("*.py"))
                if scripts:
                    resources.append(f"{len(scripts)} scripts")
            if skill.references_dir:
                refs = list(skill.references_dir.glob("*.md"))
                if refs:
                    resources.append(f"{len(refs)} refs")
            if resources:
                print(f"  {'':<26} {dim('Has: ' + ', '.join(resources))}")
        except Exception as e:
            print(f"  {magenta(name):<26} {red(f'Error: {e}')}")

    print()
    print(dim(f"  Use with: {cyan('--skills')} {','.join(skills[:2])}"))
    print(dim("  Skills provide knowledge/instructions to the agent."))
    print(dim(f"  Available commands support: {cyan('run --skills <list>')}, {cyan('chat --skills <list>')}, {cyan('agent --skills <list>')}"))
    print()

    return 0


def cmd_soul(args: argparse.Namespace) -> int:
    """Inspect a Soul Spec package."""
    try:
        from .soul import load_soul, build_system_prompt, SoulLoader
    except ImportError:
        print(f"{red('Error:')} Soul module not available")
        return 1
    
    path = args.path
    level = args.level
    
    try:
        loader = SoulLoader()
        soul = loader.load(path, level=level)
    except FileNotFoundError as e:
        print(f"{red('Error:')} Soul package not found: {e}")
        return 1
    except Exception as e:
        print(f"{red('Error:')} Failed to load soul: {e}")
        return 1
    
    # Display soul info
    print()
    print(bold(f"👻 Soul Spec Package") + dim(" · ClawSouls v0.5"))
    print(dim("─" * 70))
    print()
    
    # Basic info
    print(f"  {cyan('Name:')}        {soul.display_name} ({dim(soul.name)})")
    print(f"  {cyan('Version:')}     {soul.version}")
    print(f"  {cyan('Spec:')}        v{soul.spec_version}")
    print(f"  {cyan('Author:')}      {soul.author.name}" + (f" ({soul.author.github})" if soul.author.github else ""))
    print(f"  {cyan('License:')}     {soul.license}")
    print()
    
    print(f"  {cyan('Description:')}")
    print(f"    {soul.description}")
    print()
    
    # Disclosure summary
    if soul.disclosure and soul.disclosure.summary:
        print(f"  {cyan('Summary:')}")
        print(f"    {soul.disclosure.summary}")
        print()
    
    # Tags and category
    if soul.tags:
        print(f"  {cyan('Tags:')}       {', '.join(soul.tags)}")
    print(f"  {cyan('Category:')}   {soul.category}")
    print()
    
    # Environment
    if soul.environment.value != "virtual":
        print(f"  {yellow('Environment:')} {soul.environment.value}")
        print(f"  {yellow('Interaction:')} {soul.interaction_mode.value}")
        if soul.hardware_constraints:
            hc = soul.hardware_constraints
            caps = []
            if hc.has_display: caps.append("display")
            if hc.has_speaker: caps.append("speaker")
            if hc.has_microphone: caps.append("microphone")
            if hc.has_camera: caps.append("camera")
            if caps:
                print(f"  {yellow('Hardware:')}   {', '.join(caps)}")
        if soul.safety and soul.safety.physical:
            print(f"  {yellow('Safety:')}     {soul.safety.physical.contact_policy.value}")
        print()
    
    # Allowed tools
    if soul.allowed_tools:
        print(f"  {cyan('Allowed Tools:')} {', '.join(soul.allowed_tools)}")
    
    # Recommended skills
    if soul.recommended_skills:
        required = [s.name for s in soul.recommended_skills if s.required]
        optional = [s.name for s in soul.recommended_skills if not s.required]
        if required:
            print(f"  {cyan('Required Skills:')} {', '.join(required)}")
        if optional:
            print(f"  {cyan('Optional Skills:')} {', '.join(optional)}")
    
    # Compatibility
    if soul.compatibility.frameworks:
        print(f"  {cyan('Frameworks:')}   {', '.join(soul.compatibility.frameworks)}")
    if soul.compatibility.models:
        print(f"  {cyan('Models:')}       {', '.join(soul.compatibility.models)}")
    
    print()
    
    # Validation
    if args.validate:
        issues = soul.validate()
        if issues:
            print(f"  {red('Validation Issues:')}")
            for issue in issues:
                print(f"    {red('✗')} {issue}")
        else:
            print(f"  {green('✓')} Validation passed")
        print()
    
    # Loaded content
    if level >= 2:
        print(dim("─" * 70))
        if soul.soul_content:
            print(f"\n  {cyan('SOUL.md:')}")
            lines = soul.soul_content.split("\n")
            for line in lines[:10]:
                print(f"    {line}")
            if len(lines) > 10:
                remaining = len(lines) - 10
                print(f"    {dim('...')} ({remaining} more lines)")
        
        if soul.identity_content:
            print(f"\n  {cyan('IDENTITY.md:')}")
            lines = soul.identity_content.split("\n")
            for line in lines[:10]:
                print(f"    {line}")
            if len(lines) > 10:
                remaining = len(lines) - 10
                print(f"    {dim('...')} ({remaining} more lines)")
    
    # Show generated system prompt
    if args.prompt:
        prompt = loader.build_system_prompt(soul, level=level)
        print()
        print(dim("─" * 70))
        print(f"\n  {cyan('Generated System Prompt:')}")
        print(dim("─" * 70))
        print(prompt)
    
    print()
    return 0


def cmd_sessions(args: argparse.Namespace) -> int:
    """List or delete saved sessions."""
    try:
        from .core.persistent_memory import PersistentMemory
    except ImportError:
        print(f"{red('Error:')} Persistent memory not available (requires sqlite3)")
        return 1

    if args.delete:
        # Delete mode
        session_id = args.delete
        deleted = PersistentMemory.delete_session(session_id)
        if deleted:
            print(f"{green('✓')} Deleted session: {cyan(session_id)}")
        else:
            print(f"{red('✗')} Session not found: {cyan(session_id)}")
            return 1
    else:
        # List mode
        sessions = PersistentMemory.list_sessions()
        if not sessions:
            print("No saved sessions found.")
            _hint = 'agentnova run --session <name> "<prompt>"'
            print(f"\n  Start a session with: {cyan(_hint)}")
            return 0

        ID_W = 20
        MSGS_W = 8
        CREATED_W = 19
        UPDATED_W = 19

        print()
        print(f"{bright_cyan('⚛ AgentNova')} - Saved Sessions")
        print(f"{dim('  DB:')} ~/.agentnova/memory.db")
        print(dim("-" * (4 + ID_W + MSGS_W + CREATED_W + UPDATED_W)))
        print(f"  {'Session':<{ID_W}} {'Msgs':>{MSGS_W}}  {'Created':<{CREATED_W}}  {'Updated':<{UPDATED_W}}")
        print(dim("-" * (4 + ID_W + MSGS_W + CREATED_W + UPDATED_W)))

        for s in sessions:
            sid = s["session_id"]
            msgs = s["message_count"]
            created = s["created_at"][:19].replace("T", " ")
            updated = s["updated_at"][:19].replace("T", " ")
            print(f"  {cyan(sid):<{ID_W}} {msgs:>{MSGS_W}}  {dim(created):<{CREATED_W}}  {dim(updated):<{UPDATED_W}}")

        print(dim("-" * (4 + ID_W + MSGS_W + CREATED_W + UPDATED_W)))
        print(f"Total: {bright_green(str(len(sessions)))} sessions")
        _resume = 'agentnova run --session <name> "<prompt>"'
        print(f"\n{dim('Resume a session:')} {cyan(_resume)}")
        print(f"{dim('Delete a session:')} {cyan('agentnova sessions --delete <name>')}")

    return 0


def cmd_update(args: argparse.Namespace) -> int:
    """Update AgentNova to the latest version from GitHub."""
    print(f"{bright_cyan('⚛ AgentNova')} - Updating from GitHub...")
    print(f"{dim('Running:')} pip install git+https://github.com/VTSTech/AgentNova.git --force-reinstall")
    print()

    import subprocess as sp
    result = sp.run(
        [sys.executable, "-m", "pip", "install",
         "git+https://github.com/VTSTech/AgentNova.git", "--force-reinstall"],
        capture_output=True,
        text=True,
    )

    if result.returncode == 0:
        print(f"{green('✓ Updated successfully!')}")
        # Show the installed version
        try:
            version_result = sp.run(
                [sys.executable, "-m", "agentnova", "version"],
                capture_output=True,
                text=True,
            )
            if version_result.returncode == 0 and version_result.stdout.strip():
                print(version_result.stdout.strip())
        except Exception:
            pass
    else:
        print(f"{red('✗ Update failed.')}")
        if result.stderr:
            print()
            for line in result.stderr.strip().split("\n")[-5:]:
                print(f"  {dim(line)}")
        return 1

    return 0


def main(argv: Optional[list[str]] = None) -> int:
    """Main entry point."""
    parser = create_parser()
    args = parser.parse_args(argv)

    if args.command is None:
        print_banner()
        parser.print_help()
        return 0

    commands = {
        "run": cmd_run,
        "chat": cmd_chat,
        "agent": cmd_agent,
        "models": cmd_models,
        "tools": cmd_tools,
        "test": cmd_test,
        "turbo": cmd_turbo,
        "version": cmd_version,
        "config": cmd_config,
        "modelfile": cmd_modelfile,
        "skills": cmd_skills,
        "soul": cmd_soul,
        "sessions": cmd_sessions,
        "update": cmd_update,
    }

    handler = commands.get(args.command)
    if handler:
        return handler(args)

    parser.print_help()
    return 0


if __name__ == "__main__":
    sys.exit(main())