# Copyright (C) 2026 GoodData Corporation

"""Rule-selection config for embedded and repo-owned rules."""

from __future__ import annotations

import fnmatch
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, cast

import yaml

from aida_mcp.utils.aida_paths import get_repo_config_root

logger = logging.getLogger(__name__)

SELECTION_CONFIG_NAME = "rules_selection.yaml"
LOCAL_SELECTION_CONFIG_NAME = "rules_selection.local.yaml"

_GLOB_TOKENS = {"*", "?", "["}

_VALID_TOP_LEVEL_KEYS = {"version", "defaults", "include", "exclude", "presets", "use_presets"}
_VALID_DEFAULTS_EMBEDDED = {"core_only", "selected"}
_VALID_DEFAULTS_REPO = {"all", "selected"}
_VALID_SOURCES = {"embedded", "repo"}


class RulesSelectionConfigError(Exception):
    """Raised when rules_selection.yaml contains configuration errors."""

    def __init__(self, errors: list[str]) -> None:
        self.errors = errors
        bullet_list = "\n".join(f"  - {e}" for e in errors)
        super().__init__(f"rules_selection.yaml has {len(errors)} error(s):\n{bullet_list}")


@dataclass(frozen=True, slots=True)
class Selector:
    source: str
    path: str


@dataclass(frozen=True, slots=True)
class SelectionDefaults:
    embedded: str = "core_only"
    repo: str = "all"


@dataclass(frozen=True, slots=True)
class SelectionPreset:
    include: tuple[Selector, ...] = ()
    exclude: tuple[Selector, ...] = ()


@dataclass(frozen=True, slots=True)
class RulesSelectionConfig:
    defaults: SelectionDefaults = SelectionDefaults()
    include: tuple[Selector, ...] = ()
    exclude: tuple[Selector, ...] = ()
    presets: dict[str, SelectionPreset] = field(default_factory=dict)
    use_presets: tuple[str, ...] = ()
    errors: tuple[str, ...] = ()


@dataclass(frozen=True, slots=True)
class SelectionResult:
    embedded_paths: tuple[str, ...]
    repo_paths: tuple[str, ...]


@dataclass(frozen=True, slots=True)
class _PartialConfig:
    defaults_embedded: str | None = None
    defaults_repo: str | None = None
    include: tuple[Selector, ...] = ()
    exclude: tuple[Selector, ...] = ()
    presets: dict[str, SelectionPreset] = field(default_factory=dict)
    use_presets: tuple[str, ...] = ()
    errors: tuple[str, ...] = ()


def load_rules_selection_config(*, workspace_root: Path) -> RulesSelectionConfig:
    config_root = get_repo_config_root(workspace_root)
    shared_path = config_root / SELECTION_CONFIG_NAME
    local_path = config_root / LOCAL_SELECTION_CONFIG_NAME

    shared = _load_partial_config(shared_path)
    local = _load_partial_config(local_path)

    defaults = SelectionDefaults(
        embedded=local.defaults_embedded or shared.defaults_embedded or "core_only",
        repo=local.defaults_repo or shared.defaults_repo or "all",
    )
    include = (*shared.include, *local.include)
    exclude = (*shared.exclude, *local.exclude)
    presets = dict(shared.presets)
    presets.update(local.presets)
    use_presets = (*shared.use_presets, *local.use_presets)
    errors = (*shared.errors, *local.errors)

    return RulesSelectionConfig(
        defaults=defaults,
        include=include,
        exclude=exclude,
        presets=presets,
        use_presets=use_presets,
        errors=errors,
    )


