"""Tests for hermes_cli.gateway_windows."""

import logging
import os
import subprocess
from pathlib import Path
from types import SimpleNamespace

import pytest

import hermes_cli.gateway as gateway
import hermes_cli.gateway_windows as gateway_windows
import hermes_cli.setup as setup


_BREAKAWAY_MARKER = "_HERMES_GATEWAY_BREAKAWAY"


def test_exec_schtasks_decodes_ansi_output_under_utf8_mode(monkeypatch):
    """schtasks emits the ANSI code page even when Python runs in UTF-8 mode; a non-ASCII account
    path in the task XML must survive `_exec_schtasks` intact or `scheduled_task_drift` reports
    "launcher arguments differs" forever (#116193). Drives the production seam with the bytes the
    reporter captured (GBK for 方舟)."""
    monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
    monkeypatch.setattr(gateway_windows.shutil, "which", lambda name: "schtasks.exe")
    monkeypatch.setattr(gateway_windows.locale, "getpreferredencoding", lambda *a, **k: "utf-8")
    monkeypatch.setattr(gateway_windows, "_windows_console_encodings", lambda: ["cp936"], raising=False)
    xml = r'<Arguments>//B //Nologo "C:\Users\方舟\AppData\Local\hermes\gateway-service\Hermes_Gateway.vbs"</Arguments>'

    def fake_run(argv, **kwargs):
        # schtasks writes cp936 bytes; honour text/encoding like the real subprocess would.
        out, err = xml.encode("gbk"), "错误: 拒绝访问。".encode("gbk")
        if kwargs.get("text"):
            enc, errors = kwargs.get("encoding") or "utf-8", kwargs.get("errors") or "strict"
            out, err = out.decode(enc, errors), err.decode(enc, errors)
        return subprocess.CompletedProcess(argv, 0, stdout=out, stderr=err)

    monkeypatch.setattr(gateway_windows.subprocess, "run", fake_run)

    code, out, err = gateway_windows._exec_schtasks(["/Query", "/TN", "Hermes_Gateway", "/XML"])

    assert (code, out) == (0, xml)
    assert gateway_windows._is_access_denied(err) and gateway_windows._should_fall_back(1, err)


@pytest.mark.platforms("windows")
def test_exec_schtasks_round_trips_non_ascii_task_argument_live(monkeypatch):
    """Real schtasks.exe on a real task whose argument carries a non-ASCII (ANSI-representable)
    character, queried from a UTF-8-mode interpreter: the template/live comparison in
    `scheduled_task_drift` needs the exact characters back (#116193)."""
    monkeypatch.setattr(gateway_windows.locale, "getpreferredencoding", lambda *a, **k: "utf-8")
    task = f"Hermes_Test_{os.getpid()}"
    # schtasks stores /TR in the system ANSI code page, so the marker must be representable THERE:
    # ë is one byte in every Western ACP but is destroyed ("?") on cp936/932/949 hosts, and a CJK
    # literal fails the other way on cp1252 (#119845). Derive it from the live ACP; skip only when
    # no non-ASCII candidate survives, so the #116193 guard keeps its coverage on every locale.
    import ctypes
    acp = f"cp{ctypes.windll.kernel32.GetACP()}"

    def _encodable(text: str) -> bool:
        try:
            return text.encode(acp).decode(acp) == text
        except (UnicodeError, LookupError):
            return False

    marker = next((c for c in ("Zo\u00eb", "\u65b9\u821f", "\u30c6\u30b9\u30c8", "\ud55c\uae00") if _encodable(c)), None)
    if marker is None:
        pytest.skip(f"no non-ASCII marker is representable in the host ANSI code page {acp}")
    created = subprocess.run(
        ["schtasks", "/Create", "/F", "/TN", task, "/SC", "ONLOGON", "/TR", f'wscript.exe //B "C:\\{marker}\\x.vbs"'],
        capture_output=True, timeout=30,
    )
    assert created.returncode == 0, created.stderr
    try:
        code, out, _err = gateway_windows._exec_schtasks(["/Query", "/TN", task, "/XML"])
    finally:
        subprocess.run(["schtasks", "/Delete", "/F", "/TN", task], capture_output=True, timeout=30)
    assert code == 0
    assert marker in out, out


def test_localized_access_denied_uses_existing_fallback_paths():
    """Localized schtasks denial still reaches elevation/startup fallback handling."""
    detail = "错误: 拒绝访问。"

    assert gateway_windows._should_fall_back(1, detail)
    assert gateway_windows._is_access_denied(detail)


def test_schtasks_encoding_falls_back_to_utf8(monkeypatch):
    """A broken/empty locale must not leave us without a decoder (issue #38172)."""

    monkeypatch.setattr(gateway_windows.locale, "getpreferredencoding", lambda *a, **k: "")
    assert gateway_windows._schtasks_encoding() == "utf-8"

    def _boom(*args, **kwargs):
        raise RuntimeError("locale exploded")

    monkeypatch.setattr(gateway_windows.locale, "getpreferredencoding", _boom)
    assert gateway_windows._schtasks_encoding() == "utf-8"




