#!/usr/bin/env python3
"""lsp_owner() must classify a tmux-hosted agent session's language server as
"agent" even though the tmux pane's tty is inherited by every descendant --
including the LSP process itself, not only the agent CLI. Two distinct bugs
this guards against: (1) checking tty/editor-ness before agent-session-ness
per ancestor, and (2) even with correct per-ancestor order, stopping at the
FIRST tty-bearing process in the walk -- which is the LSP process itself (tty
inherited from the pane), before the walk ever reaches the agent ancestor.
Both misclassify every tmux-hosted agent chain as "human" (permanently exempt
from the idle-LSP reaper)."""

from __future__ import annotations

import importlib.util
import os
import unittest
import unittest.mock

_BIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "bin")
_SPEC = importlib.util.spec_from_file_location(
    "_agent_reaper_lib", os.path.join(_BIN, "_agent_reaper_lib.py"))
lib = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(lib)


def _stat(comm, ppid, tty_nr=0):
    return {"comm": comm, "ppid": ppid, "tty_nr": tty_nr, "utime": 0,
            "stime": 0, "starttime": 0}


class ChainFixture:
    """Builds a pid -> (stat, cmdline) table and patches lib.read_proc_stat /
    lib.read_proc_cmdline to walk it, matching real /proc semantics."""

    def __init__(self):
        self.stats = {}
        self.cmdlines = {}

    def add(self, pid, comm, ppid, cmdline="", tty_nr=0):
        self.stats[pid] = _stat(comm, ppid, tty_nr)
        self.cmdlines[pid] = cmdline
        return self

    def _read_stat(self, pid):
        return self.stats.get(pid)

    def _read_cmdline(self, pid):
        return self.cmdlines.get(pid, "")

    def patches(self):
        return [
            unittest.mock.patch.object(lib, "read_proc_stat", self._read_stat),
            unittest.mock.patch.object(lib, "read_proc_cmdline", self._read_cmdline),
        ]


class LspOwnerTests(unittest.TestCase):
    def _run(self, fixture, start_pid):
        patches = fixture.patches()
        for p in patches:
            p.start()
            self.addCleanup(p.stop)
        return lib.lsp_owner(start_pid)

    def test_tmux_hosted_agent_session_classifies_agent(self) -> None:
        # tsserver <- typescript-language-server <- claude(tty!=0) <- tmux <- systemd --user <- init
        # every process up to and including claude inherits the pane's tty --
        # the real-world shape observed live, tty inherited by the LSP itself.
        f = ChainFixture()
        f.add(100, "tsserver", 101, "node /usr/local/bin/tsserver", tty_nr=34816)
        f.add(101, "MainThread", 102,
              "/usr/bin/node /usr/local/bin/typescript-language-server --stdio",
              tty_nr=34816)
        f.add(102, "claude", 103, "/home/user/.local/bin/claude", tty_nr=34816)
        f.add(103, "tmux: server", 104, "tmux: server", tty_nr=0)
        f.add(104, "systemd", 1, "/lib/systemd/systemd --user")
        kind, owner = self._run(f, 100)
        self.assertEqual(kind, "agent")
        self.assertEqual(owner, 102)

    def test_tmux_hosted_agent_session_with_bwrap_layer_classifies_agent(self) -> None:
        f = ChainFixture()
        f.add(200, "tsserver", 201, "node /usr/local/bin/tsserver", tty_nr=34816)
        f.add(201, "MainThread", 202,
              "/usr/bin/node /usr/local/bin/typescript-language-server --stdio",
              tty_nr=34816)
        f.add(202, "claude", 203, "/home/user/.local/bin/claude", tty_nr=34816)
        f.add(203, "bwrap", 204, "bwrap --ro-bind / /", tty_nr=34816)
        f.add(204, "tmux: server", 205, "tmux: server", tty_nr=0)
        f.add(205, "systemd", 1, "/lib/systemd/systemd --user")
        kind, owner = self._run(f, 200)
        self.assertEqual(kind, "agent")
        self.assertEqual(owner, 202)

    def test_genuine_human_tty_chain_classifies_human(self) -> None:
        # same shape, tty inherited all the way down to the LSP, but the
        # tty-bearing ancestor is a real interactive shell, not an agent CLI
        # -- must stay "human", never a candidate.
        f = ChainFixture()
        f.add(300, "tsserver", 301, "node /usr/local/bin/tsserver", tty_nr=34816)
        f.add(301, "MainThread", 302,
              "/usr/bin/node /usr/local/bin/typescript-language-server --stdio",
              tty_nr=34816)
        f.add(302, "bash", 303, "/bin/bash", tty_nr=34816)
        f.add(303, "tmux: server", 304, "tmux: server", tty_nr=0)
        f.add(304, "systemd", 1, "/lib/systemd/systemd --user")
        kind, owner = self._run(f, 300)
        self.assertEqual(kind, "human")
        self.assertEqual(owner, 300)

    def test_gui_editor_comm_classifies_human(self) -> None:
        f = ChainFixture()
        f.add(400, "tsserver", 401, "node /usr/local/bin/tsserver")
        f.add(401, "MainThread", 402,
              "/usr/bin/node /usr/local/bin/typescript-language-server --stdio")
        f.add(402, "code", 403, "/usr/bin/code", tty_nr=0)
        f.add(403, "systemd", 1, "/lib/systemd/systemd --user")
        kind, owner = self._run(f, 400)
        self.assertEqual(kind, "human")
        self.assertEqual(owner, 402)

    def test_orphaned_chain_reaches_init_classifies_orphan(self) -> None:
        f = ChainFixture()
        f.add(500, "tsserver", 501, "node /usr/local/bin/tsserver")
        f.add(501, "MainThread", 502,
              "/usr/bin/node /usr/local/bin/typescript-language-server --stdio")
        f.add(502, "systemd", 1, "/lib/systemd/systemd --user")
        kind, owner = self._run(f, 500)
        self.assertEqual(kind, "orphan")
        self.assertIsNone(owner)

    def test_unresolvable_chain_classifies_none(self) -> None:
        f = ChainFixture()
        f.add(600, "tsserver", 601, "node /usr/local/bin/tsserver")
        # pid 601 missing from the table -> read_proc_stat returns None
        kind, owner = self._run(f, 600)
        self.assertIsNone(kind)
        self.assertIsNone(owner)


if __name__ == "__main__":
    unittest.main(verbosity=2)
