#!/usr/bin/env python3
"""
Second Opinion MCP Server - Multi-model analysis with DeepSeek, Moonshot (Kimi), OpenRouter, and OpenAI.
Provides second opinions, consensus debates, critical challenges, code reviews,
architectural plan reviews, and implementation reviews.
Secrets via platform-appropriate credential storage.
"""
import asyncio
import atexit
import logging
import os
import re
import time
from asyncio import Semaphore
from datetime import datetime
from pathlib import Path
from typing import Literal

import httpx
from openai import AsyncOpenAI, APIError, RateLimitError, APITimeoutError, APIConnectionError
from mcp.server.fastmcp import FastMCP
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

from second_opinion_mcp.credentials import CredentialManager

# Library logging pattern: NullHandler by default, host app configures
logger = logging.getLogger("second-opinion")
logger.addHandler(logging.NullHandler())

# Logging policy:
# - ERROR: Failures affecting operation (API errors, file access failures)
# - WARNING: Recoverable issues (rate limits, truncation, deprecated usage)
# - INFO: Significant operations (client creation, file saves, workflow steps)
# - DEBUG: Detailed diagnostics (cache stats, request details, content sizes)


# Path security: allowed roots and blocked system paths
ALLOWED_ROOTS: list[Path] = []


def _init_allowed_roots() -> list[Path]:
    """Initialize allowed project roots from environment or defaults."""
    env_roots = os.environ.get("SECOND_OPINION_ALLOWED_ROOTS", "")
    if env_roots:
        return [Path(p).resolve() for p in env_roots.split(os.pathsep) if p]
    # Default safe directories (common project locations)
    home = Path.home()
    defaults = [
        home / d for d in [
            "Projects", "repos", "code", "src", "work", "dev",
            "Documents", "OneDrive", "Desktop"
        ]
    ]
    # On Windows, also check for OneDrive on other drives (business OneDrive)
    if os.name == "nt":
        import glob
        for drive in "CDEFGH":
            # Match "OneDrive - *" business folders
            for onedrive in glob.glob(f"{drive}:\\OneDrive*"):
                defaults.append(Path(onedrive))
    return [p for p in defaults if p.exists()]


def _configure_logging() -> None:
    """Configure logging from environment variables.

    SECOND_OPINION_LOG_LEVEL: Set log level (DEBUG, INFO, WARNING, ERROR)
    SECOND_OPINION_LOG_ENABLED: If set, enable logging to stderr with timestamps
    """
    level_name = os.environ.get("SECOND_OPINION_LOG_LEVEL", "WARNING").upper()
    level = getattr(logging, level_name, logging.WARNING)

    if os.environ.get("SECOND_OPINION_LOG_ENABLED"):
        handler = logging.StreamHandler()
        handler.setFormatter(logging.Formatter(
            '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
        ))
        logger.addHandler(handler)
        logger.setLevel(level)


def _validate_configuration() -> None:
    """Validate environment configuration at startup.

    Called after MODELS is defined to validate:
    - SECOND_OPINION_PROVIDERS contains only known providers
    - SECOND_OPINION_ALLOWED_ROOTS paths exist (warning only)
    - Rate limit values are within valid ranges
    """
    errors = []

    # Validate SECOND_OPINION_PROVIDERS
    env_providers = os.environ.get("SECOND_OPINION_PROVIDERS", "")
    if env_providers:
        requested = [p.strip().lower() for p in env_providers.split(",") if p.strip()]
        requested = ["moonshot" if p == "kimi" else p for p in requested]
        unknown = [p for p in requested if p not in MODELS]
        if unknown:
            errors.append(f"Unknown providers in SECOND_OPINION_PROVIDERS: {unknown}")

    # Validate SECOND_OPINION_ALLOWED_ROOTS (warning only, paths may not exist yet)
    env_roots = os.environ.get("SECOND_OPINION_ALLOWED_ROOTS", "")
    if env_roots:
        for p in env_roots.split(os.pathsep):
            if p and not Path(p).exists():
                logger.warning("Allowed root does not exist: %s", p)

    # Validate rate limit values
    try:
        concurrent = int(os.environ.get("SECOND_OPINION_MOONSHOT_CONCURRENT", "3"))
        if concurrent < 1 or concurrent > 10:
            errors.append(f"SECOND_OPINION_MOONSHOT_CONCURRENT must be 1-10, got {concurrent}")
    except ValueError as e:
        errors.append(f"Invalid SECOND_OPINION_MOONSHOT_CONCURRENT: {e}")

    try:
        rpm = int(os.environ.get("SECOND_OPINION_MOONSHOT_RPM", "20"))
        if rpm < 1 or rpm > 100:
            errors.append(f"SECOND_OPINION_MOONSHOT_RPM must be 1-100, got {rpm}")
    except ValueError as e:
        errors.append(f"Invalid SECOND_OPINION_MOONSHOT_RPM: {e}")

    if errors:
        raise RuntimeError("Configuration errors: " + "; ".join(errors))


def _is_safe_directory(path: Path) -> bool:
    """Check if path is under an allowed root, not in blocked system paths."""
    # Defense-in-depth: reject paths with traversal patterns
    path_str = str(path)
    if '..' in path_str:
        logger.warning("Path traversal pattern detected: %s", path_str)
        return False

    resolved = path.resolve()

    # Block known system directories
    blocked = [
        Path("/etc"), Path("/var"), Path("/root"), Path("/proc"), Path("/sys"),
        Path("/boot"), Path("/lib"), Path("/lib64"), Path("/usr/lib"),
        Path("C:/Windows"), Path("C:/Program Files"), Path("C:/Program Files (x86)"),
        Path("C:/ProgramData"), Path("C:/System Volume Information"),
    ]
    for b in blocked:
        try:
            resolved.relative_to(b.resolve())
            return False
        except ValueError:
            continue

    # Must be under an allowed root
    for allowed in ALLOWED_ROOTS:
        try:
            resolved.relative_to(allowed)
            return True
        except ValueError:
            continue
    return False


mcp = FastMCP("second-opinion")

# Initialize allowed roots at module load
ALLOWED_ROOTS.extend(_init_allowed_roots())

# Initialize credential manager (singleton)
_credential_manager: CredentialManager | None = None


def _get_credential_manager() -> CredentialManager:
    """Get or create the credential manager singleton."""
    global _credential_manager
    if _credential_manager is None:
        _credential_manager = CredentialManager()
    return _credential_manager


# Input validation constants
MAX_PATH_LENGTH = 4096
MAX_PROMPT_LENGTH = 500_000
MAX_FOCUS_AREAS = 20

# Streaming memory limits
MAX_STREAMING_CONTENT_BYTES = 10 * 1024 * 1024  # 10MB
MAX_STREAMING_REASONING_BYTES = 5 * 1024 * 1024  # 5MB

# Rate Limiting Strategy
#
# DeepSeek: NO client-side rate limiting needed
# - DeepSeek API has no rate limits (can handle 1 trillion tokens/day)
# - No concurrency limits imposed by the API
# - During high traffic, requests queue server-side with 10-minute timeout
# - Source: DeepSeek API Reference - "DeepSeek does not constrain rate limits"
#
# Moonshot (Kimi): Client-side rate limiting REQUIRED
# - Tier 0 limits: 3 concurrent requests, 20 RPM, 500K TPM, 1.5M TPD
# - Rate limits calculated using max_tokens parameter, not actual output
# - Source: Moonshot API Reference - Rate Limits section
#
# Configurable via environment variables for different API tiers
MOONSHOT_MAX_CONCURRENT = int(os.environ.get("SECOND_OPINION_MOONSHOT_CONCURRENT", "3"))
MOONSHOT_RPM_LIMIT = int(os.environ.get("SECOND_OPINION_MOONSHOT_RPM", "20"))

# HTTP connection pool configuration
HTTP_MAX_CONNECTIONS = 10
HTTP_MAX_KEEPALIVE = 5
HTTP_CONNECT_TIMEOUT = 10.0
HTTP_READ_TIMEOUT = 300.0

_moonshot_semaphore: Semaphore | None = None
_moonshot_request_times: list[float] = []


def _get_moonshot_semaphore() -> Semaphore:
    """Get or create the Moonshot concurrency semaphore."""
    global _moonshot_semaphore
    if _moonshot_semaphore is None:
        _moonshot_semaphore = Semaphore(MOONSHOT_MAX_CONCURRENT)
    return _moonshot_semaphore


async def _check_moonshot_rpm() -> None:
    """Check and enforce Moonshot RPM limit, waiting if necessary."""
    global _moonshot_request_times
    now = time.monotonic()
    # Keep only requests from the last 60 seconds
    _moonshot_request_times = [t for t in _moonshot_request_times if now - t < 60]

    if len(_moonshot_request_times) >= MOONSHOT_RPM_LIMIT:
        wait_time = 60 - (now - _moonshot_request_times[0]) + 0.1
        if wait_time > 0:
            logger.info("Moonshot RPM limit reached, waiting %.1f seconds", wait_time)
            await asyncio.sleep(wait_time)
            await _check_moonshot_rpm()  # Recheck after waiting

    _moonshot_request_times.append(time.monotonic())