@pytest.mark.platforms("windows")
def test_build_gateway_argv_keeps_venv_console_python_for_uv_venv(monkeypatch, tmp_path):
    """No pythonw / base-interpreter detour: the venv console python.exe is
    launched hidden (CREATE_NO_WINDOW) so descendants inherit its hidden
    console instead of flashing their own (#54220/#56747).

    Windows-only: ``_build_gateway_argv()`` asserts the host is Windows and the
    argv/env overlay it returns is built from real Windows path separators and
    ``Scripts/python.exe`` layout — a patched ``sys.platform`` covered the
    branch but not any of that.
    """

    project = tmp_path / "project"
    scripts = project / "venv" / "Scripts"
    site_packages = project / "venv" / "Lib" / "site-packages"
    hermes_home = tmp_path / "hermes-home"
    base = tmp_path / "uv" / "python" / "cpython-3.11-windows-x86_64-none"
    scripts.mkdir(parents=True)
    site_packages.mkdir(parents=True)
    hermes_home.mkdir()
    base.mkdir(parents=True)

    venv_python = scripts / "python.exe"
    venv_pythonw = scripts / "pythonw.exe"
    base_pythonw = base / "pythonw.exe"
    for exe in (venv_python, venv_pythonw, base_pythonw):
        exe.write_text("", encoding="utf-8")
    (project / "venv" / "pyvenv.cfg").write_text(
        f"home = {base}\nimplementation = CPython\nuv = 0.11.14\nversion_info = 3.11.15\n",
        encoding="utf-8",
    )

    import hermes_cli.gateway as gateway

    monkeypatch.setattr(gateway, "PROJECT_ROOT", project)
    monkeypatch.setattr(gateway, "get_python_path", lambda: str(venv_python))
    monkeypatch.setattr(gateway, "_profile_arg", lambda hermes_home: "")
    monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: str(hermes_home))

    argv, cwd, env_overlay = gateway_windows._build_gateway_argv()

    assert argv[:3] == [str(venv_python), "-m", "hermes_cli.main"]
    assert cwd == str(hermes_home.resolve())
    assert env_overlay["VIRTUAL_ENV"] == str(project / "venv")
    assert str(project) in env_overlay["PYTHONPATH"].split(gateway_windows.os.pathsep)


@pytest.mark.platforms("windows")
def test_spawn_detached_marks_primary_breakaway_success(monkeypatch, tmp_path, caplog):
    """A successful breakaway spawn reports true without a warning."""
    argv = ["python.exe", "-m", "hermes_cli.main", "gateway", "run"]
    cwd = str(tmp_path)
    calls = []

    def fake_popen(call_argv, **kwargs):
        calls.append((call_argv, kwargs))
        return SimpleNamespace(pid=12345)

    monkeypatch.setattr(
        gateway_windows,
        "_build_gateway_argv",
        lambda home=None: (argv, cwd, {"HERMES_GATEWAY_DETACHED": "1"}),
    )
    monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: tmp_path)
    monkeypatch.setattr(gateway_windows.subprocess, "Popen", fake_popen)
    caplog.set_level(logging.WARNING, logger=gateway_windows.__name__)

    assert gateway_windows._spawn_detached() == 12345
    assert len(calls) == 1
    actual_argv, kwargs = calls[0]
    assert actual_argv == argv
    assert kwargs["cwd"] == cwd
    assert kwargs["creationflags"] == gateway_windows.windows_detach_flags()
    assert kwargs["env"][_BREAKAWAY_MARKER] == "1"
    assert kwargs["stdin"] is subprocess.DEVNULL
    assert kwargs["stdout"] is kwargs["stderr"]
    assert not caplog.records


@pytest.mark.platforms("windows")
def test_spawn_detached_warns_and_marks_no_breakaway_fallback(
    monkeypatch, tmp_path, caplog
):
    """A denied breakaway retries once with private false metadata."""
    argv = ["python.exe", "-m", "hermes_cli.main", "gateway", "run"]
    cwd = str(tmp_path)
    calls = []

    def fake_popen(call_argv, **kwargs):
        calls.append((call_argv, kwargs))
        if len(calls) == 1:
            error = OSError(13, "Access is denied")
            error.winerror = 5
            raise error
        return SimpleNamespace(pid=23456)

    monkeypatch.setattr(
        gateway_windows,
        "_build_gateway_argv",
        lambda home=None: (
            argv,
            cwd,
            {"HERMES_GATEWAY_DETACHED": "1", "SECRET_SENTINEL": "do-not-log"},
        ),
    )
    monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: tmp_path)
    monkeypatch.setattr(gateway_windows.subprocess, "Popen", fake_popen)
    caplog.set_level(logging.WARNING, logger=gateway_windows.__name__)

    assert gateway_windows._spawn_detached() == 23456
    assert len(calls) == 2
    (argv_primary, primary), (argv_fallback, fallback) = calls
    assert argv_primary == argv_fallback == argv
    assert primary["cwd"] == fallback["cwd"] == cwd
    assert primary["creationflags"] == gateway_windows.windows_detach_flags()
    assert (
        fallback["creationflags"]
        == gateway_windows.windows_detach_flags_without_breakaway()
    )
    assert primary["stdin"] is fallback["stdin"] is subprocess.DEVNULL
    assert primary["stdout"] is primary["stderr"]
    assert fallback["stdout"] is fallback["stderr"]
    assert Path(primary["stdout"].name) == Path(fallback["stdout"].name)
    assert primary["close_fds"] is fallback["close_fds"] is True
    assert primary["env"] is not fallback["env"]
    assert primary["env"][_BREAKAWAY_MARKER] == "1"
    assert fallback["env"][_BREAKAWAY_MARKER] == "0"
    assert {
        key: value for key, value in primary["env"].items() if key != _BREAKAWAY_MARKER
    } == {
        key: value for key, value in fallback["env"].items() if key != _BREAKAWAY_MARKER
    }

    warnings = [
        record for record in caplog.records if record.levelno == logging.WARNING
    ]
    assert len(warnings) == 1
    assert "5" in warnings[0].getMessage()
    assert "do-not-log" not in warnings[0].getMessage()
    assert str(tmp_path) not in warnings[0].getMessage()


