"""Centralized JSONC config for jcodemunch-mcp."""

import hashlib
import json
import logging
import os
import re
import threading
from copy import deepcopy
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

_GLOBAL_CONFIG: dict[str, Any] = {}
_PROJECT_CONFIGS: dict[str, dict[str, Any]] = {}
_PROJECT_CONFIG_HASHES: dict[str, str] = {}
_DEPRECATED_ENV_VARS_LOGGED: set[str] = set()
_CONFIG_LOCK = threading.Lock()
_REPO_PATH_CACHE: dict[str, str] = {}

ENV_VAR_MAPPING = {
    "JCODEMUNCH_USE_AI_SUMMARIES": "use_ai_summaries",
    "JCODEMUNCH_TRUSTED_FOLDERS": "trusted_folders",
    "JCODEMUNCH_TRUSTED_FOLDERS_WHITELIST_MODE": "trusted_folders_whitelist_mode",
    "JCODEMUNCH_MAX_FOLDER_FILES": "max_folder_files",
    "JCODEMUNCH_MAX_INDEX_FILES": "max_index_files",
    "JCODEMUNCH_STALENESS_DAYS": "staleness_days",
    "JCODEMUNCH_MAX_RESULTS": "max_results",
    "JCODEMUNCH_FILE_TREE_MAX_FILES": "file_tree_max_files",
    "JCODEMUNCH_GITIGNORE_WARN_THRESHOLD": "gitignore_warn_threshold",
    "JCODEMUNCH_EXTRA_IGNORE_PATTERNS": "extra_ignore_patterns",
    "JCODEMUNCH_EXTRA_EXTENSIONS": "extra_extensions",
    "JCODEMUNCH_CONTEXT_PROVIDERS": "context_providers",
    "JCODEMUNCH_REDACT_SOURCE_ROOT": "redact_source_root",
    "JCODEMUNCH_STATS_FILE_INTERVAL": "stats_file_interval",
    "JCODEMUNCH_SHARE_SAVINGS": "share_savings",
    "JCODEMUNCH_SUMMARIZER_CONCURRENCY": "summarizer_concurrency",
    "JCODEMUNCH_SUMMARIZER_MAX_FAILURES": "summarizer_max_failures",
    "JCODEMUNCH_ALLOW_REMOTE_SUMMARIZER": "allow_remote_summarizer",
    "JCODEMUNCH_RATE_LIMIT": "rate_limit",
    "JCODEMUNCH_TRANSPORT": "transport",
    "JCODEMUNCH_HOST": "host",
    "JCODEMUNCH_PORT": "port",
    "JCODEMUNCH_WATCH": "watch",
    "JCODEMUNCH_WATCH_DEBOUNCE_MS": "watch_debounce_ms",
    "JCODEMUNCH_WATCH_EXTRA_IGNORE": "watch_extra_ignore",
    "JCODEMUNCH_WATCH_FOLLOW_SYMLINKS": "watch_follow_symlinks",
    "JCODEMUNCH_WATCH_IDLE_TIMEOUT": "watch_idle_timeout",
    "JCODEMUNCH_WATCH_LOG": "watch_log",
    "JCODEMUNCH_WATCH_PATHS": "watch_paths",
    "JCODEMUNCH_FRESHNESS_MODE": "freshness_mode",
    "JCODEMUNCH_SUMMARIZER_PROVIDER": "summarizer_provider",
    "JCODEMUNCH_SUMMARIZER_MODEL": "summarizer_model",
    "JCODEMUNCH_EMBED_MODEL": "embed_model",
    "JCODEMUNCH_CLAUDE_POLL_INTERVAL": "claude_poll_interval",
    "JCODEMUNCH_LOG_LEVEL": "log_level",
    "JCODEMUNCH_LOG_FILE": "log_file",
    "JCODEMUNCH_PATH_MAP": "path_map",
    "JCODEMUNCH_TRUSTED_FOLDERS_ENV": "trusted_folders",
    "JCODEMUNCH_CROSS_REPO_DEFAULT": "cross_repo_default",
}


def _global_config_path() -> Path:
    """Return the path to the global config.jsonc."""
    storage = os.environ.get("CODE_INDEX_PATH", str(Path.home() / ".code-index"))
    return Path(storage) / "config.jsonc"


def _global_storage_path() -> Path:
    """Return the global storage directory path."""
    return Path(os.environ.get("CODE_INDEX_PATH", str(Path.home() / ".code-index")))


_LANG_BLOCK_RE = re.compile(
    r'("languages"\s*:\s*)(\[.*?\]|null)',
    re.DOTALL
)
# NOTE: The non-greedy \[.*?\] pattern will break if a ] character appears
# inside a comment within the languages block (e.g., // see note [1]).
# This cannot happen with auto-generated content but is a limitation for
# hand-edited configs containing such patterns.


def _parse_active_languages(content: str) -> set[str] | None:
    """Extract uncommented language names from the languages array in JSONC content.

    Returns:
        set of active language names, or
        None if the languages key is null or absent (meaning "all languages").
    """
    m = _LANG_BLOCK_RE.search(content)
    if not m:
        return None
    block = m.group(2)
    if block.strip() == "null":
        return None
    active = set()
    for line in block.splitlines():
        # Strip inline // comments before matching (handle "python", // comment style)
        code_part = line.split("//")[0]
        code_stripped = code_part.strip()
        if code_stripped.startswith("//"):
            continue
        for lang_m in re.finditer(r'"([a-z_+#]+)"', code_stripped):
            active.add(lang_m.group(1))
    return active


