"""Tests for the Spider class and related components."""

import logging
import tempfile
from pathlib import Path

import pytest

from scrapling.spiders.spider import Spider, SessionConfigurationError, LogCounterHandler, BLOCKED_CODES
from scrapling.spiders.request import Request
from scrapling.spiders.session import SessionManager
from scrapling.spiders.result import CrawlStats
from scrapling.core._types import Any, Dict, AsyncGenerator


class TestLogCounterHandler:
    """Test LogCounterHandler for tracking log counts."""

    def test_initial_counts_are_zero(self):
        """Test that handler starts with zero counts."""
        handler = LogCounterHandler()
        counts = handler.get_counts()

        assert counts["debug"] == 0
        assert counts["info"] == 0
        assert counts["warning"] == 0
        assert counts["error"] == 0
        assert counts["critical"] == 0

    def test_counts_debug_messages(self):
        """Test counting debug level messages."""
        handler = LogCounterHandler()
        record = logging.LogRecord(
            name="test",
            level=logging.DEBUG,
            pathname="",
            lineno=0,
            msg="test",
            args=(),
            exc_info=None,
        )

        handler.emit(record)
        handler.emit(record)

        assert handler.get_counts()["debug"] == 2

    def test_counts_info_messages(self):
        """Test counting info level messages."""
        handler = LogCounterHandler()
        record = logging.LogRecord(
            name="test",
            level=logging.INFO,
            pathname="",
            lineno=0,
            msg="test",
            args=(),
            exc_info=None,
        )

        handler.emit(record)

        assert handler.get_counts()["info"] == 1

    def test_counts_warning_messages(self):
        """Test counting warning level messages."""
        handler = LogCounterHandler()
        record = logging.LogRecord(
            name="test",
            level=logging.WARNING,
            pathname="",
            lineno=0,
            msg="test",
            args=(),
            exc_info=None,
        )

        handler.emit(record)

        assert handler.get_counts()["warning"] == 1

    def test_counts_error_messages(self):
        """Test counting error level messages."""
        handler = LogCounterHandler()
        record = logging.LogRecord(
            name="test",
            level=logging.ERROR,
            pathname="",
            lineno=0,
            msg="test",
            args=(),
            exc_info=None,
        )

        handler.emit(record)

        assert handler.get_counts()["error"] == 1

    def test_counts_critical_messages(self):
        """Test counting critical level messages."""
        handler = LogCounterHandler()
        record = logging.LogRecord(
            name="test",
            level=logging.CRITICAL,
            pathname="",
            lineno=0,
            msg="test",
            args=(),
            exc_info=None,
        )

        handler.emit(record)

        assert handler.get_counts()["critical"] == 1

    def test_counts_multiple_levels(self):
        """Test counting messages at different levels."""
        handler = LogCounterHandler()

        levels = [
            logging.DEBUG,
            logging.DEBUG,
            logging.INFO,
            logging.WARNING,
            logging.ERROR,
            logging.ERROR,
            logging.ERROR,
            logging.CRITICAL,
        ]

        for level in levels:
            record = logging.LogRecord(
                name="test",
                level=level,
                pathname="",
                lineno=0,
                msg="test",
                args=(),
                exc_info=None,
            )
            handler.emit(record)

        counts = handler.get_counts()
        assert counts["debug"] == 2
        assert counts["info"] == 1
        assert counts["warning"] == 1
        assert counts["error"] == 3
        assert counts["critical"] == 1


class TestBlockedCodes:
    """Test BLOCKED_CODES constant."""

    def test_blocked_codes_contains_expected_values(self):
        """Test that BLOCKED_CODES contains expected HTTP status codes."""
        assert 401 in BLOCKED_CODES  # Unauthorized
        assert 403 in BLOCKED_CODES  # Forbidden
        assert 407 in BLOCKED_CODES  # Proxy Authentication Required
        assert 429 in BLOCKED_CODES  # Too Many Requests
        assert 444 in BLOCKED_CODES  # Connection Closed Without Response (nginx)
        assert 500 in BLOCKED_CODES  # Internal Server Error
        assert 502 in BLOCKED_CODES  # Bad Gateway
        assert 503 in BLOCKED_CODES  # Service Unavailable
        assert 504 in BLOCKED_CODES  # Gateway Timeout

    def test_blocked_codes_does_not_contain_success(self):
        """Test that success codes are not blocked."""
        assert 200 not in BLOCKED_CODES
        assert 201 not in BLOCKED_CODES
        assert 204 not in BLOCKED_CODES
        assert 301 not in BLOCKED_CODES
        assert 302 not in BLOCKED_CODES


class ConcreteSpider(Spider):
    """Concrete spider implementation for testing."""

    name = "test_spider"
    start_urls = ["https://example.com"]

    async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
        yield {"url": str(response)}


