"""Tests for daemon process lifecycle — PID file, signals, CLI commands."""

from __future__ import annotations

import asyncio
import os
import signal
import time
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from loom.daemon.process import (
    is_daemon_running,
    read_pid,
    remove_pid,
    setup_daemon_logging,
    stop_daemon,
    write_pid,
)


# ---------------------------------------------------------------------------
# PID file helpers
# ---------------------------------------------------------------------------


class TestWritePid:
    def test_writes_current_pid(self, tmp_path: Path):
        pid_file = tmp_path / "daemon.pid"
        write_pid(pid_file)
        assert pid_file.read_text() == str(os.getpid())

    def test_creates_parent_dirs(self, tmp_path: Path):
        pid_file = tmp_path / "nested" / "dir" / "daemon.pid"
        write_pid(pid_file)
        assert pid_file.exists()
        assert pid_file.read_text() == str(os.getpid())

    def test_overwrites_existing(self, tmp_path: Path):
        pid_file = tmp_path / "daemon.pid"
        pid_file.write_text("99999")
        write_pid(pid_file)
        assert pid_file.read_text() == str(os.getpid())


class TestReadPid:
    def test_reads_valid_pid(self, tmp_path: Path):
        pid_file = tmp_path / "daemon.pid"
        pid_file.write_text("12345")
        assert read_pid(pid_file) == 12345

    def test_returns_none_for_missing_file(self, tmp_path: Path):
        assert read_pid(tmp_path / "nope.pid") is None

    def test_returns_none_for_invalid_content(self, tmp_path: Path):
        pid_file = tmp_path / "daemon.pid"
        pid_file.write_text("not-a-number")
        assert read_pid(pid_file) is None

    def test_handles_whitespace(self, tmp_path: Path):
        pid_file = tmp_path / "daemon.pid"
        pid_file.write_text("  42  \n")
        assert read_pid(pid_file) == 42


class TestRemovePid:
    def test_removes_existing_file(self, tmp_path: Path):
        pid_file = tmp_path / "daemon.pid"
        pid_file.write_text("123")
        remove_pid(pid_file)
        assert not pid_file.exists()

    def test_no_error_if_missing(self, tmp_path: Path):
        remove_pid(tmp_path / "nope.pid")  # should not raise


class TestIsDaemonRunning:
    def test_returns_none_if_no_pid_file(self, tmp_path: Path):
        assert is_daemon_running(tmp_path / "daemon.pid") is None

    def test_returns_pid_if_process_alive(self, tmp_path: Path):
        pid_file = tmp_path / "daemon.pid"
        pid_file.write_text(str(os.getpid()))  # current process is alive
        assert is_daemon_running(pid_file) == os.getpid()

    def test_returns_none_and_cleans_stale_pid(self, tmp_path: Path):
        pid_file = tmp_path / "daemon.pid"
        # Use a PID that's almost certainly not running
        pid_file.write_text("99999999")
        with patch("loom.daemon.process.os.kill", side_effect=ProcessLookupError):
            result = is_daemon_running(pid_file)
        assert result is None
        assert not pid_file.exists()  # stale PID file cleaned up

    def test_returns_pid_on_permission_error(self, tmp_path: Path):
        pid_file = tmp_path / "daemon.pid"
        pid_file.write_text("12345")
        with patch("loom.daemon.process.os.kill", side_effect=PermissionError):
            assert is_daemon_running(pid_file) == 12345


# ---------------------------------------------------------------------------
# Logging setup
# ---------------------------------------------------------------------------


class TestSetupDaemonLogging:
    def test_creates_log_directory(self, tmp_path: Path):
        log_file = tmp_path / "logs" / "daemon.log"
        setup_daemon_logging(log_file)
        assert log_file.parent.is_dir()

    def test_sets_root_logger_level(self, tmp_path: Path):
        import logging
        log_file = tmp_path / "daemon.log"
        setup_daemon_logging(log_file, level="DEBUG")
        assert logging.getLogger().level == logging.DEBUG
        # Clean up
        logging.getLogger().handlers.clear()

    def test_writes_to_log_file(self, tmp_path: Path):
        import logging
        log_file = tmp_path / "daemon.log"
        setup_daemon_logging(log_file)
        logging.getLogger("test.daemon").info("hello from test")
        # Flush handlers
        for h in logging.getLogger().handlers:
            h.flush()
        content = log_file.read_text()
        assert "hello from test" in content
        # Clean up
        logging.getLogger().handlers.clear()


# ---------------------------------------------------------------------------
# Signal wiring
# ---------------------------------------------------------------------------


class TestWireSignals:
    def test_sigterm_sets_shutdown_event(self):
        from loom.daemon.process import _wire_signals

        shutdown = asyncio.Event()
        loop = asyncio.new_event_loop()
        _wire_signals(shutdown, loop)

        # Simulate SIGTERM by calling the handler directly
        handler = signal.getsignal(signal.SIGTERM)
        handler(signal.SIGTERM, None)

        # The handler uses call_soon_threadsafe, so run the loop briefly
        loop.run_until_complete(asyncio.sleep(0.01))
        assert shutdown.is_set()
        loop.close()

        # Restore default signal handlers
        signal.signal(signal.SIGTERM, signal.SIG_DFL)
        signal.signal(signal.SIGHUP, signal.SIG_DFL)


# ---------------------------------------------------------------------------
# stop_daemon
# ---------------------------------------------------------------------------


