"""
Config Loader - Load groknroll configuration from multiple sources

Loads configuration from (in priority order, later overrides earlier):
1. Global: `~/.groknroll/groknroll.json`
2. Environment path: `GROKNROLL_CONFIG` path to file
3. Project: `./groknroll.json`
4. Inline: `GROKNROLL_CONFIG_CONTENT` JSON string (highest priority, OpenCode compatible)

Supports JSONC (JSON with comments).

Security Features (SEC-018):
- Configurable JSON size limits to prevent DoS via large payloads
- File size validation before reading
- Content size validation for inline configs

Examples:
    loader = ConfigLoader(project_path=Path.cwd())
    config = loader.load()  # Returns merged config dict

    # Or use convenience function
    config = load_config()

    # Inline config via environment variable (highest priority)
    # export GROKNROLL_CONFIG_CONTENT='{"permission":{"bash":"allow"}}'
"""

import json
import os
import re
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional


# SEC-018: JSON size limits to prevent DoS
MAX_CONFIG_FILE_SIZE = 1 * 1024 * 1024  # 1 MB max for config files
MAX_INLINE_CONFIG_SIZE = 100 * 1024  # 100 KB max for inline configs
MAX_JSON_NESTING_DEPTH = 50  # Maximum nesting depth


class ConfigSizeError(ValueError):
    """Raised when config exceeds size limits."""
    pass


def _validate_json_size(content: str, max_size: int, source_name: str) -> None:
    """
    Validate JSON content size.

    SEC-018: Prevents DoS via large payloads.

    Args:
        content: The JSON content
        max_size: Maximum allowed size in bytes
        source_name: Name of the source for error messages

    Raises:
        ConfigSizeError: If content exceeds max size
    """
    content_size = len(content.encode('utf-8'))
    if content_size > max_size:
        raise ConfigSizeError(
            f"Config from {source_name} exceeds maximum size. "
            f"Size: {content_size:,} bytes, Max: {max_size:,} bytes"
        )


def _validate_json_depth(obj: Any, current_depth: int = 0, max_depth: int = MAX_JSON_NESTING_DEPTH) -> None:
    """
    Validate JSON nesting depth.

    SEC-018: Prevents stack overflow from deeply nested JSON.

    Args:
        obj: The JSON object to validate
        current_depth: Current nesting depth
        max_depth: Maximum allowed depth

    Raises:
        ConfigSizeError: If nesting exceeds max depth
    """
    if current_depth > max_depth:
        raise ConfigSizeError(
            f"Config exceeds maximum nesting depth of {max_depth}. "
            f"This may indicate a malformed or malicious config."
        )

    if isinstance(obj, dict):
        for value in obj.values():
            _validate_json_depth(value, current_depth + 1, max_depth)
    elif isinstance(obj, list):
        for item in obj:
            _validate_json_depth(item, current_depth + 1, max_depth)


# OPT-001: Module-level cache for parsed configs
# Cache key: (path, mtime) -> parsed config
_config_cache: dict[tuple[str, float], dict[str, Any]] = {}
_cache_lock = threading.Lock()


@dataclass
class ConfigSource:
    """
    Represents a configuration source

    Attributes:
        path: Path to the config file
        exists: Whether the file exists
        config: Loaded configuration (if exists)
        error: Error message (if loading failed)
    """

    path: Path
    exists: bool = False
    config: Optional[dict[str, Any]] = None
    error: Optional[str] = None


@dataclass
class ConfigLoadResult:
    """
    Result of configuration loading

    Attributes:
        config: The loaded configuration dict
        sources: List of sources that were checked
        success: Whether loading succeeded
        warnings: List of warning messages
        error: Error message (if loading failed)
    """

    config: dict[str, Any] = field(default_factory=dict)
    sources: list[ConfigSource] = field(default_factory=list)
    success: bool = True
    warnings: list[str] = field(default_factory=list)
    error: Optional[str] = None