class TestSpiderInit:
    """Test Spider initialization."""

    def test_spider_requires_name(self):
        """Test that spider without name raises ValueError."""

        class NoNameSpider(Spider):
            async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
                yield None

        with pytest.raises(ValueError, match="must have a name"):
            NoNameSpider()

    def test_spider_initializes_logger(self):
        """Test that spider creates a logger."""
        spider = ConcreteSpider()

        assert spider.logger is not None
        assert spider.logger.name == "scrapling.spiders.test_spider"

    def test_spider_logger_has_log_counter(self):
        """Test that spider logger has log counter handler."""
        spider = ConcreteSpider()

        assert spider._log_counter is not None
        assert isinstance(spider._log_counter, LogCounterHandler)

    def test_spider_with_crawldir(self):
        """Test spider initialization with crawldir."""
        with tempfile.TemporaryDirectory() as tmpdir:
            spider = ConcreteSpider(crawldir=tmpdir)

            assert spider.crawldir == Path(tmpdir)

    def test_spider_without_crawldir(self):
        """Test spider initialization without crawldir."""
        spider = ConcreteSpider()

        assert spider.crawldir is None

    def test_spider_custom_interval(self):
        """Test spider with custom checkpoint interval."""
        spider = ConcreteSpider(interval=60.0)

        assert spider._interval == 60.0

    def test_spider_default_interval(self):
        """Test spider has default checkpoint interval."""
        spider = ConcreteSpider()

        assert spider._interval == 300.0

    def test_spider_repr(self):
        """Test spider string representation."""
        spider = ConcreteSpider()

        repr_str = repr(spider)

        assert "ConcreteSpider" in repr_str
        assert "test_spider" in repr_str


class TestSpiderClassAttributes:
    """Test Spider class attribute defaults."""

    def test_default_concurrent_requests(self):
        """Test default concurrent_requests is 4."""
        assert ConcreteSpider.concurrent_requests == 4

    def test_default_concurrent_requests_per_domain(self):
        """Test default concurrent_requests_per_domain is 0 (disabled)."""
        assert ConcreteSpider.concurrent_requests_per_domain == 0

    def test_default_download_delay(self):
        """Test default download_delay is 0."""
        assert ConcreteSpider.download_delay == 0.0

    def test_default_max_blocked_retries(self):
        """Test default max_blocked_retries is 3."""
        assert ConcreteSpider.max_blocked_retries == 3

    def test_default_logging_level(self):
        """Test default logging level is DEBUG."""
        assert ConcreteSpider.logging_level == logging.DEBUG

    def test_default_allowed_domains_empty(self):
        """Test default allowed_domains is empty set."""
        assert ConcreteSpider.allowed_domains == set()


class TestSpiderSessionConfiguration:
    """Test Spider session configuration."""

    def test_default_configure_sessions(self):
        """Test that default configure_sessions adds a session."""
        spider = ConcreteSpider()

        assert len(spider._session_manager) > 0

    def test_configure_sessions_error_raises_custom_exception(self):
        """Test that errors in configure_sessions raise SessionConfigurationError."""

        class BadSessionSpider(Spider):
            name = "bad_spider"

            def configure_sessions(self, manager: SessionManager) -> None:
                raise RuntimeError("Configuration failed!")

            async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
                yield None

        with pytest.raises(SessionConfigurationError, match="Configuration failed"):
            BadSessionSpider()

    def test_configure_sessions_no_sessions_raises(self):
        """Test that not adding any sessions raises SessionConfigurationError."""

        class NoSessionSpider(Spider):
            name = "no_session_spider"

            def configure_sessions(self, manager: SessionManager) -> None:
                pass  # Don't add any sessions

            async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
                yield None

        with pytest.raises(SessionConfigurationError, match="did not add any sessions"):
            NoSessionSpider()


class TestSpiderStartRequests:
    """Test Spider start_requests method."""

    @pytest.mark.asyncio
    async def test_start_requests_yields_from_start_urls(self):
        """Test that start_requests yields requests for start_urls."""

        class MultiUrlSpider(Spider):
            name = "multi_url"
            start_urls = [
                "https://example.com/1",
                "https://example.com/2",
                "https://example.com/3",
            ]

            async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
                yield None

        spider = MultiUrlSpider()
        requests = [r async for r in spider.start_requests()]

        assert len(requests) == 3
        assert requests[0].url == "https://example.com/1"
        assert requests[1].url == "https://example.com/2"
        assert requests[2].url == "https://example.com/3"

    @pytest.mark.asyncio
    async def test_start_requests_no_urls_raises(self):
        """Test that start_requests raises when no start_urls."""

        class NoUrlSpider(Spider):
            name = "no_url"
            start_urls = []

            async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
                yield None

        spider = NoUrlSpider()

        with pytest.raises(RuntimeError, match="no starting point"):
            async for _ in spider.start_requests():
                pass

    @pytest.mark.asyncio
    async def test_start_requests_uses_default_session(self):
        """Test that start_requests uses default session ID."""
        spider = ConcreteSpider()
        requests = [r async for r in spider.start_requests()]

        # Should use the default session from session manager
        default_sid = spider._session_manager.default_session_id
        assert requests[0].sid == default_sid