class TestStopDaemon:
    def test_returns_false_if_not_running(self, tmp_path: Path):
        config = MagicMock()
        config.daemon.pid_file_path = "daemon.pid"
        assert stop_daemon(config, tmp_path) is False

    def test_sends_sigterm_and_waits(self, tmp_path: Path):
        pid_file = tmp_path / "daemon.pid"
        pid_file.write_text("12345")

        config = MagicMock()
        config.daemon.pid_file_path = "daemon.pid"

        call_count = 0

        def mock_kill(pid, sig):
            nonlocal call_count
            if sig == 0:
                call_count += 1
                if call_count == 1:
                    # First check: still alive
                    return
                # Second check: gone
                raise ProcessLookupError
            # SIGTERM: no-op
            assert sig == signal.SIGTERM

        with patch("loom.daemon.process.os.kill", side_effect=mock_kill), \
             patch("loom.daemon.process.is_daemon_running", return_value=12345), \
             patch("loom.daemon.process.time.sleep"):
            result = stop_daemon(config, tmp_path, timeout=5)

        assert result is True


# ---------------------------------------------------------------------------
# start_daemon guards
# ---------------------------------------------------------------------------


class TestStartDaemonGuards:
    def test_exits_if_already_running(self, tmp_path: Path):
        from loom.daemon.process import start_daemon

        pid_file = tmp_path / "daemon.pid"
        pid_file.write_text(str(os.getpid()))

        config = MagicMock()
        config.daemon.pid_file_path = "daemon.pid"
        config.daemon.log_file_path = "logs/daemon.log"

        with pytest.raises(SystemExit):
            start_daemon(config, tmp_path, foreground=True)


# ---------------------------------------------------------------------------
# CLI commands (Click integration)
# ---------------------------------------------------------------------------


class TestDaemonCLI:
    @pytest.fixture
    def cli_runner(self):
        from click.testing import CliRunner
        return CliRunner()

    @pytest.fixture
    def cli(self):
        from loom.cli import cli
        return cli

    def test_daemon_start_already_running(self, cli_runner, cli, tmp_path):
        """Start command detects already-running daemon."""
        pid_file = tmp_path / ".loom" / "daemon.pid"
        pid_file.parent.mkdir(parents=True)
        pid_file.write_text(str(os.getpid()))

        with patch("loom.daemon.process.start_daemon") as mock_start:
            mock_start.side_effect = SystemExit(1)
            result = cli_runner.invoke(cli, ["daemon", "start"], catch_exceptions=False)
            # The CLI should invoke start_daemon
            assert mock_start.called or result.exit_code != 0

    def test_daemon_stop_not_running(self, cli_runner, cli, tmp_path):
        """Stop command reports when daemon is not running."""
        with patch("loom.config.load_config") as mock_config:
            mock_config.return_value = MagicMock(
                daemon=MagicMock(pid_file_path=".loom/daemon.pid"),
            )
            with patch("loom.daemon.process.stop_daemon", return_value=False):
                result = cli_runner.invoke(cli, ["daemon", "stop"])
                assert "not running" in result.output.lower()

    def test_daemon_stop_success(self, cli_runner, cli):
        """Stop command confirms successful stop."""
        with patch("loom.config.load_config") as mock_config:
            mock_config.return_value = MagicMock(
                daemon=MagicMock(pid_file_path=".loom/daemon.pid"),
            )
            with patch("loom.daemon.process.stop_daemon", return_value=True):
                result = cli_runner.invoke(cli, ["daemon", "stop"])
                assert "stopped" in result.output.lower()

    def test_daemon_status_not_running(self, cli_runner, cli):
        """Status command reports when daemon is not running."""
        with patch("loom.config.load_config") as mock_config:
            mock_config.return_value = MagicMock(
                daemon=MagicMock(pid_file_path=".loom/daemon.pid"),
            )
            with patch("loom.daemon.process.is_daemon_running", return_value=None):
                result = cli_runner.invoke(cli, ["daemon", "status"])
                assert "not running" in result.output.lower()

    def test_daemon_status_running_with_health(self, cli_runner, cli):
        """Status command shows PID and health data."""
        with patch("loom.config.load_config") as mock_config:
            mock_config.return_value = MagicMock(
                daemon=MagicMock(pid_file_path=".loom/daemon.pid"),
                redis=MagicMock(url="redis://localhost:6379"),
            )
            with patch("loom.daemon.process.is_daemon_running", return_value=12345):
                # Mock the async health check
                async def mock_health(config):
                    pass
                with patch("loom.cli._daemon_status_health", side_effect=mock_health):
                    result = cli_runner.invoke(cli, ["daemon", "status"])
                    assert "12345" in result.output

    def test_daemon_logs_no_file(self, cli_runner, cli, tmp_path):
        """Logs command reports when no log file exists."""
        with patch("loom.config.load_config") as mock_config:
            mock_config.return_value = MagicMock(
                daemon=MagicMock(log_file_path=".loom/logs/daemon.log"),
            )
            result = cli_runner.invoke(cli, ["daemon", "logs"])
            assert "no daemon log" in result.output.lower()

    def test_daemon_logs_shows_content(self, cli_runner, cli, tmp_path, monkeypatch):
        """Logs command shows log file contents."""
        monkeypatch.chdir(tmp_path)
        log_dir = tmp_path / ".loom" / "logs"
        log_dir.mkdir(parents=True)
        log_file = log_dir / "daemon.log"
        log_file.write_text("line1\nline2\nline3\n")

        with patch("loom.config.load_config") as mock_config:
            mock_config.return_value = MagicMock(
                daemon=MagicMock(log_file_path=".loom/logs/daemon.log"),
            )
            result = cli_runner.invoke(cli, ["daemon", "logs", "-n", "2"])
            assert "line2" in result.output
            assert "line3" in result.output

    def test_daemon_group_help(self, cli_runner, cli):
        """Daemon group shows help with subcommands."""
        result = cli_runner.invoke(cli, ["daemon", "--help"])
        assert "start" in result.output
        assert "stop" in result.output
        assert "status" in result.output
        assert "logs" in result.output
