"""Tests for the cancellation module."""

from __future__ import annotations

import signal
import threading
import time
from unittest.mock import patch

import pytest

from oclawma.cancellation import (
    CancellationError,
    CancellationReason,
    CancellationToken,
    TimeoutConfig,
    TimeoutManager,
    run_with_timeout,
)


class TestCancellationReason:
    """Test CancellationReason enum."""

    def test_enum_values(self):
        """Test enum values are correct."""
        assert CancellationReason.USER_REQUEST.value == "user_request"
        assert CancellationReason.TIMEOUT_SOFT.value == "timeout_soft"
        assert CancellationReason.TIMEOUT_HARD.value == "timeout_hard"
        assert CancellationReason.SHUTDOWN.value == "shutdown"
        assert CancellationReason.PREEMPTION.value == "preemption"


class TestCancellationError:
    """Test CancellationError exception."""

    def test_error_creation(self):
        """Test creating a cancellation error."""
        error = CancellationError(CancellationReason.USER_REQUEST, "Test message")

        assert error.reason == CancellationReason.USER_REQUEST
        assert error.message == "Test message"
        assert "user_request" in str(error)
        assert "Test message" in str(error)

    def test_error_without_message(self):
        """Test creating a cancellation error without message."""
        error = CancellationError(CancellationReason.TIMEOUT_SOFT)

        assert error.reason == CancellationReason.TIMEOUT_SOFT
        assert error.message == ""
        assert "timeout_soft" in str(error)


class TestCancellationToken:
    """Test CancellationToken class."""

    def test_initial_state(self):
        """Test initial token state."""
        token = CancellationToken()

        assert token.is_cancelled() is False
        assert token.reason is None
        assert token.message == ""
        assert token.cancelled_at is None

    def test_request_cancel(self):
        """Test requesting cancellation."""
        token = CancellationToken()

        result = token.request_cancel(CancellationReason.USER_REQUEST, "Stop it")

        assert result is True
        assert token.is_cancelled() is True
        assert token.reason == CancellationReason.USER_REQUEST
        assert token.message == "Stop it"
        assert token.cancelled_at is not None

    def test_request_cancel_already_cancelled(self):
        """Test requesting cancellation when already cancelled."""
        token = CancellationToken()
        token.request_cancel(CancellationReason.USER_REQUEST)

        result = token.request_cancel(CancellationReason.TIMEOUT_HARD, "Different reason")

        assert result is False
        # Original reason should be preserved
        assert token.reason == CancellationReason.USER_REQUEST

    def test_check_cancelled_raises(self):
        """Test check_cancelled raises when cancelled."""
        token = CancellationToken()
        token.request_cancel(CancellationReason.TIMEOUT_SOFT, "Time's up")

        with pytest.raises(CancellationError) as exc_info:
            token.check_cancelled()

        assert exc_info.value.reason == CancellationReason.TIMEOUT_SOFT
        assert exc_info.value.message == "Time's up"

    def test_check_cancelled_no_raise(self):
        """Test check_cancelled doesn't raise when not cancelled."""
        token = CancellationToken()

        # Should not raise
        token.check_cancelled()

    def test_add_callback_invoked_on_cancel(self):
        """Test callback is invoked when cancellation is requested."""
        token = CancellationToken()
        callback_called = threading.Event()
        received_reason = None
        received_message = None

        def callback(reason: CancellationReason, message: str):
            nonlocal received_reason, received_message
            received_reason = reason
            received_message = message
            callback_called.set()

        token.add_callback(callback)
        token.request_cancel(CancellationReason.PREEMPTION, "Higher priority job")

        callback_called.wait(timeout=1.0)

        assert callback_called.is_set()
        assert received_reason == CancellationReason.PREEMPTION
        assert received_message == "Higher priority job"

    def test_add_callback_invoked_immediately_if_already_cancelled(self):
        """Test callback is invoked immediately if token already cancelled."""
        token = CancellationToken()
        token.request_cancel(CancellationReason.USER_REQUEST, "Already cancelled")

        callback_called = threading.Event()

        def callback(reason: CancellationReason, message: str):
            callback_called.set()

        token.add_callback(callback)

        callback_called.wait(timeout=1.0)
        assert callback_called.is_set()

    def test_remove_callback(self):
        """Test removing a callback."""
        token = CancellationToken()

        def callback(reason: CancellationReason, message: str):
            pass

        token.add_callback(callback)
        result = token.remove_callback(callback)

        assert result is True

        # Callback should not be called after removal
        call_count = [0]

        def counting_callback(reason: CancellationReason, message: str):
            call_count[0] += 1

        token.add_callback(counting_callback)
        token.remove_callback(counting_callback)
        token.request_cancel(CancellationReason.USER_REQUEST)

        time.sleep(0.1)
        assert call_count[0] == 0

    def test_remove_callback_not_found(self):
        """Test removing a callback that wasn't added."""
        token = CancellationToken()

        def callback(reason: CancellationReason, message: str):
            pass

        result = token.remove_callback(callback)
        assert result is False

    def test_reset(self):
        """Test resetting the token."""
        token = CancellationToken()
        token.request_cancel(CancellationReason.USER_REQUEST, "Original")

        token.reset()

        assert token.is_cancelled() is False
        assert token.reason is None
        assert token.message == ""
        assert token.cancelled_at is None

    def test_thread_safety(self):
        """Test thread-safe cancellation."""
        token = CancellationToken()
        cancelled_count = [0]

        def cancel_worker():
            if token.request_cancel(CancellationReason.USER_REQUEST):
                cancelled_count[0] += 1

        threads = [threading.Thread(target=cancel_worker) for _ in range(10)]

        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # Only one thread should have successfully cancelled
        assert cancelled_count[0] == 1
        assert token.is_cancelled() is True