def _validate_string_input(value: str, max_length: int, name: str) -> tuple[str | None, str]:
    """Validate user-provided string. Returns (error_message, sanitized_value)."""
    if not isinstance(value, str):
        return f"{name} must be a string", ""
    if '\x00' in value:
        logger.warning("Null byte detected in %s input", name)
        return f"{name} contains invalid null byte", ""
    if len(value) > max_length:
        return f"{name} exceeds maximum length of {max_length}", ""
    return None, value


def _validate_path_input(path: str) -> tuple[str | None, str]:
    """Validate user-provided path string."""
    return _validate_string_input(path, MAX_PATH_LENGTH, "Path")


def _validate_prompt_input(prompt: str) -> tuple[str | None, str]:
    """Validate user-provided prompt string."""
    return _validate_string_input(prompt, MAX_PROMPT_LENGTH, "Prompt")


# Model configurations with full provider support
MODELS: dict[str, dict] = {
    "deepseek": {
        "enabled": True,
        "keyring_service": "second-opinion",
        "keyring_name": "deepseek",
        "base_url": "https://api.deepseek.com",
        "model": "deepseek-reasoner",
        "available_models": ["deepseek-reasoner", "deepseek-chat"],
        "max_tokens": 32768,  # Match reasoner default (max 64K)
        "context_window": 128000,
        "headers": {},
        "requires_streaming": False,
        "supports_streaming": True,  # Optional streaming available
        "temperature": 1.0,  # Default (0.0 for coding, 1.3 for conversation)
        "supports_thinking": True,
        "supports_json_mode": True,  # DeepSeek V3.2 supports JSON output
    },
    "moonshot": {
        "enabled": True,
        "keyring_service": "second-opinion",
        "keyring_name": "moonshot",
        "base_url": "https://api.moonshot.ai/v1",
        "model": "kimi-k2.5",
        "available_models": [
            "kimi-k2.5",              # Default, thinking enabled
            "kimi-k2-turbo-preview",  # Fast production (60-100 tok/s)
            "kimi-k2-0905-preview",   # Agentic coding optimized
            "kimi-k2-thinking",       # Deep reasoning
            "kimi-k2-thinking-turbo", # Fast reasoning
        ],
        "max_tokens": 32768,
        "context_window": 262144,  # 256K tokens
        "headers": {},
        "requires_streaming": True,  # Thinking models need streaming
        "temperature": 1.0,  # Required for kimi-k2.5 thinking mode (0.6 when thinking disabled)
        "supports_thinking": True,
        "supports_json_mode": True,
    },
    "openrouter": {
        "enabled": False,  # Disabled by default
        "keyring_service": "second-opinion",
        "keyring_name": "openrouter",
        "base_url": "https://openrouter.ai/api/v1",
        "model": "moonshotai/kimi-k2",
        "available_models": ["moonshotai/kimi-k2", "deepseek/deepseek-r1"],
        "max_tokens": 16384,
        "context_window": 128000,
        "headers": {},
        "requires_streaming": False,
        "temperature": None,
        "supports_thinking": False,
        "supports_json_mode": False,
    },
    "openai": {
        "enabled": False,  # Disabled by default
        "keyring_service": "second-opinion",
        "keyring_name": "openai",
        "base_url": "https://api.openai.com/v1",
        "oauth_base_url": "https://chatgpt.com/backend-api/codex",
        "model": "gpt-5.2-thinking",           # API key default
        "oauth_model": "gpt-5.3-codex",        # OAuth default
        "available_models": [
            "gpt-5.3-codex",     # OAuth default (ChatGPT backend)
            "gpt-5.2-codex",     # OAuth alternative
            "gpt-5.2-thinking",  # API key default, reasoning-enabled
            "gpt-5.2-instant",   # Fast responses
            "gpt-5.2-pro",       # Maximum capability
        ],
        "max_tokens": 128000,
        "context_window": 400000,
        "headers": {},
        "requires_streaming": False,
        "supports_streaming": True,
        "temperature": 1.0,
        "supports_thinking": True,
        "supports_json_mode": True,
    },
}


def _init_enabled_providers() -> None:
    """Initialize enabled providers from env var or auto-detect from credentials.

    If SECOND_OPINION_PROVIDERS is set, use exactly those providers.
    Otherwise, auto-detect: enable any provider that has credentials configured.
    """
    env_providers = os.environ.get("SECOND_OPINION_PROVIDERS", "")
    if env_providers:
        requested = [p.strip().lower() for p in env_providers.split(",") if p.strip()]
        # Map kimi alias to moonshot
        requested = ["moonshot" if p == "kimi" else p for p in requested]
        for provider in MODELS:
            MODELS[provider]["enabled"] = provider in requested
        logger.info("Providers configured from env: %s", requested)
        return

    # Auto-detect: enable providers that have credentials configured
    manager = _get_credential_manager()
    auto_enabled = []
    for provider in MODELS:
        has_credentials = _provider_has_credentials(provider, manager)
        MODELS[provider]["enabled"] = has_credentials
        if has_credentials:
            auto_enabled.append(provider)

    if auto_enabled:
        logger.info("Providers auto-detected from credentials: %s", auto_enabled)


def _provider_has_credentials(provider: str, manager: CredentialManager) -> bool:
    """Check if a provider has credentials configured.

    For OpenAI, checks OAuth tokens first, then API key.
    For other providers, checks for API key.
    """
    if provider == "openai":
        # Check OAuth tokens first, then API key
        try:
            from second_opinion_mcp.openai_auth import get_openai_token
            if get_openai_token() is not None:
                return True
        except Exception:
            pass
        return bool(manager.get(provider))

    return bool(manager.get(provider))


# Initialize providers and validate configuration at module load
_init_enabled_providers()
_configure_logging()
_validate_configuration()


# Provider type includes backwards-compatible kimi alias
Provider = Literal["deepseek", "moonshot", "openrouter", "openai", "kimi"]


def _validate_provider(provider: str) -> tuple[str, str | None]:
    """Validate and normalize provider. Returns (normalized_provider, error_or_None)."""
    # Handle kimi -> moonshot alias
    if provider == "kimi":
        logger.warning("Provider 'kimi' is deprecated, using 'moonshot' instead")
        provider = "moonshot"

    if provider not in MODELS:
        available = [p for p in MODELS if MODELS[p].get("enabled")]
        return provider, f"Error: Unknown provider '{provider}'. Available: {', '.join(available)}"

    if not MODELS[provider].get("enabled"):
        available = [p for p in MODELS if MODELS[p].get("enabled")]
        return provider, f"Error: Provider '{provider}' is disabled. Enabled: {', '.join(available)}"

    return provider, None


def _validate_model(provider: str, model: str) -> str | None:
    """Validate model for provider. Returns error message if invalid, None if OK."""
    if not model:
        return None  # Use default
    config = MODELS.get(provider)
    if not config:
        return f"Error: Unknown provider '{provider}'."
    available = config.get("available_models", [])
    if model not in available:
        return f"Error: Model '{model}' not available for {provider}. Available: {', '.join(available)}."
    return None


def _validate_api_key(provider: str, key: str) -> tuple[bool, str]:
    """Validate API key format for provider.

    Returns:
        (valid, error_message) tuple. If valid is True, error_message is empty.
    """
    if not key or not key.strip():
        return False, f"Empty API key for {provider}"

    # Provider-specific format validators
    validators = {
        "deepseek": lambda k: k.startswith("sk-") and len(k) >= 20,
        "moonshot": lambda k: bool(re.match(r'^[a-zA-Z0-9_-]{32,}$', k)),
        "openrouter": lambda k: k.startswith("sk-or-") and len(k) >= 20,
        # OpenAI: standard API key (sk-...) or OAuth JWT token (100+ chars)
        "openai": lambda k: (k.startswith("sk-") and len(k) >= 20) or len(k) >= 100,
    }

    validator = validators.get(provider)
    if validator and not validator(key):
        return False, f"Invalid {provider} API key format"
    return True, ""


def get_key(provider: str) -> str:
    """Retrieve and validate API key using platform-appropriate credential storage.

    For OpenAI, checks OAuth tokens first (own, then Codex CLI), then API key.
    """
    manager = _get_credential_manager()
    key = None

    # OpenAI: try OAuth tokens first (free options before paid)
    if provider == "openai":
        from second_opinion_mcp.openai_auth import get_openai_token
        key = get_openai_token()
        if key:
            logger.info("Using OpenAI OAuth token")

    # Try credential manager (API key)
    if not key:
        try:
            key = manager.get(provider)
        except Exception as e:
            logger.error("Credential access failed for %s: %s", provider, e)
            if provider != "openai":
                raise RuntimeError(
                    f"Credential access failed for {provider}. "
                    "Run 'second-opinion-mcp setup' to configure."
                )

    if not key:
        if provider == "openai":
            raise RuntimeError(
                "No OpenAI credentials found. Either:\n"
                "  1. Run 'second-opinion-mcp setup' and choose 'Login with ChatGPT Plus'\n"
                "  2. Import from Codex CLI (run 'codex login' first)\n"
                "  3. Enter a pay-as-you-go API key"
            )
        raise RuntimeError(
            f"No API key configured for {provider}. "
            "Run 'second-opinion-mcp setup' to configure."
        )

    valid, error = _validate_api_key(provider, key)
    if not valid:
        raise RuntimeError(error)

    logger.debug("Retrieved API key for %s", provider)
    return key