class ConfigLoader:
    """
    Load groknroll configuration from multiple sources

    Sources are checked in order (later sources override earlier):
    1. Global: `~/.groknroll/groknroll.json`
    2. Environment: path from `GROKNROLL_CONFIG` env var
    3. Project: `./groknroll.json`

    Supports JSONC (JSON with comments).

    Example:
        loader = ConfigLoader(project_path=Path.cwd())
        result = loader.load_with_result()

        if result.success:
            print(f"Loaded config with {len(result.sources)} sources")
            print(result.config)
        else:
            print(f"Error: {result.error}")
    """

    CONFIG_FILENAME = "groknroll.json"
    GLOBAL_CONFIG_DIR = "~/.groknroll"
    ENV_VAR_NAME = "GROKNROLL_CONFIG"
    ENV_VAR_CONTENT = "GROKNROLL_CONFIG_CONTENT"  # Inline JSON config (highest priority)

    # Regex patterns for stripping comments
    LINE_COMMENT = re.compile(r"//.*$", re.MULTILINE)
    BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)

    def __init__(self, project_path: Optional[Path] = None):
        """
        Initialize ConfigLoader

        Args:
            project_path: Project root directory (defaults to cwd)
        """
        self.project_path = project_path or Path.cwd()

    @property
    def project_config_path(self) -> Path:
        """Get project config file path"""
        return self.project_path / self.CONFIG_FILENAME

    @property
    def global_config_path(self) -> Path:
        """Get global config file path"""
        return Path(self.GLOBAL_CONFIG_DIR).expanduser() / self.CONFIG_FILENAME

    @property
    def env_config_path(self) -> Optional[Path]:
        """Get config file path from environment variable"""
        env_path = os.environ.get(self.ENV_VAR_NAME)
        if env_path:
            return Path(env_path).expanduser()
        return None

    @property
    def inline_config_content(self) -> Optional[str]:
        """
        Get inline config content from environment variable.

        OpenCode compatible: GROKNROLL_CONFIG_CONTENT contains raw JSON.
        This has highest priority and overrides all file-based configs.
        """
        return os.environ.get(self.ENV_VAR_CONTENT)

    def load(self) -> dict[str, Any]:
        """
        Load configuration from all sources

        Returns:
            Merged configuration dict

        Raises:
            ValueError: If a config file has invalid JSON
        """
        result = self.load_with_result()
        if not result.success and result.error:
            raise ValueError(result.error)
        return result.config

    def load_with_result(self) -> ConfigLoadResult:
        """
        Load configuration and return detailed result

        Returns:
            ConfigLoadResult with config and metadata
        """
        sources: list[ConfigSource] = []
        warnings: list[str] = []
        merged_config: dict[str, Any] = {}

        # Load sources in order (later overrides earlier)
        paths_to_check = [
            ("global", self.global_config_path),
            ("env", self.env_config_path),
            ("project", self.project_config_path),
        ]

        for source_name, path in paths_to_check:
            if path is None:
                continue

            source = self._load_source(path)
            sources.append(source)

            if source.exists and source.config is not None:
                # Merge config (shallow for now - deep merge in merger.py)
                merged_config.update(source.config)
            elif source.exists and source.error:
                # File exists but failed to load
                return ConfigLoadResult(
                    config={},
                    sources=sources,
                    success=False,
                    error=f"Failed to load {source_name} config: {source.error}",
                )

        # Load inline config (highest priority, OpenCode compatible)
        inline_content = self.inline_config_content
        if inline_content:
            inline_source = self._load_inline_config(inline_content)
            sources.append(inline_source)

            if inline_source.config is not None:
                merged_config.update(inline_source.config)
            elif inline_source.error:
                return ConfigLoadResult(
                    config={},
                    sources=sources,
                    success=False,
                    error=f"Failed to parse inline config: {inline_source.error}",
                )

        return ConfigLoadResult(
            config=merged_config,
            sources=sources,
            success=True,
            warnings=warnings,
        )

    def _load_inline_config(self, content: str) -> ConfigSource:
        """
        Load configuration from inline JSON string.

        SEC-018: Validates inline config size.

        Args:
            content: JSON string from GROKNROLL_CONFIG_CONTENT

        Returns:
            ConfigSource with loaded config or error
        """
        source = ConfigSource(path=Path("<inline>"))
        source.exists = True

        try:
            # SEC-018: Validate inline config size
            _validate_json_size(content, MAX_INLINE_CONFIG_SIZE, "inline config")

            config = self._parse_jsonc(content)
            source.config = config
        except ConfigSizeError as e:
            source.error = str(e)
        except json.JSONDecodeError as e:
            source.error = f"Invalid JSON: {e}"
        except Exception as e:
            source.error = f"Error parsing inline config: {e}"

        return source

    def _load_source(self, path: Path) -> ConfigSource:
        """
        Load a single configuration source with caching.

        OPT-001: Caches parsed config with file mtime.
        Invalidates on file change. Thread-safe cache access.

        SEC-018: Validates file size before reading.

        Args:
            path: Path to config file

        Returns:
            ConfigSource with loaded config or error
        """
        source = ConfigSource(path=path)

        if not path.exists():
            return source

        source.exists = True

        try:
            # SEC-018: Check file size before reading
            file_size = path.stat().st_size
            if file_size > MAX_CONFIG_FILE_SIZE:
                source.error = (
                    f"Config file exceeds maximum size. "
                    f"Size: {file_size:,} bytes, Max: {MAX_CONFIG_FILE_SIZE:,} bytes"
                )
                return source

            # OPT-001: Check cache first using path + mtime as key
            mtime = path.stat().st_mtime
            cache_key = (str(path.resolve()), mtime)

            with _cache_lock:
                if cache_key in _config_cache:
                    source.config = _config_cache[cache_key]
                    return source

            # Cache miss - load and parse
            content = path.read_text(encoding="utf-8")

            # SEC-018: Validate content size after reading (accounts for encoding)
            _validate_json_size(content, MAX_CONFIG_FILE_SIZE, str(path))

            config = self._parse_jsonc(content)

            # Store in cache (thread-safe)
            with _cache_lock:
                _config_cache[cache_key] = config
                # Cleanup old cache entries for same path (different mtime)
                path_str = str(path.resolve())
                old_keys = [k for k in _config_cache if k[0] == path_str and k != cache_key]
                for old_key in old_keys:
                    del _config_cache[old_key]

            source.config = config
        except json.JSONDecodeError as e:
            source.error = f"Invalid JSON: {e}"
        except UnicodeDecodeError as e:
            source.error = f"Encoding error: {e}"
        except Exception as e:
            source.error = f"Error reading file: {e}"

        return source

    def _parse_jsonc(self, content: str) -> dict[str, Any]:
        """
        Parse JSONC (JSON with comments)

        Strips // and /* */ comments before parsing.

        SEC-018: Validates nesting depth.

        Args:
            content: JSONC content string

        Returns:
            Parsed configuration dict

        Raises:
            json.JSONDecodeError: If JSON is invalid
            ConfigSizeError: If nesting depth is exceeded
        """
        # Strip comments
        stripped = self._strip_comments(content)

        # Parse JSON
        config = json.loads(stripped)

        # Ensure it's a dict
        if not isinstance(config, dict):
            raise json.JSONDecodeError("Config must be a JSON object", content, 0)

        # SEC-018: Validate nesting depth
        _validate_json_depth(config)

        return config

    def _strip_comments(self, content: str) -> str:
        """
        Strip comments from JSONC content using regex patterns.

        PERF-001: Optimized using regex instead of character-by-character parsing.
        ~10x faster on large config files.

        Handles:
        - // line comments
        - /* block comments */
        - Comments inside strings are preserved

        Args:
            content: JSONC content string

        Returns:
            JSON content with comments removed
        """
        # Use a state machine approach with regex for better performance
        # We need to track string state to avoid stripping comments inside strings
        result = []
        pos = 0
        in_string = False

        # Combined pattern for efficiency: match strings, line comments, or block comments
        # Using a single pass with alternation
        # Note: Use [^\n]* for line comments to avoid DOTALL making . match newlines
        pattern = re.compile(
            r'("(?:[^"\\]|\\.)*")'  # Match complete strings (group 1)
            r'|(//[^\n]*)'  # Match line comments (group 2) - [^\n]* to not cross lines
            r'|(/\*.*?\*/)',  # Match block comments (group 3)
            re.MULTILINE | re.DOTALL
        )

        last_end = 0
        for match in pattern.finditer(content):
            # Add content before this match
            result.append(content[last_end:match.start()])

            if match.group(1):  # String - keep it
                result.append(match.group(1))
            # Groups 2 and 3 are comments - skip them (don't append)

            last_end = match.end()

        # Add remaining content after last match
        result.append(content[last_end:])

        return "".join(result)

    def load_file(self, path: Path) -> dict[str, Any]:
        """
        Load a single config file

        Args:
            path: Path to config file

        Returns:
            Configuration dict

        Raises:
            FileNotFoundError: If file doesn't exist
            ValueError: If file has invalid JSON
        """
        if not path.exists():
            raise FileNotFoundError(f"Config file not found: {path}")

        source = self._load_source(path)
        if source.error:
            raise ValueError(source.error)

        return source.config or {}

    def get_source_paths(self) -> list[Path]:
        """
        Get all config source paths that would be checked

        Returns:
            List of paths in order of precedence
        """
        paths = [self.global_config_path]

        if self.env_config_path:
            paths.append(self.env_config_path)

        paths.append(self.project_config_path)

        return paths

    def get_active_sources(self) -> list[Path]:
        """
        Get config source paths that exist

        Returns:
            List of existing config file paths
        """
        return [p for p in self.get_source_paths() if p.exists()]

    def __str__(self) -> str:
        return f"ConfigLoader(project={self.project_path})"

    def __repr__(self) -> str:
        return f"ConfigLoader(project_path='{self.project_path}')"


