"""Runtime integrity checking for tamper detection.

Computes and verifies SHA-256 hashes of the running executable and
critical modules. Provides a background watchdog thread that
periodically re-checks integrity.
"""

from __future__ import annotations

import hashlib
import logging
import os
import sys
import threading
from pathlib import Path
from typing import Callable

logger = logging.getLogger(__name__)

_HASH_BUF_SIZE = 1 << 16  # 64 KiB read chunks

_lock = threading.Lock()
_cached_hash: str | None = None
_watchdog_started = False
_on_tamper_callback: Callable[[], None] | None = None


def compute_self_hash() -> str:
    """Compute SHA-256 of the running executable or main script.

    If running as a PyInstaller frozen bundle, hashes sys.executable.
    Otherwise, hashes the main script (__main__.__file__).
    """
    if getattr(sys, "frozen", False):
        target = Path(sys.executable)
    else:
        import __main__

        main_file = getattr(__main__, "__file__", None)
        if main_file is None:
            logger.warning("Cannot determine main script path; returning empty hash")
            return ""
        target = Path(main_file).resolve()

    return _hash_file(target)


def verify_integrity(expected_hash: str | None = None) -> bool:
    """Verify runtime integrity by comparing executable hash.

    Args:
        expected_hash: If None, computes and caches the hash (first run).
                       If provided, compares against current hash.

    Returns:
        True if integrity is OK (hashes match or first-run caching).
    """
    global _cached_hash

    current = compute_self_hash()
    if not current:
        # Cannot compute hash — treat as OK to avoid false positives.
        return True

    with _lock:
        if expected_hash is None:
            if _cached_hash is None:
                _cached_hash = current
                logger.debug("Integrity hash cached: %s", current[:16])
            return _cached_hash == current

        return current == expected_hash


def start_integrity_watchdog(
    interval: float = 300.0,
    on_tamper: Callable[[], None] | None = None,
) -> None:
    """Start a daemon thread that periodically verifies integrity.

    The watchdog re-computes the executable hash every *interval* seconds
    and compares it to the value cached on first call to
    :func:`verify_integrity`.  If a mismatch is detected, a CRITICAL log
    message is emitted and the optional *on_tamper* callback is invoked.

    This function is idempotent — calling it more than once is a no-op.

    Args:
        interval: Seconds between checks (default 300).
        on_tamper: Optional callback invoked on tamper detection.
    """
    global _watchdog_started, _on_tamper_callback

    with _lock:
        if _watchdog_started:
            return
        _watchdog_started = True
        _on_tamper_callback = on_tamper

    # Ensure we have a cached baseline hash.
    verify_integrity()

    thread = threading.Thread(
        target=_watchdog_loop,
        args=(interval,),
        name="integrity-watchdog",
        daemon=True,
    )
    thread.start()
    logger.info("Integrity watchdog started (interval=%ss)", interval)


def get_module_hashes() -> dict[str, str]:
    """Return SHA-256 hashes of critical bundled modules.

    When running inside a PyInstaller bundle, iterates ``sys._MEIPASS``
    and hashes every ``.pyc`` file found.  Returns an empty dict when
    not running frozen.
    """
    meipass: str | None = getattr(sys, "_MEIPASS", None)
    if meipass is None:
        return {}

    base = Path(meipass)
    result: dict[str, str] = {}

    for pyc in sorted(base.rglob("*.pyc")):
        rel = str(pyc.relative_to(base))
        try:
            result[rel] = _hash_file(pyc)
        except OSError:
            logger.warning("Failed to hash module: %s", rel)

    logger.debug("Hashed %d bundled modules", len(result))
    return result


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _hash_file(path: Path) -> str:
    """Return the SHA-256 hex digest of a file."""
    h = hashlib.sha256()
    with open(path, "rb") as f:
        while True:
            chunk = f.read(_HASH_BUF_SIZE)
            if not chunk:
                break
            h.update(chunk)
    return h.hexdigest()


def _watchdog_loop(interval: float) -> None:
    """Background loop that periodically checks integrity."""
    stop_event = threading.Event()

    while not stop_event.wait(interval):
        try:
            current = compute_self_hash()
            if not current:
                continue

            with _lock:
                baseline = _cached_hash
                callback = _on_tamper_callback

            if baseline is None:
                # No baseline yet — skip this round.
                continue

            if current != baseline:
                logger.critical(
                    "INTEGRITY VIOLATION: hash changed from %s to %s",
                    baseline[:16],
                    current[:16],
                )
                if callback is not None:
                    try:
                        callback()
                    except Exception:
                        logger.exception("on_tamper callback raised an exception")
        except Exception:
            logger.exception("Integrity watchdog encountered an error")
