"""Unit tests for core error handler module.

Tests custom exceptions, retry logic, error recovery strategies,
file corruption detection, and the with_error_recovery decorator.
"""

import os
from unittest.mock import MagicMock, patch

import pytest

from src.core.error_handler import (
    DownloadError,
    FileCorruptionDetector,
    NetworkError,
    NonRetryableError,
    RecoveryStrategies,
    RetryableError,
    file_corruption_detector,
    recovery_strategies,
    with_error_recovery,
)


class TestCustomExceptions:
    """Tests for custom exception classes."""

    def test_retryable_error_is_exception(self):
        """RetryableError is a proper Exception subclass."""
        with pytest.raises(RetryableError):
            raise RetryableError("transient failure")

    def test_non_retryable_error_is_exception(self):
        """NonRetryableError is a proper Exception subclass."""
        with pytest.raises(NonRetryableError):
            raise NonRetryableError("permanent failure")

    def test_network_error_is_exception(self):
        """NetworkError is a proper Exception subclass."""
        with pytest.raises(NetworkError):
            raise NetworkError("connection lost")

    def test_download_error_is_exception(self):
        """DownloadError is a proper Exception subclass."""
        with pytest.raises(DownloadError):
            raise DownloadError("download failed")

    def test_exception_message_preserved(self):
        """Custom exceptions preserve their message string."""
        msg = "specific failure reason"
        err = RetryableError(msg)
        assert str(err) == msg

    def test_exceptions_are_independent(self):
        """Each error type is a distinct class (no inheritance among them)."""
        assert not issubclass(RetryableError, NonRetryableError)
        assert not issubclass(NetworkError, DownloadError)
        assert not issubclass(DownloadError, NetworkError)


class TestRecoveryStrategiesNetworkFailure:
    """Tests for RecoveryStrategies.handle_network_failure."""

    def test_success_on_first_attempt(self):
        """Returns result immediately if function succeeds."""
        func = MagicMock(return_value="ok")
        result = RecoveryStrategies.handle_network_failure(func)
        assert result == "ok"
        assert func.call_count == 1

    def test_retries_on_network_error(self):
        """Retries up to 3 times on NetworkError."""
        func = MagicMock(
            side_effect=[NetworkError("fail"), NetworkError("fail"), "ok"]
        )
        result = RecoveryStrategies.handle_network_failure(func)
        assert result == "ok"
        assert func.call_count == 3

    def test_retries_on_connection_error(self):
        """Retries on ConnectionError (built-in)."""
        func = MagicMock(
            side_effect=[ConnectionError("fail"), "ok"]
        )
        result = RecoveryStrategies.handle_network_failure(func)
        assert result == "ok"
        assert func.call_count == 2

    def test_raises_after_max_retries(self):
        """Raises NetworkError after 3 failed attempts."""
        func = MagicMock(side_effect=NetworkError("persistent failure"))
        with pytest.raises(NetworkError):
            RecoveryStrategies.handle_network_failure(func)
        assert func.call_count == 3

    def test_passes_args_and_kwargs(self):
        """Arguments are forwarded to the wrapped function."""
        func = MagicMock(return_value="result")
        RecoveryStrategies.handle_network_failure(func, "arg1", key="val")
        func.assert_called_with("arg1", key="val")

    def test_non_network_error_not_caught(self):
        """Non-network exceptions propagate immediately without retry."""
        func = MagicMock(side_effect=ValueError("bad input"))
        with pytest.raises(ValueError):
            RecoveryStrategies.handle_network_failure(func)
        assert func.call_count == 1


class TestRecoveryStrategiesDownloadFailure:
    """Tests for RecoveryStrategies.handle_download_failure."""

    def test_success_on_first_attempt(self):
        """Returns result if function succeeds first time."""
        func = MagicMock(return_value="downloaded")
        result = RecoveryStrategies.handle_download_failure(func)
        assert result == "downloaded"
        assert func.call_count == 1

    def test_retries_on_download_error(self):
        """Retries up to 2 times on DownloadError."""
        func = MagicMock(
            side_effect=[DownloadError("fail"), "ok"]
        )
        result = RecoveryStrategies.handle_download_failure(func)
        assert result == "ok"
        assert func.call_count == 2

    def test_raises_after_max_retries(self):
        """Raises DownloadError after 2 failed attempts."""
        func = MagicMock(side_effect=DownloadError("persistent"))
        with pytest.raises(DownloadError):
            RecoveryStrategies.handle_download_failure(func)
        assert func.call_count == 2

    def test_download_max_retries_is_two(self):
        """Download recovery allows exactly 2 attempts."""
        call_count = 0

        def counting_func():
            nonlocal call_count
            call_count += 1
            raise DownloadError("fail")

        with pytest.raises(DownloadError):
            RecoveryStrategies.handle_download_failure(counting_func)
        assert call_count == 2


