# Copyright (C) 2026 GoodData Corporation

"""Classify changed files into validation domains."""

from __future__ import annotations

import fnmatch
import logging
import os
from collections.abc import Iterable, Mapping, MutableMapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import cast

import yaml

from aida_mcp.utils.aida_paths import DOT_AIDA

logger = logging.getLogger(__name__)

ENV_CONFIG_ROOT = "AIDA_CONFIG_ROOT"
ENV_WORKSPACE_ROOT = "AIDA_WORKSPACE_ROOT"


def _workspace_root_from_env() -> Path | None:
    override = os.environ.get(ENV_WORKSPACE_ROOT)
    if not override:
        return None
    try:
        p = Path(override).expanduser()
        if not p.is_absolute():
            p = (Path.cwd() / p).resolve()
        return p if p.exists() else None
    except Exception:
        return None


def _default_change_domains_path() -> Path:
    # 1) explicit config root directory
    root = os.environ.get(ENV_CONFIG_ROOT)
    if isinstance(root, str) and root.strip():
        base = Path(root.strip()).expanduser()
        if not base.is_absolute():
            base = (Path.cwd() / base).resolve()
        return base / "change_domains.yaml"

    # 2) workspace-root based (repo-owned artifacts)
    ws = _workspace_root_from_env()
    if ws is not None:
        cand = ws / DOT_AIDA / "change_domains.yaml"
        if cand.exists():
            return cand

        cand2 = ws / "config" / "change_domains.yaml"
        if cand2.exists():
            return cand2

    # 3) working-directory based fallback
    cwd = Path.cwd().resolve()
    cand3 = cwd / DOT_AIDA / "change_domains.yaml"
    if cand3.exists():
        return cand3

    # 4) package-relative fallback (works from source and relocated paths)
    for parent in Path(__file__).resolve().parents:
        cand4 = parent / "config" / "change_domains.yaml"
        if cand4.exists():
            return cand4

    # Last resort (may not exist): keep deterministic path under current cwd.
    return cand3


@dataclass(slots=True)
class DomainRule:
    """A rule describing how to map files to a validation domain."""

    id: str
    description: str
    match_globs: list[str]
    root_depth: int | None = None

    def matches(self, path: str) -> bool:
        """Check whether the rule matches the provided path."""
        return any(fnmatch.fnmatch(path, pattern) for pattern in self.match_globs)

    def derive_root(self, path: str) -> str:
        """
        Derive the component root for the matched path.

        root_depth indicates the number of leading path segments that
        identify the component. For example, a depth of 2 turns
        services/reporting-api/src/Main.kt → services/reporting-api.
        """
        if self.root_depth is None:
            return ""

        if self.root_depth <= 0:
            return "."

        segments = path.split("/")
        if len(segments) >= self.root_depth:
            return "/".join(segments[: self.root_depth])

        return "/".join(segments)


def _new_file_set() -> set[str]:
    return set()


@dataclass(slots=True)
class DomainMatch:
    """Represents a set of files that belong to the same validation domain."""

    domain: str
    root: str
    files: set[str] = field(default_factory=_new_file_set)

    def add(self, path: str) -> None:
        self.files.add(path)

    def to_dict(self) -> dict[str, str | list[str]]:
        """Serialize the match for easy consumption by planners."""
        return {
            "domain": self.domain,
            "root": self.root,
            "files": sorted(self.files),
        }