def _build_languages_block(detected: set[str]) -> str:
    """Build a languages array block with detected languages uncommented."""
    from .parser.languages import LANGUAGE_REGISTRY
    all_langs = sorted(LANGUAGE_REGISTRY.keys())
    lines = []
    for lang in all_langs:
        if lang in detected:
            lines.append(f'     "{lang}",')
        else:
            lines.append(f'     // "{lang}",')
    return '"languages": [\n' + '\n'.join(lines) + '\n  ]'


def invalidate_project_config_cache(source_root: str) -> None:
    """Evict source_root from the project config cache, forcing reload on next access."""
    resolved = str(Path(source_root).resolve())
    with _CONFIG_LOCK:
        _PROJECT_CONFIGS.pop(resolved, None)
        _PROJECT_CONFIG_HASHES.pop(resolved, None)


def _check_raw_local_adaptive(local_path: Path) -> tuple[bool, str]:
    """Check if languages_adaptive is True in the raw (unmerged) local config file.

    Reads and parses the JSONC file directly — does NOT use the merged
    _PROJECT_CONFIGS cache, because the user requires that when a local
    config exists, ONLY the local file's languages_adaptive value matters
    (absent = False, not inherited from global).

    Returns:
        Tuple of (is_adaptive, content) — content is the raw file text.
    """
    try:
        content = local_path.read_text(encoding="utf-8-sig")
        raw = json.loads(_strip_jsonc(content))
        return bool(raw.get("languages_adaptive", False)), content
    except (json.JSONDecodeError, ValueError, OSError):
        return False, ""


def _apply_languages_adaptation(content: str, detected: set[str]) -> str | None:
    """Apply language adaptation to content, replacing the languages block.

    Returns the adapted content, or None if no languages block exists to adapt.

    Note: The regex uses non-greedy matching which may break if a ] character
    appears inside a comment within the languages block (e.g., // see note [1]).
    This cannot happen with auto-generated content but is a limitation for
    hand-edited configs.
    """
    active = _parse_active_languages(content)
    # active is None when languages key is null/absent → always update (convert to array)
    if active is not None and active == detected:
        return None  # no change needed

    new_block = _build_languages_block(detected)
    m = _LANG_BLOCK_RE.search(content)
    if not m:
        logger.debug("No languages block found — cannot apply adaptation")
        return None

    new_content = content[:m.start()] + new_block + content[m.end():]
    return new_content


def apply_adaptive_languages(source_root: str, detected: set[str]) -> bool:
    """Apply adaptive language configuration to {source_root}/.jcodemunch.jsonc.

    Decision tree:
      No local config + global languages_adaptive=True  → create from global copy + adapt
      Local config   + raw local languages_adaptive=True → surgical update
      Otherwise                                          → no-op

    Returns True if the file was created or modified.
    """
    if not detected:
        return False

    local_path = Path(source_root) / ".jcodemunch.jsonc"
    created = False

    if not local_path.exists():
        # ─── Stage 1: no local config — check global ─────────────────────────
        if not _GLOBAL_CONFIG.get("languages_adaptive", False):
            return False
        global_path = _global_config_path()
        if global_path.exists():
            content = global_path.read_text(encoding="utf-8")
        else:
            content = generate_template()
        # Ensure languages_adaptive: true is written to the new local config
        # Handle both commented-out (// "languages_adaptive": false,) and active keys
        lines = content.splitlines()
        new_lines = []
        key_found = False
        for line in lines:
            if '"languages_adaptive"' in line:
                # Replace any version of this line (commented or not)
                new_lines.append('  "languages_adaptive": true,')
                key_found = True
            else:
                new_lines.append(line)
        if not key_found:
            # Insert after opening brace line
            final_lines = []
            for line in new_lines:
                final_lines.append(line)
                if line.strip() == "{":
                    final_lines.append('  "languages_adaptive": true,')
            new_lines = final_lines
        content = "\n".join(new_lines)

        # Apply language adaptation BEFORE the first write (avoids double-write)
        adapted = _apply_languages_adaptation(content, detected)
        if adapted is not None:
            content = adapted
        # Always write in Stage 1 — the file doesn't exist yet and needs
        # languages_adaptive: true at minimum, even if languages already match.
        local_path.write_text(content, encoding="utf-8")
        invalidate_project_config_cache(source_root)
        logger.info("Created project config from global: %s", local_path)
        return True
    else:
        # ─── Stage 2: local config exists — check RAW local value ─────────────
        is_adaptive, content = _check_raw_local_adaptive(local_path)
        if not is_adaptive:
            return False
        # content is already loaded — no second read needed

    # ─── Apply adaptation ────────────────────────────────────────────────────────
    new_content = _apply_languages_adaptation(content, detected)
    if new_content is None:
        return False  # no change needed or no block to adapt

    if new_content == content:
        return False

    local_path.write_text(new_content, encoding="utf-8")
    invalidate_project_config_cache(source_root)
    logger.info("Adaptive languages: %s → %s", local_path, sorted(detected))
    return True

