"""Detection rule update mechanism — CDC model for AI agent security.

Periodically fetches updated detection rules from the Obol API.
Rules are cached locally at ~/.obol/rules.json with version tracking.
All detection still runs on-device — only RULES are fetched.

Zero external dependencies (stdlib only).

The CDC model: server publishes rules based on cross-org intelligence,
SDK pulls them periodically. This allows:
  - New threat patterns deployed without SDK upgrade
  - Known-bad domains/IPs pushed to all agents
  - Adjusted thresholds based on real-world data
  - Emergency rules for active threats

Usage:
    from obol.hooks.rule_update import get_rules

    # In detection.py / categories.py:
    rules = get_rules(config)
    # {"extra_sensitive_paths": [...], "blocked_domains": [...], ...}
"""
import datetime
import hashlib
import hmac
import json
import os
import sys
import time
import urllib.error
import urllib.request


# ---- Constants ----------------------------------------------------------------

# Rule schema version — increment when format changes
RULE_SCHEMA_VERSION = 1

# Default update interval: 1 hour
DEFAULT_UPDATE_INTERVAL = 3600

# Maximum response size for rule fetch (10 MB) — prevents memory DoS
_MAX_RULES_RESPONSE = 10 * 1024 * 1024

# Local cache path
_RULES_CACHE = os.path.expanduser("~/.obol/rules.json")


# ---- Empty rule set -----------------------------------------------------------

def _empty_rules() -> dict:
    """Return empty rule set with correct schema."""
    return {
        "version": 0,
        "schema_version": RULE_SCHEMA_VERSION,
        "fetched_at": None,
        "extra_sensitive_paths": [],
        "extra_risky_domains": [],
        "extra_safe_domains": [],
        "blocked_domains": [],
        "extra_injection_patterns": [],
        "threshold_overrides": {},
        "emergency_rules": [],
    }


# ---- RuleUpdater --------------------------------------------------------------

