"""Tests for hermes_cli.service_manager — the abstract ServiceManager
protocol, the detect_service_manager() entry point, and the host-side
adapter wrappers (Systemd / Launchd / Windows).

The s6 backend is added in Phase 3; its tests live alongside the
implementation in this same file once that phase ships.
"""
from __future__ import annotations

import pytest

from hermes_cli.service_manager import (
    LaunchdServiceManager,
    S6ServiceManager,
    ServiceManager,
    ServiceManagerKind,
    SystemdServiceManager,
    WindowsServiceManager,
    detect_service_manager,
    get_service_manager,
    validate_profile_name,
)


# ---------------------------------------------------------------------------
# validate_profile_name
# ---------------------------------------------------------------------------






# ---------------------------------------------------------------------------
# detect_service_manager
# ---------------------------------------------------------------------------






# ---------------------------------------------------------------------------
# _s6_running — must work for unprivileged users, not just root
# ---------------------------------------------------------------------------


def _patch_s6_paths(
    monkeypatch: pytest.MonkeyPatch,
    *,
    comm: str | OSError | None,
    basedir_is_dir: bool,
) -> None:
    """Stub /proc/1/comm and /run/s6/basedir for _s6_running tests."""
    from pathlib import Path as _Path

    real_read_text = _Path.read_text
    real_is_dir = _Path.is_dir

    def fake_read_text(self, *args, **kwargs):  # type: ignore[override]
        if str(self) == "/proc/1/comm":
            if isinstance(comm, OSError):
                raise comm
            if comm is None:
                raise FileNotFoundError(2, "No such file or directory")
            return comm + "\n"
        return real_read_text(self, *args, **kwargs)

    def fake_is_dir(self):  # type: ignore[override]
        if str(self) == "/run/s6/basedir":
            return basedir_is_dir
        return real_is_dir(self)

    monkeypatch.setattr(_Path, "read_text", fake_read_text)
    monkeypatch.setattr(_Path, "is_dir", fake_is_dir)






# ---------------------------------------------------------------------------
# Backend wrappers — kind + registration unsupported on hosts
# ---------------------------------------------------------------------------


def test_systemd_manager_kind_and_registration_unsupported() -> None:
    mgr = SystemdServiceManager()
    assert mgr.kind == "systemd"
    assert mgr.supports_runtime_registration() is False
    with pytest.raises(NotImplementedError):
        mgr.register_profile_gateway("foo")
    with pytest.raises(NotImplementedError):
        mgr.unregister_profile_gateway("foo")
    assert mgr.list_profile_gateways() == []
    # Protocol conformance — runtime_checkable lets us assert this.
    assert isinstance(mgr, ServiceManager)


# ---------------------------------------------------------------------------
# Lifecycle delegation — wrappers must call through to module-level fns
# ---------------------------------------------------------------------------




def test_windows_manager_lifecycle_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
    called: list[str] = []
    # Force-import the submodule so monkeypatch's attribute lookup
    # against the `hermes_cli` package succeeds — gateway_windows is
    # imported lazily inside the wrapper and may not yet be loaded.
    import hermes_cli.gateway_windows  # noqa: F401

    class _FakeWindowsModule:
        @staticmethod
        def start() -> None: called.append("start")
        @staticmethod
        def stop() -> None: called.append("stop")
        @staticmethod
        def restart() -> None: called.append("restart")
        @staticmethod
        def is_installed() -> bool: return True

    monkeypatch.setattr("hermes_cli.gateway_windows", _FakeWindowsModule)
    monkeypatch.setattr(
        "hermes_cli.gateway.find_gateway_pids",
        lambda **kw: [12345],
    )
    mgr = WindowsServiceManager()
    mgr.start("ignored")
    mgr.stop("ignored")
    mgr.restart("ignored")
    assert called == ["start", "stop", "restart"]
    assert mgr.is_running("ignored") is True




# ---------------------------------------------------------------------------
# get_service_manager factory
# ---------------------------------------------------------------------------




# ---------------------------------------------------------------------------
# S6ServiceManager — unit tests against a tmp-path scandir (no real s6)
# ---------------------------------------------------------------------------