DEFAULTS = {
    "use_ai_summaries": "auto",
    "trusted_folders": [],
    "trusted_folders_whitelist_mode": True,
    "max_folder_files": 2000,
    "max_index_files": 10000,
    "staleness_days": 7,
    "max_results": 500,
    "file_tree_max_files": 500,
    "gitignore_warn_threshold": 500,
    "extra_ignore_patterns": [],
    "exclude_secret_patterns": [],
    "extra_extensions": {},
    "context_providers": True,
    "meta_fields": [],  # [] = no _meta (token-efficient; set null in config for all fields)
    "languages": None,  # None = all languages
    "languages_adaptive": False,
    "disabled_tools": ["test_summarizer"],
    "descriptions": {},
    "transport": "stdio",
    "host": "127.0.0.1",
    "port": 8901,
    "rate_limit": 0,
    "watch": False,
    "watch_debounce_ms": 2000,
    "watch_extra_ignore": [],
    "watch_follow_symlinks": False,
    "watch_idle_timeout": None,
    "watch_log": None,
    "watch_paths": [],
    "freshness_mode": "relaxed",
    "strict_timeout_ms": 500,
    "summarizer_provider": "",
    "summarizer_model": "",
    "embed_model": "",
    "claude_poll_interval": 5.0,
    "log_level": "WARNING",
    "log_file": None,
    "redact_source_root": False,
    "stats_file_interval": 3,
    "share_savings": True,
    "summarizer_concurrency": 4,
    "summarizer_max_failures": 3,
    "allow_remote_summarizer": False,
    "path_map": "",
    "cross_repo_default": False,
}

CONFIG_TYPES = {
    "use_ai_summaries": (bool, str),
    "trusted_folders": list,
    "trusted_folders_whitelist_mode": bool,
    "max_folder_files": int,
    "max_index_files": int,
    "staleness_days": int,
    "max_results": int,
    "file_tree_max_files": int,
    "gitignore_warn_threshold": int,
    "extra_ignore_patterns": list,
    "exclude_secret_patterns": list,
    "extra_extensions": dict,
    "context_providers": bool,
    "meta_fields": (list, type(None)),
    "languages": (list, type(None)),
    "languages_adaptive": bool,
    "disabled_tools": list,
    "descriptions": dict,
    "transport": str,
    "host": str,
    "port": int,
    "rate_limit": int,
    "watch": bool,
    "watch_debounce_ms": int,
    "watch_extra_ignore": list,
    "watch_follow_symlinks": bool,
    "watch_idle_timeout": (int, type(None)),
    "watch_log": (str, type(None)),
    "watch_paths": list,
    "freshness_mode": str,
    "strict_timeout_ms": int,
    "summarizer_provider": str,
    "summarizer_model": str,
    "embed_model": str,
    "claude_poll_interval": float,
    "log_level": str,
    "log_file": (str, type(None)),
    "redact_source_root": bool,
    "stats_file_interval": int,
    "share_savings": bool,
    "summarizer_concurrency": int,
    "summarizer_max_failures": int,
    "allow_remote_summarizer": bool,
    "path_map": str,
    "cross_repo_default": bool,
    "version": str,
}


def _strip_jsonc(text: str) -> str:
    """Strip // and /* */ comments from JSONC, respecting quoted strings.

    Also strips trailing commas (common in JSONC but invalid in JSON).
    """
    result, i, n = [], 0, len(text)
    in_str = False
    while i < n:
        ch = text[i]
        if in_str:
            result.append(ch)
            if ch == '\\' and i + 1 < n:
                result.append(text[i + 1])
                i += 2
                continue
            if ch == '"':
                in_str = False
            i += 1
        elif ch == '"':
            in_str = True
            result.append(ch)
            i += 1
        elif ch == '/' and i + 1 < n and text[i + 1] == '/':
            # Line comment — strip trailing comma and spaces from previous content
            if result and result[-1] == ',':
                result.pop()
                while result and result[-1] in (' ', '\t'):
                    result.pop()
            end = text.find('\n', i)
            i = n if end == -1 else end
        elif ch == '/' and i + 1 < n and text[i + 1] == '*':
            # Block comment — skip to */
            end = text.find('*/', i + 2)
            if end == -1:
                i = n
            else:
                end_i = end + 2
                if end_i < n and text[end_i] == ',':
                    # Comma immediately after */ — strip it
                    i = end_i + 1
                elif end_i < n and text[end_i] == '\n':
                    # Newline after */ — strip trailing comma only
                    # Walk back to find the last non-whitespace character
                    j = len(result) - 1
                    while j >= 0 and result[j] in (' ', '\t'):
                        j -= 1
                    if j >= 0 and result[j] == ',':
                        result.pop()  # pop comma only
                    i = end_i
                else:
                    i = end_i
        else:
            result.append(ch)
            i += 1

    output = ''.join(result)
    final = []
    j = 0
    m = len(output)
    while j < m:
        ch = output[j]
        if ch == '"':
            backslash_count = 0
            k = j - 1
            while k >= 0 and output[k] == '\\':
                backslash_count += 1
                k -= 1
            if backslash_count % 2 == 1:
                final.append(ch)
                j += 1
                continue
            final.append(ch)
            j += 1
            while j < m:
                final.append(output[j])
                if output[j] == '"':
                    backslash_count = 0
                    k = j - 1
                    while k >= 0 and output[k] == '\\':
                        backslash_count += 1
                        k -= 1
                    if backslash_count % 2 == 0:
                        j += 1
                        break
                j += 1
        elif ch in ('}', ']'):
            # Strip trailing whitespace and comma before this
            while final and final[-1] in (' ', '\t', '\n', '\r'):
                final.pop()
            if final and final[-1] == ',':
                final.pop()
            final.append(ch)
            j += 1
        else:
            final.append(ch)
            j += 1

    return ''.join(final)