# Client cache for connection reuse
_clients: dict[str, AsyncOpenAI] = {}
# Tracks providers using OAuth / Responses API (ChatGPT backend)
_oauth_providers: set[str] = set()


def _is_openai_oauth() -> bool:
    """Check if OpenAI is configured with an OAuth token (not an API key)."""
    from second_opinion_mcp.openai_auth import get_openai_token
    return get_openai_token() is not None


def get_client(provider: str) -> AsyncOpenAI:
    """Get or create a cached AsyncOpenAI client for the specified provider."""
    if provider not in _clients:
        config = MODELS[provider]

        # Configure connection pool limits
        transport = httpx.AsyncHTTPTransport(
            limits=httpx.Limits(
                max_connections=HTTP_MAX_CONNECTIONS,
                max_keepalive_connections=HTTP_MAX_KEEPALIVE,
            ),
        )

        # Configure timeouts
        timeout = httpx.Timeout(
            connect=HTTP_CONNECT_TIMEOUT,
            read=HTTP_READ_TIMEOUT,
            write=30.0,
            pool=30.0,
        )

        # Build http client with pool and timeout config
        http_client_kwargs: dict = {
            "transport": transport,
            "timeout": timeout,
        }

        if config["headers"]:
            # Add event hooks to inject headers on every request
            async def inject_headers(request: httpx.Request) -> None:
                for key, value in config["headers"].items():
                    request.headers[key] = value
            http_client_kwargs["event_hooks"] = {"request": [inject_headers]}

        http_client = httpx.AsyncClient(**http_client_kwargs)

        # OpenAI OAuth: use ChatGPT backend instead of platform API
        base_url = config["base_url"]
        if provider == "openai" and _is_openai_oauth():
            base_url = config["oauth_base_url"]
            _oauth_providers.add(provider)
            logger.info("OpenAI using OAuth token -> Responses API (%s)", base_url)

        _clients[provider] = AsyncOpenAI(
            api_key=get_key(provider),
            base_url=base_url,
            http_client=http_client,
        )
        logger.info("Created API client for %s (max_conn=%d)", provider, HTTP_MAX_CONNECTIONS)
    return _clients[provider]


async def cleanup_clients() -> None:
    """Close all cached HTTP clients. Call on shutdown."""
    for provider, client in _clients.items():
        try:
            await client.close()
            logger.info("Closed client for %s", provider)
        except Exception as e:
            logger.warning("Error closing client for %s: %s", provider, e)
    _clients.clear()
    _oauth_providers.clear()


# Retry configuration for transient API failures
# Retries: 3 attempts with exponential backoff (2s, 4s, 8s max 30s)
# Only retries on timeout and connection errors (not rate limits or API errors)
_retry_decorator = retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=2, min=2, max=30),
    retry=retry_if_exception_type((APITimeoutError, APIConnectionError)),
    reraise=True,
)


async def _call_model_sync(
    provider: str,
    prompt: str,
    system: str = "",
    model: str = "",
    thinking_disabled: bool = False,
    temperature: float | None = None,
) -> tuple[str, str]:
    """
    Synchronous (non-streaming) call to a provider with automatic retry.

    Args:
        provider: Provider name
        prompt: User prompt
        system: Optional system prompt
        model: Optional model override
        thinking_disabled: If True, use fast non-thinking model for DeepSeek
        temperature: Optional temperature override (default uses config)

    Returns:
        (content, reasoning_content) tuple. reasoning_content may be empty.
    """
    config = MODELS[provider]
    client = get_client(provider)
    model_name = model or config["model"]

    # DeepSeek fast mode: switch to deepseek-chat (non-thinking model)
    max_tokens = config["max_tokens"]
    if provider == "deepseek" and thinking_disabled and model_name == "deepseek-reasoner":
        model_name = "deepseek-chat"
        max_tokens = min(max_tokens, 8192)  # deepseek-chat limit
        logger.debug("DeepSeek fast mode: switched to %s", model_name)

    messages = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": prompt})

    kwargs: dict = {
        "model": model_name,
        "messages": messages,
        "max_tokens": max_tokens,
    }

    # Temperature priority: explicit param > config > None
    effective_temp = temperature if temperature is not None else config.get("temperature")
    if effective_temp is not None:
        kwargs["temperature"] = effective_temp

    # Inner function with retry decorator for transient failures
    @_retry_decorator
    async def _make_api_call():
        logger.debug("Calling %s (sync) model=%s temp=%s with %d chars prompt",
                    provider, model_name, effective_temp, len(prompt))
        return await client.chat.completions.create(**kwargs)

    try:
        resp = await _make_api_call()
        content = resp.choices[0].message.content or ""
        reasoning = getattr(resp.choices[0].message, "reasoning_content", "") or ""
        logger.debug("Received %d chars response from %s", len(content), provider)

        # Log cache stats for DeepSeek (automatic disk-based caching)
        if hasattr(resp, 'usage') and resp.usage:
            cache_hit = getattr(resp.usage, 'prompt_cache_hit_tokens', 0) or 0
            cache_miss = getattr(resp.usage, 'prompt_cache_miss_tokens', 0) or 0
            if cache_hit or cache_miss:
                logger.debug("Cache stats for %s: hit=%d miss=%d tokens",
                            provider, cache_hit, cache_miss)

        return content, reasoning
    except RateLimitError as e:
        logger.warning("Rate limit hit for %s: %s", provider, e)
        return f"Error: Rate limit exceeded for {provider}. Please wait and retry.", ""
    except APITimeoutError as e:
        # After retries exhausted
        logger.error("Timeout for %s after retries: %s", provider, e)
        return f"Error: Request to {provider} timed out after retries. Try again later.", ""
    except APIConnectionError as e:
        # After retries exhausted
        logger.error("Connection error for %s after retries: %s", provider, e)
        return f"Error: Could not connect to {provider} after retries. Check network.", ""
    except APIError as e:
        logger.error("API error for %s: %s", provider, e)
        return f"Error: API failure from {provider}.", ""