class TestStableWindowsGatewayWorkingDir:
    def test_stable_gateway_working_dir_uses_hermes_home(self, tmp_path, monkeypatch):
        home = tmp_path / ".hermes"
        home.mkdir()
        monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: home)
        assert gateway_windows._stable_gateway_working_dir(tmp_path / "checkout") == str(home.resolve())

    def test_stable_gateway_working_dir_falls_back_to_project_root(self, tmp_path, monkeypatch):
        missing = tmp_path / "missing" / ".hermes"
        project = tmp_path / "checkout"
        monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: missing)
        assert gateway_windows._stable_gateway_working_dir(project) == str(project)








@pytest.mark.platforms("windows")
def test_elevated_gateway_command_uses_hidden_console_python(monkeypatch):
    """UAC handoff launches console python with SW_HIDE — a single hidden
    console, not console-less pythonw (#54220/#56747), and no visible
    elevated cmd.exe window left open.

    Windows-only: the code path runs behind ``_assert_windows()`` and goes
    through ``ctypes.windll.shell32``, neither of which exists on a faked
    host. ShellExecuteW itself stays mocked — it would raise a real UAC
    prompt — but the host identity is genuine.
    """
    calls = []

    class FakeShell32:
        def ShellExecuteW(self, hwnd, verb, executable, params, cwd, show):
            calls.append((hwnd, verb, executable, params, cwd, show))
            return 33

    class FakeWindll:
        shell32 = FakeShell32()

    monkeypatch.setattr(gateway_windows, "_current_profile_cli_args", lambda: ["--profile", "alice"])
    monkeypatch.setattr(gateway_windows.sys, "executable", r"C:\Hermes\venv\Scripts\python.exe")
    monkeypatch.setattr(gateway_windows.ctypes, "windll", FakeWindll(), raising=False)

    assert gateway_windows._launch_elevated_gateway_command("install", ["--start-now", "--elevated-handoff"])

    assert len(calls) == 1
    _hwnd, verb, executable, params, cwd, show = calls[0]
    assert verb == "runas"
    assert executable == r"C:\Hermes\venv\Scripts\python.exe"
    assert "--profile alice gateway install --start-now --elevated-handoff" in params
    assert show == 0
    assert cwd


def test_install_scheduled_task_recreates_instead_of_change(monkeypatch, tmp_path):
    """Install must delete+create so stale minute-repeat task settings are not preserved.

    Host-agnostic on purpose: ``_install_scheduled_task`` only renders the task
    XML and shells out through ``_exec_schtasks`` (mocked here as the genuine
    external dependency), so no platform fake is needed.
    """
    calls = []
    script_path = tmp_path / "Hermes_Gateway_alice.cmd"
    xml_seen = {}

    monkeypatch.setattr(gateway_windows, "_resolve_task_user", lambda: r"DOMAIN\\alice")

    def fake_schtasks(args):
        calls.append(tuple(args))
        if args[0] == "/Delete":
            return (0, "SUCCESS", "")
        if args[0] == "/Create":
            xml_path = Path(args[args.index("/XML") + 1])
            xml_seen["text"] = xml_path.read_text(encoding="utf-16")
            return (0, "SUCCESS", "")
        raise AssertionError(f"unexpected schtasks args: {args}")

    monkeypatch.setattr(gateway_windows, "_exec_schtasks", fake_schtasks)
    ok, detail = gateway_windows._install_scheduled_task("Hermes_Gateway_alice", script_path)

    assert ok is True
    assert "/Change" not in [arg for call in calls for arg in call]
    assert calls[0][:4] == ("/Delete", "/F", "/TN", "Hermes_Gateway_alice")
    assert calls[1][0] == "/Create"
    assert "/XML" in calls[1]
    assert "/SC" not in calls[1]
    assert "<Delay>PT30S</Delay>" in xml_seen["text"]
    assert "<StartWhenAvailable>true</StartWhenAvailable>" in xml_seen["text"]
    assert "<StopOnIdleEnd>false</StopOnIdleEnd>" in xml_seen["text"]
    assert "<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>" in xml_seen["text"]
    assert "<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>" in xml_seen["text"]
    assert "<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>" in xml_seen["text"]
    assert "<RestartOnFailure>" in xml_seen["text"]
    assert "<Count>999</Count>" in xml_seen["text"]
    # Scheduled Task launches the console-less .vbs via wscript.exe, never cmd.exe
    # (issue #45599 fix A: no console -> no logon CTRL_CLOSE_EVENT / 0xC000013A).
    assert "<Command>wscript.exe</Command>" in xml_seen["text"]
    assert "//B //Nologo" in xml_seen["text"]
    assert "Hermes_Gateway_alice.vbs" in xml_seen["text"]
    assert "cmd.exe" not in xml_seen["text"]


