"""Escape hatch budget checks for Guard.

Core-only module: pure computation over parsed escape hatch summaries.
No I/O imports.
"""

from __future__ import annotations

from math import ceil

from deal import post, pre

from invar.core.exempt_patterns import matches_file_symbol_pattern
from invar.core.models import (
    ESCAPE_TIER_COST,
    ESCAPE_TIER_MAP,
    EscapeHatchDetail,
    EscapeHatchSummary,
    EscapeHatchTier,
    FileInfo,
    RuleConfig,
    Severity,
    SymbolKind,
    Violation,
)

NON_SUPPRESSIBLE_INLINE_RULES: frozenset[str] = frozenset(
    ["dead_export", "stub_body", "missing_contract", "missing_doctest"]
)


@pre(lambda tier: isinstance(tier, EscapeHatchTier))
@post(lambda result: isinstance(result, bool))
def _is_tier_limited(tier: EscapeHatchTier) -> bool:
    return tier in (EscapeHatchTier.SUPPRESSIBLE, EscapeHatchTier.EXPENSIVE)


@pre(
    lambda config, tier, per_file: (
        isinstance(config, RuleConfig)
        and isinstance(tier, EscapeHatchTier)
        and isinstance(per_file, bool)
    )
)
@post(lambda result: result >= 0)
def _tier_limit(config: RuleConfig, tier: EscapeHatchTier, per_file: bool) -> int:
    if tier == EscapeHatchTier.SUPPRESSIBLE:
        return (
            config.escape_suppressible_per_file
            if per_file
            else config.escape_suppressible_per_project
        )
    if tier == EscapeHatchTier.EXPENSIVE:
        return config.escape_expensive_per_file if per_file else config.escape_expensive_per_project
    return 0


@pre(
    lambda count, limit, warning_threshold: (
        count >= 0 and limit >= 0 and 0.0 <= warning_threshold <= 1.0
    )
)
@post(lambda result: result is None or result in (Severity.WARNING, Severity.ERROR))
def _limit_severity(count: int, limit: int, warning_threshold: float) -> Severity | None:
    if count <= 0:
        return None
    if count >= limit:
        return Severity.ERROR
    if count >= ceil(limit * warning_threshold):
        return Severity.WARNING
    return None


@pre(lambda rule: bool(rule))
@post(lambda result: isinstance(result, EscapeHatchTier))
def _rule_tier(rule: str) -> EscapeHatchTier:
    return ESCAPE_TIER_MAP.get(rule, EscapeHatchTier.SUPPRESSIBLE)


@pre(
    lambda detail, config: isinstance(detail, EscapeHatchDetail) and isinstance(config, RuleConfig)
)
@post(lambda result: isinstance(result, bool))
def _is_exempt(detail: EscapeHatchDetail, config: RuleConfig) -> bool:
    # Preferred shape: rule -> [file_or_symbol_patterns]
    direct_patterns: list[str] = []
    if (rule_patterns := config.escape_exempt_patterns.get(detail.rule)) is not None:
        direct_patterns.extend(rule_patterns)
    if (wildcard_patterns := config.escape_exempt_patterns.get("*")) is not None:
        direct_patterns.extend(wildcard_patterns)

    if direct_patterns:
        return any(
            matches_file_symbol_pattern(detail.file, detail.function_name, pattern)
            for pattern in direct_patterns
            if pattern
        )

    # Backward-compatible shape: file_pattern -> [rules]
    for file_pattern, rules in config.escape_exempt_patterns.items():
        if not file_pattern:
            continue
        if matches_file_symbol_pattern(detail.file, detail.function_name, file_pattern) and (
            "*" in rules or detail.rule in rules
        ):
            return True
    return False


@pre(
    lambda detail, file_infos: (
        isinstance(detail, EscapeHatchDetail)
        and isinstance(file_infos, list)
        and all(isinstance(info, FileInfo) for info in file_infos)
    )
)
@post(lambda result: result is None or bool(result))
def _resolve_enclosing_function(
    detail: EscapeHatchDetail, file_infos: list[FileInfo]
) -> str | None:
    if detail.line <= 0:
        return None
    file_info = next((info for info in file_infos if info.path == detail.file), None)
    if file_info is None:
        return None

    candidates = [
        symbol
        for symbol in file_info.symbols
        if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD)
        and symbol.line <= detail.line <= symbol.end_line
    ]
    if not candidates:
        return None

    narrowest = min(candidates, key=lambda symbol: symbol.end_line - symbol.line)
    return narrowest.name