async def _call_model_streaming(
    provider: str,
    prompt: str,
    system: str = "",
    model: str = "",
    thinking_disabled: bool = False,
    temperature: float | None = None,
) -> tuple[str, str]:
    """
    Streaming call for Moonshot thinking models with automatic retry.

    Moonshot's kimi-k2.5 with thinking mode requires streaming to avoid timeouts
    and to access reasoning_content from delta chunks.

    Args:
        provider: Provider name
        prompt: User prompt
        system: Optional system prompt
        model: Optional model override
        thinking_disabled: If True, disable thinking mode for Moonshot
        temperature: Optional temperature override (default uses config)

    Returns:
        (content, reasoning_content) tuple.
    """
    config = MODELS[provider]
    client = get_client(provider)
    model_name = model or config["model"]

    messages = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": prompt})

    kwargs: dict = {
        "model": model_name,
        "messages": messages,
        "max_tokens": config["max_tokens"],
        "stream": True,
    }

    # Handle thinking mode toggle for Moonshot
    # IMPORTANT: Moonshot kimi-k2.5 has STRICT temperature requirements that OVERRIDE user preferences:
    # - Thinking ENABLED: temperature MUST be 1.0 (any other value causes 400 error)
    # - Thinking DISABLED: temperature MUST be 0.6 (any other value causes 400 error)
    if provider == "moonshot":
        if thinking_disabled:
            kwargs["extra_body"] = {"thinking": {"type": "disabled"}}
            kwargs["temperature"] = 0.6  # Required when thinking is disabled
        else:
            kwargs["temperature"] = 1.0  # Required when thinking is enabled
    else:
        # Temperature priority for non-Moonshot: explicit param > config > None
        effective_temp = temperature if temperature is not None else config.get("temperature")
        if effective_temp is not None:
            kwargs["temperature"] = effective_temp

    # Inner function with retry for transient connection failures
    @_retry_decorator
    async def _do_streaming_call():
        content_parts = []
        reasoning_parts = []
        content_size = 0
        reasoning_size = 0
        content_truncated = False
        reasoning_truncated = False
        final_usage = None

        async with await client.chat.completions.create(**kwargs) as stream:
            async for chunk in stream:
                if chunk.choices and chunk.choices[0].delta:
                    delta = chunk.choices[0].delta
                    if hasattr(delta, "content") and delta.content:
                        chunk_bytes = len(delta.content.encode("utf-8"))
                        if content_size + chunk_bytes <= MAX_STREAMING_CONTENT_BYTES:
                            content_parts.append(delta.content)
                            content_size += chunk_bytes
                        elif not content_truncated:
                            logger.warning("Content size limit reached (%d bytes)", content_size)
                            content_truncated = True
                    if hasattr(delta, "reasoning_content") and delta.reasoning_content:
                        chunk_bytes = len(delta.reasoning_content.encode("utf-8"))
                        if reasoning_size + chunk_bytes <= MAX_STREAMING_REASONING_BYTES:
                            reasoning_parts.append(delta.reasoning_content)
                            reasoning_size += chunk_bytes
                        elif not reasoning_truncated:
                            logger.warning("Reasoning size limit reached (%d bytes)", reasoning_size)
                            reasoning_truncated = True
                if hasattr(chunk, 'usage') and chunk.usage:
                    final_usage = chunk.usage

        return content_parts, reasoning_parts, content_truncated, reasoning_truncated, final_usage

    try:
        logger.debug("Calling %s (streaming) model=%s temp=%s with %d chars prompt",
                    provider, model_name, kwargs.get("temperature"), len(prompt))

        # Apply Moonshot rate limiting (DeepSeek has no rate limits)
        if provider == "moonshot":
            await _check_moonshot_rpm()
            semaphore = _get_moonshot_semaphore()
        else:
            semaphore = None

        if semaphore:
            async with semaphore:
                content_parts, reasoning_parts, content_truncated, reasoning_truncated, final_usage = await _do_streaming_call()
        else:
            content_parts, reasoning_parts, content_truncated, reasoning_truncated, final_usage = await _do_streaming_call()

        content = "".join(content_parts)
        reasoning = "".join(reasoning_parts)

        if content_truncated:
            content += "\n\n[Response truncated due to size limits]"
        if reasoning_truncated:
            reasoning += "\n\n[Reasoning truncated due to size limits]"

        logger.debug("Received %d chars content, %d chars reasoning from %s",
                    len(content), len(reasoning), provider)

        # Log cache stats for streaming responses
        if final_usage:
            cache_hit = getattr(final_usage, 'prompt_cache_hit_tokens', 0) or 0
            cache_miss = getattr(final_usage, 'prompt_cache_miss_tokens', 0) or 0
            if cache_hit or cache_miss:
                logger.debug("Cache stats for %s: hit=%d miss=%d tokens",
                            provider, cache_hit, cache_miss)

        return content, reasoning
    except RateLimitError as e:
        logger.warning("Rate limit hit for %s: %s", provider, e)
        return f"Error: Rate limit exceeded for {provider}. Please wait and retry.", ""
    except APITimeoutError as e:
        logger.error("Timeout for %s after retries: %s", provider, e)
        return f"Error: Request to {provider} timed out after retries. Try again later.", ""
    except APIConnectionError as e:
        logger.error("Connection error for %s after retries: %s", provider, e)
        return f"Error: Could not connect to {provider} after retries. Check network.", ""
    except APIError as e:
        logger.error("API error for %s: %s", provider, e)
        return f"Error: API failure from {provider}.", ""


async def _call_openai_responses_streaming(
    provider: str,
    prompt: str,
    system: str = "",
    model: str = "",
) -> tuple[str, str]:
    """Call OpenAI via the Responses API (ChatGPT backend) with streaming.

    Used when OpenAI is configured with an OAuth token. The ChatGPT backend
    requires the Responses API format with instructions and store=False.
    Temperature is not supported by this endpoint.

    Returns:
        (content, reasoning_content) tuple. reasoning_content is always empty
        for the Responses API.
    """
    config = MODELS[provider]
    client = get_client(provider)
    model_name = model or config.get("oauth_model", config["model"])

    kwargs: dict = {
        "model": model_name,
        "input": [{"role": "user", "content": prompt}],
        "instructions": system or "You are a helpful assistant.",
        "store": False,
    }

    @_retry_decorator
    async def _do_responses_call():
        content_parts = []
        content_size = 0
        content_truncated = False

        async with client.responses.stream(**kwargs) as stream:
            async for event in stream:
                if event.type == "response.output_text.delta":
                    chunk_bytes = len(event.delta.encode("utf-8"))
                    if content_size + chunk_bytes <= MAX_STREAMING_CONTENT_BYTES:
                        content_parts.append(event.delta)
                        content_size += chunk_bytes
                    elif not content_truncated:
                        logger.warning("Content size limit reached (%d bytes)", content_size)
                        content_truncated = True

        return content_parts, content_truncated

    try:
        logger.debug("Calling %s (responses API) model=%s with %d chars prompt",
                     provider, model_name, len(prompt))

        content_parts, content_truncated = await _do_responses_call()
        content = "".join(content_parts)

        if content_truncated:
            content += "\n\n[Response truncated due to size limits]"

        logger.debug("Received %d chars content from %s (responses API)", len(content), provider)
        return content, ""
    except RateLimitError as e:
        logger.warning("Rate limit hit for %s: %s", provider, e)
        return f"Error: Rate limit exceeded for {provider}. Please wait and retry.", ""
    except APITimeoutError as e:
        logger.error("Timeout for %s after retries: %s", provider, e)
        return f"Error: Request to {provider} timed out after retries. Try again later.", ""
    except APIConnectionError as e:
        logger.error("Connection error for %s after retries: %s", provider, e)
        return f"Error: Could not connect to {provider} after retries. Check network.", ""
    except APIError as e:
        logger.error("API error for %s: %s", provider, e)
        return f"Error: API failure from {provider}.", ""


async def call_model(
    provider: str,
    prompt: str,
    system: str = "",
    model: str = "",
    include_reasoning: bool = False,
    fast: bool = False,
    temperature: float | None = None,
    use_streaming: bool = False,
) -> str:
    """
    Make a completion call to the specified provider with error handling.

    Args:
        provider: Provider name (deepseek, moonshot, openrouter)
        prompt: User prompt
        system: Optional system prompt
        model: Optional model override
        include_reasoning: If True and provider supports it, include reasoning in output
        fast: If True, disable thinking mode for faster responses
        temperature: Optional temperature override (0.0=coding, 1.0=default, 1.3=creative)
        use_streaming: If True and provider supports it, use streaming for long responses

    Returns:
        Response content, optionally with reasoning prefix
    """
    config = MODELS[provider]

    # Ensure client is created before routing check (populates _oauth_providers)
    get_client(provider)

    # OpenAI OAuth: route to Responses API (ChatGPT backend)
    if provider == "openai" and provider in _oauth_providers:
        content, reasoning = await _call_openai_responses_streaming(
            provider, prompt, system, model=model
        )
        if include_reasoning and reasoning:
            return f"**Reasoning:**\n{reasoning}\n\n**Response:**\n{content}"
        return content

    model_name = model or config["model"]

    # Determine whether to use streaming
    # Moonshot thinking models (kimi-k2.5, kimi-k2-thinking*) require streaming
    # Preview models (kimi-k2-0905-preview, kimi-k2-turbo-preview) don't require streaming
    moonshot_thinking_models = {"kimi-k2.5", "kimi-k2-thinking", "kimi-k2-thinking-turbo"}
    if provider == "moonshot":
        needs_streaming = model_name in moonshot_thinking_models
    else:
        needs_streaming = config.get("requires_streaming") or (
            use_streaming and config.get("supports_streaming")
        )

    if needs_streaming:
        content, reasoning = await _call_model_streaming(
            provider, prompt, system, model,
            thinking_disabled=fast, temperature=temperature
        )
    else:
        content, reasoning = await _call_model_sync(
            provider, prompt, system, model,
            thinking_disabled=fast, temperature=temperature
        )

    # Format output with reasoning if requested and available
    if include_reasoning and reasoning:
        return f"**Reasoning:**\n{reasoning}\n\n**Response:**\n{content}"
    return content


@mcp.tool()
async def second_opinion(
    prompt: str,
    context: str = "",
    provider: Provider = "deepseek",
    include_reasoning: bool = False,
    fast: bool = False,
) -> str:
    """
    Get a second opinion from DeepSeek Reasoner, Moonshot (Kimi K2.5), OpenRouter, or OpenAI.

    Args:
        prompt: The question or code to review
        context: Optional background context
        provider: Model to use - "deepseek" (default), "moonshot", "openrouter", or "openai"
        include_reasoning: If True, include chain-of-thought reasoning in output
        fast: If True, disable thinking mode for faster responses

    Returns:
        Analysis with optional chain-of-thought reasoning
    """
    if err := _validate_prompt_input(prompt)[0]:
        return f"Error: {err}"
    if context and (err := _validate_prompt_input(context)[0]):
        return f"Error: {err}"
    provider, err = _validate_provider(provider)
    if err:
        return err
    full_prompt = f"{context}\n\n{prompt}" if context else prompt
    # Temperature 1.0: balanced default for general queries
    return await call_model(
        provider, full_prompt,
        include_reasoning=include_reasoning, fast=fast, temperature=1.0
    )