@pytest.fixture
def s6_scandir(tmp_path):
    """Empty scandir for the S6ServiceManager tests."""
    d = tmp_path / "service"
    d.mkdir()
    return d


@pytest.fixture
def fake_subprocess_run(monkeypatch: pytest.MonkeyPatch):
    """Capture subprocess.run calls + always return success. Lets the
    S6ServiceManager tests run on hosts that don't have s6-svc /
    s6-svscanctl installed.

    Records are normalized: leading ``/command/`` is stripped from
    cmd[0] so assertions can match on the bare s6-svc / s6-svstat /
    s6-svscanctl name regardless of whether the manager calls them
    via absolute path or bare name."""
    calls: list[list[str]] = []

    def _fake(cmd, **kw):
        import subprocess as _sp
        seq = list(cmd) if isinstance(cmd, (list, tuple)) else [str(cmd)]
        if seq and seq[0].startswith("/command/"):
            seq[0] = seq[0][len("/command/"):]
        calls.append(seq)
        return _sp.CompletedProcess(cmd, 0, "", "")

    monkeypatch.setattr("subprocess.run", _fake)
    return calls


# ---------------------------------------------------------------------------
# _seed_supervise_skeleton — unit tests
# ---------------------------------------------------------------------------
#
# The skeleton helper pre-creates the dirs and FIFOs that s6-supervise
# would otherwise create as root mode 0700, locking out the
# unprivileged hermes user from every lifecycle op. These tests run
# against tmp_path and assert the produced layout — the live-container
# verification (against real s6-svc / s6-svstat) lives in
# tests/docker/test_s6_profile_gateway_integration.py.


def test_seed_supervise_skeleton_creates_expected_layout(tmp_path) -> None:
    """Verifies the dirs + FIFO + modes the helper lays down."""
    import stat

    from hermes_cli.service_manager import _seed_supervise_skeleton

    svc_dir = tmp_path / "gateway-foo"
    svc_dir.mkdir()

    _seed_supervise_skeleton(svc_dir)

    # Top-level event/ — s6-svlisten1 event subscription dir.
    event = svc_dir / "event"
    assert event.is_dir(), "missing top-level event/"
    assert stat.S_IMODE(event.stat().st_mode) == 0o3730, (
        f"event/ mode = {oct(event.stat().st_mode)}, want 03730"
    )

    # supervise/ dir.
    supervise = svc_dir / "supervise"
    assert supervise.is_dir(), "missing supervise/"
    assert stat.S_IMODE(supervise.stat().st_mode) == 0o755

    # supervise/event/.
    supervise_event = supervise / "event"
    assert supervise_event.is_dir(), "missing supervise/event/"
    assert stat.S_IMODE(supervise_event.stat().st_mode) == 0o3730

    # supervise/control FIFO.
    control = supervise / "control"
    assert control.exists(), "missing supervise/control FIFO"
    assert stat.S_ISFIFO(control.stat().st_mode), (
        "supervise/control must be a FIFO"
    )
    assert stat.S_IMODE(control.stat().st_mode) == 0o660










def test_render_run_script_uses_replace_to_take_over_stale_holder() -> None:
    """NS-505: the supervised gateway must exec ``gateway run --replace``.

    Without ``--replace`` a gateway started OUTSIDE s6 (a stray shell
    ``hermes gateway run``, an agent action, the Open WebUI helper) holds
    the per-HERMES_HOME PID lock; the supervised slot then execs a bare
    ``gateway run``, hits the "Another gateway instance is already
    running" guard, exits non-zero, and s6 restarts it — a restart loop
    that never binds. ``--replace`` makes the supervised gateway reap the
    stale holder and win, so s6 is authoritative for the slot.

    Covers both the default (root HERMES_HOME, no ``-p``) and named-profile
    render paths.
    """
    default_text = S6ServiceManager._render_run_script("default", {})
    # Root profile: bare `hermes gateway run --replace` (no -p flag).
    assert "hermes gateway run --replace" in default_text
    assert "hermes -p default" not in default_text
    # Every exec line that launches the gateway must carry --replace, so
    # neither the non-root nor the privilege-drop branch can spin.
    gateway_execs = [
        line for line in default_text.splitlines()
        if "gateway run" in line
    ]
    assert gateway_execs, "no gateway run exec line rendered"
    assert all("--replace" in line for line in gateway_execs), (
        f"a gateway run line is missing --replace: {gateway_execs}"
    )

    named_text = S6ServiceManager._render_run_script("coder", {})
    named_execs = [
        line for line in named_text.splitlines() if "gateway run" in line
    ]
    assert named_execs
    assert all("--replace" in line for line in named_execs), (
        f"a named-profile gateway run line is missing --replace: {named_execs}"
    )


