"""Tests for process.py browser cleanup: _parse_etime, _list_browser_processes,
cleanup_browsers, close_browser_session, get_last_agent_pid."""

import io
import os
import signal
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock, call, patch

import pytest

import process


# ═══════════════════════════════════════════════════════════════════════════
# _parse_etime
# ═══════════════════════════════════════════════════════════════════════════


class TestParseEtime:
    def test_seconds_only(self):
        assert process._parse_etime("45") == 45

    def test_mm_ss(self):
        assert process._parse_etime("05:30") == 330

    def test_hh_mm_ss(self):
        assert process._parse_etime("02:30:15") == 9015

    def test_days_hh_mm_ss(self):
        assert process._parse_etime("1-00:00:00") == 86400

    def test_days_mm_ss(self):
        assert process._parse_etime("2-05:30") == 2 * 86400 + 330

    def test_whitespace(self):
        assert process._parse_etime("  10:05  ") == 605

    def test_zero(self):
        assert process._parse_etime("00:00") == 0

    def test_large_days(self):
        assert process._parse_etime("30-12:00:00") == 30 * 86400 + 12 * 3600


# ═══════════════════════════════════════════════════════════════════════════
# get_last_agent_pid
# ═══════════════════════════════════════════════════════════════════════════


class TestGetLastAgentPid:
    def test_initially_none(self):
        old = process._last_streaming_pid
        process._last_streaming_pid = None
        try:
            assert process.get_last_agent_pid() is None
        finally:
            process._last_streaming_pid = old

    def test_returns_set_value(self):
        old = process._last_streaming_pid
        process._last_streaming_pid = 42
        try:
            assert process.get_last_agent_pid() == 42
        finally:
            process._last_streaming_pid = old


# ═══════════════════════════════════════════════════════════════════════════
# _list_browser_processes
# ═══════════════════════════════════════════════════════════════════════════