class TestTimeoutConfig:
    """Test TimeoutConfig dataclass."""

    def test_default_creation(self):
        """Test creating config with defaults."""
        config = TimeoutConfig()

        assert config.soft_timeout is None
        assert config.hard_timeout is None
        assert config.grace_period == 5.0

    def test_custom_creation(self):
        """Test creating config with custom values."""
        config = TimeoutConfig(
            soft_timeout=30.0,
            hard_timeout=60.0,
            grace_period=10.0,
        )

        assert config.soft_timeout == 30.0
        assert config.hard_timeout == 60.0
        assert config.grace_period == 10.0

    def test_invalid_soft_timeout(self):
        """Test invalid soft timeout."""
        with pytest.raises(ValueError, match="soft_timeout must be positive"):
            TimeoutConfig(soft_timeout=0)

        with pytest.raises(ValueError, match="soft_timeout must be positive"):
            TimeoutConfig(soft_timeout=-1)

    def test_invalid_hard_timeout(self):
        """Test invalid hard timeout."""
        with pytest.raises(ValueError, match="hard_timeout must be positive"):
            TimeoutConfig(hard_timeout=0)

    def test_invalid_grace_period(self):
        """Test invalid grace period."""
        with pytest.raises(ValueError, match="grace_period must be non-negative"):
            TimeoutConfig(grace_period=-1)