class ChangeDomainRegistry:
    """Registry of change classification rules."""

    def __init__(self, rules: list[DomainRule]):
        self.rules = rules

    @classmethod
    def from_config(cls, config: Mapping[str, object]) -> ChangeDomainRegistry:
        """Create registry from loaded YAML config."""
        raw_rules_obj = config.get("domains", [])
        if not isinstance(raw_rules_obj, list):
            raise ValueError("domains must be a list")

        rules: list[DomainRule] = []
        raw_rules_seq = cast(list[object], raw_rules_obj)

        for raw_rule_candidate in raw_rules_seq:
            if not isinstance(raw_rule_candidate, Mapping):
                logger.warning("Skipping invalid rule entry: %r", raw_rule_candidate)
                continue

            raw_rule_obj = cast(Mapping[str, object], raw_rule_candidate)
            raw_rule_map: dict[str, object] = {str(key): value for key, value in raw_rule_obj.items()}

            rule_id_value = raw_rule_map.get("id")
            if not isinstance(rule_id_value, str) or not rule_id_value:
                logger.warning("Skipping rule without a valid id: %r", raw_rule_map)
                continue

            description_value = raw_rule_map.get("description")
            description = str(description_value) if description_value is not None else ""

            match_globs_value = raw_rule_map.get("match_globs", [])
            if not isinstance(match_globs_value, list):
                logger.warning("Rule %s has invalid match_globs: %r", rule_id_value, match_globs_value)
                continue

            match_globs_value_list = cast(list[object], match_globs_value)
            match_globs = [pattern for pattern in match_globs_value_list if isinstance(pattern, str)]
            if len(match_globs) != len(match_globs_value_list):
                logger.warning("Rule %s contains non-string match_globs; ignoring them", rule_id_value)

            if not match_globs:
                logger.warning("Rule %s has no match_globs; skipping", rule_id_value)
                continue

            root_depth_obj = raw_rule_map.get("root_depth")
            root_depth: int | None
            if root_depth_obj is None:
                root_depth = None
            elif isinstance(root_depth_obj, int):
                root_depth = root_depth_obj
            else:
                logger.warning("Rule %s has non-integer root_depth: %r", rule_id_value, root_depth_obj)
                root_depth = None

            rules.append(
                DomainRule(
                    id=rule_id_value,
                    description=description,
                    match_globs=match_globs,
                    root_depth=root_depth,
                )
            )

        return cls(rules)

    @classmethod
    def load(cls, config_path: Path | None = None) -> ChangeDomainRegistry:
        """Load registry from YAML file."""
        if config_path is None:
            config_path = _default_change_domains_path()

        if not config_path.exists():
            logger.warning("Change domain config not found: %s", config_path)
            return cls([])

        with config_path.open() as handle:
            loaded_config = yaml.safe_load(handle)

        if loaded_config is None:
            raw_config: dict[str, object] = {}
        elif isinstance(loaded_config, dict):
            raw_config = cast(dict[str, object], loaded_config)
        else:
            raise ValueError("Change domain config must be a mapping")

        return cls.from_config(raw_config)

    def classify(self, paths: Iterable[Path | str]) -> list[DomainMatch]:
        """Classify paths into their validation domains."""
        matches: MutableMapping[tuple[str, str], DomainMatch] = {}

        for path_obj in paths:
            normalized = self._normalize_path(path_obj)
            if not normalized:
                continue

            for rule in self.rules:
                if not rule.matches(normalized):
                    continue

                root = rule.derive_root(normalized)
                key = (rule.id, root)

                if key not in matches:
                    matches[key] = DomainMatch(domain=rule.id, root=root)
                matches[key].add(normalized)

        return sorted(matches.values(), key=lambda match: (match.domain, match.root))

    @staticmethod
    def _normalize_path(path: Path | str) -> str:
        """Return a POSIX-style relative path without leading './'."""
        path_str = path.as_posix() if isinstance(path, Path) else path

        path_str = path_str.strip()
        if not path_str:
            return ""

        # Remove leading ./ or /
        while path_str.startswith("./"):
            path_str = path_str[2:]
        if path_str.startswith("/"):
            path_str = path_str[1:]

        return path_str


_registry: ChangeDomainRegistry | None = None
_registry_path: Path | None = None
_registry_mtime: float | None = None


def get_change_domain_registry() -> ChangeDomainRegistry:
    """Get the cached change domain registry instance."""
    global _registry, _registry_mtime, _registry_path

    config_path = _default_change_domains_path()
    try:
        mtime = config_path.stat().st_mtime if config_path.exists() else None
    except Exception:
        mtime = None

    if _registry is None or _registry_path != config_path or _registry_mtime != mtime:
        _registry = ChangeDomainRegistry.load(config_path=config_path)
        _registry_path = config_path
        _registry_mtime = mtime
    return _registry


def classify_paths(paths: Iterable[Path | str]) -> list[DomainMatch]:
    """
    Convenience helper to classify paths using the global registry.

    Args:
        paths: Iterable of changed file paths.

    Returns:
        List of domain matches with grouped files.
    """
    registry = get_change_domain_registry()
    return registry.classify(paths)
