"""ProgramLauncher — launch any program via hotkey, invisible to CSCA/SEB.

Workflow:
  1. Press hotkey (default: Ctrl+F6)
  2. Configured program launches silently in background
  3. Press hotkey again — program is killed (toggle)

Stealth launch techniques used to bypass CSCA monitoring:
  Windows:
    - CREATE_NO_WINDOW + DETACHED_PROCESS flags (no console)
    - ShellExecuteEx with SW_HIDE (no visible window creation event)
    - Process launched as child of explorer.exe (parent PID spoofing)
    - No taskbar entry, no WH_SHELL hook notification
  macOS:
    - NSWorkspace launchApplication or subprocess with no terminal

Config keys (program_launcher section):
  path        — path to executable or command  (e.g. "calc.exe", "C:\\tools\\app.exe")
  args        — list of arguments              (default: [])
  hotkey      — hotkey to toggle launch/kill   (default: "ctrl+f6")
  enabled     — enable/disable                 (default: true)
  hide_window — hide the launched window       (default: true)
"""

from __future__ import annotations

import logging
import subprocess
import sys
import threading
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from ..config import Config

log = logging.getLogger(__name__)

# Windows process creation flags
_CREATE_NO_WINDOW    = 0x08000000
_DETACHED_PROCESS    = 0x00000008
_CREATE_NEW_CONSOLE  = 0x00000010

# SW_ show flags for ShellExecuteEx
_SW_HIDE             = 0
_SW_SHOWMINIMIZED    = 2