def compute_selected_rule_paths(
    *,
    embedded_paths: set[str],
    repo_paths: set[str],
    config: RulesSelectionConfig,
) -> SelectionResult:
    errors = list(config.errors)
    selected_embedded: set[str] = set()
    selected_repo: set[str] = set()

    if config.defaults.embedded == "core_only":
        selected_embedded = {p for p in embedded_paths if p.startswith("core/")}
    elif config.defaults.embedded == "selected":
        selected_embedded = set()
    else:
        errors.append(
            f"defaults.embedded: unknown value '{config.defaults.embedded}'. "
            f"Must be one of: {', '.join(sorted(_VALID_DEFAULTS_EMBEDDED))}."
        )
        selected_embedded = {p for p in embedded_paths if p.startswith("core/")}

    if config.defaults.repo == "all":
        selected_repo = set(repo_paths)
    elif config.defaults.repo == "selected":
        selected_repo = set()
    else:
        errors.append(
            f"defaults.repo: unknown value '{config.defaults.repo}'. "
            f"Must be one of: {', '.join(sorted(_VALID_DEFAULTS_REPO))}."
        )
        selected_repo = set(repo_paths)

    preset_include: list[Selector] = []
    preset_exclude: list[Selector] = []
    for preset_name in config.use_presets:
        preset = config.presets.get(preset_name)
        if preset is None:
            available = ", ".join(sorted(config.presets.keys())) if config.presets else "(none defined)"
            errors.append(f"use_presets: unknown preset '{preset_name}'. Available presets: {available}.")
            continue
        preset_include.extend(preset.include)
        preset_exclude.extend(preset.exclude)

    include_selectors = [*preset_include, *config.include]
    exclude_selectors = [*preset_exclude, *config.exclude]

    all_selectors = [
        *((s, "include") for s in include_selectors),
        *((s, "exclude") for s in exclude_selectors),
    ]

    for selector, role in all_selectors:
        match_embedded = selector.source == "embedded"
        candidates = embedded_paths if match_embedded else repo_paths
        matched = _apply_selector_with_match_count(
            selector=selector,
            candidates=candidates,
            selected_embedded=selected_embedded,
            selected_repo=selected_repo,
            is_embedded=match_embedded,
            include=role == "include",
        )
        if matched == 0:
            hint = _suggest_extension_match(selector.path, candidates)
            msg = (
                f"{role}: selector {{source: {selector.source}, path: {selector.path}}} "
                f"matched 0 {selector.source} rules."
            )
            if hint:
                msg += f" Did you mean '{hint}'?"
            errors.append(msg)

    if errors:
        raise RulesSelectionConfigError(errors)

    return SelectionResult(
        embedded_paths=tuple(sorted(selected_embedded)),
        repo_paths=tuple(sorted(selected_repo)),
    )


def _apply_selector_with_match_count(
    *,
    selector: Selector,
    candidates: set[str],
    selected_embedded: set[str],
    selected_repo: set[str],
    is_embedded: bool,
    include: bool,
) -> int:
    selected = selected_embedded if is_embedded else selected_repo
    matched = 0
    for path in candidates:
        if not _matches_selector(path=path, selector=selector.path):
            continue
        matched += 1
        if include:
            selected.add(path)
        else:
            selected.discard(path)
    return matched


def _suggest_extension_match(selector_path: str, candidates: set[str]) -> str | None:
    """If a selector without extension would match with .mdc added, return the suggestion."""
    normalized = selector_path.strip()
    # Only suggest for non-glob, non-directory, extension-less selectors.
    if any(token in normalized for token in _GLOB_TOKENS):
        return None
    if normalized.endswith("/"):
        return None
    if "." in normalized.rsplit("/", 1)[-1]:
        return None
    # Try adding .mdc extension.
    with_ext = f"{normalized}.mdc"
    for path in candidates:
        if path == with_ext or fnmatch.fnmatch(path, with_ext):
            return with_ext
    return None


def _matches_selector(*, path: str, selector: str) -> bool:
    normalized = selector.strip()
    if not normalized:
        return False
    if any(token in normalized for token in _GLOB_TOKENS):
        return fnmatch.fnmatch(path, normalized)
    if normalized.endswith("/"):
        return path.startswith(normalized)
    return path == normalized or path.startswith(f"{normalized}/")