@pre(
    lambda summary, config, file_infos: (
        isinstance(summary, EscapeHatchSummary)
        and isinstance(config, RuleConfig)
        and isinstance(file_infos, list)
    )
)
@post(lambda result: all(isinstance(v, Violation) for v in result))
def check_escape_budget(
    summary: EscapeHatchSummary, config: RuleConfig, file_infos: list[FileInfo]
) -> list[Violation]:
    """Check budget and policy constraints for escape hatches.

    Checks:
    - per-file and per-project limits per suppressible/expensive rule
    - aggregate weighted budget
    - combination penalties by enclosing function
    - non-suppressible inline marker detection
    - exempt-pattern pool size guardrails

    Args:
        summary: Escape hatch summary extracted from source comments.
        config: Rule configuration containing budget thresholds and limits.
        file_infos: Parsed files used to map marker line numbers to enclosing symbols.

    Returns:
        Violations generated by budget and policy checks.

    Raises:
        ValueError: Never raised by this function.

    Examples:
        >>> check_escape_budget(EscapeHatchSummary(), RuleConfig(), [])
        []

        >>> # Under-limit: no findings
        >>> s = EscapeHatchSummary(details=[
        ...     EscapeHatchDetail(file="a.py", line=3, rule="dead_assign", reason="legacy"),
        ...     EscapeHatchDetail(file="a.py", line=10, rule="file_size", reason="migration"),
        ... ])
        >>> fi = [FileInfo(path="a.py", lines=50)]
        >>> check_escape_budget(s, RuleConfig(), fi)
        []

        >>> # Warning threshold at ceil(limit * threshold)
        >>> s = EscapeHatchSummary(details=[
        ...     EscapeHatchDetail(file="warn.py", line=1, rule="dead_assign", reason="r1"),
        ...     EscapeHatchDetail(file="warn.py", line=2, rule="dead_assign", reason="r2"),
        ... ])
        >>> cfg = RuleConfig(escape_suppressible_per_file=3, escape_warning_threshold=0.66)
        >>> vs = check_escape_budget(s, cfg, [FileInfo(path="warn.py", lines=20)])
        >>> any(v.rule == "escape_hatch_file_limit" and v.severity == Severity.WARNING for v in vs)
        True

        >>> # Over limit -> ERROR
        >>> s = EscapeHatchSummary(details=[
        ...     EscapeHatchDetail(file="e.py", line=1, rule="dead_assign", reason="r1"),
        ...     EscapeHatchDetail(file="e.py", line=2, rule="dead_assign", reason="r2"),
        ... ])
        >>> cfg = RuleConfig(escape_suppressible_per_file=1)
        >>> vs = check_escape_budget(s, cfg, [FileInfo(path="e.py", lines=20)])
        >>> any(v.rule == "escape_hatch_file_limit" and v.severity == Severity.ERROR for v in vs)
        True

        >>> # Non-suppressible inline rules always error
        >>> s = EscapeHatchSummary(details=[
        ...     EscapeHatchDetail(file="x.py", line=5, rule="missing_contract", reason="no"),
        ... ])
        >>> vs = check_escape_budget(s, RuleConfig(), [FileInfo(path="x.py", lines=20)])
        >>> [v.rule for v in vs]
        ['escape_hatch_non_suppressible']

        >>> # Combination penalty with 2 hatches doubles budget weight
        >>> from invar.core.models import Symbol
        >>> sym = Symbol(name="f", kind=SymbolKind.FUNCTION, line=1, end_line=20)
        >>> s = EscapeHatchSummary(details=[
        ...     EscapeHatchDetail(file="c.py", line=5, rule="dead_assign", reason="r1"),
        ...     EscapeHatchDetail(file="c.py", line=6, rule="dead_assign", reason="r2"),
        ... ])
        >>> cfg = RuleConfig(escape_budget_limit=3)
        >>> vs = check_escape_budget(s, cfg, [FileInfo(path="c.py", lines=40, symbols=[sym])])
        >>> any(v.rule == "escape_hatch_budget" and v.severity == Severity.ERROR for v in vs)
        True

        >>> # 3+ stacked escapes in one function is unconditional combination ERROR
        >>> s = EscapeHatchSummary(details=[
        ...     EscapeHatchDetail(file="k.py", line=5, rule="dead_assign", reason="r1"),
        ...     EscapeHatchDetail(file="k.py", line=6, rule="shell_result", reason="r2"),
        ...     EscapeHatchDetail(file="k.py", line=7, rule="file_size", reason="r3"),
        ... ])
        >>> vs = check_escape_budget(s, RuleConfig(), [FileInfo(path="k.py", lines=40, symbols=[sym])])
        >>> any(v.rule == "escape_hatch_combination" and v.severity == Severity.ERROR for v in vs)
        True

        >>> # Mixed-tier stacking: non-suppressible contributes to stacking count
        >>> s = EscapeHatchSummary(details=[
        ...     EscapeHatchDetail(file="m.py", line=5, rule="dead_export", reason="legacy"),
        ...     EscapeHatchDetail(file="m.py", line=6, rule="shell_result", reason="interop"),
        ... ])
        >>> cfg = RuleConfig(escape_budget_limit=5)
        >>> vs = check_escape_budget(s, cfg, [FileInfo(path="m.py", lines=40, symbols=[sym])])
        >>> any(v.rule == "escape_hatch_budget" and v.severity == Severity.ERROR for v in vs)
        True

        >>> # Exempt filtering removes matching markers from budget counting,
        >>> # and exempt pool can still be warned on
        >>> s = EscapeHatchSummary(details=[
        ...     EscapeHatchDetail(file="legacy/a.py", line=1, rule="dead_assign", reason="r1"),
        ...     EscapeHatchDetail(file="legacy/a.py", line=2, rule="dead_assign", reason="r2"),
        ... ])
        >>> cfg = RuleConfig(
        ...     escape_exempt_patterns={"legacy/*.py": ["dead_assign"]},
        ...     escape_exempt_warning=2,
        ...     escape_exempt_limit=99,
        ...     escape_suppressible_per_project=0,
        ... )
        >>> vs = check_escape_budget(s, cfg, [FileInfo(path="legacy/a.py", lines=10)])
        >>> any(v.rule == "escape_hatch_project_limit" and v.severity == Severity.WARNING and "exempt" in v.message.lower() for v in vs)
        True
    """
    violations: list[Violation] = []

    details = summary.details
    for detail in details:
        detail.function_name = _resolve_enclosing_function(detail, file_infos)

    exempt_details = [detail for detail in details if _is_exempt(detail, config)]
    non_exempt_details = [detail for detail in details if not _is_exempt(detail, config)]

    exempt_count = len(exempt_details)
    if exempt_count >= config.escape_exempt_limit and exempt_count > 0:
        violations.append(
            Violation(
                rule="escape_hatch_project_limit",
                severity=Severity.ERROR,
                file="<project>",
                message=(
                    "Exempt escape hatches exceed limit: "
                    f"{exempt_count}/{config.escape_exempt_limit}"
                ),
            )
        )
    elif exempt_count >= config.escape_exempt_warning and exempt_count > 0:
        violations.append(
            Violation(
                rule="escape_hatch_project_limit",
                severity=Severity.WARNING,
                file="<project>",
                message=(
                    f"Exempt escape hatches near limit: {exempt_count}/{config.escape_exempt_limit}"
                ),
            )
        )

    grouped_by_function: dict[tuple[str, str], list[EscapeHatchDetail]] = {}
    for detail in non_exempt_details:
        if detail.function_name is None:
            continue
        key = (detail.file, detail.function_name)
        grouped_by_function.setdefault(key, []).append(detail)

    multiplier_by_detail: dict[int, int] = {id(detail): 1 for detail in non_exempt_details}
    for (file_path, function_name), grouped in grouped_by_function.items():
        if len(grouped) >= 2:
            for detail in grouped:
                multiplier_by_detail[id(detail)] = 2
        if len(grouped) >= 3:
            first = grouped[0]
            violations.append(
                Violation(
                    rule="escape_hatch_combination",
                    severity=Severity.ERROR,
                    file=file_path,
                    line=first.line,
                    message=(
                        f"Function '{function_name}' has {len(grouped)} stacked escape hatches "
                        "(3+ is forbidden)"
                    ),
                )
            )

    for detail in details:
        if detail.rule in NON_SUPPRESSIBLE_INLINE_RULES:
            violations.append(
                Violation(
                    rule="escape_hatch_non_suppressible",
                    severity=Severity.ERROR,
                    file=detail.file,
                    line=detail.line,
                    message=(
                        f"{detail.rule} cannot be suppressed inline. "
                        "This rule requires human authorization. "
                        "Ask the project owner to declare an exemption in pyproject.toml. "
                        "Do NOT modify pyproject.toml yourself."
                    ),
                )
            )

    file_rule_counts: dict[tuple[str, str], int] = {}
    project_rule_counts: dict[str, int] = {}
    budget_total = 0

    for detail in non_exempt_details:
        tier = _rule_tier(detail.rule)
        multiplier = multiplier_by_detail.get(id(detail), 1)
        budget_total += ESCAPE_TIER_COST[tier] * multiplier

        if not _is_tier_limited(tier):
            continue

        file_rule_key = (detail.file, detail.rule)
        file_rule_counts[file_rule_key] = file_rule_counts.get(file_rule_key, 0) + multiplier
        project_rule_counts[detail.rule] = project_rule_counts.get(detail.rule, 0) + multiplier

    for (file_path, rule), count in sorted(file_rule_counts.items()):
        tier = _rule_tier(rule)
        limit = _tier_limit(config, tier, per_file=True)
        severity = _limit_severity(count, limit, config.escape_warning_threshold)
        if severity is None:
            continue
        violations.append(
            Violation(
                rule="escape_hatch_file_limit",
                severity=severity,
                file=file_path,
                message=f"File rule budget {rule}: {count}/{limit}",
            )
        )

    for rule, count in sorted(project_rule_counts.items()):
        tier = _rule_tier(rule)
        limit = _tier_limit(config, tier, per_file=False)
        severity = _limit_severity(count, limit, config.escape_warning_threshold)
        if severity is None:
            continue
        violations.append(
            Violation(
                rule="escape_hatch_project_limit",
                severity=severity,
                file="<project>",
                message=f"Project rule budget {rule}: {count}/{limit}",
            )
        )

    budget_severity = _limit_severity(
        budget_total, config.escape_budget_limit, config.escape_warning_threshold
    )
    if budget_severity is not None:
        violations.append(
            Violation(
                rule="escape_hatch_budget",
                severity=budget_severity,
                file="<project>",
                message=f"Escape hatch weighted budget: {budget_total}/{config.escape_budget_limit}",
            )
        )

    return violations