def _validate_type(key: str, value: Any, expected_type: type | tuple) -> bool:
    """Validate value against expected type."""
    if key == "trusted_folders":
        return isinstance(value, list) and all(isinstance(item, str) for item in value)
    if key == "use_ai_summaries":
        if isinstance(value, bool):
            return True
        if isinstance(value, str):
            return value.lower() in {"true", "false", "auto"}
        return False
    if isinstance(expected_type, tuple):
        return isinstance(value, expected_type)
    return isinstance(value, expected_type)


def load_config(storage_path: str | None = None) -> None:
    """Load global config.jsonc. Called once from main()."""
    global _GLOBAL_CONFIG

    # Determine config path
    if storage_path:
        config_path = Path(storage_path) / "config.jsonc"
    else:
        config_path = _global_config_path()

    # Auto-create default config if missing
    if not config_path.exists():
        config_path.parent.mkdir(parents=True, exist_ok=True)
        template = generate_template()
        config_path.write_text(template, encoding="utf-8")
        logger.info("Created default config at %s", config_path)

    # Load config
    _explicit_keys: set[str] = set()  # Track keys explicitly set in config file
    if config_path.exists():
        try:
            content = config_path.read_text(encoding="utf-8-sig")  # utf-8-sig handles BOM
            stripped = _strip_jsonc(content)
            loaded = json.loads(stripped)

            # Start with defaults, then overlay valid config values
            _GLOBAL_CONFIG = deepcopy(DEFAULTS)
            for key, value in loaded.items():
                if key in CONFIG_TYPES:
                    if _validate_type(key, value, CONFIG_TYPES[key]):
                        # Special validation for languages list
                        if key == "languages" and isinstance(value, list):
                            from .parser.languages import LANGUAGE_REGISTRY

                            valid_langs = []
                            for lang in value:
                                if lang in LANGUAGE_REGISTRY:
                                    valid_langs.append(lang)
                                else:
                                    logger.warning(
                                        "Config key 'languages' contains unknown language '%s'. "
                                        "Known languages: %s...",
                                        lang,
                                        list(LANGUAGE_REGISTRY.keys())[:5],
                                    )
                            _GLOBAL_CONFIG[key] = valid_langs
                        elif key == "trusted_folders" and isinstance(value, list):
                            valid_folders = set()
                            for folder in value:
                                expanded_folder = Path(folder).expanduser()
                                if expanded_folder.is_absolute():
                                    valid_folders.add(expanded_folder.resolve())
                                else:
                                    raise ValueError(
                                        "Config key 'trusted_folders' contains non-absolute path "
                                        f"'{folder}'"
                                    )

                            _GLOBAL_CONFIG[key] = list(valid_folders)
                        else:
                            _GLOBAL_CONFIG[key] = value
                        _explicit_keys.add(key)  # Track explicitly set keys
                    else:
                        logger.warning(
                            "Config key '%s' has invalid type. "
                            "Expected %s, got %s. Using default.",
                            key,
                            CONFIG_TYPES[key],
                            type(value).__name__,
                        )
                    # Ignore unknown keys silently

        except json.JSONDecodeError as e:
            logger.error("Failed to parse config.jsonc: %s", e)
            _GLOBAL_CONFIG = deepcopy(DEFAULTS)
        except Exception as e:
            logger.error("Failed to load config.jsonc: %s", e)
            _GLOBAL_CONFIG = deepcopy(DEFAULTS)
    else:
        _GLOBAL_CONFIG = DEFAULTS.copy()

    # Apply env var fallback for keys not explicitly set in config
    _apply_env_var_fallback(_explicit_keys)


def _parse_env_value(value: str, expected_type: type | tuple, key: str | None = None) -> Any:
    """Parse env var string to expected type."""
    # use_ai_summaries accepts "auto", "true", "false" as strings;
    # generic bool parsing would coerce "auto" to False.
    if key == "use_ai_summaries":
        return value.strip().lower()
    try:
        if isinstance(expected_type, tuple):
            for t in expected_type:
                if t == type(None):
                    continue
                parsed = _parse_env_value(value, t)
                if parsed is not None:
                    return parsed
            return None
        if expected_type == bool:
            return value.lower() in ("true", "1", "yes", "on")
        elif expected_type == int:
            return int(value)
        elif expected_type == float:
            return float(value)
        elif expected_type == str:
            return value
        elif expected_type == list:
            try:
                return json.loads(value)
            except (ValueError, json.JSONDecodeError):
                result = []
                for token in value.split(","):
                    token = token.strip()
                    if token:
                        result.append(token)
                return result
        elif expected_type == dict:
            try:
                return json.loads(value)
            except (ValueError, json.JSONDecodeError):
                result = {}
                for token in value.split(","):
                    token = token.strip()
                    if not token or ":" not in token:
                        continue
                    ext, _, lang = token.partition(":")
                    ext = ext.strip()
                    lang = lang.strip()
                    if ext and lang:
                        result[ext] = lang
                return result
        else:
            logger.warning("Unknown config type %s for env var value: %s", expected_type, value)
            return None
    except (ValueError, json.JSONDecodeError):
        logger.warning("Failed to parse env var value: %s", value)
        return None


