"""Configuration Drift Detector for MCP servers.

Detects unauthorized or unexpected changes in MCP server configurations
by comparing current state against a saved baseline snapshot.
Identifies new tools, changed permissions, modified environment variables,
and new endpoints.

Zero external dependencies — pure Python stdlib.
"""

from __future__ import annotations

import hashlib
import json
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


# ---------- Severity ----------

class DriftSeverity:
    """Drift severity constants."""
    CRITICAL = "critical"
    HIGH = "high"
    MEDIUM = "medium"
    LOW = "low"
    INFO = "info"


# ---------- Data Models ----------

@dataclass
class DriftItem:
    """A single configuration drift finding."""

    category: str  # tools, permissions, env_vars, endpoints, config
    change_type: str  # added, removed, modified
    key: str
    old_value: str = ""
    new_value: str = ""
    severity: str = DriftSeverity.MEDIUM
    recommendation: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "category": self.category,
            "change_type": self.change_type,
            "key": self.key,
            "old_value": self.old_value,
            "new_value": self.new_value,
            "severity": self.severity,
            "recommendation": self.recommendation,
        }


@dataclass
class DriftReport:
    """Aggregated drift detection report."""

    baseline_path: str = ""
    current_path: str = ""
    drifts: list[DriftItem] = field(default_factory=list)
    timestamp: str = ""
    baseline_hash: str = ""
    current_hash: str = ""

    @property
    def total_drifts(self) -> int:
        return len(self.drifts)

    @property
    def critical_count(self) -> int:
        return sum(1 for d in self.drifts if d.severity == DriftSeverity.CRITICAL)

    @property
    def high_count(self) -> int:
        return sum(1 for d in self.drifts if d.severity == DriftSeverity.HIGH)

    @property
    def medium_count(self) -> int:
        return sum(1 for d in self.drifts if d.severity == DriftSeverity.MEDIUM)

    @property
    def low_count(self) -> int:
        return sum(1 for d in self.drifts if d.severity == DriftSeverity.LOW)

    @property
    def has_critical(self) -> bool:
        return self.critical_count > 0

    @property
    def severity_counts(self) -> dict[str, int]:
        return {
            "critical": self.critical_count,
            "high": self.high_count,
            "medium": self.medium_count,
            "low": self.low_count,
        }

    @property
    def exit_code(self) -> int:
        if self.critical_count > 0:
            return 2
        if self.high_count > 0:
            return 1
        return 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "baseline_path": self.baseline_path,
            "current_path": self.current_path,
            "total_drifts": self.total_drifts,
            "severity_counts": self.severity_counts,
            "exit_code": self.exit_code,
            "timestamp": self.timestamp,
            "baseline_hash": self.baseline_hash,
            "current_hash": self.current_hash,
            "drifts": [d.to_dict() for d in self.drifts],
        }