def test_gateway_vbs_script_is_console_less(monkeypatch):
    """The .vbs launcher must avoid cmd.exe entirely and Run pythonw hidden
    (issue #45599 fix A: no console -> no logon CTRL_CLOSE_EVENT / 0xC000013A)."""
    monkeypatch.setattr(
        gateway_windows,
        "_resolve_detached_python",
        lambda exe: (r"C:\venv\Scripts\pythonw.exe", Path(r"C:\venv"), []),
    )
    content = gateway_windows._build_gateway_vbs_script(
        r"C:\venv\Scripts\python.exe",
        r"C:\Hermes",
        r"C:\Hermes",
        "--profile work",
    )
    assert "cmd.exe" not in content.lower()
    assert 'CreateObject("WScript.Shell")' in content
    assert "pythonw.exe" in content
    assert "hermes_cli.main" in content
    assert "gateway run" in content
    assert ", 0, False" in content  # hidden window, detached/async
    for var in ("HERMES_HOME", "PYTHONIOENCODING", "HERMES_GATEWAY_DETACHED", "VIRTUAL_ENV", "PYTHONPATH"):
        assert var in content
    assert "--profile" in content and "work" in content
    assert content.endswith("\r\n")


def test_atomic_write_leaves_no_staging_file_when_swap_fails(monkeypatch, tmp_path):
    """The Startup folder is the staging dir: a leftover .tmp there is opened by Windows at every login."""
    startup = tmp_path / "Startup"
    startup.mkdir()
    entry, staging = startup / "Hermes_Gateway.vbs", startup / "Hermes_Gateway.tmp"

    def _denied(self, target):
        raise PermissionError(5, "Access is denied")

    monkeypatch.setattr(Path, "replace", _denied)
    with pytest.raises(PermissionError):
        gateway_windows._atomic_write(entry, "launcher\r\n", staging)

    assert sorted(p.name for p in startup.iterdir()) == []


def test_uninstall_and_reinstall_sweep_stale_startup_staging_file(monkeypatch, tmp_path):
    """Debris from a pre-fix failed swap is removed by uninstall() and by an install() that takes the
    Scheduled Task path (which never rewrites the Startup folder itself)."""
    startup = tmp_path / "Startup"
    startup.mkdir()
    entry, staging = startup / "Hermes_Gateway_alice.vbs", startup / "Hermes_Gateway_alice.tmp"
    script = tmp_path / "task" / "Hermes_Gateway_alice.cmd"

    monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
    monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_alice")
    monkeypatch.setattr(gateway_windows, "get_task_script_path", lambda: script)
    monkeypatch.setattr(gateway_windows, "get_startup_entry_path", lambda: entry)
    monkeypatch.setattr(gateway_windows, "_legacy_startup_entry_path", lambda: startup / "Hermes_Gateway_alice.cmd")
    monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)

    staging.write_text("stale", encoding="utf-8")
    gateway_windows.uninstall()
    assert not staging.exists()

    staging.write_text("stale", encoding="utf-8")
    monkeypatch.setattr(gateway_windows, "_prompt_install_choices", lambda *a, **k: (False, True))
    monkeypatch.setattr(gateway_windows, "_write_task_script", lambda: script)
    monkeypatch.setattr(gateway_windows, "_is_running_as_admin", lambda: True)
    monkeypatch.setattr(gateway_windows, "_install_scheduled_task", lambda name, path: (True, "created"))
    monkeypatch.setattr(gateway_windows, "_print_next_steps", lambda: None)
    gateway_windows.install()
    assert not staging.exists()


def _startup_with_fallback_and_legacy_entries(monkeypatch, tmp_path):
    """A Startup folder holding both the .vbs fallback and the pre-#45610 .cmd launcher."""
    startup = tmp_path / "Startup"
    startup.mkdir(parents=True)
    script = tmp_path / "gateway-service" / "Hermes_Gateway_alice.cmd"
    vbs, cmd = startup / "Hermes_Gateway_alice.vbs", startup / "Hermes_Gateway_alice.cmd"
    vbs.write_text(gateway_windows._build_startup_launcher(script), encoding="utf-8")
    cmd.write_text("@echo off", encoding="utf-8")
    monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
    monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_alice")
    monkeypatch.setattr(gateway_windows, "get_startup_entry_path", lambda: vbs)
    monkeypatch.setattr(gateway_windows, "_legacy_startup_entry_path", lambda: cmd)
    monkeypatch.setattr(gateway_windows, "_write_task_script", lambda: script)
    return startup, script


def test_scheduled_task_install_removes_startup_entries_that_would_double_launch(monkeypatch, tmp_path, capsys):
    """#80569: a Scheduled Task install beside an earlier Startup fallback (or legacy .cmd) left both
    firing at logon. Installing the task converges to the task alone."""
    startup, _script = _startup_with_fallback_and_legacy_entries(monkeypatch, tmp_path)
    monkeypatch.setattr(gateway_windows, "_prompt_install_choices", lambda *a, **k: (False, True))
    monkeypatch.setattr(gateway_windows, "_is_running_as_admin", lambda: True)
    monkeypatch.setattr(gateway_windows, "_install_scheduled_task", lambda name, path: (True, "created"))
    monkeypatch.setattr(gateway_windows, "_print_next_steps", lambda: None)

    gateway_windows.install()

    assert sorted(p.name for p in startup.iterdir()) == []
    assert "Removed redundant Windows login item" in capsys.readouterr().out