class TestTimeoutManager:
    """Test TimeoutManager class."""

    def test_initial_state(self):
        """Test initial manager state."""
        config = TimeoutConfig()
        manager = TimeoutManager(config)

        assert manager.config == config
        assert manager.token is not None
        assert manager.is_expired() is False
        assert manager.get_remaining_time() is None
        assert manager.get_elapsed_time() == 0.0

    def test_set_timeout(self):
        """Test updating timeout configuration."""
        config = TimeoutConfig(soft_timeout=10, hard_timeout=20)
        manager = TimeoutManager(config)

        manager.set_timeout(soft_timeout=30, hard_timeout=60, grace_period=15)

        assert manager.config.soft_timeout == 30
        assert manager.config.hard_timeout == 60
        assert manager.config.grace_period == 15

    def test_start_monitoring_returns_token(self):
        """Test start_monitoring returns a token."""
        manager = TimeoutManager()
        token = manager.start_monitoring(job_id="test-job")

        assert token is not None
        assert isinstance(token, CancellationToken)

        manager.stop_monitoring()

    def test_soft_timeout_triggers_cancel(self):
        """Test soft timeout triggers cancellation."""
        config = TimeoutConfig(soft_timeout=0.1)
        manager = TimeoutManager(config)
        token = manager.start_monitoring()

        # Wait for soft timeout
        time.sleep(0.2)

        assert token.is_cancelled() is True
        assert token.reason == CancellationReason.TIMEOUT_SOFT

        manager.stop_monitoring()

    def test_hard_timeout_triggers_cancel(self):
        """Test hard timeout triggers cancellation."""
        config = TimeoutConfig(hard_timeout=0.1)
        manager = TimeoutManager(config)
        token = manager.start_monitoring()

        # Wait for hard timeout
        time.sleep(0.2)

        assert token.is_cancelled() is True
        assert token.reason == CancellationReason.TIMEOUT_HARD

        manager.stop_monitoring()

    def test_stop_monitoring_cancels_timers(self):
        """Test stop_monitoring cancels active timers."""
        config = TimeoutConfig(soft_timeout=10, hard_timeout=20)
        manager = TimeoutManager(config)
        token = manager.start_monitoring()

        manager.stop_monitoring()

        # Wait a bit to ensure timer would have fired
        time.sleep(0.1)

        # Token should not be cancelled since we stopped monitoring
        assert token.is_cancelled() is False

    def test_cancel_after(self):
        """Test cancel_after method."""
        manager = TimeoutManager()
        token = manager.token

        manager.cancel_after(0.1, CancellationReason.USER_REQUEST)

        assert token.is_cancelled() is False

        time.sleep(0.2)

        assert token.is_cancelled() is True
        assert token.reason == CancellationReason.USER_REQUEST

    def test_extend_timeout(self):
        """Test extending timeout."""
        config = TimeoutConfig(soft_timeout=0.2)
        manager = TimeoutManager(config)
        token = manager.start_monitoring()

        # Extend before timeout
        result = manager.extend_timeout(1.0)
        assert result is True

        # Wait for original timeout
        time.sleep(0.3)

        # Should not be cancelled because we extended
        assert token.is_cancelled() is False

        manager.stop_monitoring()

    def test_extend_timeout_already_cancelled(self):
        """Test extending timeout when already cancelled."""
        config = TimeoutConfig(soft_timeout=0.1)
        manager = TimeoutManager(config)
        manager.start_monitoring()

        time.sleep(0.2)

        result = manager.extend_timeout(1.0)
        assert result is False

        manager.stop_monitoring()

    def test_get_remaining_time(self):
        """Test getting remaining time."""
        config = TimeoutConfig(hard_timeout=1.0)
        manager = TimeoutManager(config)
        manager.start_monitoring()

        remaining = manager.get_remaining_time()
        assert remaining is not None
        assert 0 < remaining <= 1.0

        manager.stop_monitoring()

    def test_get_elapsed_time(self):
        """Test getting elapsed time."""
        manager = TimeoutManager()
        manager.start_monitoring()

        time.sleep(0.1)

        elapsed = manager.get_elapsed_time()
        assert elapsed >= 0.1

        manager.stop_monitoring()

    def test_is_expired(self):
        """Test is_expired method."""
        config = TimeoutConfig(hard_timeout=0.1)
        manager = TimeoutManager(config)
        manager.start_monitoring()

        assert manager.is_expired() is False

        time.sleep(0.2)

        assert manager.is_expired() is True

        manager.stop_monitoring()

    @patch("os.kill")
    def test_hard_timeout_signals_process(self, mock_kill):
        """Test hard timeout sends signal to process."""
        config = TimeoutConfig(hard_timeout=0.1, grace_period=0.1)
        manager = TimeoutManager(config)
        manager.start_monitoring(job_id="test", target_pid=12345)

        time.sleep(0.2)

        # SIGTERM should have been sent
        mock_kill.assert_called_with(12345, signal.SIGTERM)

        manager.stop_monitoring()

    @patch("os.kill")
    def test_force_kill_signals_process(self, mock_kill):
        """Test force kill sends SIGKILL to process."""
        config = TimeoutConfig(hard_timeout=0.1, grace_period=0.1)
        manager = TimeoutManager(config)
        manager.start_monitoring(job_id="test", target_pid=12345)

        time.sleep(0.35)  # Wait for hard timeout + grace period

        # Both SIGTERM and SIGKILL should have been sent
        assert mock_kill.call_count >= 1
        # Last call should be SIGKILL
        mock_kill.assert_called_with(12345, signal.SIGKILL)

        manager.stop_monitoring()

    @patch("os.kill")
    def test_signal_process_handles_errors(self, mock_kill):
        """Test signal_process handles various errors."""
        mock_kill.side_effect = ProcessLookupError("No such process")

        config = TimeoutConfig(hard_timeout=0.1)
        manager = TimeoutManager(config)
        manager.start_monitoring(target_pid=99999)

        time.sleep(0.2)

        # Should handle the error gracefully
        manager.stop_monitoring()

    def test_hard_timeout_callback(self):
        """Test hard timeout callback is invoked."""
        callback_called = threading.Event()

        def on_hard_timeout():
            callback_called.set()

        config = TimeoutConfig(hard_timeout=0.1)
        manager = TimeoutManager(config)
        manager.start_monitoring(on_hard_timeout=on_hard_timeout)

        callback_called.wait(timeout=1.0)

        assert callback_called.is_set()
        manager.stop_monitoring()

    def test_force_kill_callback(self):
        """Test force kill callback is invoked."""
        callback_called = threading.Event()

        def on_force_kill():
            callback_called.set()

        config = TimeoutConfig(hard_timeout=0.05, grace_period=0.05)
        manager = TimeoutManager(config)
        manager.start_monitoring(on_force_kill=on_force_kill)

        callback_called.wait(timeout=1.0)

        assert callback_called.is_set()
        manager.stop_monitoring()