@dataclass
class ConfigSnapshot:
    """Snapshot of an MCP server configuration."""

    path: str = ""
    timestamp: str = ""
    config_hash: str = ""
    tools: dict[str, Any] = field(default_factory=dict)
    permissions: dict[str, Any] = field(default_factory=dict)
    env_vars: dict[str, str] = field(default_factory=dict)
    endpoints: list[str] = field(default_factory=list)
    raw_config: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return {
            "path": self.path,
            "timestamp": self.timestamp,
            "config_hash": self.config_hash,
            "tools": self.tools,
            "permissions": self.permissions,
            "env_vars": self.env_vars,
            "endpoints": self.endpoints,
            "raw_config": self.raw_config,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> ConfigSnapshot:
        """Create a ConfigSnapshot from a dict."""
        return cls(
            path=data.get("path", ""),
            timestamp=data.get("timestamp", ""),
            config_hash=data.get("config_hash", ""),
            tools=data.get("tools", {}),
            permissions=data.get("permissions", {}),
            env_vars=data.get("env_vars", {}),
            endpoints=data.get("endpoints", []),
            raw_config=data.get("raw_config", {}),
        )


# ---------- Config Extraction ----------

def _compute_hash(data: dict[str, Any]) -> str:
    """Compute SHA-256 hash of config data."""
    raw = json.dumps(data, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]


def _extract_tools_from_config(config: dict[str, Any]) -> dict[str, Any]:
    """Extract tool definitions from MCP config JSON."""
    tools: dict[str, Any] = {}
    # Claude Desktop format: mcpServers -> server -> tools
    for server_name, server_config in config.get("mcpServers", {}).items():
        if isinstance(server_config, dict):
            server_tools = server_config.get("tools", {})
            if isinstance(server_tools, dict):
                for tool_name, tool_def in server_tools.items():
                    tools[f"{server_name}.{tool_name}"] = tool_def
            elif isinstance(server_tools, list):
                for tool in server_tools:
                    if isinstance(tool, str):
                        tools[f"{server_name}.{tool}"] = {}
                    elif isinstance(tool, dict) and "name" in tool:
                        tools[f"{server_name}.{tool['name']}"] = tool
    # Generic tools key at top level
    top_tools = config.get("tools", {})
    if isinstance(top_tools, dict):
        for name, defn in top_tools.items():
            tools[name] = defn
    elif isinstance(top_tools, list):
        for tool in top_tools:
            if isinstance(tool, str):
                tools[tool] = {}
            elif isinstance(tool, dict) and "name" in tool:
                tools[tool["name"]] = tool
    return tools


def _extract_permissions(config: dict[str, Any]) -> dict[str, Any]:
    """Extract permission settings from MCP config."""
    perms: dict[str, Any] = {}
    # Check mcpServers for per-server permissions
    for server_name, server_config in config.get("mcpServers", {}).items():
        if isinstance(server_config, dict):
            if "permissions" in server_config:
                perms[server_name] = server_config["permissions"]
            if "allowedDirectories" in server_config:
                perms[f"{server_name}.allowedDirectories"] = server_config["allowedDirectories"]
            if "allowedCommands" in server_config:
                perms[f"{server_name}.allowedCommands"] = server_config["allowedCommands"]
    # Top-level permissions
    if "permissions" in config:
        perms["_global"] = config["permissions"]
    if "allowedDirectories" in config:
        perms["_global.allowedDirectories"] = config["allowedDirectories"]
    return perms


def _extract_env_vars(config: dict[str, Any]) -> dict[str, str]:
    """Extract environment variable settings from MCP config."""
    env_vars: dict[str, str] = {}
    for server_name, server_config in config.get("mcpServers", {}).items():
        if isinstance(server_config, dict):
            env = server_config.get("env", {})
            if isinstance(env, dict):
                for k, v in env.items():
                    env_vars[f"{server_name}.{k}"] = str(v)
    # Top-level env
    top_env = config.get("env", {})
    if isinstance(top_env, dict):
        for k, v in top_env.items():
            env_vars[k] = str(v)
    return env_vars


def _extract_endpoints(config: dict[str, Any]) -> list[str]:
    """Extract server endpoints/commands from MCP config."""
    endpoints: list[str] = []
    for server_name, server_config in config.get("mcpServers", {}).items():
        if isinstance(server_config, dict):
            cmd = server_config.get("command", "")
            args = server_config.get("args", [])
            if cmd:
                full = f"{server_name}: {cmd}"
                if args and isinstance(args, list):
                    full += " " + " ".join(str(a) for a in args)
                endpoints.append(full)
    return sorted(endpoints)


# ---------- Snapshot Creation ----------

def create_snapshot(path: Path) -> ConfigSnapshot:
    """Create a configuration snapshot from a file or directory.

    Args:
        path: Path to MCP config file (JSON) or directory containing one.

    Returns:
        ConfigSnapshot with extracted configuration data.
    """
    config_file = _find_config_file(path)
    if config_file is None:
        return ConfigSnapshot(
            path=str(path),
            timestamp=_now_iso(),
        )

    try:
        content = config_file.read_text(encoding="utf-8", errors="replace")
        config = json.loads(content)
    except (json.JSONDecodeError, OSError):
        return ConfigSnapshot(
            path=str(config_file),
            timestamp=_now_iso(),
        )

    tools = _extract_tools_from_config(config)
    permissions = _extract_permissions(config)
    env_vars = _extract_env_vars(config)
    endpoints = _extract_endpoints(config)

    snapshot = ConfigSnapshot(
        path=str(config_file),
        timestamp=_now_iso(),
        tools=tools,
        permissions=permissions,
        env_vars=env_vars,
        endpoints=endpoints,
        raw_config=config,
    )
    snapshot.config_hash = _compute_hash(config)
    return snapshot


def create_snapshot_from_dict(config: dict[str, Any], path: str = "") -> ConfigSnapshot:
    """Create a configuration snapshot from a dict.

    Args:
        config: MCP config dict.
        path: Optional path label.

    Returns:
        ConfigSnapshot.
    """
    tools = _extract_tools_from_config(config)
    permissions = _extract_permissions(config)
    env_vars = _extract_env_vars(config)
    endpoints = _extract_endpoints(config)

    snapshot = ConfigSnapshot(
        path=path,
        timestamp=_now_iso(),
        tools=tools,
        permissions=permissions,
        env_vars=env_vars,
        endpoints=endpoints,
        raw_config=config,
    )
    snapshot.config_hash = _compute_hash(config)
    return snapshot


def save_snapshot(snapshot: ConfigSnapshot, output_path: Path) -> None:
    """Save a snapshot to a JSON file."""
    output_path.write_text(
        json.dumps(snapshot.to_dict(), indent=2, ensure_ascii=False),
        encoding="utf-8",
    )


def load_snapshot(path: Path) -> ConfigSnapshot:
    """Load a snapshot from a JSON file."""
    content = path.read_text(encoding="utf-8", errors="replace")
    data = json.loads(content)
    return ConfigSnapshot.from_dict(data)


# ---------- Drift Detection ----------

def _severity_for_category(category: str, change_type: str) -> str:
    """Determine severity based on drift category and change type."""
    if category == "tools" and change_type == "added":
        return DriftSeverity.HIGH
    if category == "tools" and change_type == "removed":
        return DriftSeverity.MEDIUM
    if category == "tools" and change_type == "modified":
        return DriftSeverity.HIGH
    if category == "permissions":
        return DriftSeverity.CRITICAL
    if category == "env_vars" and change_type == "added":
        return DriftSeverity.MEDIUM
    if category == "env_vars" and change_type == "modified":
        return DriftSeverity.HIGH
    if category == "env_vars" and change_type == "removed":
        return DriftSeverity.LOW
    if category == "endpoints" and change_type == "added":
        return DriftSeverity.HIGH
    if category == "endpoints" and change_type == "removed":
        return DriftSeverity.MEDIUM
    return DriftSeverity.MEDIUM


def _recommendation_for_drift(item: DriftItem) -> str:
    """Generate a recommendation for a drift item."""
    recs = {
        ("tools", "added"): "Review the new tool for security implications before deployment",
        ("tools", "removed"): "Verify tool removal was intentional and documented",
        ("tools", "modified"): "Review tool definition changes for security regressions",
        ("permissions", "added"): "Audit new permissions — restrict to minimum required access",
        ("permissions", "removed"): "Verify permission removal does not break functionality",
        ("permissions", "modified"): "Review permission changes — ensure no privilege escalation",
        ("env_vars", "added"): "Check if new environment variable contains secrets or sensitive data",
        ("env_vars", "removed"): "Verify env var removal was intentional",
        ("env_vars", "modified"): "Review env var change — check for leaked secrets or misconfig",
        ("endpoints", "added"): "Audit new endpoint for security — validate command and arguments",
        ("endpoints", "removed"): "Verify endpoint removal was planned",
    }
    return recs.get((item.category, item.change_type), "Review this configuration change")


def _compare_dicts(
    baseline: dict[str, Any],
    current: dict[str, Any],
    category: str,
) -> list[DriftItem]:
    """Compare two dicts and return drift items."""
    drifts: list[DriftItem] = []
    baseline_keys = set(baseline.keys())
    current_keys = set(current.keys())

    # Added keys
    for key in sorted(current_keys - baseline_keys):
        item = DriftItem(
            category=category,
            change_type="added",
            key=key,
            new_value=json.dumps(current[key], ensure_ascii=False)
            if not isinstance(current[key], str) else current[key],
            severity=_severity_for_category(category, "added"),
        )
        item.recommendation = _recommendation_for_drift(item)
        drifts.append(item)

    # Removed keys
    for key in sorted(baseline_keys - current_keys):
        item = DriftItem(
            category=category,
            change_type="removed",
            key=key,
            old_value=json.dumps(baseline[key], ensure_ascii=False)
            if not isinstance(baseline[key], str) else baseline[key],
            severity=_severity_for_category(category, "removed"),
        )
        item.recommendation = _recommendation_for_drift(item)
        drifts.append(item)

    # Modified keys
    for key in sorted(baseline_keys & current_keys):
        old_val = baseline[key]
        new_val = current[key]
        if old_val != new_val:
            item = DriftItem(
                category=category,
                change_type="modified",
                key=key,
                old_value=json.dumps(old_val, ensure_ascii=False)
                if not isinstance(old_val, str) else old_val,
                new_value=json.dumps(new_val, ensure_ascii=False)
                if not isinstance(new_val, str) else new_val,
                severity=_severity_for_category(category, "modified"),
            )
            item.recommendation = _recommendation_for_drift(item)
            drifts.append(item)

    return drifts


def _compare_lists(
    baseline: list[str],
    current: list[str],
    category: str,
) -> list[DriftItem]:
    """Compare two lists and return drift items."""
    drifts: list[DriftItem] = []
    baseline_set = set(baseline)
    current_set = set(current)

    for item_str in sorted(current_set - baseline_set):
        item = DriftItem(
            category=category,
            change_type="added",
            key=item_str,
            new_value=item_str,
            severity=_severity_for_category(category, "added"),
        )
        item.recommendation = _recommendation_for_drift(item)
        drifts.append(item)

    for item_str in sorted(baseline_set - current_set):
        item = DriftItem(
            category=category,
            change_type="removed",
            key=item_str,
            old_value=item_str,
            severity=_severity_for_category(category, "removed"),
        )
        item.recommendation = _recommendation_for_drift(item)
        drifts.append(item)

    return drifts


def check_drift(
    baseline: ConfigSnapshot,
    current: ConfigSnapshot,
) -> DriftReport:
    """Compare two snapshots and detect configuration drift.

    Args:
        baseline: The baseline (known-good) snapshot.
        current: The current snapshot to compare.

    Returns:
        DriftReport with all detected drifts.
    """
    report = DriftReport(
        baseline_path=baseline.path,
        current_path=current.path,
        timestamp=_now_iso(),
        baseline_hash=baseline.config_hash,
        current_hash=current.config_hash,
    )

    # Compare tools
    report.drifts.extend(_compare_dicts(baseline.tools, current.tools, "tools"))
    # Compare permissions
    report.drifts.extend(_compare_dicts(baseline.permissions, current.permissions, "permissions"))
    # Compare env vars
    report.drifts.extend(_compare_dicts(baseline.env_vars, current.env_vars, "env_vars"))
    # Compare endpoints
    report.drifts.extend(_compare_lists(baseline.endpoints, current.endpoints, "endpoints"))

    return report


def check_drift_from_file(
    baseline_path: Path,
    current_path: Path,
) -> DriftReport:
    """Compare a baseline snapshot file against a current config.

    Args:
        baseline_path: Path to saved baseline snapshot JSON.
        current_path: Path to current MCP config file or directory.

    Returns:
        DriftReport.
    """
    baseline = load_snapshot(baseline_path)
    current = create_snapshot(current_path)
    return check_drift(baseline, current)


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

def _now_iso() -> str:
    """Return current timestamp as ISO 8601 string."""
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())