def test_reconcile_leaves_one_autostart_mechanism(monkeypatch, tmp_path):
    """#80569: what `hermes update` and `hermes doctor --fix` run. Beside a registered task every
    Startup entry is redundant; with no task a legacy .cmd next to the .vbs is. After reconcile
    nothing is redundant and exactly one mechanism remains."""
    startup, _script = _startup_with_fallback_and_legacy_entries(monkeypatch, tmp_path)
    registered = {"task": True}
    monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: registered["task"])

    assert len(gateway_windows.redundant_autostart_entries()) == 2
    done, warnings = gateway_windows.reconcile_autostart_launchers()
    assert (len(done), warnings) == (2, [])
    assert gateway_windows.redundant_autostart_entries() == []
    assert list(startup.iterdir()) == []

    # No task: the .vbs fallback is the mechanism, a leftover legacy .cmd beside it is the duplicate.
    registered["task"] = False
    _startup_with_fallback_and_legacy_entries(monkeypatch, tmp_path / "no-task")
    assert [p.suffix for p in gateway_windows.redundant_autostart_entries()] == [".cmd"]
    gateway_windows.reconcile_autostart_launchers()
    assert gateway_windows.redundant_autostart_entries() == []
    assert [p.name for p in (tmp_path / "no-task" / "Startup").iterdir()] == ["Hermes_Gateway_alice.vbs"]


def test_reconcile_warns_when_legacy_entry_cannot_be_removed(monkeypatch, tmp_path):
    """#80569: no task, legacy .cmd locked. The .vbs gets written but the .cmd survives, so both fire
    at logon; reconcile must warn instead of reporting a migration, and doctor --fix must not count it."""
    import sys
    from hermes_cli import doctor_platform
    from hermes_cli.doctor_report import Finding

    startup, _script = _startup_with_fallback_and_legacy_entries(monkeypatch, tmp_path)
    (startup / "Hermes_Gateway_alice.vbs").unlink()   # legacy-only install
    monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
    real_unlink = Path.unlink

    def locked_unlink(self, *args, **kwargs):
        if self.suffix == ".cmd":
            raise PermissionError(13, "Access is denied", str(self))
        return real_unlink(self, *args, **kwargs)

    monkeypatch.setattr(Path, "unlink", locked_unlink)

    done, warnings = gateway_windows.reconcile_autostart_launchers()
    assert done == [] and len(warnings) == 1 and "Hermes_Gateway_alice.cmd" in warnings[0]
    assert sorted(p.name for p in startup.iterdir()) == ["Hermes_Gateway_alice.cmd", "Hermes_Gateway_alice.vbs"]
    assert [p.suffix for p in gateway_windows.redundant_autostart_entries()] == [".cmd"]

    monkeypatch.setattr(sys, "platform", "win32")
    f = Finding()
    doctor_platform._check_windows_gateway_autostart(True, f)
    assert f.fixed == 0 and len(f.manual_issues) == 1


def test_status_names_and_uninstall_removes_pre_suffix_launchers(monkeypatch, tmp_path, capsys):
    """#116157: a Scheduled Task ``Hermes_Gateway`` and a Startup ``Hermes_Gateway.vbs`` left from before
    per-profile suffixes are invisible to every ``get_task_name()``-keyed operation. ``status`` must name
    them and ``uninstall`` must remove them (files unlinked, ``schtasks /Delete`` issued for the task)."""
    startup, home = tmp_path / "Startup", tmp_path / "home"
    (home / "gateway-service").mkdir(parents=True)
    startup.mkdir()
    legacy_vbs = startup / "Hermes_Gateway.vbs"
    legacy_vbs.write_text(gateway_windows._build_startup_launcher(home / "gateway-service" / "Hermes_Gateway.cmd"), encoding="utf-8")
    legacy_pair = home / "gateway-service" / "Hermes_Gateway.cmd"
    legacy_pair.write_text("legacy", encoding="utf-8")
    schtasks_calls = []
    registered = {"Hermes_Gateway"}
    task_xml = gateway_windows._build_scheduled_task_xml("Hermes_Gateway", home / "gateway-service" / "Hermes_Gateway.vbs", None)

    def fake_schtasks(args):
        schtasks_calls.append(args)
        name = args[args.index("/TN") + 1]
        if args[0] == "/Delete":
            registered.discard(name)
            return (0, "SUCCESS", "")
        return (0, task_xml, "") if name in registered else (1, "", "ERROR: The system cannot find the file specified.")

    monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
    monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_alice")
    monkeypatch.setattr(gateway_windows, "get_task_script_path", lambda: home / "gateway-service" / "Hermes_Gateway_alice.cmd")
    monkeypatch.setattr(gateway_windows, "get_startup_entry_path", lambda: startup / "Hermes_Gateway_alice.vbs")
    monkeypatch.setattr(gateway_windows, "_legacy_startup_entry_path", lambda: startup / "Hermes_Gateway_alice.cmd")
    monkeypatch.setattr(gateway_windows, "_startup_dir", lambda: startup)
    monkeypatch.setattr(gateway_windows, "_hermes_home", lambda: home)
    monkeypatch.setattr(gateway_windows, "_exec_schtasks", fake_schtasks)
    monkeypatch.setattr(gateway_windows, "_gateway_pids", lambda *a, **k: [])
    monkeypatch.setattr(gateway_windows, "_print_start_attestation_warning", lambda: None)

    gateway_windows.status()
    out = capsys.readouterr().out
    assert f"legacy pre-suffix Windows login item still installed: {legacy_vbs}" in out
    assert f"legacy pre-suffix task script still installed: {legacy_pair}" in out
    assert "legacy pre-suffix Scheduled Task still installed: Hermes_Gateway" in out

    gateway_windows.uninstall()
    out = capsys.readouterr().out
    assert "Removed legacy pre-suffix Scheduled Task 'Hermes_Gateway'" in out
    assert not legacy_vbs.exists() and not legacy_pair.exists()
    assert ["/Delete", "/F", "/TN", "Hermes_Gateway"] in schtasks_calls
    gateway_windows.status()
    assert "legacy pre-suffix" not in capsys.readouterr().out


