"""Orphan Desktop-local ``hermes serve`` reap at backend start.

When Desktop dies uncleanly, local ``serve --host 127.0.0.1 --port 0``
children can be reparented to pid 1 and keep full MCP trees alive. The next
boot must clear those corpses without touching intentional fixed-port serves
(e.g. ``--port 9119`` remote dashboards).
"""

from __future__ import annotations

import os
from unittest.mock import patch

from hermes_cli.dashboard_procs import (
    _is_desktop_local_serve_cmdline,
    _reap_orphaned_desktop_local_serves,
)


def test_desktop_local_serve_shape_matches_ephemeral_loopback():
    assert _is_desktop_local_serve_cmdline(
        "python -m hermes_cli.main serve --host 127.0.0.1 --port 0"
    )
    assert _is_desktop_local_serve_cmdline(
        "hermes serve --isolated --host 127.0.0.1 --port 0 --ssh-owner-nonce abc"
    )
    assert _is_desktop_local_serve_cmdline(
        "/venv/bin/hermes serve --host=127.0.0.1 --port=0"
    )


def test_desktop_local_serve_shape_spares_fixed_port_and_non_serve():
    assert not _is_desktop_local_serve_cmdline(
        "hermes serve --host 100.106.105.2 --port 9119 --skip-build"
    )
    assert not _is_desktop_local_serve_cmdline(
        "hermes serve --host 127.0.0.1 --port 9119"
    )
    assert not _is_desktop_local_serve_cmdline("hermes gateway run --replace")
    assert not _is_desktop_local_serve_cmdline(
        "vim notes about hermes serve --port 0"
    )


def test_reap_only_kills_ppid1_local_serves():
    scanned = [
        (111, "hermes serve --host 127.0.0.1 --port 0"),  # orphan local
        (222, "hermes serve --host 127.0.0.1 --port 0"),  # still has parent
        (333, "hermes serve --host 100.1.2.3 --port 9119"),  # fixed remote
        (444, "hermes serve --isolated --host 127.0.0.1 --port 0"),  # orphan isolated
    ]
    ppids = {111: 1, 222: 50, 333: 1, 444: 1}
    terms: list[int] = []
    live = {111, 222, 333, 444}

    def fake_kill(pid, sig):
        if sig == 0:
            if pid in live:
                return None
            raise ProcessLookupError()
        if sig == 15:
            terms.append(pid)
            live.discard(pid)
            return None
        if sig == 9:
            live.discard(pid)
            return None
        return None

    with (
        patch(
            "hermes_cli.dashboard_procs._scan_dashboard_processes",
            return_value=scanned,
        ),
        patch(
            "hermes_cli.dashboard_procs._process_ppid",
            side_effect=lambda pid: ppids.get(pid),
        ),
        patch("os.kill", side_effect=fake_kill),
        patch("sys.platform", "darwin"),
    ):
        os.environ.pop("HERMES_DESKTOP_CHILD_PID", None)
        result = _reap_orphaned_desktop_local_serves(
            sleep_fn=lambda _s: None,
            signal_term=15,
            signal_kill=9,
            process_age_seconds_fn=lambda _pid: 600.0,
        )

    assert set(result["matched"]) == {111, 444}
    assert set(terms) == {111, 444}
    assert set(result["killed"]) == {111, 444}
    assert 222 not in terms
    assert 333 not in terms


def test_reap_passes_child_pid_exclude_to_scan():
    with (
        patch(
            "hermes_cli.dashboard_procs._scan_dashboard_processes",
            return_value=[],
        ) as scan,
        patch("sys.platform", "darwin"),
        patch.dict(os.environ, {"HERMES_DESKTOP_CHILD_PID": "999,111"}, clear=False),
    ):
        result = _reap_orphaned_desktop_local_serves(sleep_fn=lambda _s: None)

    assert result["matched"] == []
    exclude = scan.call_args.kwargs["exclude_pids"]
    assert 111 in exclude
    assert 999 in exclude


# ---------------------------------------------------------------------------
# Regression: a legitimately lock-owned SSH remote backend (started by another
# client/machine) must survive the boot reap even though it matches the
# Desktop-local serve shape and is orphaned at ppid 1. Production incident:
# the local Desktop app's reboot reap killed a Mac Mini backend that a MacBook
# had launched over SSH — its PID was the recorded owner in backend.lock.json.
# ---------------------------------------------------------------------------

import json
from hermes_cli.dashboard_procs import (
    _lock_owned_serve_pids,
    _valid_lockfile_payload,
)