def _apply_env_var_fallback(explicit_keys: set[str] | None = None) -> None:
    """Apply deprecated env var fallback for keys not explicitly set in config."""
    global _GLOBAL_CONFIG

    if explicit_keys is None:
        explicit_keys = set()

    for env_var, config_key in ENV_VAR_MAPPING.items():
        # Skip if config key was explicitly set in config file
        if config_key in explicit_keys:
            continue

        env_value = os.environ.get(env_var)
        if env_value is not None:
            # Log warning once per var
            if env_var not in _DEPRECATED_ENV_VARS_LOGGED:
                logger.warning(
                    f"Deprecated: Using {env_var} environment variable. "
                    f"This will be removed in v2.0. Use config.jsonc instead."
                )
                _DEPRECATED_ENV_VARS_LOGGED.add(env_var)

            # Parse and apply value
            expected_type = CONFIG_TYPES.get(config_key)
            if expected_type is None:
                continue
            parsed = _parse_env_value(env_value, expected_type, key=config_key)  # type: ignore[arg-type]
            if parsed is not None:
                _GLOBAL_CONFIG[config_key] = parsed


def _resolve_repo_key(repo: str) -> str | None:
    """Resolve a repo identifier to the absolute path key used in _PROJECT_CONFIGS.

    _PROJECT_CONFIGS is keyed by resolved absolute paths (e.g. "D:\\...\\project").
    The 'repo' argument from tool calls may be:
    - An absolute path (already a valid key)
    - A repo identifier like "jcodemunch-mcp" or "local/jcodemunch-mcp-384d867b"

    Returns the resolved key if found, None otherwise.
    """
    if repo in _PROJECT_CONFIGS:
        return repo
    if repo in _REPO_PATH_CACHE:
        cached = _REPO_PATH_CACHE[repo]
        # None = negative cache (unknown repo), str = resolved path
        return cached
    try:
        from .storage.index_store import IndexStore
        store = IndexStore(base_path=str(_global_storage_path()))
        repos = store.list_repos()
        for entry in repos:
            source_root = entry.get("source_root", "")
            if not source_root:
                continue
            resolved = str(Path(source_root).resolve())
            display_name = entry.get("display_name", "")
            repo_name = entry.get("repo", "")
            if display_name:
                _REPO_PATH_CACHE[display_name] = resolved
            if repo_name:
                _REPO_PATH_CACHE[repo_name] = resolved
            if repo == display_name or repo == repo_name or repo == resolved:
                return resolved
    except Exception:
        pass
    # Negative cache: avoids repeated IndexStore scans for unknown identifiers
    _REPO_PATH_CACHE[repo] = None  # type: ignore[assignment]
    return None


def get(key: str, default: Any = None, repo: str | None = None) -> Any:
    """Get config value. If repo is given, uses merged project config."""
    if repo:
        resolved = _resolve_repo_key(repo)
        if resolved and resolved in _PROJECT_CONFIGS:
            return _PROJECT_CONFIGS[resolved].get(key, default)
    return _GLOBAL_CONFIG.get(key, default)


def _content_hash(content: str) -> str:
    """Compute SHA-256 hash of content (first 12 hex chars)."""
    return hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]


def load_project_config(source_root: str) -> None:
    """Load and cache .jcodemunch.jsonc for a project.

    Uses hash-based caching: if the config file content hasn't changed,
    the cached config is reused. This handles:
    - First-time indexing (no cache)
    - Incremental reindexes (cache hit, no parse)
    - Config file edited (hash changed, reload)
    - File touched but unchanged (hash same, no reload)
    - Index dropped and recreated (cache still valid if file unchanged)

    Thread-safe: uses _CONFIG_LOCK to protect global dict mutations.
    """
    project_config_path = Path(source_root) / ".jcodemunch.jsonc"
    repo_key = str(Path(source_root).resolve())

    if project_config_path.exists():
        try:
            content = project_config_path.read_text(encoding="utf-8-sig")
            content_hash = _content_hash(content)

            with _CONFIG_LOCK:
                if repo_key in _PROJECT_CONFIGS:
                    if _PROJECT_CONFIG_HASHES.get(repo_key) == content_hash:
                        return

            stripped = _strip_jsonc(content)
            project_config = json.loads(stripped)

            with _CONFIG_LOCK:
                merged = deepcopy(_GLOBAL_CONFIG)
                for key, value in project_config.items():
                    if key in CONFIG_TYPES:
                        if _validate_type(key, value, CONFIG_TYPES[key]):
                            if key == "trusted_folders" and isinstance(value, list):
                                valid_folders = set()
                                project_root = Path(source_root).resolve()
                                for folder in value:
                                    if folder == "." or folder == "./":
                                        expanded_folder = project_root
                                    elif folder.startswith("./"):
                                        expanded_folder = (
                                            (project_root / folder[2:])
                                            .expanduser()
                                            .resolve()
                                        )
                                        if (
                                            expanded_folder != project_root
                                            and project_root
                                            not in expanded_folder.parents
                                        ):
                                            raise ValueError(
                                                "Project config key 'trusted_folders' entry escapes project root "
                                                f"'{folder}'"
                                            )
                                    elif not Path(folder).expanduser().is_absolute():
                                        expanded_folder = (
                                            (project_root / folder)
                                            .expanduser()
                                            .resolve()
                                        )
                                        if (
                                            expanded_folder != project_root
                                            and project_root
                                            not in expanded_folder.parents
                                        ):
                                            raise ValueError(
                                                "Project config key 'trusted_folders' entry escapes project root "
                                                f"'{folder}'"
                                            )
                                    else:
                                        expanded_folder = (
                                            Path(folder).expanduser().resolve()
                                        )
                                    valid_folders.add(expanded_folder)
                                merged[key] = list(valid_folders)
                            else:
                                merged[key] = value
                        else:
                            logger.warning(
                                "Project config key '%s' has invalid type. Using global default.",
                                key,
                            )
                _PROJECT_CONFIGS[repo_key] = merged
                _PROJECT_CONFIG_HASHES[repo_key] = content_hash
        except Exception as e:
            logger.warning("Failed to load project config: %s", e)
            with _CONFIG_LOCK:
                _PROJECT_CONFIGS[repo_key] = deepcopy(_GLOBAL_CONFIG)
    else:
        with _CONFIG_LOCK:
            if repo_key not in _PROJECT_CONFIGS:
                _PROJECT_CONFIGS[repo_key] = deepcopy(_GLOBAL_CONFIG)
            _PROJECT_CONFIG_HASHES.pop(repo_key, None)