def test_render_finish_script_exits_125_on_ex_config() -> None:
    """The finish script must translate exit 78 (EX_CONFIG) into exit 125
    (permanent failure) so s6 stops restarting on fatal config errors.
    See #51228."""
    text = S6ServiceManager._render_finish_script()
    assert '[ "$1" = "78" ]' in text
    assert "exit 125" in text
    assert "exit 0" in text






# ---------------------------------------------------------------------------
# Lifecycle errors — friendly messages, not raw CalledProcessError
# ---------------------------------------------------------------------------










# ---------------------------------------------------------------------------
# S6 stop writes a planned-stop marker (issue #42675)
#
# `hermes gateway stop` inside a container dispatches through
# S6ServiceManager.stop() -> `s6-svc -d`, which SIGTERMs the gateway.
# That SIGTERM is indistinguishable from the one s6/Docker sends on a
# container restart unless we mark the intentional stop first. Without
# the marker, the gateway's shutdown handler can't tell an operator
# stop from a restart kill, and the gateway_state=stopped suppression
# (run.py) would never engage for explicit stops.
# ---------------------------------------------------------------------------


def _log_run_setup_fragment(rendered: str) -> str:
    """Keep mkdir/rm setup from ``_render_log_run``; stop before ``s6-log``."""
    keep: list[str] = []
    for line in rendered.splitlines(keepends=True):
        if line.startswith("#!/") or "shellcheck" in line:
            continue
        if "s6-log" in line:
            break
        keep.append(line)
    return "#!/bin/sh\n" + "".join(keep)


def test_s6_log_run_creates_leaf_as_hermes_without_chown(
    s6_scandir, fake_subprocess_run,
) -> None:
    """log/run must not root-chown/unlink volume paths; create leaf as hermes.

    #45258 parent ownership is stage2's job (``logs/gateways`` seeded as
    hermes). Restartable log/run must not pathname-chown or pathname-rm a
    hermes-writable tree from root — that is a symlink TOCTOU hole.
    """
    mgr = S6ServiceManager(scandir=s6_scandir)
    mgr.register_profile_gateway("coder")

    log_text = (s6_scandir / "gateway-coder" / "log" / "run").read_text()

    assert not any(line.lstrip().startswith("chown ") for line in log_text.splitlines()), (
        "restartable log/run must not invoke chown on hermes-writable paths; "
        f"saw: {log_text!r}"
    )
    assert 's6-setuidgid hermes mkdir -p "$log_dir"' in log_text
    assert 's6-setuidgid hermes rm -f "$log_dir/lock"' in log_text
    assert 'else\n  mkdir -p "$log_dir"\n  rm -f "$log_dir/lock"\nfi\n' in log_text
    # Lock cleanup must not remain a bare root-context pathname op after fi.
    after_fi = log_text.split("fi\n", 1)[-1]
    assert 'rm -f "$log_dir/lock"' not in after_fi

    mkdir_as_hermes_idx = log_text.index('s6-setuidgid hermes mkdir -p "$log_dir"')
    rm_as_hermes_idx = log_text.index('s6-setuidgid hermes rm -f "$log_dir/lock"')
    exec_idx = log_text.index("s6-log 1 ")
    assert mkdir_as_hermes_idx < rm_as_hermes_idx < exec_idx

    # Runtime path expansion, never a baked-in absolute path.
    assert '/opt/data/logs/gateways"' not in log_text