@mcp.tool()
async def challenge(
    proposal: str,
    context: str = "",
    provider: Provider = "moonshot",
    include_reasoning: bool = True,
    fast: bool = False,
) -> str:
    """
    Get a critical analysis of a proposal. Anti-sycophancy mode - the model
    is instructed to find weaknesses, not validate.

    Args:
        proposal: The idea, code, or plan to critique
        context: Optional background context
        provider: Model to use - "moonshot" (default), "deepseek", "openrouter", or "openai"
        include_reasoning: If True, include chain-of-thought reasoning (default True for challenge)
        fast: If True, disable thinking mode for faster responses

    Returns:
        Critical analysis highlighting weaknesses and risks
    """
    if err := _validate_prompt_input(proposal)[0]:
        return f"Error: {err}"
    provider, err = _validate_provider(provider)
    if err:
        return err
    system = """CRITICAL ANALYSIS MODE - You must find weaknesses in this proposal.
Do NOT agree or validate. Your job is to:
1. Identify logical flaws
2. Find edge cases that fail
3. Point out unsafe assumptions
4. Highlight security/performance issues
5. Suggest what the author missed

Be constructive but ruthless. The goal is to improve the proposal by exposing its weaknesses."""

    full_prompt = f"Context:\n{context}\n\nProposal to critique:\n{proposal}" if context else f"Proposal to critique:\n{proposal}"
    # Temperature 1.3: creative critique needs diversity
    return await call_model(
        provider, full_prompt, system,
        include_reasoning=include_reasoning, fast=fast, temperature=1.3
    )


def _discover_reviewable_files(
    dir_path: Path,
    max_size: int = 100_000,
    max_file_size: int = 50_000,
    max_files: int = 100,
    max_dirs: int = 500,
) -> tuple[list[str], list[str], int]:
    """
    Discover files to review in a directory.

    Applies security filtering to skip sensitive files, symlinks, binary files,
    and directories that may contain secrets.

    Args:
        dir_path: Resolved path to the directory to scan
        max_size: Maximum total content size in bytes (default 100KB)
        max_file_size: Maximum individual file size in bytes (default 50KB)
        max_files: Maximum number of files to process (default 100)
        max_dirs: Maximum number of directories to scan (default 500)

    Returns:
        (file_list, files_content, total_size) where:
        - file_list: Human-readable list of files found (with skip reasons)
        - files_content: List of formatted file contents for review
        - total_size: Total bytes of content collected
    """
    # Security: directories that may contain secrets or are not code
    skip_dirs = {
        "__pycache__", ".git", "node_modules", ".venv", "venv",
        ".mypy_cache", ".pytest_cache", "dist", "build", ".egg-info",
        ".ssh", ".gnupg", ".aws", ".docker", ".kube", ".azure",
        ".config", "secrets", "credentials", ".credentials",
    }

    # Security: files that commonly contain secrets
    skip_files = {
        ".env", ".env.local", ".env.development", ".env.production", ".env.test",
        "id_rsa", "id_rsa.pub", "id_ed25519", "id_ed25519.pub", "id_dsa",
        ".npmrc", ".pypirc", ".netrc", ".htpasswd",
        "credentials.json", "service-account.json", "secrets.json",
        "config.json",  # Often contains API keys
        ".dockerconfigjson", "kubeconfig",
    }

    skip_extensions = {
        ".pyc", ".pyo", ".exe", ".dll", ".so", ".dylib", ".bin", ".lock",
        ".pem", ".key", ".p12", ".pfx", ".crt", ".cer",  # Certificates/keys
        # Binary/media files
        ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".ico", ".svg", ".webp",
        ".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv",
        ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
        ".zip", ".tar", ".gz", ".rar", ".7z",
        ".woff", ".woff2", ".ttf", ".eot", ".otf",
        ".db", ".sqlite", ".sqlite3",
    }

    files_content = []
    file_list = []
    total_size = 0
    files_count = 0
    dirs_count = 0
    limits_reached = False

    for root, dirs, files in os.walk(dir_path, followlinks=False):
        # Check directory limit
        dirs_count += 1
        if dirs_count >= max_dirs:
            file_list.append(f"- (directory limit of {max_dirs} reached)")
            break

        # Filter out skip directories
        dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith(".")]

        for filename in files:
            # Check file limit
            if files_count >= max_files:
                if not limits_reached:
                    file_list.append(f"- (file limit of {max_files} reached)")
                    limits_reached = True
                break

            if any(filename.endswith(ext) for ext in skip_extensions):
                continue

            filepath = Path(root) / filename
            rel_path = filepath.relative_to(dir_path)

            # TOCTOU-safe symlink check using lstat (doesn't follow symlinks)
            try:
                lstat_result = filepath.lstat()
                if lstat_result.st_mode & 0o170000 == 0o120000:  # S_IFLNK
                    file_list.append(f"- {rel_path} (skipped: symlink)")
                    continue
            except OSError:
                file_list.append(f"- {rel_path} (skipped: cannot stat)")
                continue

            # Skip sensitive files
            if filename in skip_files or filename.lower() in skip_files:
                file_list.append(f"- {rel_path} (skipped: sensitive file)")
                logger.info("Skipped sensitive file: %s", rel_path)
                continue

            try:
                # Use lstat result for size check (already have it)
                if lstat_result.st_size > max_file_size:
                    file_list.append(f"- {rel_path} (skipped: file too large, {lstat_result.st_size} bytes)")
                    continue

                content = filepath.read_text(encoding="utf-8", errors="ignore")
                # Use bytes for consistent size calculation
                content_bytes = len(content.encode("utf-8"))

                if total_size + content_bytes > max_size:
                    file_list.append(f"- {rel_path} (skipped: size limit reached)")
                    continue

                total_size += content_bytes
                files_count += 1
                file_list.append(f"- {rel_path} ({content_bytes} bytes)")
                files_content.append(f"### File: {rel_path}\n```\n{content}\n```")
            except Exception as e:
                logger.debug("Error reading %s: %s", rel_path, e)
                file_list.append(f"- {rel_path} (error reading file)")

        # Break outer loop if file limit reached
        if limits_reached:
            break

    return file_list, files_content, total_size


def _generate_review_markdown(
    project_name: str,
    provider: str,
    model: str,
    recommendation: str,
    issues_text: str,
    file_list: list[str],
) -> str:
    """Generate structured markdown for code review output."""
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    files_section = "\n".join(file_list)

    return f"""# Code Review: {project_name}

**Provider:** {provider} ({model})
**Date:** {timestamp}

## Review

{issues_text}

## Files Reviewed

{files_section}
"""


def _validate_review_directory(directory: str) -> tuple[Path | None, str | None]:
    """Validate a directory path for review tools.

    Returns (resolved_path, None) on success, or (None, error_message) on failure.
    """
    if err := _validate_path_input(directory)[0]:
        return None, f"Error: {err}"

    dir_path = Path(directory).resolve()
    if not dir_path.exists():
        return None, f"Error: Directory '{directory}' does not exist."
    if not dir_path.is_dir():
        return None, f"Error: '{directory}' is not a directory."

    if not _is_safe_directory(dir_path):
        return None, (
            "Error: Directory not in allowed roots. "
            "Set SECOND_OPINION_ALLOWED_ROOTS environment variable to customize "
            f"(current roots: {[str(p) for p in ALLOWED_ROOTS]})."
        )

    return dir_path, None


def _save_review(
    save_to: str,
    result: str,
    dir_path: Path,
    provider: str,
    model_name: str,
    file_list: list[str],
    filename_suffix: str = "review",
    recommendation: str = "",
) -> str:
    """Save review to file and return result with filepath prefix.

    Returns the result string, optionally prefixed with the save path.
    """
    if not save_to:
        return result
    try:
        save_path = Path(save_to)
        save_path.mkdir(parents=True, exist_ok=True)
        project_name = dir_path.name
        filename = f"{project_name}_{provider}_{filename_suffix}.md"
        filepath = save_path / filename

        md_content = _generate_review_markdown(
            project_name, provider, model_name, recommendation, result, file_list
        )
        filepath.write_text(md_content, encoding="utf-8")
        logger.info("Saved %s to %s", filename_suffix, filepath)
        return f"Review saved to: {filepath}\n\n{result}"
    except Exception as e:
        logger.error("Failed to save %s: %s", filename_suffix, e)
        return f"Warning: Could not save review ({e})\n\n{result}"