def _load_partial_config(path: Path) -> _PartialConfig:
    if not path.exists():
        return _PartialConfig()
    try:
        loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
    except Exception as exc:
        logger.warning("Failed to read rules selection config %s: %s", path, exc)
        return _PartialConfig()
    if not isinstance(loaded, dict):
        logger.warning("Rules selection config %s must be a mapping; ignoring.", path)
        return _PartialConfig()
    obj = cast(dict[str, object], loaded)

    errors: list[str] = []
    fname = path.name

    # Validate top-level keys.
    unknown_keys = set(obj.keys()) - _VALID_TOP_LEVEL_KEYS
    if unknown_keys:
        errors.append(
            f"{fname}: unknown top-level key(s): {', '.join(sorted(unknown_keys))}. "
            f"Valid keys: {', '.join(sorted(_VALID_TOP_LEVEL_KEYS))}."
        )

    defaults = obj.get("defaults")
    defaults_embedded: str | None = None
    defaults_repo: str | None = None
    if isinstance(defaults, dict):
        defaults_obj = cast(dict[str, object], defaults)
        embedded = defaults_obj.get("embedded")
        repo = defaults_obj.get("repo")
        if isinstance(embedded, str):
            defaults_embedded = embedded.strip()
            if defaults_embedded not in _VALID_DEFAULTS_EMBEDDED:
                errors.append(
                    f"{fname}: defaults.embedded: unknown value '{defaults_embedded}'. "
                    f"Must be one of: {', '.join(sorted(_VALID_DEFAULTS_EMBEDDED))}."
                )
        if isinstance(repo, str):
            defaults_repo = repo.strip()
            if defaults_repo not in _VALID_DEFAULTS_REPO:
                errors.append(
                    f"{fname}: defaults.repo: unknown value '{defaults_repo}'. "
                    f"Must be one of: {', '.join(sorted(_VALID_DEFAULTS_REPO))}."
                )

    include, include_errors = _parse_selectors(obj.get("include"), path=path, key="include")
    exclude, exclude_errors = _parse_selectors(obj.get("exclude"), path=path, key="exclude")
    presets, preset_errors = _parse_presets(obj.get("presets"), path=path)
    errors.extend(include_errors)
    errors.extend(exclude_errors)
    errors.extend(preset_errors)

    use_presets_raw = obj.get("use_presets")
    use_presets: list[str] = []
    if isinstance(use_presets_raw, list):
        for entry in cast(list[object], use_presets_raw):
            if isinstance(entry, str) and entry.strip():
                use_presets.append(entry.strip())

    return _PartialConfig(
        defaults_embedded=defaults_embedded,
        defaults_repo=defaults_repo,
        include=tuple(include),
        exclude=tuple(exclude),
        presets=presets,
        use_presets=tuple(use_presets),
        errors=tuple(errors),
    )


def _parse_selectors(raw: object, *, path: Path, key: str) -> tuple[list[Selector], list[str]]:
    if raw is None:
        return [], []
    if not isinstance(raw, list):
        return [], [f"{path.name}: '{key}' must be a list, got {type(raw).__name__}."]
    parsed: list[Selector] = []
    errors: list[str] = []
    fname = path.name
    for i, item in enumerate(cast(list[object], raw)):
        if isinstance(item, str):
            errors.append(
                f"{fname}: {key}[{i}]: plain string '{item}' is not valid. "
                f"Each entry must be a mapping with 'source' and 'path' keys. "
                f"Example: {{source: repo, path: {item}}}"
            )
            continue
        if not isinstance(item, dict):
            errors.append(
                f"{fname}: {key}[{i}]: expected a mapping with 'source' and 'path' keys, "
                f"got {type(item).__name__}: {item!r}."
            )
            continue
        entry = cast(dict[str, object], item)
        source = entry.get("source")
        sel_path = entry.get("path")
        if not isinstance(source, str) or not isinstance(sel_path, str):
            missing = []
            if not isinstance(source, str):
                missing.append("source")
            if not isinstance(sel_path, str):
                missing.append("path")
            errors.append(
                f"{fname}: {key}[{i}]: missing or invalid key(s): {', '.join(missing)}. "
                f"Each entry must have 'source' (embedded|repo) and 'path' (string). "
                f"Got: {item!r}"
            )
            continue
        source_normalized = source.strip()
        path_normalized = sel_path.strip()
        if not source_normalized or not path_normalized:
            errors.append(f"{fname}: {key}[{i}]: 'source' and 'path' must not be empty. Got: {item!r}")
            continue
        if source_normalized not in _VALID_SOURCES:
            errors.append(
                f"{fname}: {key}[{i}]: unknown source '{source_normalized}'. "
                f"Must be one of: {', '.join(sorted(_VALID_SOURCES))}."
            )
            continue
        parsed.append(Selector(source=source_normalized, path=path_normalized))
    return parsed, errors


def _parse_presets(raw: object, *, path: Path) -> tuple[dict[str, SelectionPreset], list[str]]:
    if raw is None:
        return {}, []
    if not isinstance(raw, dict):
        return {}, [f"{path.name}: 'presets' must be a mapping, got {type(raw).__name__}."]
    parsed: dict[str, SelectionPreset] = {}
    errors: list[str] = []
    for preset_name, preset_raw in cast(dict[str, Any], raw).items():
        if not isinstance(preset_name, str) or not isinstance(preset_raw, dict):
            errors.append(f"{path.name}: presets.{preset_name}: must be a mapping, got {type(preset_raw).__name__}.")
            continue
        include, inc_errors = _parse_selectors(
            preset_raw.get("include"), path=path, key=f"presets.{preset_name}.include"
        )
        exclude, exc_errors = _parse_selectors(
            preset_raw.get("exclude"), path=path, key=f"presets.{preset_name}.exclude"
        )
        errors.extend(inc_errors)
        errors.extend(exc_errors)
        parsed[preset_name] = SelectionPreset(include=tuple(include), exclude=tuple(exclude))
    return parsed, errors