def test_secondary_profile_leaves_default_profiles_bare_launchers_alone(monkeypatch, tmp_path, capsys):
    """The bare ``Hermes_Gateway`` task and Startup entry are the LIVE identity of the default ``~/.hermes``
    profile. From a secondary profile they are a sibling install, not this home's pre-suffix stray:
    ``uninstall`` / ``install --force`` must issue no ``schtasks /Delete`` and unlink nothing."""
    startup, home, default_home = tmp_path / "Startup", tmp_path / "profiles" / "work", tmp_path / "default"
    (home / "gateway-service").mkdir(parents=True)
    (default_home / "gateway-service").mkdir(parents=True)
    startup.mkdir()
    default_vbs = startup / "Hermes_Gateway.vbs"
    default_vbs.write_text(gateway_windows._build_startup_launcher(default_home / "gateway-service" / "Hermes_Gateway.cmd"), encoding="utf-8")
    task_xml = gateway_windows._build_scheduled_task_xml("Hermes_Gateway", default_home / "gateway-service" / "Hermes_Gateway.vbs", None)
    schtasks_calls = []

    def fake_schtasks(args):
        schtasks_calls.append(args)
        if args[0] == "/Query" and args[args.index("/TN") + 1] == "Hermes_Gateway":
            return (0, task_xml, "")
        return (1, "", "ERROR: The system cannot find the file specified.")

    monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
    monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_work")
    monkeypatch.setattr(gateway_windows, "get_task_script_path", lambda: home / "gateway-service" / "Hermes_Gateway_work.cmd")
    monkeypatch.setattr(gateway_windows, "get_startup_entry_path", lambda: startup / "Hermes_Gateway_work.vbs")
    monkeypatch.setattr(gateway_windows, "_legacy_startup_entry_path", lambda: startup / "Hermes_Gateway_work.cmd")
    monkeypatch.setattr(gateway_windows, "_startup_dir", lambda: startup)
    monkeypatch.setattr(gateway_windows, "_hermes_home", lambda: home)
    monkeypatch.setattr(gateway_windows, "_exec_schtasks", fake_schtasks)
    monkeypatch.setattr(gateway_windows, "_gateway_pids", lambda *a, **k: [])
    monkeypatch.setattr(gateway_windows, "_print_start_attestation_warning", lambda: None)

    gateway_windows.status()
    assert "legacy pre-suffix" not in capsys.readouterr().out
    gateway_windows.uninstall()
    capsys.readouterr()
    assert default_vbs.exists()
    assert not any(call[0] == "/Delete" and "Hermes_Gateway" in call for call in schtasks_calls)


# Reporter's `Export-ScheduledTask` of a task registered before the hardened template (#113670).
_PRE_HARDENING_TASK_XML = """<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
  <Settings>
    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
    <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
  </Settings>
  <Triggers>
    <LogonTrigger />
  </Triggers>
  <Actions Context="Author">
    <Exec>
      <Command>wscript.exe</Command>
      <Arguments>"C:\\Users\\me\\.hermes\\gateway-service\\Hermes_Gateway.vbs"</Arguments>
    </Exec>
  </Actions>
</Task>
"""


def test_scheduled_task_drift_names_missing_hardening_leaves(monkeypatch):
    """A pre-hardening registration is reported leaf by leaf, and the report is what
    ``hermes gateway status`` prints together with the ``hermes gateway install`` repair hint."""
    launcher = Path(r"C:\Users\me\.hermes\gateway-service\Hermes_Gateway.vbs")
    template = gateway_windows._build_scheduled_task_xml("Hermes_Gateway", launcher, r"PC\me")
    drift = gateway_windows.compare_scheduled_task_drift(_PRE_HARDENING_TASK_XML, template)
    assert drift == [
        "missing: RestartOnFailure, LogonTrigger Delay",
        "launcher arguments differs",
        "version 1.3 vs 1.4",
    ]

    printed: list[str] = []
    monkeypatch.setattr(gateway_windows, "_exec_schtasks", lambda args: (0, _PRE_HARDENING_TASK_XML if "/XML" in args else "", ""))
    monkeypatch.setattr(gateway_windows, "get_task_script_path", lambda: launcher.with_suffix(".cmd"))
    monkeypatch.setattr(gateway_windows, "_resolve_task_user", lambda: r"PC\me")
    monkeypatch.setattr("builtins.print", lambda *a, **k: printed.append(" ".join(map(str, a))))
    gateway_windows._print_scheduled_task_drift("Hermes_Gateway")
    assert printed[0].startswith("⚠ Scheduled Task registration predates the current template (missing: RestartOnFailure")
    assert "hermes gateway install" in printed[1]


