"""Shared rule engine for deterministic security scanners.

Loads rules from JSON files, compiles regex patterns, renders finding
templates, and resolves dynamic severity via named functions.

Design goals:
- Single source of truth for rule loading + compilation across all scanners.
- Template-based finding generation — no hardcoded finding dicts in Python.
- Pluggable severity functions for rules whose severity depends on context.
- Module-level caching — each JSON file is loaded and compiled once per process.
"""

from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any, Callable


# ---------------------------------------------------------------------------
# Types
# ---------------------------------------------------------------------------

CompiledRule = dict[str, Any]
"""A rule dict loaded from JSON with added '_pattern' (compiled re) and
'_skip_re' (optional compiled exclusion re) keys."""

SeverityFn = Callable[[CompiledRule, dict[str, Any]], str]
"""(rule, context) -> severity string.  context carries match-specific data
like matched groups, file path, line text, etc."""


# ---------------------------------------------------------------------------
# Severity functions
# ---------------------------------------------------------------------------

def _severity_static(rule: CompiledRule, _ctx: dict[str, Any]) -> str:
    """Return the rule's static severity (default behaviour)."""
    return rule.get("severity", "medium")


def _severity_critical_if_url_else_high(rule: CompiledRule, ctx: dict[str, Any]) -> str:
    """Critical when the matched value contains a URL scheme, otherwise high."""
    value = ctx.get("value", "")
    return "critical" if "://" in value else "high"


def _severity_critical_if_high_confidence(rule: CompiledRule, ctx: dict[str, Any]) -> str:
    """Critical when the rule's confidence is 'high', else high."""
    return "critical" if rule.get("confidence") == "high" else "high"


def _severity_high_if_unpinned_else_medium(rule: CompiledRule, ctx: dict[str, Any]) -> str:
    """High for unpinned/latest tags, medium for floating major-only tags."""
    return "high" if ctx.get("is_unpinned", False) else "medium"


_SEVERITY_REGISTRY: dict[str, SeverityFn] = {
    "static": _severity_static,
    "critical_if_url_else_high": _severity_critical_if_url_else_high,
    "critical_if_high_confidence": _severity_critical_if_high_confidence,
    "high_if_unpinned_else_medium": _severity_high_if_unpinned_else_medium,
}


def register_severity_fn(name: str, fn: SeverityFn) -> None:
    """Register a custom severity function by name."""
    _SEVERITY_REGISTRY[name] = fn


def resolve_severity(rule: CompiledRule, ctx: dict[str, Any] | None = None) -> str:
    """Determine severity for a rule given optional match context."""
    fn_name = rule.get("severity_fn", "static")
    fn = _SEVERITY_REGISTRY.get(fn_name, _severity_static)
    return fn(rule, ctx or {})


# ---------------------------------------------------------------------------
# Template rendering
# ---------------------------------------------------------------------------

def render_template(template: str, ctx: dict[str, Any]) -> str:
    """Render a template string with ``{key}`` placeholders from *ctx*.

    Unknown placeholders are left as-is so partially filled templates
    don't raise errors.
    """
    if not template:
        return ""
    try:
        return template.format_map(_SafeDict(ctx))
    except Exception:
        return template


class _SafeDict(dict):
    """Dict subclass that returns '{key}' for missing keys."""

    def __missing__(self, key: str) -> str:
        return "{" + key + "}"


# ---------------------------------------------------------------------------
# Rule loading and compilation
# ---------------------------------------------------------------------------

_CACHE: dict[str, list[CompiledRule]] = {}


def load_rules(path: str | Path, *, force_reload: bool = False) -> list[CompiledRule]:
    """Load and compile rules from a JSON file.

    Results are cached per absolute path.  Pass *force_reload=True* during
    testing to bypass the cache.

    Each rule in the JSON array may contain:
    - ``pattern`` (str) — regex pattern (compiled into ``_pattern``).
    - ``skip_if_line_matches`` (str, optional) — exclusion regex (``_skip_re``).
    - ``ignore_case`` (bool) — add ``re.IGNORECASE``.
    - ``multiline`` (bool) — add ``re.DOTALL``.
    - ``severity`` (str) — static severity value (used when ``severity_fn`` is absent).
    - ``severity_fn`` (str) — name of a registered severity function.
    - All other keys are passed through untouched.
    """
    key = str(Path(path).resolve())
    if not force_reload and key in _CACHE:
        return _CACHE[key]

    raw: list[dict] = json.loads(Path(path).read_text(encoding="utf-8"))
    compiled: list[CompiledRule] = []

    for rule in raw:
        try:
            entry: CompiledRule = {**rule}

            # Compile primary pattern (optional — some rules use check_type instead)
            if "pattern" in rule:
                flags = re.MULTILINE
                if rule.get("ignore_case"):
                    flags |= re.IGNORECASE
                if rule.get("multiline"):
                    flags |= re.DOTALL
                entry["_pattern"] = re.compile(rule["pattern"], flags)

            # Compile exclusion pattern
            skip_src = rule.get("skip_if_line_matches")
            entry["_skip_re"] = re.compile(skip_src, re.IGNORECASE) if skip_src else None

            compiled.append(entry)
        except (re.error, KeyError):
            pass  # skip malformed rules silently

    _CACHE[key] = compiled
    return compiled


def clear_cache() -> None:
    """Clear the rule compilation cache (useful in tests)."""
    _CACHE.clear()


# ---------------------------------------------------------------------------
# Finding builder
# ---------------------------------------------------------------------------

def build_finding(rule: CompiledRule, ctx: dict[str, Any]) -> dict[str, Any]:
    """Build a standard finding dict from a rule and match context.

    The rule may contain ``*_template`` keys (e.g. ``title_template``,
    ``description_template``, ``recommendation_template``) which are rendered
    with *ctx*.  Non-template keys (``title``, ``description``, etc.) are used
    as-is if no template variant exists.
    """
    severity = resolve_severity(rule, ctx)

    def _get(field: str) -> str:
        template = rule.get(f"{field}_template")
        if template:
            return render_template(template, ctx)
        return rule.get(field, "")

    finding: dict[str, Any] = {
        "ruleId": rule.get("id", ""),
        "title": _get("title"),
        "description": _get("description"),
        "severity": severity,
        "confidence": rule.get("confidence", "high"),
        "owaspCategory": rule.get("owasp", ""),
        "cweId": rule.get("cwe", ""),
        "filePath": ctx.get("file_path", ""),
        "lineStart": ctx.get("line", 1),
        "codeSnippet": ctx.get("snippet", ""),
        "recommendation": _get("recommendation"),
        "referenceLinks": rule.get("references", []),
    }
    return finding