class TestSpiderHooks:
    """Test Spider lifecycle hooks."""

    @pytest.mark.asyncio
    async def test_on_start_default(self):
        """Test default on_start doesn't raise."""
        spider = ConcreteSpider()

        # Should not raise
        await spider.on_start(resuming=False)
        await spider.on_start(resuming=True)

    @pytest.mark.asyncio
    async def test_on_close_default(self):
        """Test default on_close doesn't raise."""
        spider = ConcreteSpider()

        # Should not raise
        await spider.on_close()

    @pytest.mark.asyncio
    async def test_on_error_default(self):
        """Test default on_error logs the error."""
        spider = ConcreteSpider()
        request = Request("https://example.com")
        error = ValueError("test error")

        # Should not raise
        await spider.on_error(request, error)

    @pytest.mark.asyncio
    async def test_on_scraped_item_default_returns_item(self):
        """Test default on_scraped_item returns the item unchanged."""
        spider = ConcreteSpider()
        item = {"key": "value", "nested": {"a": 1}}

        result = await spider.on_scraped_item(item)

        assert result == item

    @pytest.mark.asyncio
    async def test_is_blocked_default_checks_status_codes(self):
        """Test default is_blocked checks blocked status codes."""

        class MockResponse:
            def __init__(self, status: int):
                self.status = status

        spider = ConcreteSpider()

        # Test blocked codes
        assert await spider.is_blocked(MockResponse(403)) is True
        assert await spider.is_blocked(MockResponse(429)) is True
        assert await spider.is_blocked(MockResponse(503)) is True

        # Test non-blocked codes
        assert await spider.is_blocked(MockResponse(200)) is False
        assert await spider.is_blocked(MockResponse(404)) is False

    @pytest.mark.asyncio
    async def test_retry_blocked_request_default_returns_request(self):
        """Test default retry_blocked_request returns the request unchanged."""

        class MockResponse:
            status = 429

        spider = ConcreteSpider()
        request = Request("https://example.com", priority=5)

        result = await spider.retry_blocked_request(request, MockResponse())

        assert result is request


class TestSpiderPause:
    """Test Spider pause functionality."""

    def test_pause_without_engine_raises(self):
        """Test that pause without active engine raises RuntimeError."""
        spider = ConcreteSpider()

        with pytest.raises(RuntimeError, match="No active crawl to stop"):
            spider.pause()


class TestSpiderStats:
    """Test Spider stats property."""

    def test_stats_without_engine_raises(self):
        """Test that accessing stats without active crawl raises."""
        spider = ConcreteSpider()

        with pytest.raises(RuntimeError, match="No active crawl"):
            _ = spider.stats


class TestSpiderCustomization:
    """Test Spider customization patterns."""

    def test_custom_concurrent_requests(self):
        """Test spider with custom concurrent_requests."""

        class CustomSpider(Spider):
            name = "custom"
            concurrent_requests = 32
            start_urls = ["https://example.com"]

            async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
                yield None

        spider = CustomSpider()
        assert spider.concurrent_requests == 32

    def test_custom_allowed_domains(self):
        """Test spider with allowed_domains."""

        class DomainSpider(Spider):
            name = "domain_spider"
            start_urls = ["https://example.com"]
            allowed_domains = {"example.com", "api.example.com"}

            async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
                yield None

        spider = DomainSpider()
        assert "example.com" in spider.allowed_domains
        assert "api.example.com" in spider.allowed_domains

    def test_custom_download_delay(self):
        """Test spider with download delay."""

        class SlowSpider(Spider):
            name = "slow"
            download_delay = 1.5
            start_urls = ["https://example.com"]

            async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
                yield None

        spider = SlowSpider()
        assert spider.download_delay == 1.5


class TestSpiderLogging:
    """Test Spider logging configuration."""

    def test_custom_logging_level(self):
        """Test spider with custom logging level."""

        class QuietSpider(Spider):
            name = "quiet"
            logging_level = logging.WARNING
            start_urls = ["https://example.com"]

            async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
                yield None

        spider = QuietSpider()
        assert spider.logger.level == logging.WARNING

    def test_log_file_creates_handler(self):
        """Test spider with log file creates file handler."""
        with tempfile.TemporaryDirectory() as tmpdir:
            log_path = Path(tmpdir) / "spider.log"

            class FileLogSpider(Spider):
                name = "file_log"
                log_file = str(log_path)
                start_urls = ["https://example.com"]

                async def parse(self, response) -> AsyncGenerator[Dict[str, Any] | Request | None, None]:
                    yield None

            spider = FileLogSpider()

            # Should have a file handler
            file_handlers = [
                h for h in spider.logger.handlers if isinstance(h, logging.FileHandler)
            ]
            assert len(file_handlers) == 1

            # Clean up
            for h in file_handlers:
                h.close()

    def test_logger_does_not_propagate(self):
        """Test that spider logger does not propagate to parent."""
        spider = ConcreteSpider()

        assert spider.logger.propagate is False


class TestSessionConfigurationError:
    """Test SessionConfigurationError exception."""

    def test_exception_message(self):
        """Test that exception preserves message."""
        error = SessionConfigurationError("Custom error message")

        assert str(error) == "Custom error message"

    def test_exception_is_exception(self):
        """Test that it's a proper exception."""
        error = SessionConfigurationError("test")

        assert isinstance(error, Exception)
