from __future__ import annotations

import re
from dataclasses import dataclass
from typing import TYPE_CHECKING

from utils.cache.yaml import load_yaml_any
from utils.roles.categories import categories_file
from utils.roles.mapping import ROLE_FILE_VARS_MAIN

from . import PROJECT_ROOT

if TYPE_CHECKING:
    from collections.abc import Iterable
    from pathlib import Path


@dataclass(frozen=True)
class DeploymentTypeRule:
    name: str
    include_re: re.Pattern[str]
    exclude_re: re.Pattern[str] | None


DEFAULT_RULES: tuple[DeploymentTypeRule, ...] = (
    DeploymentTypeRule(
        name="server",
        include_re=re.compile(r"^(web-app-|web-svc-|web-opt-)"),
        exclude_re=None,
    ),
    DeploymentTypeRule(
        name="workstation",
        include_re=re.compile(r"^(desk-|util-desk-)"),
        exclude_re=None,
    ),
    DeploymentTypeRule(
        name="universal",
        include_re=re.compile(r".*"),
        exclude_re=None,
    ),
)


def _roles_dir() -> Path:
    return PROJECT_ROOT / "roles"


def _read_yaml(path: Path) -> dict:
    if not path.is_file():
        return {}
    data = load_yaml_any(str(path), default_if_missing={})
    return data if isinstance(data, dict) else {}


def _role_lifecycle(role_dir: Path) -> str:
    """Lifecycle of the role's primary entity, empty when unresolvable.

    Reads ``meta/services.yml.<primary_entity>.lifecycle`` through the
    canonical helper. Any failure degrades to "no lifecycle" so a single
    malformed meta file cannot break role discovery.
    """
    from utils.roles.meta_lookup import get_role_lifecycle

    try:
        value = get_role_lifecycle(role_dir, role_name=role_dir.name)
    except Exception:
        return ""
    return value or ""


def _role_skip_modes(role_dir: Path) -> list[str]:
    """Deploy modes (compose/swarm) the role opts out of in test-deploy
    discovery. A malformed meta file degrades to "skips nothing" rather
    than breaking discovery."""
    from utils.roles.meta_lookup import get_role_skip

    try:
        return get_role_skip(role_dir, role_name=role_dir.name)
    except Exception:
        return []


def _get_invokable_paths() -> list[str]:
    from plugins.filter.invokable_paths import get_invokable_paths

    paths = get_invokable_paths(str(categories_file(PROJECT_ROOT)))
    if not paths:
        raise RuntimeError("No invokable paths found in categories.yml")
    return [str(p) for p in paths]


def _is_role_invokable(role_name: str, invokable_paths: Iterable[str]) -> bool:
    """True when the role is one of the invokable paths or sits below one."""
    return any(role_name == p or role_name.startswith(p + "-") for p in invokable_paths)


def _role_to_app_id(role_dir: Path) -> str:
    vars_file = role_dir / ROLE_FILE_VARS_MAIN
    if not vars_file.is_file():
        return role_dir.name

    try:
        data = _read_yaml(vars_file)
        app_id = data.get("application_id")
        return str(app_id) if app_id else role_dir.name
    except Exception:
        return role_dir.name


def list_invokable_app_ids() -> list[str]:
    roles_dir = _roles_dir()
    invokable_paths = _get_invokable_paths()
    if not invokable_paths or not roles_dir.is_dir():
        return []

    result: list[str] = []
    result.extend(
        _role_to_app_id(role_dir)
        for role_dir in sorted(
            [p for p in roles_dir.iterdir() if p.is_dir()], key=lambda p: p.name
        )
        if _is_role_invokable(role_dir.name, invokable_paths)
    )

    return sorted(set(result))


def _rule_matches_role_name(rule: DeploymentTypeRule, role_name: str) -> bool:
    if not rule.include_re.search(role_name):
        return False
    return not (rule.exclude_re and rule.exclude_re.search(role_name))


def list_invokables_by_type(
    *,
    rules: Iterable[DeploymentTypeRule] = DEFAULT_RULES,
    lifecycles: set[str] | None = None,
    skip_mode: str | None = None,
) -> dict[str, list[str]]:
    """
    Returns:
      {
        "server": [...],
        "workstation": [...],
        "universal": [...],
      }

    "universal" = invokable roles that are NOT matched by any other non-universal rule.

    If lifecycles is provided, only roles whose galaxy_info.lifecycle is in the set
    are included. Missing lifecycle is treated as "not matching".
    """
    roles_dir = _roles_dir()
    invokable_paths = _get_invokable_paths()
    if not invokable_paths or not roles_dir.is_dir():
        return {r.name: [] for r in rules}

    invokable_role_dirs: list[Path] = []
    for role_dir in sorted(
        [p for p in roles_dir.iterdir() if p.is_dir()], key=lambda p: p.name
    ):
        if not _is_role_invokable(role_dir.name, invokable_paths):
            continue

        if lifecycles is not None:
            lc = _role_lifecycle(role_dir)
            if not lc or lc not in lifecycles:
                continue

        if skip_mode is not None and skip_mode in _role_skip_modes(role_dir):
            continue

        invokable_role_dirs.append(role_dir)

    rules_list = list(rules)
    non_universal = [r for r in rules_list if r.name != "universal"]

    by_type: dict[str, list[str]] = {r.name: [] for r in rules_list}

    claimed_role_names: set[str] = set()
    for role_dir in invokable_role_dirs:
        for r in non_universal:
            if _rule_matches_role_name(r, role_dir.name):
                by_type[r.name].append(_role_to_app_id(role_dir))
                claimed_role_names.add(role_dir.name)
                break

    if "universal" in by_type:
        for role_dir in invokable_role_dirs:
            if role_dir.name not in claimed_role_names:
                by_type["universal"].append(_role_to_app_id(role_dir))

    for k, v in by_type.items():
        by_type[k] = sorted(set(v))

    return by_type


def types_from_group_names(
    group_names: Iterable[str],
    *,
    rules: Iterable[DeploymentTypeRule] = DEFAULT_RULES,
) -> list[str]:
    """
    SPOT:
      - invokable is defined by categories.yml via _get_invokable_paths()/_is_role_invokable()
      - server/workstation/universal classification is defined by DEFAULT_RULES

    Semantics:
      universal = invokable AND NOT matched by any non-universal rule.
    """
    names = [str(g).strip() for g in (group_names or []) if str(g).strip()]
    if not names:
        return []

    invokable_paths = _get_invokable_paths()
    invokable_names = [g for g in names if _is_role_invokable(g, invokable_paths)]
    if not invokable_names:
        return []

    rules_list = list(rules)
    non_universal = [r for r in rules_list if r.name != "universal"]

    matched: set[str] = set()
    claimed: set[str] = set()

    for g in invokable_names:
        for r in non_universal:
            if _rule_matches_role_name(r, g):
                matched.add(r.name)
                claimed.add(g)
                break

    if any(g not in claimed for g in invokable_names):
        matched.add("universal")

    return sorted(matched)