def _valid_lock_payload(pid: int, ownership_id: str, spawn_nonce: str) -> dict:
    """A minimally-valid backend.lock.json body matching remote-lifecycle.ts."""
    return {
        "schemaVersion": 2,
        "protocolVersion": 1,
        "ownershipId": ownership_id,
        "spawnNonce": spawn_nonce,
        "tokenFingerprint": "a" * 32,
        "pid": pid,
        "port": 0,
        "profile": "default",
        "hermesPath": "/opt/hermes/bin/hermes",
        "hermesHome": "~/.hermes",
        "logPath": f"~/.hermes/desktop-ssh/{ownership_id}/{spawn_nonce}.log",
        "startedAt": "2026-08-04T20:00:00Z",
    }


def test_lock_owned_serve_pids_reads_valid_backend_lock(tmp_path):
    oid = "f" * 32
    nonce = "d" * 16
    lock_root = tmp_path / "desktop-ssh"
    (lock_root / oid).mkdir(parents=True)
    (lock_root / oid / "backend.lock.json").write_text(
        json.dumps(_valid_lock_payload(7777, oid, nonce))
    )
    # A second, malformed lock (bad schemaVersion) must contribute nothing.
    other_oid = "e" * 32
    (lock_root / other_oid).mkdir(parents=True)
    (lock_root / other_oid / "backend.lock.json").write_text(
        json.dumps({**_valid_lock_payload(8888, other_oid, nonce), "schemaVersion": 99})
    )
    assert _lock_owned_serve_pids(base_dir=lock_root) == {7777}


def test_valid_lockfile_payload_rejects_wrong_owner_and_shape():
    oid = "f" * 32
    nonce = "d" * 16
    good = _valid_lockfile_payload(_valid_lock_payload(1, oid, nonce), oid)
    assert good is True
    # ownershipId must match the directory it lives in.
    assert _valid_lockfile_payload(
        _valid_lock_payload(1, "e" * 32, nonce), oid
    ) is False
    # pid out of range.
    bad_pid = _valid_lock_payload(1, oid, nonce)
    bad_pid["pid"] = 0
    assert _valid_lockfile_payload(bad_pid, oid) is False
    # port out of range.
    bad_port = _valid_lock_payload(1, oid, nonce)
    bad_port["port"] = 70000
    assert _valid_lockfile_payload(bad_port, oid) is False
    # spawnNonce wrong length.
    bad_nonce = _valid_lock_payload(1, oid, nonce)
    bad_nonce["spawnNonce"] = "z" * 15
    assert _valid_lockfile_payload(bad_nonce, oid) is False
    # logPath not ending in <oid>/<nonce>.log.
    bad_log = _valid_lock_payload(1, oid, nonce)
    bad_log["logPath"] = "~/.hermes/desktop-ssh/{oid}/other.log".format(oid=oid)
    assert _valid_lockfile_payload(bad_log, oid) is False


def test_reap_spare_lock_owned_ssh_remote_backend_of_foreign_client():
    """The exact production-incident shape: a foreign-client SSH remote backend
    matches the Desktop-local serve shape and is orphaned at ppid 1, but a valid
    backend.lock.json owns its PID. The reap must NOT kill it."""
    scanned = [
        (555, "hermes serve --host 127.0.0.1 --port 0"),  # lock-owned remote
        (666, "hermes serve --host 127.0.0.1 --port 0"),  # genuine orphan
    ]
    ppids = {555: 1, 666: 1}
    terms: list[int] = []
    live = {555, 666}

    def fake_kill(pid, sig):
        if sig == 0:
            if pid in live:
                return None
            raise ProcessLookupError()
        if sig == 15:
            terms.append(pid)
            live.discard(pid)
            return None
        if sig == 9:
            live.discard(pid)
            return None
        return None

    # 555 is claimed by a valid backend.lock.json; 666 is not.
    lock_owned = {555}

    with (
        patch(
            "hermes_cli.dashboard_procs._scan_dashboard_processes",
            return_value=scanned,
        ),
        patch(
            "hermes_cli.dashboard_procs._process_ppid",
            side_effect=lambda pid: ppids.get(pid),
        ),
        patch("os.kill", side_effect=fake_kill),
        patch("sys.platform", "darwin"),
    ):
        os.environ.pop("HERMES_DESKTOP_CHILD_PID", None)
        result = _reap_orphaned_desktop_local_serves(
            sleep_fn=lambda _s: None,
            signal_term=15,
            signal_kill=9,
            lock_owned_pids_fn=lambda: lock_owned,
            process_age_seconds_fn=lambda _pid: 600.0,
        )

    # Only the genuine orphan (666) is reaped; the lock-owned remote (555) lives.
    assert set(result["matched"]) == {666}
    assert set(terms) == {666}
    assert 555 not in terms
    assert set(result["killed"]) == {666}