class TestListBrowserProcesses:
    def test_returns_empty_when_no_matches(self):
        ps_output = (
            "  PID     ELAPSED COMMAND\n"
            "  123       05:30 /usr/bin/python3 my_script.py\n"
            "  456       10:00 bash\n"
        )
        with patch("process.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(stdout=ps_output)
            result = process._list_browser_processes()
        assert result == []

    def test_finds_agent_browser_daemon(self):
        ps_output = (
            "  PID     ELAPSED COMMAND\n"
            "  789       15:30 node /path/agent-browser/dist/daemon/server.js\n"
        )
        with patch("process.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(stdout=ps_output)
            result = process._list_browser_processes()
        assert len(result) == 1
        assert result[0][0] == 789
        assert result[0][1] == 930

    def test_finds_chromium_remote_debugging(self):
        ps_output = (
            "  PID     ELAPSED COMMAND\n"
            "  100    1-02:30:00 /usr/bin/chromium --remote-debugging-pipe --headless\n"
        )
        with patch("process.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(stdout=ps_output)
            result = process._list_browser_processes()
        assert len(result) == 1
        assert result[0][0] == 100
        assert result[0][1] == 86400 + 2 * 3600 + 30 * 60

    def test_handles_subprocess_error(self):
        with patch("process.subprocess.run", side_effect=OSError("fail")):
            assert process._list_browser_processes() == []

    def test_skips_malformed_lines(self):
        ps_output = (
            "  PID     ELAPSED COMMAND\n"
            "  bad line\n"
            "  789       15:30 node agent-browser/dist/daemon/x.js\n"
        )
        with patch("process.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(stdout=ps_output)
            result = process._list_browser_processes()
        assert len(result) == 1

    def test_own_patterns_exclude_remote_debugging(self):
        """Narrow patterns must NOT match generic Chrome flags."""
        ps_output = (
            "  PID     ELAPSED COMMAND\n"
            "  100       05:00 /usr/bin/chromium --remote-debugging-pipe --headless\n"
            "  200       03:00 node /x/agent-browser/dist/daemon/server.js\n"
        )
        with patch("process.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(stdout=ps_output)
            result = process._list_browser_processes(
                patterns=process._BROWSER_CMD_PATTERNS_OWN,
            )
        assert len(result) == 1
        assert result[0][0] == 200

    def test_custom_patterns_filter(self):
        ps_output = (
            "  PID     ELAPSED COMMAND\n"
            "  100       01:00 node agent-browser/dist/daemon/server.js\n"
            "  200       02:00 /usr/bin/chromium --remote-debugging-pipe\n"
        )
        with patch("process.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(stdout=ps_output)
            result = process._list_browser_processes(
                patterns=["--remote-debugging-pipe"],
            )
        assert len(result) == 1
        assert result[0][0] == 200


# ═══════════════════════════════════════════════════════════════════════════
# cleanup_browsers
# ═══════════════════════════════════════════════════════════════════════════


class TestSnapshotBrowserPids:
    def test_returns_set_of_pids(self):
        with patch("process._list_browser_processes", return_value=[(100, 60), (200, 120)]):
            result = process.snapshot_browser_pids()
        assert result == {100, 200}

    def test_returns_empty_set_when_none(self):
        with patch("process._list_browser_processes", return_value=[]):
            result = process.snapshot_browser_pids()
        assert result == set()


class TestCleanupBrowsers:
    def test_returns_zero_when_no_processes(self):
        with patch("process._list_browser_processes", return_value=[]):
            result = process.cleanup_browsers(99999, pre_snapshot=set())
        assert result == 0

    def test_kills_new_browser_from_snapshot_diff(self):
        pre = {100}
        with patch("process._list_browser_processes", return_value=[(100, 120), (200, 30)]), \
             patch("process.kill_tree") as mock_kill, \
             patch("os.kill"):
            result = process.cleanup_browsers(100, pre_snapshot=pre)
        mock_kill.assert_called()
        assert result >= 1

    def test_does_not_kill_pre_existing_browser(self):
        pre = {100, 200}
        with patch("process._list_browser_processes", return_value=[(100, 120), (200, 30)]), \
             patch("process.kill_tree") as mock_kill:
            result = process.cleanup_browsers(100, pre_snapshot=pre)
        mock_kill.assert_not_called()
        assert result == 0

    def test_kills_stale_processes(self):
        stale_age = process.BROWSER_MAX_AGE_SECONDS + 100
        with patch("process._list_browser_processes", return_value=[(500, stale_age)]) as mock_list, \
             patch("process.kill_tree") as mock_kill, \
             patch("os.kill"):
            result = process.cleanup_browsers(100, pre_snapshot=set())
        mock_kill.assert_called()
        assert result >= 1
        # Strategy 2 must call with narrow patterns
        mock_list.assert_any_call(patterns=process._BROWSER_CMD_PATTERNS_OWN)

    def test_stale_kill_uses_narrow_patterns(self):
        """Strategy 2 only matches agent-browser / playwright, not user Chrome."""
        stale_age = process.BROWSER_MAX_AGE_SECONDS + 100

        def fake_list(patterns=None):
            if patterns is process._BROWSER_CMD_PATTERNS_OWN:
                return []
            return [(500, stale_age)]

        with patch("process._list_browser_processes", side_effect=fake_list), \
             patch("process.kill_tree") as mock_kill:
            result = process.cleanup_browsers(100, pre_snapshot=set())
        mock_kill.assert_not_called()
        assert result == 0

    def test_does_not_kill_young_non_new_browser(self):
        pre = {500}
        with patch("process._list_browser_processes", return_value=[(500, 60)]), \
             patch("process.kill_tree") as mock_kill:
            result = process.cleanup_browsers(100, pre_snapshot=pre)
        mock_kill.assert_not_called()
        assert result == 0

    def test_logs_when_log_fh_provided(self):
        log_fh = io.StringIO()
        stale_age = process.BROWSER_MAX_AGE_SECONDS + 100
        with patch("process._list_browser_processes", return_value=[(500, stale_age)]), \
             patch("process.kill_tree"), \
             patch("os.kill"):
            process.cleanup_browsers(100, log_fh=log_fh, pre_snapshot=set())
        assert "cleanup" in log_fh.getvalue().lower()

    def test_handles_dead_stale_process(self):
        stale_age = process.BROWSER_MAX_AGE_SECONDS + 100
        with patch("process._list_browser_processes", return_value=[(500, stale_age)]), \
             patch("os.kill", side_effect=OSError("no such process")):
            result = process.cleanup_browsers(100, pre_snapshot=set())
        assert result == 0

    def test_no_snapshot_skips_strategy_1(self):
        """When pre_snapshot is None, only stale cleanup runs."""
        with patch("process._list_browser_processes", return_value=[(500, 60)]), \
             patch("process.kill_tree") as mock_kill:
            result = process.cleanup_browsers(100, pre_snapshot=None)
        mock_kill.assert_not_called()
        assert result == 0


# ═══════════════════════════════════════════════════════════════════════════
# BROWSER_MAX_AGE_SECONDS constant
# ═══════════════════════════════════════════════════════════════════════════


class TestBrowserConstants:
    def test_max_age_is_three_hours(self):
        assert process.BROWSER_MAX_AGE_SECONDS == 3 * 3600

    def test_cmd_patterns_not_empty(self):
        assert len(process._BROWSER_CMD_PATTERNS) > 0

    def test_own_patterns_subset_of_all(self):
        for p in process._BROWSER_CMD_PATTERNS_OWN:
            assert p in process._BROWSER_CMD_PATTERNS

    def test_broad_patterns_include_remote_debugging(self):
        assert "--remote-debugging-pipe" in process._BROWSER_CMD_PATTERNS_BROAD


# ═══════════════════════════════════════════════════════════════════════════
# close_browser_session
# ═══════════════════════════════════════════════════════════════════════════


class TestCloseBrowserSession:
    def test_success(self):
        with patch("process.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(returncode=0)
            assert process.close_browser_session("ab-myproj") is True
        mock_run.assert_called_once_with(
            ["agent-browser", "--session", "ab-myproj", "close"],
            capture_output=True, text=True, timeout=15,
        )

    def test_nonzero_return(self):
        with patch("process.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(
                returncode=1, stderr="no such session", stdout="",
            )
            assert process.close_browser_session("ab-myproj") is False

    def test_agent_browser_not_installed(self):
        with patch("process.subprocess.run", side_effect=FileNotFoundError):
            assert process.close_browser_session("ab-myproj") is False

    def test_timeout(self):
        with patch("process.subprocess.run",
                   side_effect=subprocess.TimeoutExpired("cmd", 15)):
            log_fh = io.StringIO()
            assert process.close_browser_session("ab-myproj", log_fh=log_fh) is False
            assert "timed out" in log_fh.getvalue().lower()

    def test_logs_on_success(self):
        log_fh = io.StringIO()
        with patch("process.subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(returncode=0)
            process.close_browser_session("ab-myproj", log_fh=log_fh)
        assert "ab-myproj" in log_fh.getvalue()
        assert "closed" in log_fh.getvalue().lower()
