"""Exam software process detection for stealth-mode auto-activation.

Detects Safe Exam Browser (SEB) and other exam proctoring software.
Results are cached (5s for process check, 60s for version info).

macOS: pgrep process detection + plist version reading.
Windows: tasklist process detection + PowerShell version reading.

Built-in process lists cover SEB and common Chinese exam software (CSCA).
Additional processes can be added via config.yaml:
  stealth:
    auto_detect_processes:
      - "MyExam.exe"
"""

from __future__ import annotations

import logging
import re
import subprocess
import sys
import threading
import time
from collections.abc import Sequence

log = logging.getLogger(__name__)

# Process name patterns matched against full command line (macOS pgrep -f).
_EXAM_PROCESS_NAMES_MACOS: frozenset[str] = frozenset(
    {
        # Safe Exam Browser
        "Safe Exam Browser",
        "SafeExamBrowser",
        # CSCA exam client (Windows-native; may run via Wine/Parallels on macOS)
        "CSCATestSoftware",
        "ProcessChecker",
        "ksCtrlSvc",
        "ksHook",
        # Common Chinese exam proctoring software
        "csca",
        "CSCA",
        "ExamClient",
        "examclient",
        "LockDownBrowser",
        "Respondus LockDown",
    }
)

# Exact .exe names for Windows tasklist /FI "IMAGENAME eq <name>" queries.
_EXAM_PROCESS_NAMES_WINDOWS: frozenset[str] = frozenset(
    {
        # Safe Exam Browser
        "SafeExamBrowser.exe",
        "SEB.exe",
        # CSCA (Chinese Software and Certificate Authority exam client)
        # Executables from CSCATestSoftware package:
        "CSCATestSoftware.exe",  # Main exam client (connects to ly.csca.cn)
        "ProcessChecker.exe",  # Process monitor — reports process list to localhost:6000
        "ksCtrlSvc.exe",  # Kernel-level control service
        "ksHook.exe",  # Input hook manager (intercepts keyboard/mouse)
        "ksHash.exe",  # File integrity checker (validates all DLLs via MD5)
        "RecStatus.exe",  # Screen/webcam recording status monitor
        # Generic CSCA variants
        "csca.exe",
        "CSCA.exe",
        "ExamClient.exe",
        "examclient.exe",
        "LockDownBrowser.exe",
        "LockDown.exe",
        "exammonitor.exe",
        "ExamMonitor.exe",
    }
)

# Legacy aliases kept for backward compatibility
_SEB_PROCESS_NAMES_MACOS = _EXAM_PROCESS_NAMES_MACOS
_SEB_PROCESS_NAMES_WINDOWS = _EXAM_PROCESS_NAMES_WINDOWS

# TTL cache: avoid hammering pgrep/tasklist on every call.
_TTL_SECONDS: float = 2.0
_cache_lock = threading.Lock()
_cache_result: bool = False
_cache_ts: float = 0.0

# Version cache: separate TTL to avoid repeated disk/system access.
_VERSION_TTL_SECONDS: float = 60.0
_version_cache: str | None = None
_version_cache_ts: float = 0.0

# Default SEB app paths per platform.
_SEB_APP_PATH_MACOS = "/Applications/Safe Exam Browser.app"
_SEB_PLIST_PATH_MACOS = f"{_SEB_APP_PATH_MACOS}/Contents/Info.plist"
_SEB_EXE_PATH_WINDOWS = r"C:\Program Files\SafeExamBrowser\Application\SafeExamBrowser.exe"


def is_exam_running(
    extra_processes: Sequence[str] | None = None,
    ttl: float | None = None,
) -> bool:
    """Check if any supported exam proctoring software is currently running.

    Checks the built-in list of known exam software (SEB, CSCA, LockDown) plus
    any additional process names supplied via *extra_processes* (from config.yaml
    ``stealth.auto_detect_processes``).

    Result is cached for *ttl* seconds (default: module-level ``_TTL_SECONDS``).
    """
    global _cache_result, _cache_ts

    effective_ttl = ttl if ttl is not None else _TTL_SECONDS
    now = time.monotonic()
    with _cache_lock:
        if now - _cache_ts < effective_ttl:
            return _cache_result

    found = _detect_exam_now(extra_processes)
    with _cache_lock:
        _cache_result = found
        _cache_ts = now
    return found