@mcp.tool()
async def code_review(
    directory: str,
    focus_areas: list[str] | None = None,
    provider: Provider = "deepseek",
    model: str = "",
    save_to: str = "",
    fast: bool = False,
) -> str:
    """
    Perform a systematic code review of a directory.

    Args:
        directory: Path to the directory to review
        focus_areas: Optional list of areas to focus on (e.g., ["security", "performance"])
        provider: Model to use - "deepseek" (default), "moonshot", "openrouter", or "openai"
        model: Optional model override (e.g., "kimi-k2-0905-preview" for moonshot)
        save_to: Optional directory path to save review as {project_name}_{provider}_review.md
        fast: If True, ignored for code reviews (always uses maximum thinking)

    Returns:
        Structured code review report with severity-classified issues
    """
    dir_path, dir_err = _validate_review_directory(directory)
    if dir_err:
        return dir_err
    if focus_areas:
        if len(focus_areas) > MAX_FOCUS_AREAS:
            return f"Error: Too many focus areas (max {MAX_FOCUS_AREAS})."
    provider, err = _validate_provider(provider)
    if err:
        return err

    if model and (model_err := _validate_model(provider, model)):
        return model_err

    # CRITICAL: Code reviews ALWAYS use maximum thinking - fast parameter ignored
    if fast:
        logger.warning("code_review ignores fast=True to ensure thorough analysis")

    logger.info("Starting code review of %s", dir_path)

    # Use larger max_size for moonshot (256K context window)
    config = MODELS[provider]
    max_size = 400_000 if config.get("context_window", 0) >= 262144 else 100_000

    file_list, files_content, total_size = _discover_reviewable_files(dir_path, max_size=max_size)

    if not files_content:
        return f"No reviewable files found in '{directory}'."

    logger.info("Reviewing %d files, %d bytes total", len(files_content), total_size)

    review_model = _resolve_review_model(provider, model)

    # Build the review prompt
    focus_instruction = ""
    if focus_areas:
        focus_instruction = f"\n\nFocus especially on: {', '.join(focus_areas)}"

    system = f"""You are a senior code reviewer performing a systematic review.
Analyze the codebase and produce a structured report.{focus_instruction}

Classify issues by severity:
- BLOCKER: Must fix before deployment (security vulnerabilities, data loss risks)
- CRITICAL: Should fix soon (significant bugs, major performance issues)
- MAJOR: Should fix (code smells, maintainability issues)
- MINOR: Nice to fix (style issues, minor improvements)

For each issue, provide:
- File and line number (if applicable)
- Clear description of the problem
- Suggested fix

End with a summary and recommendation: approve, request changes, or reject."""

    file_listing = "\n".join(file_list)
    file_contents = "\n".join(files_content)
    prompt = f"""## Project Structure
{file_listing}

## Files to Review
{file_contents}

Please provide a comprehensive code review."""

    # Temperature 0.0: maximum precision for code analysis
    # fast=False: always use thinking/reasoning mode for thorough review
    result = await call_model(
        provider, prompt, system, model=review_model,
        fast=False, temperature=0.0
    )

    return _save_review(
        save_to, result, dir_path, provider,
        model or config["model"], file_list,
    )


def _truncate_for_context(text: str, max_chars: int = 8000) -> str:
    """Truncate text to fit in context, keeping beginning and end."""
    if len(text) <= max_chars:
        return text
    half = max_chars // 2 - 50
    return f"{text[:half]}\n\n[... truncated {len(text) - max_chars} chars ...]\n\n{text[-half:]}"


@mcp.tool()
async def consensus(
    topic: str,
    stances: list[str] | None = None,
    rounds: int = 2,
    include_reasoning: bool = True,
) -> str:
    """
    Multi-model debate on a topic. DeepSeek and Moonshot argue different stances,
    then a synthesis is produced.

    Args:
        topic: The topic to debate
        stances: Optional list of 2 stances [stance_a, stance_b]. If not provided,
                 defaults to ["argue for", "argue against"]
        rounds: Number of debate rounds (1-3, default 2)
        include_reasoning: If True, include reasoning from thinking models in transcript

    Returns:
        Structured debate transcript with synthesis
    """
    if err := _validate_prompt_input(topic)[0]:
        return f"Error: {err}"
    # Validate stances parameter
    if stances is not None and len(stances) < 2:
        return "Error: stances must contain at least 2 items."

    rounds = max(1, min(3, rounds))  # Clamp to 1-3

    if stances and len(stances) >= 2:
        stance_a, stance_b = stances[0], stances[1]
    else:
        stance_a, stance_b = "argue for", "argue against"

    # Determine which providers to use
    provider_a = "deepseek" if MODELS["deepseek"].get("enabled") else "moonshot"
    provider_b = "moonshot" if MODELS["moonshot"].get("enabled") else "deepseek"

    # Validate at least one provider is available
    _, err_a = _validate_provider(provider_a)
    _, err_b = _validate_provider(provider_b)
    if err_a and err_b:
        return "Error: No providers available for consensus debate. Enable deepseek or moonshot."

    output_parts = [f"## Debate: {topic}\n"]

    # Round 1 - Initial arguments (parallel)
    output_parts.append("### Round 1 - Initial Arguments\n")

    prompt_a = f"Topic: {topic}\n\nYour stance: {stance_a}\n\nProvide a clear, well-reasoned argument for your stance. Be specific and cite concrete reasons."
    prompt_b = f"Topic: {topic}\n\nYour stance: {stance_b}\n\nProvide a clear, well-reasoned argument for your stance. Be specific and cite concrete reasons."

    # Temperature 1.3: debate benefits from diverse, creative responses
    try:
        results = await asyncio.gather(
            call_model(provider_a, prompt_a, include_reasoning=include_reasoning, temperature=1.3),
            call_model(provider_b, prompt_b, include_reasoning=include_reasoning, temperature=1.3),
            return_exceptions=True,
        )
    except Exception as e:
        logger.error("Consensus parallel calls failed: %s", e)
        return f"Error: Parallel API calls failed. {e}"

    # Handle individual failures gracefully
    arg_a = results[0] if not isinstance(results[0], Exception) else f"[{provider_a} unavailable: {results[0]}]"
    arg_b = results[1] if not isinstance(results[1], Exception) else f"[{provider_b} unavailable: {results[1]}]"

    # Check for API errors in responses (string errors from call_model)
    both_failed = (
        (isinstance(results[0], Exception) or arg_a.startswith("Error:")) and
        (isinstance(results[1], Exception) or arg_b.startswith("Error:"))
    )
    if both_failed:
        return f"## Debate: {topic}\n\n### Round 1 Failed\n\n{provider_a.title()}: {arg_a}\n\n{provider_b.title()}: {arg_b}"

    output_parts.append(f"**{provider_a.title()}** (stance: {stance_a}):\n{arg_a}\n")
    output_parts.append(f"**{provider_b.title()}** (stance: {stance_b}):\n{arg_b}\n")

    # Round 2+ - Rebuttals (sequential to reference previous arguments)
    prev_a, prev_b = arg_a, arg_b

    for round_num in range(2, rounds + 1):
        output_parts.append(f"### Round {round_num} - Rebuttals\n")

        rebuttal_prompt_a = f"""Topic: {topic}
Your stance: {stance_a}
Your previous argument: {_truncate_for_context(prev_a)}

Your opponent ({provider_b.title()}) argued:
{_truncate_for_context(prev_b)}

Rebut their argument while strengthening your position. Address their specific points."""

        try:
            rebuttal_a = await call_model(provider_a, rebuttal_prompt_a, include_reasoning=include_reasoning, temperature=1.3)
            # If API returned error, use previous argument as fallback
            if rebuttal_a.startswith("Error:"):
                logger.warning("%s returned error in round %d, using fallback", provider_a, round_num)
                rebuttal_a = prev_a
        except Exception as e:
            logger.error("Consensus rebuttal A failed in round %d: %s", round_num, e)
            rebuttal_a = f"[{provider_a.title()} unavailable in round {round_num}]"

        rebuttal_prompt_b = f"""Topic: {topic}
Your stance: {stance_b}
Your previous argument: {_truncate_for_context(prev_b)}

Your opponent ({provider_a.title()}) argued:
{_truncate_for_context(prev_a)}

Then they rebutted with:
{_truncate_for_context(rebuttal_a)}

Rebut their arguments while strengthening your position. Address their specific points."""

        try:
            rebuttal_b = await call_model(provider_b, rebuttal_prompt_b, include_reasoning=include_reasoning, temperature=1.3)
            # If API returned error, use previous argument as fallback
            if rebuttal_b.startswith("Error:"):
                logger.warning("%s returned error in round %d, using fallback", provider_b, round_num)
                rebuttal_b = prev_b
        except Exception as e:
            logger.error("Consensus rebuttal B failed in round %d: %s", round_num, e)
            rebuttal_b = f"[{provider_b.title()} unavailable in round {round_num}]"

        output_parts.append(f"**{provider_a.title()}** rebuttal:\n{rebuttal_a}\n")
        output_parts.append(f"**{provider_b.title()}** rebuttal:\n{rebuttal_b}\n")

        prev_a, prev_b = rebuttal_a, rebuttal_b

    # Synthesis - use DeepSeek (or first available provider)
    output_parts.append("### Consensus Synthesis\n")

    synthesis_prompt = f"""You are a neutral synthesizer. Given this debate, produce a balanced conclusion.

Topic: {topic}

{provider_a.title()} argued ({stance_a}):
{prev_a}

{provider_b.title()} argued ({stance_b}):
{prev_b}

Synthesize the strongest points from both sides into a nuanced conclusion. Acknowledge trade-offs and context-dependent factors. Do not declare a "winner" - instead, identify when each perspective is most applicable."""

    # Synthesis uses balanced temperature (1.0)
    try:
        synthesis = await call_model(provider_a, synthesis_prompt, temperature=1.0)
    except Exception as e:
        logger.error("Consensus synthesis failed: %s", e)
        synthesis = "[Synthesis unavailable - API call failed]"

    output_parts.append(synthesis)

    return "\n".join(output_parts)