class RuleUpdater:
    """Fetches and caches detection rule updates from Obol API.

    The CDC model: server publishes rules, SDK pulls and applies locally.
    All detection execution stays on-device. Server rules ADD to built-in
    patterns — they never REMOVE built-in patterns (defense in depth).
    """

    def __init__(self, api_url: str, api_key: str,
                 cache_path: str = None,
                 update_interval: int = DEFAULT_UPDATE_INTERVAL):
        """Initialize rule updater.

        Args:
            api_url: Base Obol API URL (e.g., "https://obol.grayowlresearch.com/v1")
            api_key: API key for authentication
            cache_path: Local cache file path. Defaults to ~/.obol/rules.json
            update_interval: Seconds between update checks. Default 3600 (1 hour).
        """
        self._api_url = api_url.rstrip("/")
        self._api_key = api_key
        self._cache_path = cache_path or _RULES_CACHE
        self._update_interval = update_interval
        self._rules = self._load_cache()
        self._last_check = 0

    def get_rules(self) -> dict:
        """Get the current merged rule set.

        Checks for updates if enough time has passed since last check.
        Returns cached rules immediately if API is unreachable.

        Returns dict with sections:
            "extra_sensitive_paths": list of regex pattern strings
            "extra_risky_domains": list of domain strings
            "extra_safe_domains": list of domain strings
            "blocked_domains": list of domain strings (known C2, exfil)
            "extra_injection_patterns": list of [pattern, category] pairs
            "threshold_overrides": dict of threshold name -> value
            "emergency_rules": list of emergency rule dicts
            "version": int
            "fetched_at": ISO timestamp or None
        """
        try:
            now = time.time()
            if now - self._last_check >= self._update_interval:
                self._check_for_updates()
                self._last_check = now
        except Exception:
            pass  # Use cached rules on any failure
        return self._rules

    def force_update(self) -> bool:
        """Force an immediate rule fetch. Returns True if rules were updated."""
        try:
            return self._check_for_updates()
        except Exception:
            return False

    @property
    def version(self) -> int:
        """Current rule set version."""
        return self._rules.get("version", 0)

    @property
    def age_seconds(self) -> float:
        """Seconds since rules were last fetched from server."""
        fetched = self._rules.get("fetched_at")
        if not fetched:
            return float("inf")
        try:
            dt = datetime.datetime.fromisoformat(fetched.rstrip("Z"))
            now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
            return max(0.0, (now - dt).total_seconds())
        except Exception:
            return float("inf")

    # ---- Internal -------------------------------------------------------------

    def _check_for_updates(self) -> bool:
        """Fetch rules from API if newer version available."""
        current_version = self._rules.get("version", 0)
        url = f"{self._api_url}/rules?since={current_version}"

        req = urllib.request.Request(
            url,
            headers={
                "Authorization": f"Bearer {self._api_key}",
                "User-Agent": "obol-sdk/0.2.0",
                "Accept": "application/json",
            },
        )

        try:
            with urllib.request.urlopen(req, timeout=10) as resp:
                if resp.status == 204:
                    return False  # No updates available
                raw = resp.read(_MAX_RULES_RESPONSE + 1)
                if len(raw) > _MAX_RULES_RESPONSE:
                    return False  # Response too large — reject
                data = json.loads(raw.decode())
        except urllib.error.HTTPError as e:
            if e.code == 304:
                return False  # Not modified
            return False
        except Exception:
            return False

        # Validate response structure
        if not isinstance(data, dict):
            return False
        if data.get("schema_version", 0) != RULE_SCHEMA_VERSION:
            return False  # Incompatible schema, skip

        new_version = data.get("version", 0)
        if new_version <= current_version:
            return False  # Not newer

        # Verify rule integrity (server signs with HMAC)
        if not self._verify_rules(data):
            return False

        # Merge and persist
        self._rules = self._merge_rules(data)
        self._save_cache()
        return True

    def _verify_rules(self, data: dict) -> bool:
        """Verify rule integrity via HMAC signature.

        The server signs the rules payload with a key derived from the
        org's API key. This prevents MITM rule injection.

        Unsigned rules (no signature field) are accepted for backwards
        compatibility during rollout.
        """
        signature = data.get("signature")
        if not signature:
            # No signature = unsigned rules (backwards compat)
            # Warn so operators know unsigned rules are being accepted
            print("[obol] WARNING: accepting unsigned rules (no signature field)", file=sys.stderr)
            return True

        # Compute expected HMAC over canonical JSON (excluding signature)
        data_copy = {k: v for k, v in data.items() if k != "signature"}
        payload = json.dumps(data_copy, sort_keys=True).encode()
        key = hashlib.sha256(self._api_key.encode()).digest()
        expected = hmac.new(key, payload, hashlib.sha256).hexdigest()

        return hmac.compare_digest(expected, signature)

    def _merge_rules(self, server_rules: dict) -> dict:
        """Merge server rules into local rule set.

        Server rules ADD to the built-in detection patterns.
        They never REMOVE built-in patterns (defense in depth).
        Type-validated: wrong types fall back to safe defaults.
        Size-bounded: arrays capped at 1000 to prevent memory DoS.
        """
        _MAX_LIST = 1000

        def _list(key):
            v = server_rules.get(key)
            if not isinstance(v, list):
                return []
            # Filter None/empty and cap size
            return [x for x in v if x is not None][:_MAX_LIST]

        def _dict(key):
            v = server_rules.get(key)
            return v if isinstance(v, dict) else {}

        # Injection patterns require [pattern_str, category_str] pairs
        raw_patterns = server_rules.get("extra_injection_patterns")
        if isinstance(raw_patterns, list):
            validated_patterns = [
                p for p in raw_patterns[:_MAX_LIST]
                if isinstance(p, (list, tuple))
                and len(p) == 2
                and isinstance(p[0], str)
                and isinstance(p[1], str)
            ]
        else:
            validated_patterns = []

        return {
            "version": server_rules.get("version", 0),
            "schema_version": RULE_SCHEMA_VERSION,
            "fetched_at": datetime.datetime.now(
                datetime.timezone.utc
            ).replace(tzinfo=None).isoformat() + "Z",
            "extra_sensitive_paths": _list("extra_sensitive_paths"),
            "extra_risky_domains": _list("extra_risky_domains"),
            "extra_safe_domains": _list("extra_safe_domains"),
            "blocked_domains": _list("blocked_domains"),
            "extra_injection_patterns": validated_patterns,
            "threshold_overrides": _dict("threshold_overrides"),
            "emergency_rules": _list("emergency_rules"),
        }

    def _load_cache(self) -> dict:
        """Load cached rules from disk.

        Validates schema version. The in-memory version is always authoritative;
        _check_for_updates() rejects versions <= current_version, so downgrades
        via API are already blocked. This only loads at startup.
        """
        try:
            with open(self._cache_path) as f:
                raw = f.read(2 * 1024 * 1024)  # 2MB cap
            data = json.loads(raw)
            if isinstance(data, dict) and data.get("schema_version") == RULE_SCHEMA_VERSION:
                # Sanity: version must be a non-negative int
                v = data.get("version", 0)
                if not isinstance(v, int) or v < 0:
                    data["version"] = 0
                return data
        except (FileNotFoundError, json.JSONDecodeError, OSError):
            pass
        return _empty_rules()

    def _save_cache(self):
        """Persist rules to disk with restrictive permissions (0600)."""
        tmp = None
        try:
            cache_dir = os.path.dirname(self._cache_path)
            os.makedirs(cache_dir, exist_ok=True)
            # Atomic write: tmp → rename
            tmp = self._cache_path + ".tmp"
            fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
            with os.fdopen(fd, "w") as f:
                json.dump(self._rules, f)
            os.replace(tmp, self._cache_path)
        except Exception:
            # Clean up orphaned tmp file on failure
            if tmp:
                try:
                    os.unlink(tmp)
                except OSError:
                    pass


# ---- Module-level convenience -------------------------------------------------

_updater_instance = None


def get_rules(config: dict = None) -> dict:
    """Get the current rule set, initializing updater if needed.

    Called by detection.py and categories.py to check for server-pushed rules.
    Lazy-initializes the RuleUpdater from config on first call.

    Args:
        config: Obol config dict with "api_key" and "api_url" keys.
                If None, returns empty rules (no server connection).

    Returns:
        Rule set dict. May be empty if no config or API unreachable.
    """
    global _updater_instance

    if _updater_instance is not None:
        return _updater_instance.get_rules()

    if not config:
        return _empty_rules()

    api_key = config.get("api_key", "")
    api_url = config.get("api_url", "")
    if not api_key or not api_url:
        return _empty_rules()

    try:
        _updater_instance = RuleUpdater(api_url, api_key)
        return _updater_instance.get_rules()
    except Exception:
        return _empty_rules()


def reset():
    """Reset the singleton updater. Useful for testing."""
    global _updater_instance
    _updater_instance = None