def _list_repos_for_config() -> list[dict]:
    """Get list of indexed repos for project config loading.

    Deferred import to avoid circular dependency at module load time.
    """
    from .storage.index_store import IndexStore
    storage_path = os.environ.get("CODE_INDEX_PATH", str(Path.home() / ".code-index"))
    store = IndexStore(base_path=storage_path)
    return store.list_repos()


def load_all_project_configs() -> None:
    """Load project configs for all already-indexed local repos.

    Called once at server startup after load_config(). Discovers all indexed
    local repos via list_repos() and loads their .jcodemunch.jsonc files.
    Remote repos (empty source_root) are skipped.
    """
    if not _GLOBAL_CONFIG:
        return

    try:
        repos = _list_repos_for_config()
        for repo_entry in repos:
            source_root = repo_entry.get("source_root", "")
            if not source_root:
                continue
            repo_key = str(Path(source_root).resolve())
            if repo_key not in _PROJECT_CONFIGS:
                load_project_config(source_root)
    except Exception as e:
        logger.warning("Failed to load project configs at startup: %s", e)


def is_tool_disabled(tool_name: str, repo: str | None = None) -> bool:
    """Check if a tool is in disabled_tools."""
    disabled = get("disabled_tools", [], repo=repo)
    return tool_name in disabled


def is_language_enabled(language: str, repo: str | None = None) -> bool:
    """Check if a language is in the languages list."""
    languages = get("languages", None, repo=repo)
    if languages is None:  # None = all enabled
        return True
    return language in languages


def get_descriptions() -> dict:
    """Get the nested descriptions dict."""
    return _GLOBAL_CONFIG.get("descriptions", {})


def validate_config(config_path: str) -> list[str]:
    """Validate a config.jsonc file and return a list of issue messages.

    Returns an empty list if the config is valid.
    Checks:
    - File exists
    - JSONC parses to valid JSON
    - All keys have correct types
    - Unknown keys are flagged (warning, not error)
    """
    issues: list[str] = []
    path = Path(config_path)

    if not path.exists():
        return [f"Config file not found: {config_path}"]

    try:
        content = path.read_text(encoding="utf-8-sig")  # utf-8-sig handles BOM
        stripped = _strip_jsonc(content)
        loaded = json.loads(stripped)
    except json.JSONDecodeError as e:
        return [f"Config parse error: {e}"]

    # Validate types
    for key, value in loaded.items():
        if key in CONFIG_TYPES:
            if not _validate_type(key, value, CONFIG_TYPES[key]):
                if key == "use_ai_summaries":
                    issues.append(
                        f"Config key 'use_ai_summaries' has invalid value {value!r}: "
                        f'expected one of: "auto", "true", "false" (or boolean true/false)'
                    )
                else:
                    expected = CONFIG_TYPES[key]
                    type_name = getattr(expected, "__name__", str(expected))
                    issues.append(
                        f"Config key '{key}' has invalid type: "
                        f"expected {type_name}, got {type(value).__name__}"
                    )
            elif key == "trusted_folders":
                for entry in value:
                    if not Path(entry).expanduser().is_absolute():
                        issues.append(
                            f"trusted_folders entry '{entry}' must be an absolute path"
                        )
        else:
            issues.append(f"Config key '{key}' is not recognized (unknown key)")

    return issues