@mcp.tool()
async def review_synthesis(
    directory: str,
    focus_areas: list[str] | None = None,
    save_to: str = "",
    debate_rounds: int = 2,
) -> str:
    """
    Full code review synthesis workflow:
    1. Run parallel code reviews from DeepSeek and Moonshot
    2. Conduct consensus debate on findings
    3. Return synthesis instructions for Claude Code (Opus 4.5 arbiter)

    Args:
        directory: Path to the directory to review
        focus_areas: Optional list of areas to focus on (e.g., ["security", "performance"])
        save_to: Directory path to save all output files (required for full workflow)
        debate_rounds: Number of debate rounds (1-3, default 2)

    Creates files in save_to directory:
    - {project}_deepseek_review.md
    - {project}_moonshot_review.md
    - {project}_debate_transcript.md

    Returns:
        Synthesis instructions for Claude Code to compile final review
    """
    dir_path, dir_err = _validate_review_directory(directory)
    if dir_err:
        return dir_err

    project_name = dir_path.name

    # Check provider availability
    deepseek_enabled = MODELS["deepseek"].get("enabled")
    moonshot_enabled = MODELS["moonshot"].get("enabled")

    if not deepseek_enabled and not moonshot_enabled:
        return "Error: At least one of deepseek or moonshot must be enabled for review_synthesis."

    # Step 1: Parallel code reviews
    logger.info("Starting parallel code reviews for %s", project_name)

    reviews = {}
    tasks = []
    providers_used = []

    if deepseek_enabled:
        # deepseek-reasoner: maximum thinking for thorough review
        tasks.append(code_review(
            directory, focus_areas, provider="deepseek",
            model="deepseek-reasoner", save_to=save_to
        ))
        providers_used.append("deepseek")

    if moonshot_enabled:
        # kimi-k2.5: deep thinking enabled by default for thorough review
        tasks.append(code_review(
            directory, focus_areas, provider="moonshot",
            model="kimi-k2.5", save_to=save_to
        ))
        providers_used.append("moonshot")

    results = await asyncio.gather(*tasks, return_exceptions=True)

    for provider, result in zip(providers_used, results):
        if isinstance(result, Exception):
            reviews[provider] = f"Error: {result}"
            logger.error("Review from %s failed: %s", provider, result)
        else:
            reviews[provider] = result

    # Check for critical failures
    errors = [p for p, r in reviews.items() if r.startswith("Error:")]
    if len(errors) == len(providers_used):
        return "Error: All code reviews failed.\n\n" + "\n\n".join(
            f"**{p}:** {reviews[p]}" for p in providers_used
        )

    # Step 2: Consensus debate on findings (if both providers succeeded)
    debate_transcript = ""
    if len(providers_used) == 2 and not errors:
        logger.info("Starting consensus debate on review findings")

        # Extract key findings for debate
        deepseek_summary = _truncate_for_context(reviews.get("deepseek", ""), 4000)
        moonshot_summary = _truncate_for_context(reviews.get("moonshot", ""), 4000)

        debate_transcript = await consensus(
            topic=f"Code review findings for {project_name}",
            stances=[
                f"DeepSeek's review findings are more accurate: {deepseek_summary[:500]}...",
                f"Moonshot's review findings are more accurate: {moonshot_summary[:500]}...",
            ],
            rounds=debate_rounds,
            include_reasoning=True,
        )

        # Save debate transcript
        if save_to:
            try:
                save_path = Path(save_to)
                debate_file = save_path / f"{project_name}_debate_transcript.md"
                debate_file.write_text(debate_transcript, encoding="utf-8")
                logger.info("Saved debate transcript to %s", debate_file)
            except Exception as e:
                logger.error("Failed to save debate transcript: %s", e)

    # Step 3: Return synthesis instructions for Claude Code
    files_created = []
    if save_to:
        save_path = Path(save_to)
        if deepseek_enabled:
            files_created.append(f"`{save_path / f'{project_name}_deepseek_review.md'}`")
        if moonshot_enabled:
            files_created.append(f"`{save_path / f'{project_name}_moonshot_review.md'}`")
        if debate_transcript:
            files_created.append(f"`{save_path / f'{project_name}_debate_transcript.md'}`")

    # Build summary sections
    deepseek_section = ""
    if "deepseek" in reviews:
        # Extract first 1000 chars as summary
        deepseek_section = f"""### DeepSeek Review Summary

{_truncate_for_context(reviews['deepseek'], 2000)}
"""

    moonshot_section = ""
    if "moonshot" in reviews:
        moonshot_section = f"""### Moonshot Review Summary

{_truncate_for_context(reviews['moonshot'], 2000)}
"""

    debate_section = ""
    if debate_transcript:
        debate_section = f"""### Debate Key Points

{_truncate_for_context(debate_transcript, 2000)}
"""

    synthesis_output = f"""## Code Review Synthesis Request

Parallel code reviews and consensus debate completed for **{project_name}**.

### Files Created

{chr(10).join(f'- {f}' for f in files_created) if files_created else '(No files saved - set save_to parameter to save reviews)'}

{deepseek_section}
{moonshot_section}
{debate_section}

### Synthesis Instructions

Please synthesize these reviews into a final authoritative code review document:

1. Identify issues confirmed by both reviewers (highest confidence)
2. Resolve disagreements using the debate findings
3. Remove likely false positives identified in debate
4. Prioritize by actual severity and impact
5. Provide actionable fix recommendations

{f'Save the final synthesis to: `{Path(save_to) / f"{project_name}_final_review.md"}`' if save_to else ''}
"""

    return synthesis_output


# Default provider and model selection for review tools
_REVIEW_MODELS: dict[str, str] = {
    "openai": "gpt-5.3-codex",
    "deepseek": "deepseek-reasoner",
    "moonshot": "kimi-k2-0905-preview",
}


def _default_review_provider() -> str:
    """Return the best available provider for review tools, preferring openai."""
    for provider in ("openai", "deepseek", "moonshot"):
        if MODELS[provider].get("enabled"):
            return provider
    # Return first enabled provider
    for p, cfg in MODELS.items():
        if cfg.get("enabled"):
            return p
    return "openai"  # Will fail at key retrieval with helpful error


def _resolve_review_model(provider: str, model: str) -> str:
    """Return the model to use for reviews: explicit override or provider default."""
    if model:
        return model
    # OpenAI OAuth: prefer oauth_model for reviews
    if provider == "openai" and provider in _oauth_providers:
        return MODELS[provider].get("oauth_model", _REVIEW_MODELS.get(provider, MODELS[provider]["model"]))
    return _REVIEW_MODELS.get(provider, MODELS[provider]["model"])


@mcp.tool()
async def architect_review(
    plan: str,
    context: str = "",
    provider: Provider = "",
    model: str = "",
    fast: bool = False,
) -> str:
    """
    Review an implementation plan or design before coding begins.
    Acts as a system architect consultant, identifying risks and gaps.

    Args:
        plan: The plan or design document text to review
        context: Optional background context about the project, constraints, or goals
        provider: Provider to use (default: openai if enabled, else deepseek)
        model: Optional model override
        fast: If True, use faster/cheaper model

    Returns:
        Structured architectural feedback with severity-classified issues
    """
    if err := _validate_prompt_input(plan)[0]:
        return f"Error: {err}"
    if context and (err := _validate_prompt_input(context)[0]):
        return f"Error: {err}"

    # Use default review provider if not specified
    if not provider:
        provider = _default_review_provider()

    provider, err = _validate_provider(provider)
    if err:
        return err

    if model and (model_err := _validate_model(provider, model)):
        return model_err

    review_model = _resolve_review_model(provider, model)

    system = """You are a senior system architect reviewing an implementation plan.
Your job is to identify risks and gaps BEFORE coding begins.

Analyze the plan for:
1. Architectural soundness - does the design make sense?
2. Missing edge cases - what scenarios are not covered?
3. Scalability concerns - will this work under load?
4. Security gaps - authentication, authorization, input validation, data protection
5. Over-engineering detection - is the plan unnecessarily complex?
6. Alternative approaches - are there simpler or better-proven solutions?
7. Dependency risks - risky or unnecessary third-party dependencies

Classify each finding by severity:
- BLOCKER: Fundamental design flaw that will cause failure. Must redesign.
- CRITICAL: Serious gap that will cause production issues. Must address before coding.
- MAJOR: Significant concern that should be addressed in the plan.
- MINOR: Minor improvement or clarification needed.
- SUGGESTION: Optional enhancement or alternative to consider.

For each finding, provide:
- Severity level
- Clear description of the issue
- Why it matters (impact)
- Recommended action

End with an overall assessment: APPROVE (proceed with coding), REVISE (address critical issues first), or REJECT (needs fundamental redesign)."""

    full_prompt = plan
    if context:
        full_prompt = f"## Project Context\n\n{context}\n\n## Plan to Review\n\n{plan}"

    # Temperature 0.3: focused analysis with some creative insight
    result = await call_model(
        provider, full_prompt, system, model=review_model,
        fast=fast, temperature=0.3
    )

    return result