def is_seb_running(
    extra_processes: Sequence[str] | None = None,
    ttl: float | None = None,
) -> bool:
    """Alias for is_exam_running() — kept for backward compatibility."""
    return is_exam_running(extra_processes, ttl=ttl)


def _detect_exam_now(extra_processes: Sequence[str] | None = None) -> bool:
    """Uncached exam software detection."""
    if sys.platform == "darwin":
        names = set(_EXAM_PROCESS_NAMES_MACOS)
        if extra_processes:
            names.update(extra_processes)
        for name in names:
            try:
                result = subprocess.run(
                    ["pgrep", "-f", re.escape(name)],
                    capture_output=True,
                    timeout=2,
                )
                if result.returncode == 0:
                    log.debug("Exam process detected (macOS): %s", name)
                    return True
            except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
                continue
    elif sys.platform == "win32":
        names = set(_EXAM_PROCESS_NAMES_WINDOWS)
        if extra_processes:
            # Normalise: add .exe suffix if missing so tasklist filter works
            for p in extra_processes:
                names.add(p if p.lower().endswith(".exe") else p + ".exe")
        # Hide console window on Windows (--windowed PyInstaller builds)
        _si = subprocess.STARTUPINFO()
        _si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
        _si.wShowWindow = 0  # SW_HIDE
        for name in names:
            try:
                result = subprocess.run(
                    ["tasklist", "/FI", f"IMAGENAME eq {name}", "/NH"],
                    capture_output=True,
                    text=True,
                    timeout=3,
                    startupinfo=_si,
                    creationflags=0x08000000,  # CREATE_NO_WINDOW
                )
                # tasklist prints the process name in stdout when found
                stem = name.removesuffix(".exe").removesuffix(".EXE")
                if result.returncode == 0 and stem.lower() in result.stdout.lower():
                    log.debug("Exam process detected (Windows): %s", name)
                    return True
            except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
                continue
    return False


# Legacy alias
_detect_seb_now = _detect_exam_now


def get_seb_version() -> str | None:
    """Get the installed SEB version string.

    macOS: reads CFBundleShortVersionString from Info.plist.
    Windows: reads file version via PowerShell.
    Result is cached for 60 seconds.
    """
    global _version_cache, _version_cache_ts

    now = time.monotonic()
    with _cache_lock:
        if now - _version_cache_ts < _VERSION_TTL_SECONDS:
            return _version_cache

    version = _detect_seb_version_now()
    with _cache_lock:
        _version_cache = version
        _version_cache_ts = now

    if version:
        log.info("SEB version detected: %s", version)

    return version


def _detect_seb_version_now() -> str | None:
    """Uncached SEB version detection."""
    if sys.platform == "darwin":
        return _get_seb_version_macos()
    elif sys.platform == "win32":
        return _get_seb_version_windows()
    return None


def _get_seb_version_macos() -> str | None:
    """Read SEB version from Info.plist on macOS."""
    import plistlib

    try:
        with open(_SEB_PLIST_PATH_MACOS, "rb") as f:
            plist = plistlib.load(f)
        version = plist.get("CFBundleShortVersionString")
        return str(version) if version else None
    except (FileNotFoundError, OSError, plistlib.InvalidFileException):
        return None


def _get_seb_version_windows() -> str | None:
    """Read SEB version via PowerShell on Windows."""
    try:
        result = subprocess.run(
            [
                "powershell",
                "-NoProfile",
                "-Command",
                f'(Get-Item "{_SEB_EXE_PATH_WINDOWS}").VersionInfo.ProductVersion',
            ],
            capture_output=True,
            text=True,
            timeout=5,
        )
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout.strip()
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        pass
    return None


def reset_cache() -> None:
    """Reset all caches. Useful for testing."""
    global _cache_result, _cache_ts, _version_cache, _version_cache_ts
    with _cache_lock:
        _cache_result = False
        _cache_ts = 0.0
        _version_cache = None
        _version_cache_ts = 0.0