class TestFileCorruptionDetector:
    """Tests for FileCorruptionDetector."""

    def test_valid_large_file(self, tmp_path):
        """File larger than 1MB is considered valid."""
        filepath = tmp_path / "video.mp4"
        filepath.write_bytes(b"\x00" * (1024 * 1024 + 1))
        assert FileCorruptionDetector.is_valid_video_file(str(filepath))

    def test_file_too_small(self, tmp_path):
        """File smaller than 1MB is invalid."""
        filepath = tmp_path / "video.mp4"
        filepath.write_bytes(b"\x00" * 100)
        assert not FileCorruptionDetector.is_valid_video_file(str(filepath))

    def test_exactly_1mb_is_invalid(self, tmp_path):
        """File of exactly 1MB (1048576 bytes) is invalid (needs > 1MB)."""
        filepath = tmp_path / "video.mp4"
        filepath.write_bytes(b"\x00" * (1024 * 1024))
        assert not FileCorruptionDetector.is_valid_video_file(str(filepath))

    def test_nonexistent_file(self):
        """Nonexistent file returns False."""
        assert not FileCorruptionDetector.is_valid_video_file("/no/such/file")

    def test_module_level_instance(self):
        """Module provides a pre-created FileCorruptionDetector instance."""
        assert isinstance(file_corruption_detector, FileCorruptionDetector)


class TestWithErrorRecoveryDecorator:
    """Tests for the with_error_recovery decorator."""

    def test_success_returns_result(self):
        """Decorated function returns result on success."""

        @with_error_recovery(max_retries=3, context="test")
        def succeed():
            return 42

        assert succeed() == 42

    def test_retries_on_generic_exception(self):
        """Generic exception triggers retry."""
        counter = {"n": 0}

        @with_error_recovery(max_retries=3, context="test")
        def fail_then_succeed():
            counter["n"] += 1
            if counter["n"] < 3:
                raise RuntimeError("not yet")
            return "ok"

        assert fail_then_succeed() == "ok"
        assert counter["n"] == 3

    def test_non_retryable_error_raises_immediately(self):
        """NonRetryableError is not retried - raises on first occurrence."""
        counter = {"n": 0}

        @with_error_recovery(max_retries=5, context="test")
        def permanent_failure():
            counter["n"] += 1
            raise NonRetryableError("do not retry")

        with pytest.raises(NonRetryableError):
            permanent_failure()
        assert counter["n"] == 1

    def test_raises_last_error_after_exhausting_retries(self):
        """After max retries, the last error is raised."""

        @with_error_recovery(max_retries=2, context="test")
        def always_fail():
            raise ValueError("always fails")

        with pytest.raises(ValueError, match="always fails"):
            always_fail()

    def test_max_retries_limits_attempts(self):
        """Function is called exactly max_retries times when always failing."""
        counter = {"n": 0}

        @with_error_recovery(max_retries=4, context="test")
        def counting_fail():
            counter["n"] += 1
            raise RuntimeError("fail")

        with pytest.raises(RuntimeError):
            counting_fail()
        assert counter["n"] == 4

    def test_preserves_function_name(self):
        """Decorator preserves the original function name via functools.wraps."""

        @with_error_recovery(max_retries=1, context="test")
        def my_function():
            pass

        assert my_function.__name__ == "my_function"

    def test_context_used_in_logging(self):
        """Context string is used in error log messages."""
        counter = {"n": 0}

        @with_error_recovery(max_retries=2, context="my_context")
        def fail_once():
            counter["n"] += 1
            if counter["n"] == 1:
                raise RuntimeError("oops")
            return "ok"

        with patch("src.core.error_handler.logger") as mock_logger:
            fail_once()
            # Should have logged a warning with context
            mock_logger.warning.assert_called()
            # context is a %s arg, so check all positional call args
            logged_args = mock_logger.warning.call_args[0]
            assert any("my_context" in str(arg) for arg in logged_args)

    def test_retryable_error_is_retried(self):
        """RetryableError (standard Exception subclass) is retried."""
        counter = {"n": 0}

        @with_error_recovery(max_retries=3, context="test")
        def retryable():
            counter["n"] += 1
            if counter["n"] < 3:
                raise RetryableError("try again")
            return "done"

        assert retryable() == "done"
        assert counter["n"] == 3

    def test_passes_arguments_through(self):
        """Decorated function receives all arguments correctly."""

        @with_error_recovery(max_retries=1, context="test")
        def add(a, b, c=0):
            return a + b + c

        assert add(1, 2, c=3) == 6


class TestModuleLevelInstances:
    """Tests for module-level singleton instances."""

    def test_recovery_strategies_instance(self):
        """Module provides a RecoveryStrategies instance."""
        assert isinstance(recovery_strategies, RecoveryStrategies)

    def test_file_corruption_detector_instance(self):
        """Module provides a FileCorruptionDetector instance."""
        assert isinstance(file_corruption_detector, FileCorruptionDetector)
