# SPDX-FileCopyrightText: 2025 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

"""
Claude Code configuration management module.

Handles Claude config files, environment variables, and model configuration.
This module provides configuration lifecycle management for Claude Code agents.
"""

import importlib
import json
import os
import random
import string
from pathlib import Path
from typing import Any, Callable, Dict, Optional

from executor.config.config import get_wegent_mcp_url
from shared.logger import setup_logger
from shared.models.execution import ExecutionRequest
from shared.utils.crypto import decrypt_sensitive_data, is_data_encrypted

logger = setup_logger("claude_code_config_manager")


def generate_claude_code_user_id() -> str:
    """Generate a random user ID for Claude Code."""
    return "".join(random.choices(string.ascii_lowercase + string.digits, k=64))


class HookManager:
    """
    Manages hook functions for Claude Code configuration.

    Hooks allow customization of Claude Code behavior at various points
    such as model creation and configuration.
    """

    _hooks: Dict[str, Callable] = {}

    @classmethod
    def load_hooks(cls) -> None:
        """Load hook configuration from /app/config/claude_hooks.json if exists."""
        if cls._hooks:
            return

        hook_config_path = Path("/app/config/claude_hooks.json")
        if not hook_config_path.exists():
            logger.debug(
                "No hook configuration file found at /app/config/claude_hooks.json"
            )
            return

        try:
            with open(hook_config_path, "r") as f:
                hook_config = json.load(f)
                logger.info(f"Loading hook configuration from {hook_config_path}")

                for hook_name, hook_path in hook_config.items():
                    try:
                        module_path, func_name = hook_path.rsplit(".", 1)
                        module = importlib.import_module(module_path)
                        hook_func = getattr(module, func_name)
                        cls._hooks[hook_name] = hook_func
                        logger.info(
                            f"Successfully loaded hook: {hook_name} from {hook_path}"
                        )
                    except Exception as e:
                        logger.warning(
                            f"Failed to load hook {hook_name} from {hook_path}: {e}"
                        )
        except Exception as e:
            logger.warning(
                f"Failed to load hook configuration from {hook_config_path}: {e}"
            )

    @classmethod
    def get_hook(cls, hook_name: str) -> Optional[Callable]:
        """Get a hook function by name."""
        return cls._hooks.get(hook_name)

    @classmethod
    def has_hook(cls, hook_name: str) -> bool:
        """Check if a hook is registered."""
        return hook_name in cls._hooks


def resolve_env_value(value: str) -> str:
    """Resolve a value that may be an environment variable template or encrypted.

    Handles different formats:
    1. ${VAR_NAME} - environment variable template, replace with os.environ value
    2. Encrypted value - decrypt using decrypt_sensitive_data
    3. Plain value - use as-is

    Args:
        value: The value to resolve

    Returns:
        The resolved value
    """
    import re

    if not value:
        return value

    # Check for ${VAR_NAME} pattern and replace with env var
    env_var_pattern = r"^\$\{([^}]+)\}$"
    match = re.match(env_var_pattern, value)
    if match:
        var_name = match.group(1)
        resolved = os.environ.get(var_name, "")
        if resolved:
            logger.info(f"Resolved env var ${{{var_name}}} from environment")
        else:
            logger.warning(f"Environment variable {var_name} not found")
        return resolved

    # Check if encrypted and decrypt
    if is_data_encrypted(value):
        decrypted = decrypt_sensitive_data(value)
        if decrypted:
            logger.info("Decrypted sensitive data")
            return decrypted
        logger.warning("Failed to decrypt sensitive data")
        return ""

    # Return as-is
    return value


def build_claude_json_config() -> Dict[str, Any]:
    """Build non-sensitive user preferences config for claude.json.

    Returns:
        Dictionary with Claude Code user preferences
    """
    return {
        "numStartups": 2,
        "installMethod": "unknown",
        "autoUpdates": True,
        "sonnet45MigrationComplete": True,
        "userID": generate_claude_code_user_id(),
        "hasCompletedOnboarding": True,
        "lastOnboardingVersion": "2.0.14",
        "bypassPermissionsModeAccepted": True,
        "hasOpusPlanDefault": False,
        "lastReleaseNotesSeen": "2.0.14",
        "isQualifiedForDataSharing": False,
    }


