# 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 = {"*", "?", "["}


@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, ...] = ()


@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, ...] = ()


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)

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


def compute_selected_rule_paths(
    *,
    embedded_paths: set[str],
    repo_paths: set[str],
    config: RulesSelectionConfig,
) -> SelectionResult:
    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:
        logger.warning("Unknown defaults.embedded=%s; using core_only.", config.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:
        logger.warning("Unknown defaults.repo=%s; using all.", config.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:
            logger.warning("Unknown rules preset '%s'; skipping.", preset_name)
            continue
        preset_include.extend(preset.include)
        preset_exclude.extend(preset.exclude)

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

    for selector in include_selectors:
        _apply_selector(
            selector=selector,
            embedded_paths=embedded_paths,
            repo_paths=repo_paths,
            selected_embedded=selected_embedded,
            selected_repo=selected_repo,
            include=True,
        )
    for selector in exclude_selectors:
        _apply_selector(
            selector=selector,
            embedded_paths=embedded_paths,
            repo_paths=repo_paths,
            selected_embedded=selected_embedded,
            selected_repo=selected_repo,
            include=False,
        )

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


def _apply_selector(
    *,
    selector: Selector,
    embedded_paths: set[str],
    repo_paths: set[str],
    selected_embedded: set[str],
    selected_repo: set[str],
    include: bool,
) -> None:
    if selector.source not in {"embedded", "repo"}:
        logger.warning("Unknown selector source '%s'; skipping.", selector.source)
        return
    match_embedded = selector.source == "embedded"
    candidates = embedded_paths if match_embedded else repo_paths
    selected = selected_embedded if match_embedded else selected_repo

    for path in candidates:
        if not _matches_selector(path=path, selector=selector.path):
            continue
        if include:
            selected.add(path)
        else:
            selected.discard(path)


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)

    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 isinstance(repo, str):
            defaults_repo = repo.strip()

    include = _parse_selectors(obj.get("include"), path=path, key="include")
    exclude = _parse_selectors(obj.get("exclude"), path=path, key="exclude")
    presets = _parse_presets(obj.get("presets"), path=path)

    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),
    )


def _parse_selectors(raw: object, *, path: Path, key: str) -> list[Selector]:
    if raw is None:
        return []
    if not isinstance(raw, list):
        logger.warning("Rules selection %s '%s' must be a list; ignoring.", path, key)
        return []
    parsed: list[Selector] = []
    for item in cast(list[object], raw):
        if not isinstance(item, dict):
            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):
            continue
        source_normalized = source.strip()
        path_normalized = sel_path.strip()
        if not source_normalized or not path_normalized:
            continue
        parsed.append(Selector(source=source_normalized, path=path_normalized))
    return parsed


def _parse_presets(raw: object, *, path: Path) -> dict[str, SelectionPreset]:
    if raw is None:
        return {}
    if not isinstance(raw, dict):
        logger.warning("Rules selection %s 'presets' must be a mapping; ignoring.", path)
        return {}
    parsed: dict[str, SelectionPreset] = {}
    for preset_name, preset_raw in cast(dict[str, Any], raw).items():
        if not isinstance(preset_name, str) or not isinstance(preset_raw, dict):
            continue
        include = _parse_selectors(preset_raw.get("include"), path=path, key=f"presets.{preset_name}.include")
        exclude = _parse_selectors(preset_raw.get("exclude"), path=path, key=f"presets.{preset_name}.exclude")
        parsed[preset_name] = SelectionPreset(include=tuple(include), exclude=tuple(exclude))
    return parsed