class TestRunWithTimeout:
    """Test run_with_timeout function."""

    def test_successful_execution(self):
        """Test successful function execution."""

        def work(token: CancellationToken) -> str:
            return "done"

        config = TimeoutConfig(hard_timeout=1.0)
        result = run_with_timeout(work, config)

        assert result == "done"

    def test_timeout_reached(self):
        """Test function times out."""

        def slow_work(token: CancellationToken) -> str:
            time.sleep(10)
            return "done"

        config = TimeoutConfig(hard_timeout=0.1)

        with pytest.raises(CancellationError) as exc_info:
            run_with_timeout(slow_work, config)

        assert exc_info.value.reason == CancellationReason.TIMEOUT_HARD

    def test_cooperative_cancellation(self):
        """Test cooperative cancellation."""

        def cooperative_work(token: CancellationToken) -> str:
            for _ in range(100):
                token.check_cancelled()
                time.sleep(0.01)
            return "done"

        config = TimeoutConfig(soft_timeout=0.05, hard_timeout=1.0)

        with pytest.raises(CancellationError) as exc_info:
            run_with_timeout(cooperative_work, config)

        assert exc_info.value.reason == CancellationReason.TIMEOUT_SOFT

    def test_exception_propagation(self):
        """Test exceptions are properly propagated."""

        def failing_work(token: CancellationToken) -> str:
            raise ValueError("Test error")

        config = TimeoutConfig(hard_timeout=1.0)

        with pytest.raises(ValueError, match="Test error"):
            run_with_timeout(failing_work, config)

    def test_function_with_args(self):
        """Test function with additional arguments."""

        def work_with_args(token: CancellationToken, x: int, y: str, z: bool = False) -> str:
            return f"{x}-{y}-{z}"

        config = TimeoutConfig(hard_timeout=1.0)
        result = run_with_timeout(work_with_args, config, 42, "test", z=True)

        assert result == "42-test-True"