def create_claude_model_config(
    bot_config: Dict[str, Any],
    user_name: Optional[str] = None,
    git_url: Optional[str] = None,
) -> Dict[str, Any]:
    """Create Claude model configuration from bot config.

    Claude code settings: https://docs.claude.com/en/docs/claude-code/settings

    Args:
        bot_config: Bot configuration dictionary
        user_name: Optional user name for hooks
        git_url: Optional git URL for hooks

    Returns:
        Agent configuration dictionary
    """
    agent_config = bot_config.get("agent_config", {})
    env = agent_config.get("env", {})

    # Using user-defined input model configuration
    if not env.get("model"):
        return agent_config

    model_id = env.get("model_id", "")

    # Extract and resolve API key
    api_key = env.get("api_key", "")
    api_key = resolve_env_value(api_key)

    # Build environment configuration
    env_config = {
        "ANTHROPIC_MODEL": model_id,
        "ANTHROPIC_SMALL_FAST_MODEL": env.get("small_model", model_id),
        "ANTHROPIC_DEFAULT_HAIKU_MODEL": env.get("small_model", model_id),
        "ANTHROPIC_AUTH_TOKEN": api_key,
        "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": int(
            os.getenv("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "0")
        ),
        "CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY": int(
            os.getenv("CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY", "1")
        ),
        "CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK": int(
            os.getenv("CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK", "1")
        ),
    }

    base_url = env.get("base_url", "")
    if base_url:
        env_config["ANTHROPIC_BASE_URL"] = base_url.removesuffix("/v1")

    # Add other environment variables except model_id, api_key, base_url
    excluded_keys = {"model_id", "api_key", "base_url", "model", "small_model"}
    for key, value in env.items():
        if key not in excluded_keys and value is not None:
            env_config[key] = value

    # Apply post-creation hook if available
    hook = HookManager.get_hook("post_create_claude_model")
    if hook:
        try:
            final_config = hook(env_config, model_id, bot_config, user_name, git_url)
            logger.info("Applied post_create_claude_model hook")
            return final_config
        except Exception as e:
            logger.warning(f"Hook execution failed: {e}")

    final_claude_code_config = {
        "env": env_config,
        "includeCoAuthoredBy": os.getenv(
            "CLAUDE_CODE_INCLUDE_CO_AUTHORED_BY", "true"
        ).lower()
        != "false",
    }

    return final_claude_code_config


def _convert_mcp_servers_list_to_dict(mcp_servers: Any) -> Dict[str, Any]:
    """Convert MCP servers from list format to dict format.

    Claude Code SDK expects mcp_servers in dict format:
    {
        "serverName": {
            "type": "http",
            "url": "...",
            "headers": {...}
        }
    }

    But some configurations provide list format:
    [
        {"name": "serverName", "url": "...", "type": "http", "headers": {...}}
    ]

    This function converts list format to dict format.

    Args:
        mcp_servers: MCP servers configuration (list or dict)

    Returns:
        Dict format MCP servers configuration
    """
    if mcp_servers is None:
        return {}

    if isinstance(mcp_servers, dict):
        return mcp_servers

    if isinstance(mcp_servers, list):
        result = {}
        for item in mcp_servers:
            if isinstance(item, dict) and "name" in item:
                server_name = item["name"]
                # Create a copy without the "name" key for the server config
                server_config = {k: v for k, v in item.items() if k != "name"}
                # Rename "headers" to "auth" if present (for Claude Code SDK compatibility)
                # Note: Claude Code SDK uses different key names
                result[server_name] = server_config
        return result

    logger.warning(
        f"Unexpected mcp_servers type: {type(mcp_servers)}, returning empty dict"
    )
    return {}


def _append_mcp_servers(
    target: list[dict[str, Any]],
    mcp_servers: Any,
    source: str,
) -> None:
    """Append MCP servers in list or dict format to target."""
    if not mcp_servers:
        return

    if isinstance(mcp_servers, dict):
        for server_name, server_config in mcp_servers.items():
            if not isinstance(server_config, dict):
                logger.warning(
                    "[MCP] Skipping non-dict MCP server config from %s: %s",
                    source,
                    server_name,
                )
                continue
            target.append({"name": server_name, **server_config})
        return

    if isinstance(mcp_servers, list):
        for server in mcp_servers:
            if not isinstance(server, dict) or "name" not in server:
                logger.warning(
                    "[MCP] Skipping invalid MCP server entry from %s: %s",
                    source,
                    server,
                )
                continue
            target.append(server.copy())
        return

    logger.warning(
        "[MCP] Skipping unsupported MCP server config from %s: %s",
        source,
        type(mcp_servers).__name__,
    )


def _collect_mcp_servers_for_claude(
    task_data: ExecutionRequest,
    primary_bot_config: dict[str, Any],
) -> list[dict[str, Any]]:
    """Collect MCP servers for the single Claude SDK session."""
    collected: list[dict[str, Any]] = []

    bots = task_data.bot or []
    if task_data.mode == "coordinate" and len(bots) > 1:
        for index, bot in enumerate(bots):
            if not isinstance(bot, dict):
                continue
            mcp_servers = bot.get("mcp_servers") or bot.get("mcpServers")
            _append_mcp_servers(collected, mcp_servers, f"bot[{index}]")
        return collected

    mcp_servers = primary_bot_config.get("mcp_servers") or primary_bot_config.get(
        "mcpServers"
    )
    _append_mcp_servers(collected, mcp_servers, "bot[0]")
    return collected


def extract_claude_options(task_data: ExecutionRequest) -> Dict[str, Any]:
    """Extract Claude Code options from task data.

    Collects all non-None configuration parameters from task_data,
    including Ghost-level MCP servers. Skill-level MCP merging, type
    normalization, and reachability filtering are handled by the backend.

    Args:
        task_data: The task data object

    Returns:
        Dict containing valid Claude Code options
    """
    from executor.utils.mcp_utils import replace_mcp_server_variables

    # List of valid options for ClaudeAgentOptions
    valid_options = [
        "allowed_tools",
        "max_thinking_tokens",
        "system_prompt",
        "mcp_tools",
        "mcp_servers",
        "mcpServers",
        "permission_mode",
        "continue_conversation",
        "resume",
        "max_turns",
        "disallowed_tools",
        "model",
        "permission_prompt_tool_name",
        "cwd",
        "max_buffer_size",
        "include_partial_messages",
    ]

    # Collect all non-None configuration parameters
    # Set max_buffer_size to 50MB to handle large file reads (default is 1MB)
    # Enable include_partial_messages for streaming output (StreamEvent)
    options: Dict[str, Any] = {
        "setting_sources": ["user", "project", "local"],
        "max_buffer_size": 50 * 1024 * 1024,  # 50MB
        "include_partial_messages": True,  # Enable streaming output for real-time text updates
    }

    bots = task_data.bot
    bot_config = bots[0] if bots else {}

    if bot_config:
        # Create a shallow copy of bot_config to avoid modifying the original
        bot_config = bot_config.copy()

        # Extract MCP servers. Claude Code subagents share the parent SDK
        # session, so coordinate mode must register member bot MCP servers on
        # that same session.
        logger.info("[MCP] Extracting MCP servers for Claude Code session...")
        mcp_servers = _collect_mcp_servers_for_claude(task_data, bot_config)
        if mcp_servers:
            logger.info(
                "[MCP] Raw MCP servers for Claude Code: %s",
                (
                    list(mcp_servers.keys())
                    if isinstance(mcp_servers, dict)
                    else type(mcp_servers).__name__
                ),
            )
            # Replace placeholders in MCP servers config with actual values
            # NOTE: backend_url override for local mode is handled centrally
            # in LocalRunner.enqueue_task() before any agent processes the task.
            logger.info(
                "[MCP] Variable substitution context: backend_url=%s, task_token=%s",
                task_data.backend_url,
                f"{task_data.task_token[:20]}..." if task_data.task_token else "EMPTY",
            )
            mcp_servers = replace_mcp_server_variables(mcp_servers, task_data)
            # Convert list format to dict format for Claude Code SDK
            mcp_servers = _convert_mcp_servers_list_to_dict(mcp_servers)
            bot_config["mcp_servers"] = mcp_servers
            # Log detailed MCP server configs for debugging
            for name, cfg in mcp_servers.items():
                logger.info(
                    "[MCP] Server '%s': type=%s, url=%s, headers=%s",
                    name,
                    cfg.get("type", "?"),
                    cfg.get("url", "?"),
                    list(cfg.get("headers", {}).keys()),
                )
            logger.info(
                "[MCP] MCP servers after processing: %s",
                list(mcp_servers.keys()),
            )
        else:
            logger.info("[MCP] No MCP servers configured")

        # Add wegent MCP server for subscription tasks
        if task_data.is_subscription:
            wegent_mcp_url = get_wegent_mcp_url()
            wegent_mcp = {
                "wegent": {
                    "type": "http",
                    "url": wegent_mcp_url,
                }
            }
            if "mcp_servers" not in bot_config or bot_config["mcp_servers"] is None:
                bot_config["mcp_servers"] = {}
            # Handle both dict and list formats for mcp_servers
            mcp_servers = bot_config["mcp_servers"]
            if isinstance(mcp_servers, dict):
                mcp_servers.update(wegent_mcp)
            elif isinstance(mcp_servers, list):
                # Convert list to dict format
                mcp_servers = _convert_mcp_servers_list_to_dict(mcp_servers)
                mcp_servers.update(wegent_mcp)
                bot_config["mcp_servers"] = mcp_servers
            logger.info(
                f"Added wegent MCP server (HTTP) for subscription task at {wegent_mcp_url}"
            )

        for key in valid_options:
            if key in bot_config and bot_config[key] is not None:
                options[key] = bot_config[key]

    # Final summary log
    final_mcp = options.get("mcp_servers", options.get("mcpServers"))
    if final_mcp and isinstance(final_mcp, dict):
        logger.info(
            "[MCP] Final MCP servers in options: %s",
            list(final_mcp.keys()),
        )
        for name, cfg in final_mcp.items():
            if isinstance(cfg, dict):
                logger.info(
                    "[MCP] Final '%s': type=%s, url=%s, headers=%s",
                    name,
                    cfg.get("type", "?"),
                    cfg.get("url", "?"),
                    list(cfg.get("headers", {}).keys()),
                )
    elif final_mcp:
        logger.info("[MCP] No MCP servers in final options")

    return options


def get_claude_config_dir(task_id: int, cwd: Optional[str] = None) -> str:
    """Get the .claude config directory path for a task.

    Args:
        task_id: Task ID
        cwd: Optional working directory

    Returns:
        Path to .claude config directory
    """
    from executor.config import config

    if cwd:
        return os.path.join(cwd, ".claude")
    return os.path.join(config.get_workspace_root(), str(task_id), ".claude")