def test_scheduled_task_drift_is_silent_when_aligned_or_unqueryable(monkeypatch):
    """The template compared to itself (with the SID-style <UserId> schtasks exports) is not drift, and
    a failed ``schtasks /Query /XML`` prints nothing — status must never nag a healthy install."""
    launcher = Path(r"C:\Users\me\.hermes\gateway-service\Hermes_Gateway.vbs")
    template = gateway_windows._build_scheduled_task_xml("Hermes_Gateway", launcher, r"PC\me")
    exported = template.replace(r"<UserId>PC\me</UserId>", "<UserId>S-1-5-21-1-2-3-1001</UserId>")
    assert exported != template
    assert gateway_windows.compare_scheduled_task_drift(exported, template) == []
    assert gateway_windows.compare_scheduled_task_drift("not xml", template) == []

    printed: list[str] = []
    monkeypatch.setattr(gateway_windows, "_exec_schtasks", lambda args: (1, "", "ERROR: The system cannot find the file specified."))
    monkeypatch.setattr("builtins.print", lambda *a, **k: printed.append(" ".join(map(str, a))))
    gateway_windows._print_scheduled_task_drift("Hermes_Gateway")
    assert printed == []


def test_reconcile_scheduled_task_reregisters_only_on_drift(monkeypatch, tmp_path):
    """The Windows sibling of ``refresh_systemd_unit_if_needed`` (#113670): a pre-hardening
    registration is deleted and re-created from the current template (so ``RestartOnFailure`` and the
    logon ``Delay`` reach existing installs), while an aligned one is left alone."""
    script_path = tmp_path / "gateway.cmd"
    launcher = script_path.with_suffix(".vbs")
    template = gateway_windows._build_scheduled_task_xml("Hermes_Gateway", launcher, r"PC\me")
    calls: list[list[str]] = []
    registered = {"xml": _PRE_HARDENING_TASK_XML}

    def fake_schtasks(args):
        calls.append(list(args))
        if "/XML" in args and "/Query" in args:
            return (0, registered["xml"], "")
        if "/Create" in args:
            registered["xml"] = Path(args[args.index("/XML") + 1]).read_text(encoding="utf-16")
        return (0, "", "")

    monkeypatch.setattr(gateway_windows, "_exec_schtasks", fake_schtasks)
    monkeypatch.setattr(gateway_windows, "_write_task_script", lambda: script_path)
    monkeypatch.setattr(gateway_windows, "get_task_script_path", lambda: script_path)
    monkeypatch.setattr(gateway_windows, "_resolve_task_user", lambda: r"PC\me")
    monkeypatch.setattr("builtins.print", lambda *a, **k: None)

    assert gateway_windows.reconcile_scheduled_task("Hermes_Gateway") is True
    assert [c[0] for c in calls if c[0] in ("/Delete", "/Create")] == ["/Delete", "/Create"]
    assert "<RestartOnFailure>" in registered["xml"]
    assert gateway_windows.compare_scheduled_task_drift(registered["xml"], template) == []

    calls.clear()
    assert gateway_windows.reconcile_scheduled_task("Hermes_Gateway") is False
    assert not any(c[0] in ("/Delete", "/Create") for c in calls)


def _arrange_uninstalled_start(monkeypatch):
    """start() with no Scheduled Task / Startup entry; returns (install_calls, spawn_count)."""
    installs, spawns = [], []
    monkeypatch.delenv("HERMES_GATEWAY_INSTALL_START_ON_LOGIN", raising=False)
    monkeypatch.delenv("HERMES_NONINTERACTIVE", raising=False)
    monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
    monkeypatch.setattr(gateway_windows, "_print_start_attestation_warning", lambda: None)
    monkeypatch.setattr(gateway_windows, "_gateway_pids", lambda: [])
    monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
    monkeypatch.setattr(gateway_windows, "is_startup_entry_installed", lambda: False)
    monkeypatch.setattr(gateway_windows, "install", lambda **kwargs: installs.append(kwargs))
    monkeypatch.setattr(gateway_windows, "_spawn_detached", lambda: spawns.append(1) or 4242)
    monkeypatch.setattr(gateway_windows, "_report_gateway_start", lambda via: None)
    monkeypatch.setattr(gateway_windows, "_stdin_console_mode_ok", lambda: True)
    monkeypatch.setattr(gateway_windows, "_stdout_isatty", lambda: True)
    return installs, spawns


def test_stdin_interactive_only_when_isatty_and_a_console_answers_get_console_mode():
    """Windows CRT isatty() is True for the NUL device (`hermes gateway start < NUL`, stdin=DEVNULL), so
    isatty alone must not open the prompt; off Windows (no console-mode fact) isatty decides (#113977)."""
    assert gateway_windows._stdin_is_interactive(isatty=True, console_mode_ok=False) is False   # NUL
    assert gateway_windows._stdin_is_interactive(isatty=True, console_mode_ok=True) is True     # console
    assert gateway_windows._stdin_is_interactive(isatty=False, console_mode_ok=True) is False   # pipe
    assert gateway_windows._stdin_is_interactive(isatty=True, console_mode_ok=None) is True     # POSIX tty


def test_start_with_nul_stdin_starts_the_gateway_but_never_installs_login_persistence(monkeypatch, capsys):
    """isatty says TTY, GetConsoleMode says no console: `< NUL` gets the same treatment as a pipe."""
    installs, spawns = _arrange_uninstalled_start(monkeypatch)
    monkeypatch.setattr(setup, "is_interactive_stdin", lambda: True)
    monkeypatch.setattr(gateway_windows, "_stdin_console_mode_ok", lambda: False)
    monkeypatch.setattr(setup, "prompt_yes_no", lambda *a, **k: pytest.fail("no prompt on a NUL stdin"))

    gateway_windows.start()

    assert installs == [] and spawns == [1]
    assert "hermes gateway install" in capsys.readouterr().out