def test_s6_log_run_never_invokes_chown_with_symlinked_log_dir(tmp_path) -> None:
    """Symlinked ``$log_dir`` must not redirect root chown/rm to the referent."""
    import os
    import stat
    import subprocess
    import threading
    import time

    import pytest

    if os.name == "nt":
        pytest.skip("POSIX symlink + /bin/sh required")

    hermes_home = tmp_path / "hermes"
    gateways = hermes_home / "logs" / "gateways"
    gateways.mkdir(parents=True)
    leaf = gateways / "coder"
    leaf.mkdir()

    victim = tmp_path / "victim"
    victim.mkdir()
    (victim / "marker").write_text("keep", encoding="utf-8")
    (victim / "lock").write_text("keep-lock", encoding="utf-8")
    before = victim.stat()

    bin_dir = tmp_path / "bin"
    bin_dir.mkdir()
    recorder = tmp_path / "chown_calls.txt"
    (bin_dir / "chown").write_text(
        "#!/bin/sh\n"
        f'printf "%s\\n" "$*" >> "{recorder.as_posix()}"\n'
        "exit 0\n",
        encoding="utf-8",
    )
    # Pretend we are root so the script takes the s6-setuidgid setup path.
    # Mark the drop so fake rm can refuse unlink outside HERMES_HOME the way
    # a real hermes uid cannot delete a foreign root-owned lock.
    (bin_dir / "id").write_text(
        "#!/bin/sh\n"
        'if [ "$1" = "-u" ]; then echo 0; exit 0; fi\n'
        "exit 1\n",
        encoding="utf-8",
    )
    (bin_dir / "s6-setuidgid").write_text(
        "#!/bin/sh\n"
        "shift\n"
        'HERMES_TEST_DROPPED=1 exec "$@"\n',
        encoding="utf-8",
    )
    real_rm = "/bin/rm"
    (bin_dir / "rm").write_text(
        "#!/bin/sh\n"
        # Privilege-dropped: no-op. Models that hermes cannot unlink a foreign
        # root-owned lock outside the volume; avoids a realpath/rm TOCTOU in
        # the test double itself. Root-context: real rm — a residual bare
        # ``rm -f "$log_dir/lock"`` would delete victim/lock via the symlink.
        'if [ -n "$HERMES_TEST_DROPPED" ]; then\n'
        "  exit 0\n"
        "fi\n"
        f'exec {real_rm} "$@"\n',
        encoding="utf-8",
    )
    for name in ("chown", "id", "s6-setuidgid", "rm"):
        p = bin_dir / name
        p.chmod(p.stat().st_mode | stat.S_IXUSR)

    script_path = tmp_path / "log_run_setup.sh"
    script_path.write_text(
        _log_run_setup_fragment(S6ServiceManager._render_log_run("coder")),
        encoding="utf-8",
    )
    script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR)

    stop = threading.Event()

    def _clear_leaf() -> None:
        if leaf.is_symlink():
            leaf.unlink()
        elif leaf.is_dir():
            leaf.rmdir()
        elif leaf.exists():
            leaf.unlink()

    def _swap_race() -> None:
        # Alternate leaf between a real dir and a symlink to the victim while
        # the setup fragment runs — proves there is no privileged chown/rm
        # window to win, unlike a check-then-use preflight.
        while not stop.is_set():
            try:
                _clear_leaf()
                leaf.symlink_to(victim)
                time.sleep(0.001)
                _clear_leaf()
                leaf.mkdir()
            except OSError:
                pass
            time.sleep(0.001)

    env = os.environ.copy()
    env["HERMES_HOME"] = str(hermes_home)
    env["PATH"] = f"{bin_dir.as_posix()}{os.pathsep}{env.get('PATH', '')}"

    racer = threading.Thread(target=_swap_race, daemon=True)
    racer.start()
    try:
        for _ in range(40):
            proc = subprocess.run(
                ["/bin/sh", str(script_path)],
                env=env,
                capture_output=True,
                text=True,
                check=False,
            )
            assert proc.returncode == 0, (proc.stdout, proc.stderr)
            assert (victim / "lock").is_file(), "symlinked leaf must not let root unlink victim/lock"
    finally:
        stop.set()
        racer.join(timeout=2)

    assert not recorder.exists() or recorder.read_text(encoding="utf-8").strip() == ""
    after = victim.stat()
    assert after.st_uid == before.st_uid
    assert after.st_gid == before.st_gid
    assert (victim / "marker").read_text(encoding="utf-8") == "keep"
    assert (victim / "lock").read_text(encoding="utf-8") == "keep-lock"