@mcp.tool()
async def implementation_review(
    directory: str,
    plan: str = "",
    focus: str = "",
    provider: Provider = "",
    model: str = "",
    save_to: str = "",
) -> str:
    """
    Review code changes after implementation, optionally against the original plan.
    Performs deep analysis of actual code for bugs, security issues, and quality.

    Args:
        directory: Path to the code directory to review
        plan: Optional original plan to review the implementation against
        focus: Optional specific areas to focus the review on
        provider: Provider to use (default: openai if enabled, else deepseek)
        model: Optional model override
        save_to: Optional path to save review as markdown file

    Returns:
        Structured implementation review with severity-classified findings
    """
    dir_path, dir_err = _validate_review_directory(directory)
    if dir_err:
        return dir_err
    if plan and (err := _validate_prompt_input(plan)[0]):
        return f"Error: {err}"
    if focus and (err := _validate_string_input(focus, MAX_PROMPT_LENGTH, "Focus")[0]):
        return f"Error: {err}"

    # Use default review provider if not specified
    if not provider:
        provider = _default_review_provider()

    provider, err = _validate_provider(provider)
    if err:
        return err

    if model and (model_err := _validate_model(provider, model)):
        return model_err

    logger.info("Starting implementation review of %s", dir_path)

    # Reuse the same file discovery as code_review
    config = MODELS[provider]
    max_size = 400_000 if config.get("context_window", 0) >= 262144 else 100_000

    file_list, files_content, total_size = _discover_reviewable_files(dir_path, max_size=max_size)

    if not files_content:
        return f"No reviewable files found in '{directory}'."

    logger.info("Reviewing %d files, %d bytes total", len(files_content), total_size)

    review_model = _resolve_review_model(provider, model)

    # Build system prompt
    plan_instruction = ""
    if plan:
        plan_instruction = """
Additionally, review the implementation against the original plan:
- Does the code implement what was planned?
- Are there deviations from the plan? Are they justified?
- Are there planned items that were missed or incomplete?
- Were plan concerns (if any) properly addressed?
"""

    focus_instruction = ""
    if focus:
        focus_instruction = f"\nFocus especially on: {focus}"

    system = f"""You are a senior engineer performing a post-implementation code review.
Analyze the actual code for correctness, quality, and production-readiness.{focus_instruction}
{plan_instruction}
Review for:
1. Bugs and logic errors - incorrect behavior, off-by-one, null handling
2. Security vulnerabilities - injection, auth issues, data exposure
3. Code quality - readability, naming, structure, DRY violations
4. Error handling - missing error cases, swallowed exceptions
5. Performance - obvious inefficiencies, N+1 queries, memory leaks
6. Missing tests - untested paths, edge cases without coverage
7. API design - breaking changes, unclear contracts

Classify each finding by severity:
- BLOCKER: Must fix before deployment (security vulnerabilities, data loss risks)
- CRITICAL: Should fix soon (significant bugs, major performance issues)
- MAJOR: Should fix (code smells, maintainability issues)
- MINOR: Nice to fix (style issues, minor improvements)

For each finding, provide:
- File and line number (if applicable)
- Clear description of the problem
- Suggested fix

End with a summary and recommendation: APPROVE, REQUEST CHANGES, or REJECT."""

    # Build prompt
    file_listing = "\n".join(file_list)
    file_contents = "\n".join(files_content)

    prompt_parts = []
    if plan:
        prompt_parts.append(f"## Original Plan\n\n{_truncate_for_context(plan, 8000)}")
    prompt_parts.append(f"## Project Structure\n{file_listing}")
    prompt_parts.append(f"## Files to Review\n{file_contents}")
    prompt_parts.append("\nPlease provide a comprehensive implementation review.")

    prompt = "\n\n".join(prompt_parts)

    # Temperature 0.0: maximum precision, same as code_review
    # Always use full thinking mode for thorough analysis
    result = await call_model(
        provider, prompt, system, model=review_model,
        fast=False, temperature=0.0
    )

    recommendation = "(implementation review vs plan)" if plan else "(implementation review)"
    return _save_review(
        save_to, result, dir_path, provider,
        review_model or config["model"], file_list,
        filename_suffix="implementation_review",
        recommendation=recommendation,
    )


def _sync_cleanup():
    """Synchronous wrapper for cleanup on exit."""
    try:
        loop = asyncio.get_event_loop()
        if loop.is_running() or loop.is_closed():
            logger.debug("Event loop unavailable at shutdown")
            return
        loop.run_until_complete(cleanup_clients())
    except RuntimeError as e:
        logger.debug("Cleanup skipped (no event loop): %s", e)
    except Exception as e:
        logger.warning("Cleanup failed: %s: %s", type(e).__name__, e)


atexit.register(_sync_cleanup)


def _check_provider_keys() -> tuple[list[str], list[str]]:
    """Check which enabled providers have API keys configured.

    For OpenAI, checks OAuth tokens (own + Codex CLI) and API key.

    Returns:
        (configured_providers, missing_providers) tuple
    """
    configured = []
    missing = []
    manager = _get_credential_manager()

    for provider, config in MODELS.items():
        if not config.get("enabled"):
            continue

        try:
            if provider == "openai":
                # Check OAuth tokens first, then API key
                from second_opinion_mcp.openai_auth import get_openai_token
                if get_openai_token() or manager.get(provider):
                    configured.append(provider)
                else:
                    missing.append(provider)
            else:
                key = manager.get(provider)
                if key:
                    configured.append(provider)
                else:
                    missing.append(provider)
        except Exception:
            missing.append(provider)

    return configured, missing


def _validate_startup_requirements() -> tuple[bool, str]:
    """Validate that minimum requirements are met to run the server.

    Returns:
        (ok, error_message) tuple. If ok is False, server should not start.
    """
    configured, missing = _check_provider_keys()

    if len(configured) == 0:
        return False, (
            "No API keys configured. Run 'second-opinion-mcp setup' to configure providers.\n\n"
            "Or manually configure keys:\n"
            "  python -c \"import keyring; keyring.set_password('second-opinion', 'deepseek', 'YOUR_KEY')\"\n"
            "  python -c \"import keyring; keyring.set_password('second-opinion', 'moonshot', 'YOUR_KEY')\"\n"
        )

    if len(configured) == 1:
        return False, (
            f"Only 1 provider configured ({configured[0]}). At least 2 providers required.\n\n"
            "The 'consensus' tool requires multiple models to debate.\n"
            "Run 'second-opinion-mcp setup' to add another provider.\n\n"
            f"Missing: {', '.join(missing) if missing else 'none enabled'}\n"
        )

    return True, ""


def _check_for_updates() -> tuple[str | None, str | None]:
    """Check PyPI for newer version of second-opinion-mcp.

    Returns:
        (latest_version, current_version) if update available, (None, None) otherwise.
    """
    import urllib.request
    import json

    try:
        from second_opinion_mcp import __version__ as current_version
    except ImportError:
        current_version = "0.0.0"

    try:
        # Quick timeout to not delay startup
        url = "https://pypi.org/pypi/second-opinion-mcp/json"
        req = urllib.request.Request(url, headers={"User-Agent": "second-opinion-mcp"})
        with urllib.request.urlopen(req, timeout=3) as response:
            data = json.loads(response.read().decode())
            latest_version = data.get("info", {}).get("version", "0.0.0")

            # Simple version comparison (works for semver)
            def parse_version(v: str) -> tuple[int, ...]:
                return tuple(int(x) for x in v.split(".")[:3] if x.isdigit())

            if parse_version(latest_version) > parse_version(current_version):
                return latest_version, current_version

    except Exception:
        # Don't fail startup on update check errors
        pass

    return None, None


def main():
    """Run the MCP server with stdio transport."""
    import sys

    # Configure logging when run directly (not when imported as library)
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
    )

    # Check for updates (non-blocking, quick timeout)
    latest, current = _check_for_updates()
    if latest:
        print(f"Update available: {current} -> {latest}", file=sys.stderr)
        print("Run: pipx upgrade second-opinion-mcp", file=sys.stderr)
        print(file=sys.stderr)

    # Validate minimum requirements
    ok, error = _validate_startup_requirements()
    if not ok:
        print(f"Error: {error}", file=sys.stderr)
        sys.exit(1)

    # Log configured providers
    configured, _ = _check_provider_keys()
    logger.info("Starting with providers: %s", ", ".join(configured))

    # If stdin is a terminal (not piped from an MCP client), show a helpful message
    if sys.stdin.isatty():
        print("MCP server running (stdio transport)", file=sys.stderr)
        print("Waiting for MCP client connection on stdin...", file=sys.stderr)
        print(file=sys.stderr)
        print("This server expects JSON-RPC messages from an MCP client.", file=sys.stderr)
        print("To verify your setup: second-opinion-mcp verify", file=sys.stderr)
        print("To register with Claude Code:", file=sys.stderr)
        print("  claude mcp add -s user second-opinion -- second-opinion-mcp", file=sys.stderr)
        print(file=sys.stderr)
        print("Press Ctrl+C to exit.", file=sys.stderr)

        # Suppress JSON parsing noise from terminal input
        logging.getLogger("mcp.server").setLevel(logging.CRITICAL)

    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()