def test_start_with_captured_stdout_never_asks_even_on_a_console_stdin(monkeypatch, capsys):
    """Pre-#122234 Desktop update hand-offs run the NEW `gateway start --all` with the hand-off
    console as stdin and stdout captured until exit. A question there is invisible and never answered."""
    installs, spawns = _arrange_uninstalled_start(monkeypatch)
    monkeypatch.setattr(setup, "is_interactive_stdin", lambda: True)
    monkeypatch.setattr(gateway_windows, "_stdout_isatty", lambda: False)
    monkeypatch.setattr(setup, "prompt_yes_no", lambda *a, **k: pytest.fail("no prompt into captured stdout"))

    gateway_windows.start()

    assert installs == [] and spawns == [1]
    assert "hermes gateway install" in capsys.readouterr().out


def test_start_without_tty_starts_the_gateway_but_never_installs_login_persistence(monkeypatch, capsys):
    """`hermes gateway start < /dev/null` must not answer the persistence question with a default Yes
    (#113977); it starts the gateway once and points at the explicit install command."""
    installs, spawns = _arrange_uninstalled_start(monkeypatch)
    monkeypatch.setattr(setup, "is_interactive_stdin", lambda: False)
    monkeypatch.setattr(setup, "prompt_yes_no", lambda *a, **k: pytest.fail("no prompt without a TTY"))

    gateway_windows.start()

    assert installs == [] and spawns == [1]
    out = capsys.readouterr().out
    assert "hermes gateway install" in out and "did not complete" not in out


def test_start_on_tty_hands_both_answers_to_install_and_honours_the_env_opt_out(monkeypatch):
    """Yes → one install() carrying start_now+start_on_login (install spawns; start() must not spawn
    again). HERMES_GATEWAY_INSTALL_START_ON_LOGIN=0 → no question, no install, a plain start."""
    installs, spawns = _arrange_uninstalled_start(monkeypatch)
    monkeypatch.setattr(setup, "is_interactive_stdin", lambda: True)
    monkeypatch.setattr(setup, "prompt_yes_no", lambda *a, **k: True)

    gateway_windows.start()
    assert installs == [{"force": False, "start_now": True, "start_on_login": True}] and spawns == []

    installs.clear()
    monkeypatch.setenv("HERMES_GATEWAY_INSTALL_START_ON_LOGIN", "0")
    monkeypatch.setattr(setup, "prompt_yes_no", lambda *a, **k: pytest.fail("env override must skip the prompt"))
    gateway_windows.start()
    assert installs == [] and spawns == [1]














# ---------------------------------------------------------------------------
# stop() drain semantics — issue #33778
#
# Background: on Windows, asyncio.add_signal_handler raises NotImplementedError,
# so the gateway's SIGTERM handler (which drains in-flight agents and writes
# resume_pending=True) never fires when `hermes gateway stop` kills the
# process. The fix: stop() writes the planned_stop_marker first, waits for
# the gateway's marker-watcher thread to drain + exit cleanly, then escalates
# to taskkill if drain times out.
# ---------------------------------------------------------------------------










def test_hermes_owns_windows_service_requires_name_or_binary_under_a_hermes_root():
    """Task Scheduler (``Schedule`` in svchost) above a task-launched gateway is never its supervisor;
    a service is Hermes-owned only by a ``hermes*`` name or a binary under the install (#97208)."""
    roots = (
        r"C:\Users\kaize\AppData\Local\hermes\hermes-agent",
        r"C:\Users\kaize\AppData\Local\hermes\hermes-agent\venv\Scripts",
        r"C:\Users\kaize\AppData\Local\hermes\gateway-service",
    )
    owns = gateway_windows.hermes_owns_windows_service

    assert not owns("Schedule", r"C:\Windows\system32\svchost.exe -k netsvcs -p -s Schedule", roots)
    assert not owns("BITS", r"C:\Windows\System32\svchost.exe -k netsvcs -p -s BITS", roots)
    assert not owns("Other", r"C:\Users\kaize\AppData\Local\hermes\hermes-agent-fork\run.exe", roots)

    assert owns("HermesGateway", r"C:\nssm\nssm.exe", roots)
    assert owns("Hermes_Gateway_derek", "", roots)
    assert owns("gw", r'"C:\Users\KAIZE\AppData\Local\hermes\hermes-agent\venv\Scripts\hermes.exe" gateway run', roots)
    assert owns("gw", r"C:\Users\kaize\AppData\Local\hermes\gateway-service\Hermes_Gateway.cmd", roots)


def test_wizard_install_service_asks_once_and_never_starts_after_windows_install(monkeypatch):
    """The wizard asks start-now/start-on-login once, forwards both answers, and returns
    without a second start: the Windows installer owns start and the elevated child
    starts itself, so a parent-side start would re-ask the install questions and re-offer
    UAC while the child is still waiting on consent (#116550)."""
    answers = iter([True, True])
    monkeypatch.setattr(gateway, "prompt_yes_no", lambda *a, **k: next(answers))
    monkeypatch.setattr(gateway, "is_wsl", lambda: False)
    installs, starts = [], []
    monkeypatch.setattr(gateway, "_gw_windows", lambda: SimpleNamespace(
        install=lambda **kw: installs.append(kw) or True,
    ))
    monkeypatch.setattr(gateway, "_setup_service_action", lambda *a, **k: starts.append((a, k)))

    gateway._wizard_install_service("windows")

    assert installs == [{"force": False, "start_now": True, "start_on_login": True}]
    assert starts == []