def generate_template() -> str:
    """Return default config.jsonc content."""
    from . import __version__
    from .parser.languages import LANGUAGE_REGISTRY

    # Sorted alphabetically for readability - use .sorted() to ensure always sorted
    languages_list = sorted(LANGUAGE_REGISTRY.keys())
    lang_str = "\n  ".join(f'"{lang}",' for lang in languages_list)

    # All available tools (for disabled_tools reference) - sorted alphabetically
    # Removed: wait_for_fresh (v1.12.0 - check_freshness and wait_for_fresh tools removed)
    all_tools = sorted([
        "check_references",
        "embed_repo",
        "find_dead_code",
        "find_importers",
        "find_references",
        "get_blast_radius",
        "get_changed_symbols",
        "get_class_hierarchy",
        "get_context_bundle",
        "get_dependency_graph",
        "get_file_content",
        "get_file_outline",
        "get_file_tree",
        "get_ranked_context",
        "get_related_symbols",
        "get_repo_outline",
        "get_session_stats",
        "get_symbol_diff",
        "get_symbol_importance",
        "get_symbol_source",
        "index_file",
        "index_folder",
        "index_repo",
        "invalidate_cache",
        "list_repos",
        "resolve_repo",
        "search_columns",
        "search_symbols",
        "search_text",
        "suggest_queries",
        "test_summarizer",
    ])
    tools_str = "\n  // ".join(f'"{t}",' for t in all_tools)

    # All available meta_fields (for template documentation)
    # Removed (v1.12.0): index_stale, reindex_in_progress, stale_since_ms,
    #   reindex_error, reindex_failures (staleness fields removed with check_freshness)
    meta_fields_list = sorted([
        "candidates_scored",
        "powered_by",
        "timing_ms",
        "token_budget",
        "tokens_remaining",
        "tokens_used",
    ])
    # Commented-out meta_fields list (each field on its own line, like disabled_tools)
    meta_str = "\n  // ".join(f'"{mf}",' for mf in meta_fields_list)

    return f'''// jcodemunch-mcp configuration
// Global: ~/.code-index/config.jsonc
// Project: {{project_root}}/.jcodemunch.jsonc (optional, overrides global)
//
// All values below show defaults. Uncomment to override.
// Env vars still work as fallback but are deprecated.
{{
  // Config version - do not edit. Used for additive migrations.
  "version": "{__version__}",

  // === Indexing ===
  // "trusted_folders": [],
  //   Directories allowed for indexing when whitelist_mode is true.
  //   In whitelist mode (default), only these folders can be indexed.
  //   In blacklist mode (whitelist_mode=false), these folders are blocked.

  // "trusted_folders_whitelist_mode": true,
  //   true = only trust folders in trusted_folders list (default, secure).
  //   false = trust all folders EXCEPT those in trusted_folders (blocklist mode).

  // "max_folder_files": 2000,
  //   Maximum number of files to index when indexing a local folder.
  //   Prevents accidental massive indexing jobs.

  // "gitignore_warn_threshold": 500,
  //   Emit a warning during index_folder when no root .gitignore is found
  //   and the indexed file count reaches this value. Helps catch accidental
  //   indexing of build artifacts or vendored dependencies before they
  //   bloat the index. Set 0 to disable the warning entirely.

  // "max_index_files": 10000,
  //   Maximum number of files to index when indexing a GitHub repo.
  //   Separate cap from max_folder_files for different use cases.

  // "staleness_days": 7,
  //   Days before an index is considered stale (warning only, no blocking).

  // "max_results": 500,
  //   Maximum number of results returned by search operations.

  // "file_tree_max_files": 500,
  //   Maximum number of files returned by get_file_tree in a single call.
  //   Prevents token overflow on large or bloated indexes. The response
  //   includes a hint to use path_prefix when this cap is hit.
  //   Can also be overridden per-call via the max_files tool parameter.

  // "extra_ignore_patterns": [],
  //   Additional gitignore-style patterns to exclude from indexing.
  //   Merged with JCODEMUNCH_EXTRA_IGNORE_PATTERNS env var.

  // "exclude_secret_patterns": [],
  //   Glob patterns to exclude from *secret* detection.
  //   Use when *secret* has false positives on specific paths.

  // "extra_extensions": {{}},
  //   Map additional file extensions to languages.
  //   Example: {{".mpl": "cpp"}} to parse .mpl files as C++.

  // "context_providers": true,
  //   Enable context providers for enhanced AI summarization.
  //   Set false to disable (faster indexing, less context).

  // === Meta Response Control ===
  // Allowlist of _meta fields to include in responses.
  // [] (default) = no _meta at all (maximum token savings).
  // null = all fields included (set explicitly to opt in).
  // Uncomment and set to a list of field names to include only those fields.
  // All available meta fields (sorted alphabetically, each on its own line):
  "meta_fields": [
  // {meta_str}
  ],

  // === Languages ===
  // All supported languages. Comment out to disable a language
  // and its dependent features (e.g. "sql" disables dbt parsing
  // and search_columns tool).
  // Each language on its own line (sorted alphabetically):
  "languages": [
     {lang_str}
  ],

  // "languages_adaptive": false,
  //   When true, jcodemunch auto-manages the languages list in this
  //   project's .jcodemunch.jsonc based on detected languages.
  //   Detected languages are uncommented; unused ones are commented out.
  //   Runs on every index_folder call (full and incremental).
  //   Set in global config to auto-create project configs on first index.
  //   Set in project config to enable ongoing adaptation.

  // === Disabled Tools ===
  // Global: tools listed here are removed from the schema entirely.
  // Project: tools listed here are rejected at call_tool() with an
  //   explanatory error (schema is global, can't be changed per-project).
  // Default: test_summarizer disabled. Uncomment others to disable them.
  "disabled_tools": [
    // test_summarizer — diagnostic: sends a probe to the AI summarizer and
    //   reports status (ok, timeout, error, misconfigured, disabled).
    //   Remove from this list to enable it, then call it from your MCP client.
    "test_summarizer",
  // {tools_str}
  ],

  // === Descriptions ===
  // Append text to shortened tool/param descriptions.
  // Empty string = use hardcoded minimal base only.
  // _tool = tool-level description, other keys = param names.
  // _shared applies across all tools (tool-specific overrides _shared).
  // Tools not listed here keep their full current descriptions unchanged.
  "descriptions": {{
    // === Example: Uncomment to enable ===
    // "search_symbols": {{
    //   "_tool": "",
    //   "debug": "",
    //   "detail_level": "",
    //   "language": ""
    // }},
    // "find_importers": {{ "_tool": "" }},
    // "find_references": {{ "_tool": "" }},
    // "get_blast_radius": {{ "_tool": "" }},
    // "get_context_bundle": {{ "_tool": "" }},
    // "suggest_queries": {{ "_tool": "" }},
    // "_shared": {{ "repo": "" }}
  }},

  // === Transport ===
  // Protocol for MCP server communication:
  //   stdio            - Default. Uses stdin/stdout. Works everywhere.
  //   sse              - Server-Sent Events over HTTP. Persistent connection.
  //   streamable-http  - Streamable HTTP. Alternative persistent HTTP mode.
  // When using sse or streamable-http, also set host and port.
  // "transport": "stdio",
  // "host": "127.0.0.1",
  //   Bind address for HTTP transports. Use 0.0.0.0 for all interfaces.
  // "port": 8901,
  //   Port for HTTP transports (sse, streamable-http).
  // "rate_limit": 0,
  //   Max requests per minute per client IP. 0 = disabled (default).

  // === Watcher ===
  // "watch": false,
  //   Enable automatic reindexing when files change.
  //   Use "jcodemunch-mcp watch <paths>" CLI command to activate.
  // "watch_debounce_ms": 2000,
  //   Milliseconds to wait after a file change before reindexing.
  //   Higher values reduce CPU usage but slower detection.
  // "freshness_mode": "relaxed",
  //   relaxed - Default. Index remains queryable during reindex.
  //             Best for interactive use (IDE, chat).
  //   strict  - Blocks queries until fresh index is ready.
  //             Best for automation/CI where consistency matters.
  // "strict_timeout_ms": 500,
  //   Maximum milliseconds to block queries waiting for a reindex in strict mode.
  //   After this timeout the query proceeds with the stale index.
  //   Only applies when freshness_mode is "strict". Default: 500.
  // "claude_poll_interval": 5.0,
  //   Seconds between polling Claude Code worktrees for changes.

  // === Logging ===
  // "log_level": "WARNING",
  //   DEBUG, INFO, WARNING, ERROR, CRITICAL. WARNING is default for less noise.
  // "log_file": null,
  //   Path to log file. null = write to stderr.

  // === Privacy & Telemetry ===
  // "redact_source_root": false,
  //   Replace absolute source_root paths with display_name in responses.
  //   Set true to hide project paths from clients.
  // "stats_file_interval": 3,
  //   Write session_stats.json every N tool calls. 0 = disable writes.
  //   Lower values = more disk I/O but faster stats for external consumers.
  // "share_savings": true,
  //   Enable anonymous token savings telemetry (helps project funding).
  //   Set false/0 to disable.
  // "summarizer_concurrency": 4,
  //   Number of parallel threads for AI summarization.
  //   Higher = faster indexing but more API calls.
  // "summarizer_max_failures": 3,
  //   Consecutive batch failures before the AI summarizer gives up and
  //   falls back to signature summaries for remaining symbols.
  //   Set 0 to disable the circuit breaker (never stop retrying).

  // === AI Summarizer ===
  // Controls whether AI is used to generate symbol summaries during indexing.
  //   "auto"  — auto-detect provider from API key env vars (default behavior)
  //   true    — use the summarizer_provider and summarizer_model values below
  //   false   — disable AI summarization entirely (signature fallback only)
  // "use_ai_summaries": "auto",

  // AI summarizer provider to use when use_ai_summaries is true.
  // Valid values: "anthropic", "gemini", "openai", "minimax", "glm", "openrouter", "none"
  // Leave empty ("") to auto-detect from available API keys.
  // "summarizer_provider": "",

  // Model name to use for the selected summarizer provider.
  // Leave empty ("") to use the provider's default model.
  // Examples: "claude-haiku-4-5-20251001" (anthropic), "gemini-2.5-flash-lite" (gemini),
  //           "gpt-4o-mini" (openai), "minimax-m2.7" (minimax), "glm-5" (glm),
  //           "meta-llama/llama-3.3-70b-instruct:free" (openrouter)
  // "summarizer_model": "",
  // "embed_model": "",
  //   Sentence-transformers model name for local (free) semantic embeddings.
  //   Example: "all-MiniLM-L6-v2". Requires sentence-transformers package.
  //   When set, takes priority over GOOGLE_API_KEY and OPENAI_API_KEY embeddings.
  // "allow_remote_summarizer": false,
  //   Allow remote LLM endpoints for summarization (security risk).
  //   Default false blocks non-local summarization.
  // "path_map": "",
  //   Cross-platform path remapping. Format: "orig1=new1,orig2=new2".
  //   Allows indexes built on Linux to work on Windows and vice versa.
}}
'''