class ProgramLauncher:
    """Hotkey-triggered program launcher — stealthy, works under CSCA/SEB."""

    def __init__(self, config: Config) -> None:
        self._config = config
        self._process: subprocess.Popen | None = None
        self._lock = threading.Lock()
        # BUG FIX: event used to signal the monitor thread that _kill() has
        # already terminated the process so the monitor should not block
        # on WaitForSingleObject / proc.wait() using a potentially closed handle.
        self._kill_event = threading.Event()

    # ------------------------------------------------------------------ #
    # Public entry point
    # ------------------------------------------------------------------ #

    def on_hotkey(self) -> None:
        """Toggle: if running — kill; if not running — launch."""
        with self._lock:
            if self._process and self._process.poll() is None:
                self._kill()
            else:
                self._launch()

    # ------------------------------------------------------------------ #
    # Launch
    # ------------------------------------------------------------------ #

    def _launch(self) -> None:
        path = self._config.get("program_launcher.path", "")
        if not path:
            log.warning("[launcher] No program path configured — set program_launcher.path in config")
            return

        args: list[str] = self._config.get("program_launcher.args", [])
        if isinstance(args, str):
            args = args.split()
        cmd = [str(path)] + [str(a) for a in args]
        hide = bool(self._config.get("program_launcher.hide_window", True))

        log.info("[launcher] Launching: %s (hide=%s)", cmd, hide)

        try:
            if sys.platform == "win32":
                self._process = self._launch_win32(cmd, hide)
            else:
                self._process = self._launch_posix(cmd)

            if self._process:
                log.info("[launcher] Process started (pid=%s)", self._process.pid)
                # Reset kill event for this new process lifetime, then start monitor.
                # BUG FIX: pass the snapshot of self._process to _monitor directly
                # so it does not need to read self._process without the lock.
                self._kill_event.clear()
                threading.Thread(
                    target=self._monitor,
                    args=(self._process,),
                    daemon=True,
                    name="launcher-monitor",
                ).start()
        except Exception as exc:
            log.error("[launcher] Launch failed: %s", exc)

    def _launch_win32(self, cmd: list[str], hide: bool) -> subprocess.Popen | None:
        """Windows: launch with CREATE_NO_WINDOW + DETACHED_PROCESS (invisible to CSCA hooks)."""
        import ctypes
        import ctypes.wintypes

        flags = _CREATE_NO_WINDOW | _DETACHED_PROCESS

        si = subprocess.STARTUPINFO()
        si.dwFlags = subprocess.STARTF_USESHOWWINDOW
        si.wShowWindow = _SW_HIDE if hide else _SW_SHOWMINIMIZED

        try:
            # Primary method: subprocess with stealth flags
            proc = subprocess.Popen(
                cmd,
                startupinfo=si,
                creationflags=flags,
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                close_fds=True,
            )
            return proc
        except FileNotFoundError:
            log.error("[launcher] Executable not found: %s", cmd[0])
            return None
        except PermissionError:
            log.error("[launcher] Permission denied: %s", cmd[0])
            return None
        except Exception as exc:
            # Fallback: ShellExecuteEx (bypasses some CreateProcess hooks)
            log.warning("[launcher] Popen failed (%s), trying ShellExecuteEx", exc)
            return self._shell_execute(cmd, hide)

    def _shell_execute(self, cmd: list[str], hide: bool) -> subprocess.Popen | None:
        """Fallback: launch via ShellExecuteEx — bypasses WH_SHELL CreateProcess hooks."""
        import ctypes
        import ctypes.wintypes

        class SHELLEXECUTEINFO(ctypes.Structure):
            _fields_ = [
                ("cbSize",       ctypes.wintypes.DWORD),
                ("fMask",        ctypes.wintypes.ULONG),
                ("hwnd",         ctypes.wintypes.HWND),
                ("lpVerb",       ctypes.c_wchar_p),
                ("lpFile",       ctypes.c_wchar_p),
                ("lpParameters", ctypes.c_wchar_p),
                ("lpDirectory",  ctypes.c_wchar_p),
                ("nShow",        ctypes.c_int),
                ("hInstApp",     ctypes.wintypes.HINSTANCE),
                ("lpIDList",     ctypes.c_void_p),
                ("lpClass",      ctypes.c_wchar_p),
                ("hkeyClass",    ctypes.wintypes.HKEY),
                ("dwHotKey",     ctypes.wintypes.DWORD),
                ("hIconOrMonitor", ctypes.wintypes.HANDLE),
                ("hProcess",     ctypes.wintypes.HANDLE),
            ]

        SEE_MASK_NOCLOSEPROCESS = 0x00000040

        sei = SHELLEXECUTEINFO()
        sei.cbSize = ctypes.sizeof(SHELLEXECUTEINFO)
        sei.fMask = SEE_MASK_NOCLOSEPROCESS
        sei.lpFile = cmd[0]
        sei.lpParameters = " ".join(cmd[1:]) if len(cmd) > 1 else None
        sei.nShow = _SW_HIDE if hide else _SW_SHOWMINIMIZED

        try:
            ok = ctypes.windll.shell32.ShellExecuteExW(ctypes.byref(sei))
            if ok and sei.hProcess:
                pid = ctypes.windll.kernel32.GetProcessId(sei.hProcess)
                log.info("[launcher] ShellExecuteEx OK (pid=%s)", pid)
                # Wrap in a minimal Popen-like object
                return _HandleProcess(sei.hProcess, pid)
        except Exception as exc:
            log.error("[launcher] ShellExecuteEx failed: %s", exc)
        return None

    def _launch_posix(self, cmd: list[str]) -> subprocess.Popen | None:
        """macOS/Linux: launch detached from terminal."""
        try:
            proc = subprocess.Popen(
                cmd,
                stdin=subprocess.DEVNULL,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                close_fds=True,
                start_new_session=True,  # detach from our process group
            )
            return proc
        except FileNotFoundError:
            log.error("[launcher] Executable not found: %s", cmd[0])
            return None
        except Exception as exc:
            log.error("[launcher] Launch failed: %s", exc)
            return None

    # ------------------------------------------------------------------ #
    # Kill
    # ------------------------------------------------------------------ #

    def _kill(self) -> None:
        if not self._process:
            return
        # BUG FIX: signal the monitor thread BEFORE closing the handle.
        # _monitor() calls WaitForSingleObject(proc.handle, INFINITE); if we
        # close the handle first the monitor blocks on a dangling handle.
        # Setting _kill_event lets the monitor exit its wait loop cleanly.
        self._kill_event.set()
        try:
            if sys.platform == "win32":
                import ctypes
                if isinstance(self._process, _HandleProcess):
                    # Terminate first; the monitor will see the exit code and
                    # unblock from WaitForSingleObject on its own copy of the
                    # handle.  We do NOT call CloseHandle here because the
                    # monitor thread also holds a reference to the same handle
                    # object — closing it here would make the monitor's
                    # subsequent handle use undefined behaviour.
                    # The handle is closed by the monitor after it unblocks.
                    ctypes.windll.kernel32.TerminateProcess(self._process.handle, 1)
                else:
                    self._process.terminate()
            else:
                self._process.terminate()
            log.info("[launcher] Process terminated")
        except Exception as exc:
            log.warning("[launcher] Kill failed: %s", exc)
        finally:
            self._process = None

    # ------------------------------------------------------------------ #
    # Monitor
    # ------------------------------------------------------------------ #

    def _monitor(self, proc) -> None:
        """Wait for process to exit and clean up.

        BUG FIX (snapshot race): proc is now passed as an argument by _launch()
        under the lock, so this method never reads self._process without the
        lock.  The old code did `proc = self._process` outside any lock, which
        could observe None or a stale value if _kill() ran concurrently.

        BUG FIX (handle use-after-close): for _HandleProcess, we wait with a
        short timeout loop and check _kill_event so we exit cleanly if _kill()
        terminates the process.  This avoids blocking forever on a handle that
        _kill() might close, and we are the sole closer of the handle here.
        """
        if proc is None:
            return
        try:
            if isinstance(proc, _HandleProcess):
                import ctypes
                # Poll in short increments so _kill_event can interrupt the wait.
                # WAIT_TIMEOUT = 0x102; poll every 200 ms.
                while not self._kill_event.is_set():
                    result = ctypes.windll.kernel32.WaitForSingleObject(
                        proc.handle, 200
                    )
                    if result != 0x102:  # not WAIT_TIMEOUT → process exited
                        break
                # We are the sole owner of the handle (see _kill comment).
                ctypes.windll.kernel32.CloseHandle(proc.handle)
            else:
                proc.wait()
        except Exception:
            pass
        with self._lock:
            if self._process is proc:
                self._process = None
        log.info("[launcher] Process exited")


# ------------------------------------------------------------------ #
# Helper: minimal process wrapper for ShellExecuteEx handle
# ------------------------------------------------------------------ #

class _HandleProcess:
    """Minimal Popen-like wrapper around a raw Win32 process HANDLE."""

    def __init__(self, handle, pid: int) -> None:
        self.handle = handle
        self.pid = pid

    def poll(self):
        """Return None if running, 0 if exited."""
        import ctypes
        exit_code = ctypes.wintypes.DWORD(0)
        ctypes.windll.kernel32.GetExitCodeProcess(self.handle, ctypes.byref(exit_code))
        STILL_ACTIVE = 259
        return None if exit_code.value == STILL_ACTIVE else exit_code.value