def _find_config_file(path: Path) -> Path | None:
    """Find MCP config file from a path.

    Looks for common MCP config filenames.
    """
    if path.is_file():
        return path

    if path.is_dir():
        candidates = [
            "claude_desktop_config.json",
            "mcp_config.json",
            "mcp.json",
            ".mcp.json",
            "config.json",
        ]
        for name in candidates:
            candidate = path / name
            if candidate.exists():
                return candidate
    return None


# ---------- Formatters ----------

def format_drift_text(report: DriftReport) -> str:
    """Format a DriftReport as human-readable text."""
    lines: list[str] = []
    lines.append(f"\n  Configuration Drift Report")
    lines.append(f"  Baseline: {report.baseline_path}")
    lines.append(f"  Current:  {report.current_path}")
    lines.append(f"  Total drifts: {report.total_drifts}")
    lines.append(f"  Severity: critical={report.critical_count} "
                 f"high={report.high_count} "
                 f"medium={report.medium_count} "
                 f"low={report.low_count}")
    lines.append("")

    if not report.drifts:
        lines.append("  No configuration drift detected.")
        return "\n".join(lines)

    severity_icons = {
        DriftSeverity.CRITICAL: "[!!]",
        DriftSeverity.HIGH: "[!]",
        DriftSeverity.MEDIUM: "[-]",
        DriftSeverity.LOW: "[.]",
        DriftSeverity.INFO: "[i]",
    }

    # Group by category
    categories = sorted({d.category for d in report.drifts})
    for cat in categories:
        cat_drifts = [d for d in report.drifts if d.category == cat]
        lines.append(f"  {cat.upper()} ({len(cat_drifts)} changes):")
        for d in cat_drifts:
            icon = severity_icons.get(d.severity, "[-]")
            lines.append(f"    {icon} {d.change_type}: {d.key}")
            if d.old_value:
                lines.append(f"        old: {d.old_value}")
            if d.new_value:
                lines.append(f"        new: {d.new_value}")
            if d.recommendation:
                lines.append(f"        rec: {d.recommendation}")
        lines.append("")

    return "\n".join(lines)


def format_drift_json(report: DriftReport) -> str:
    """Format a DriftReport as JSON."""
    return json.dumps(report.to_dict(), indent=2, ensure_ascii=False)