def test_reap_spares_young_backend_until_desktop_can_write_lock():
    """A concurrently-starting sibling has no lock yet but is not an orphan."""
    scanned = [(777, "hermes serve --isolated --host 127.0.0.1 --port 0")]
    terms: list[int] = []

    def fake_kill(pid, sig):
        if sig == 0:
            return None
        if sig == 15:
            terms.append(pid)
        return None

    with (
        patch(
            "hermes_cli.dashboard_procs._scan_dashboard_processes",
            return_value=scanned,
        ),
        patch("hermes_cli.dashboard_procs._process_ppid", return_value=1),
        patch("os.kill", side_effect=fake_kill),
        patch("sys.platform", "darwin"),
    ):
        result = _reap_orphaned_desktop_local_serves(
            sleep_fn=lambda _s: None,
            signal_term=15,
            signal_kill=9,
            process_age_seconds_fn=lambda _pid: 2.0,
        )

    assert result["matched"] == []
    assert result["killed"] == []
    assert terms == []


def test_reap_spares_backend_when_process_age_is_unknown():
    scanned = [(778, "hermes serve --isolated --host 127.0.0.1 --port 0")]
    terms: list[int] = []

    def fake_age(_pid):
        raise RuntimeError("process disappeared during age probe")

    with (
        patch(
            "hermes_cli.dashboard_procs._scan_dashboard_processes",
            return_value=scanned,
        ),
        patch("hermes_cli.dashboard_procs._process_ppid", return_value=1),
        patch("os.kill", side_effect=lambda pid, sig: terms.append(pid) if sig == 15 else None),
        patch("sys.platform", "darwin"),
    ):
        result = _reap_orphaned_desktop_local_serves(
            sleep_fn=lambda _s: None,
            signal_term=15,
            signal_kill=9,
            process_age_seconds_fn=fake_age,
        )

    assert result["matched"] == []
    assert result["killed"] == []
    assert terms == []


def test_reap_age_boundary_makes_180_second_orphan_eligible():
    scanned = [
        (779, "hermes serve --isolated --host 127.0.0.1 --port 0"),
        (780, "hermes serve --isolated --host 127.0.0.1 --port 0"),
    ]
    terms: list[int] = []
    live = {779, 780}

    def fake_kill(pid, sig):
        if sig == 0:
            if pid in live:
                return None
            raise ProcessLookupError()
        if sig == 15:
            terms.append(pid)
            live.discard(pid)
        return None

    ages = {779: 179.999, 780: 180.0}
    with (
        patch(
            "hermes_cli.dashboard_procs._scan_dashboard_processes",
            return_value=scanned,
        ),
        patch("hermes_cli.dashboard_procs._process_ppid", return_value=1),
        patch("os.kill", side_effect=fake_kill),
        patch("sys.platform", "darwin"),
    ):
        result = _reap_orphaned_desktop_local_serves(
            sleep_fn=lambda _s: None,
            signal_term=15,
            signal_kill=9,
            process_age_seconds_fn=lambda pid: ages[pid],
        )

    assert result["matched"] == [780]
    assert result["killed"] == [780]
    assert terms == [780]


def test_reap_spare_lock_owned_backend_even_without_exclude_match(tmp_path):
    """End-to-end through the real lock scanner: a backend.lock.json on disk
    spares a matching orphaned serve even when HERMES_DESKTOP_CHILD_PID is
    unset (foreign-client backend, not ours by env either)."""
    oid = "a" * 32
    nonce = "b" * 16
    lock_root = tmp_path / "desktop-ssh"
    (lock_root / oid).mkdir(parents=True)
    (lock_root / oid / "backend.lock.json").write_text(
        json.dumps(_valid_lock_payload(4242, oid, nonce))
    )

    scanned = [(4242, "hermes serve --host 127.0.0.1 --port 0")]
    terms: list[int] = []

    def fake_kill(pid, sig):
        if sig == 15:
            terms.append(pid)
        return None

    with (
        patch(
            "hermes_cli.dashboard_procs._scan_dashboard_processes",
            return_value=scanned,
        ),
        patch(
            "hermes_cli.dashboard_procs._process_ppid",
            return_value=1,
        ),
        patch("os.kill", side_effect=fake_kill),
        patch("sys.platform", "darwin"),
    ):
        os.environ.pop("HERMES_DESKTOP_CHILD_PID", None)
        result = _reap_orphaned_desktop_local_serves(
            sleep_fn=lambda _s: None,
            signal_term=15,
            signal_kill=9,
            lock_owned_pids_fn=lambda: _lock_owned_serve_pids(base_dir=lock_root),
        )

    assert terms == []
    assert result["matched"] == []