# Convenience functions


def load_config(project_path: Optional[Path] = None) -> dict[str, Any]:
    """
    Load configuration from all sources (convenience function)

    Args:
        project_path: Project root directory

    Returns:
        Merged configuration dict

    Raises:
        ValueError: If a config file has invalid JSON
    """
    loader = ConfigLoader(project_path=project_path)
    return loader.load()


def load_config_with_result(
    project_path: Optional[Path] = None,
) -> ConfigLoadResult:
    """
    Load configuration and return detailed result (convenience function)

    Args:
        project_path: Project root directory

    Returns:
        ConfigLoadResult with config and metadata
    """
    loader = ConfigLoader(project_path=project_path)
    return loader.load_with_result()


def load_config_file(path: Path) -> dict[str, Any]:
    """
    Load a single config file (convenience function)

    Args:
        path: Path to config file

    Returns:
        Configuration dict

    Raises:
        FileNotFoundError: If file doesn't exist
        ValueError: If file has invalid JSON
    """
    loader = ConfigLoader()
    return loader.load_file(path)


def parse_jsonc(content: str) -> dict[str, Any]:
    """
    Parse JSONC content (convenience function)

    Args:
        content: JSONC string

    Returns:
        Parsed configuration dict

    Raises:
        json.JSONDecodeError: If JSON is invalid
    """
    loader = ConfigLoader()
    return loader._parse_jsonc(content)


def clear_config_cache() -> int:
    """
    Clear the config cache (OPT-001).

    Useful for testing or when config files are known to have changed.

    Returns:
        Number of cache entries cleared
    """
    with _cache_lock:
        count = len(_config_cache)
        _config_cache.clear()
        return count


def get_config_cache_stats() -> dict[str, Any]:
    """
    Get config cache statistics (OPT-001).

    Returns:
        Dict with cache stats: size, keys
    """
    with _cache_lock:
        return {
            "size": len(_config_cache),
            "paths": list(set(k[0] for k in _config_cache.keys())),
        }
