"""
TDD RED 단계: test_crawl_utils.py
crawl_utils.py 모듈에 대한 테스트 스위트
(crawl_utils.py는 아직 구현되지 않음 - TDD RED 단계)
"""

import sys
from pathlib import Path
from types import ModuleType
from typing import Optional
from unittest.mock import MagicMock

import pytest

# scripts 디렉토리를 path에 추가
sys.path.insert(0, str(Path(__file__).parent.parent))

# crawl_utils가 아직 구현되지 않을 수 있으므로 안전하게 import
_crawl_utils: Optional[ModuleType] = None
try:
    import crawl_utils as _crawl_utils  # pyright: ignore[reportMissingImports]
except ModuleNotFoundError:
    pass

_MISSING = _crawl_utils is None
_skip_if_missing = pytest.mark.skipif(
    _MISSING,
    reason="crawl_utils.py 미구현 (TDD RED 단계 - 토르가 구현 예정)",
)


# ────────────────────────────────────────────────────────
# ProxyRotator: round_robin 전략 테스트
# ────────────────────────────────────────────────────────


class TestProxyRotatorRoundRobin:
    """ProxyRotator의 round_robin 전략 테스트."""

    @_skip_if_missing
    def test_round_robin_returns_proxies_in_order(self) -> None:
        """round_robin 전략은 프록시를 순서대로 반환해야 한다."""
        assert _crawl_utils is not None
        proxies = ["http://proxy1:8080", "http://proxy2:8080", "http://proxy3:8080"]
        rotator = _crawl_utils.ProxyRotator(proxies, strategy="round_robin")

        assert rotator.get_next() == "http://proxy1:8080"
        assert rotator.get_next() == "http://proxy2:8080"
        assert rotator.get_next() == "http://proxy3:8080"

    @_skip_if_missing
    def test_round_robin_wraps_around(self) -> None:
        """round_robin은 마지막 프록시 이후 처음으로 순환해야 한다."""
        assert _crawl_utils is not None
        proxies = ["http://proxy1:8080", "http://proxy2:8080"]
        rotator = _crawl_utils.ProxyRotator(proxies, strategy="round_robin")

        rotator.get_next()  # proxy1
        rotator.get_next()  # proxy2
        assert rotator.get_next() == "http://proxy1:8080"  # 다시 proxy1

    @_skip_if_missing
    def test_round_robin_single_proxy_loops(self) -> None:
        """프록시가 1개일 때 round_robin은 계속 같은 값을 반환해야 한다."""
        assert _crawl_utils is not None
        rotator = _crawl_utils.ProxyRotator(["http://only-proxy:8080"], strategy="round_robin")

        assert rotator.get_next() == "http://only-proxy:8080"
        assert rotator.get_next() == "http://only-proxy:8080"


# ────────────────────────────────────────────────────────
# ProxyRotator: random 전략 테스트
# ────────────────────────────────────────────────────────


class TestProxyRotatorRandom:
    """ProxyRotator의 random 전략 테스트."""

    @_skip_if_missing
    def test_random_strategy_returns_value_in_list(self) -> None:
        """random 전략은 항상 프록시 목록 중 하나를 반환해야 한다."""
        assert _crawl_utils is not None
        proxies = [
            "http://proxy1:8080",
            "http://proxy2:8080",
            "http://proxy3:8080",
        ]
        rotator = _crawl_utils.ProxyRotator(proxies, strategy="random")

        for _ in range(20):
            result = rotator.get_next()
            assert result in proxies

    @_skip_if_missing
    def test_random_strategy_multiple_calls_are_from_pool(self) -> None:
        """random 전략 호출 결과가 항상 프록시 풀에 속해야 한다."""
        assert _crawl_utils is not None
        proxies = ["http://a:1", "http://b:2"]
        rotator = _crawl_utils.ProxyRotator(proxies, strategy="random")

        results = {rotator.get_next() for _ in range(50)}
        assert results.issubset(set(proxies))


# ────────────────────────────────────────────────────────
# ProxyRotator: 빈 리스트 처리 테스트
# ────────────────────────────────────────────────────────


class TestProxyRotatorEmptyList:
    """ProxyRotator 빈 리스트 엣지 케이스 테스트."""

    @_skip_if_missing
    def test_empty_list_get_next_returns_none(self) -> None:
        """빈 프록시 리스트에서 get_next()는 None을 반환해야 한다."""
        assert _crawl_utils is not None
        rotator = _crawl_utils.ProxyRotator([])

        assert rotator.get_next() is None

    @_skip_if_missing
    def test_empty_list_len_is_zero(self) -> None:
        """빈 프록시 리스트의 len()은 0이어야 한다."""
        assert _crawl_utils is not None
        rotator = _crawl_utils.ProxyRotator([])

        assert len(rotator) == 0

    @_skip_if_missing
    def test_empty_list_random_strategy_returns_none(self) -> None:
        """random 전략에서도 빈 리스트이면 None을 반환해야 한다."""
        assert _crawl_utils is not None
        rotator = _crawl_utils.ProxyRotator([], strategy="random")

        assert rotator.get_next() is None


# ────────────────────────────────────────────────────────
# ProxyRotator: remove 및 len 테스트
# ────────────────────────────────────────────────────────


class TestProxyRotatorRemoveAndLen:
    """ProxyRotator remove()와 __len__() 테스트."""

    @_skip_if_missing
    def test_remove_decreases_len(self) -> None:
        """remove() 후 len()이 1 감소해야 한다."""
        assert _crawl_utils is not None
        proxies = ["http://proxy1:8080", "http://proxy2:8080", "http://proxy3:8080"]
        rotator = _crawl_utils.ProxyRotator(proxies)

        assert len(rotator) == 3
        rotator.remove("http://proxy2:8080")
        assert len(rotator) == 2

    @_skip_if_missing
    def test_remove_excluded_from_rotation(self) -> None:
        """remove()된 프록시는 이후 get_next()에서 반환되지 않아야 한다."""
        assert _crawl_utils is not None
        proxies = ["http://proxy1:8080", "http://proxy2:8080"]
        rotator = _crawl_utils.ProxyRotator(proxies, strategy="round_robin")

        rotator.remove("http://proxy2:8080")

        for _ in range(10):
            assert rotator.get_next() != "http://proxy2:8080"

    @_skip_if_missing
    def test_remove_all_proxies_then_none(self) -> None:
        """모든 프록시를 제거하면 get_next()는 None을 반환해야 한다."""
        assert _crawl_utils is not None
        proxies = ["http://proxy1:8080"]
        rotator = _crawl_utils.ProxyRotator(proxies)

        rotator.remove("http://proxy1:8080")

        assert rotator.get_next() is None
        assert len(rotator) == 0

    @_skip_if_missing
    def test_remove_nonexistent_proxy_is_safe(self) -> None:
        """존재하지 않는 프록시 remove()는 예외 없이 처리되어야 한다."""
        assert _crawl_utils is not None
        rotator = _crawl_utils.ProxyRotator(["http://proxy1:8080"])

        # 예외 발생 없이 실행되어야 함
        rotator.remove("http://nonexistent:9999")

        assert len(rotator) == 1

    @_skip_if_missing
    def test_len_reflects_initial_count(self) -> None:
        """len()이 초기 프록시 리스트 개수와 일치해야 한다."""
        assert _crawl_utils is not None
        proxies = ["http://p1:1", "http://p2:2", "http://p3:3", "http://p4:4"]
        rotator = _crawl_utils.ProxyRotator(proxies)

        assert len(rotator) == 4


# ────────────────────────────────────────────────────────
# is_proxy_error 테스트
# ────────────────────────────────────────────────────────


class TestIsProxyError:
    """is_proxy_error() 함수 테스트."""

    @_skip_if_missing
    def test_connection_error_returns_true(self) -> None:
        """ConnectionError는 True를 반환해야 한다."""
        assert _crawl_utils is not None
        err = ConnectionError("Connection refused")

        assert _crawl_utils.is_proxy_error(err) is True

    @_skip_if_missing
    def test_timeout_error_returns_true(self) -> None:
        """TimeoutError는 True를 반환해야 한다."""
        assert _crawl_utils is not None
        err = TimeoutError("Request timed out")

        assert _crawl_utils.is_proxy_error(err) is True

    @_skip_if_missing
    def test_value_error_returns_false(self) -> None:
        """일반 ValueError는 False를 반환해야 한다."""
        assert _crawl_utils is not None
        err = ValueError("Invalid value")

        assert _crawl_utils.is_proxy_error(err) is False

    @_skip_if_missing
    def test_generic_exception_returns_false(self) -> None:
        """일반 Exception은 False를 반환해야 한다."""
        assert _crawl_utils is not None
        err = Exception("Some generic error")

        assert _crawl_utils.is_proxy_error(err) is False

    @_skip_if_missing
    def test_os_error_returns_true(self) -> None:
        """OSError(ConnectionError의 부모)는 프록시 오류로 판별해야 한다."""
        assert _crawl_utils is not None
        err = OSError("Network unreachable")

        assert _crawl_utils.is_proxy_error(err) is True

    @_skip_if_missing
    def test_http_error_404_returns_false(self) -> None:
        """404 상태를 가진 HTTP 오류는 False를 반환해야 한다."""
        assert _crawl_utils is not None
        http_err = Exception("404 Not Found")
        mock_resp = MagicMock()
        mock_resp.status_code = 404
        setattr(http_err, "response", mock_resp)

        assert _crawl_utils.is_proxy_error(http_err) is False

    @_skip_if_missing
    def test_http_error_500_returns_false(self) -> None:
        """500 서버 오류는 프록시 오류가 아니므로 False를 반환해야 한다."""
        assert _crawl_utils is not None
        http_err = Exception("500 Internal Server Error")
        mock_resp = MagicMock()
        mock_resp.status_code = 500
        setattr(http_err, "response", mock_resp)

        assert _crawl_utils.is_proxy_error(http_err) is False


# ────────────────────────────────────────────────────────
# fetch_with_retry 테스트
# ────────────────────────────────────────────────────────


class TestFetchWithRetry:
    """fetch_with_retry() 함수 테스트."""

    @_skip_if_missing
    def test_success_on_first_attempt(self) -> None:
        """첫 번째 시도에서 성공하면 Response를 반환해야 한다."""
        assert _crawl_utils is not None
        mock_response = MagicMock()
        mock_response.status_code = 200

        mock_fetcher_instance = MagicMock()
        mock_fetcher_instance.fetch.return_value = mock_response

        MockFetcherClass = MagicMock(return_value=mock_fetcher_instance)

        result = _crawl_utils.fetch_with_retry(
            "https://example.com",
            max_retries=3,
            fetcher_class=MockFetcherClass,
        )

        assert result == mock_response
        assert mock_fetcher_instance.fetch.call_count == 1

    @_skip_if_missing
    def test_retries_on_connection_error_then_succeeds(self) -> None:
        """연결 오류 후 재시도에서 성공하면 Response를 반환해야 한다."""
        assert _crawl_utils is not None
        mock_response = MagicMock()
        mock_response.status_code = 200

        mock_fetcher_instance = MagicMock()
        mock_fetcher_instance.fetch.side_effect = [
            ConnectionError("proxy down"),
            mock_response,
        ]

        MockFetcherClass = MagicMock(return_value=mock_fetcher_instance)

        result = _crawl_utils.fetch_with_retry(
            "https://example.com",
            max_retries=3,
            fetcher_class=MockFetcherClass,
        )

        assert result == mock_response
        assert mock_fetcher_instance.fetch.call_count == 2

    @_skip_if_missing
    def test_raises_last_exception_after_all_retries_fail(self) -> None:
        """모든 재시도 실패 시 마지막 예외를 raise해야 한다."""
        assert _crawl_utils is not None
        mock_fetcher_instance = MagicMock()
        mock_fetcher_instance.fetch.side_effect = ConnectionError("always fails")

        MockFetcherClass = MagicMock(return_value=mock_fetcher_instance)

        with pytest.raises(ConnectionError):
            _crawl_utils.fetch_with_retry(
                "https://example.com",
                max_retries=3,
                fetcher_class=MockFetcherClass,
            )

        assert mock_fetcher_instance.fetch.call_count == 3

    @_skip_if_missing
    def test_proxy_rotator_used_on_retry(self) -> None:
        """재시도 시 ProxyRotator.get_next()가 호출되어야 한다."""
        assert _crawl_utils is not None
        mock_response = MagicMock()
        mock_response.status_code = 200

        mock_fetcher_instance = MagicMock()
        mock_fetcher_instance.fetch.side_effect = [
            ConnectionError("first proxy down"),
            mock_response,
        ]

        MockFetcherClass = MagicMock(return_value=mock_fetcher_instance)

        mock_rotator = MagicMock()
        mock_rotator.get_next.return_value = "http://new-proxy:8080"

        _crawl_utils.fetch_with_retry(
            "https://example.com",
            max_retries=3,
            proxy_rotator=mock_rotator,
            fetcher_class=MockFetcherClass,
        )

        assert mock_rotator.get_next.call_count >= 1

    @_skip_if_missing
    def test_proxy_removed_on_proxy_error(self) -> None:
        """프록시 오류 발생 시 해당 프록시가 rotator에서 제거되어야 한다."""
        assert _crawl_utils is not None
        mock_response = MagicMock()
        mock_response.status_code = 200

        mock_fetcher_instance = MagicMock()
        mock_fetcher_instance.fetch.side_effect = [
            ConnectionError("proxy error"),
            mock_response,
        ]

        MockFetcherClass = MagicMock(return_value=mock_fetcher_instance)

        mock_rotator = MagicMock()
        bad_proxy = "http://bad-proxy:8080"
        mock_rotator.get_next.return_value = bad_proxy

        _crawl_utils.fetch_with_retry(
            "https://example.com",
            max_retries=3,
            proxy_rotator=mock_rotator,
            fetcher_class=MockFetcherClass,
        )

        mock_rotator.remove.assert_called()


# ────────────────────────────────────────────────────────
# get_resource_block_types 테스트
# ────────────────────────────────────────────────────────


class TestGetResourceBlockTypes:
    """get_resource_block_types() 함수 테스트."""

    @_skip_if_missing
    def test_default_preset_returns_expected_types(self) -> None:
        """default 프리셋은 image, font, media, stylesheet, other를 포함해야 한다."""
        assert _crawl_utils is not None
        result = _crawl_utils.get_resource_block_types("default")

        assert isinstance(result, set)
        assert "image" in result
        assert "font" in result
        assert "media" in result
        assert "stylesheet" in result
        assert "other" in result

    @_skip_if_missing
    def test_default_preset_excludes_websocket(self) -> None:
        """default 프리셋에는 websocket이 포함되지 않아야 한다."""
        assert _crawl_utils is not None
        result = _crawl_utils.get_resource_block_types("default")

        assert "websocket" not in result

    @_skip_if_missing
    def test_minimal_preset_returns_expected_types(self) -> None:
        """minimal 프리셋은 image, font, media만 포함해야 한다."""
        assert _crawl_utils is not None
        result = _crawl_utils.get_resource_block_types("minimal")

        assert "image" in result
        assert "font" in result
        assert "media" in result

    @_skip_if_missing
    def test_minimal_preset_excludes_stylesheet(self) -> None:
        """minimal 프리셋에는 stylesheet가 포함되지 않아야 한다."""
        assert _crawl_utils is not None
        result = _crawl_utils.get_resource_block_types("minimal")

        assert "stylesheet" not in result

    @_skip_if_missing
    def test_aggressive_preset_includes_all_types(self) -> None:
        """aggressive 프리셋은 websocket, manifest를 포함한 모든 타입을 가져야 한다."""
        assert _crawl_utils is not None
        result = _crawl_utils.get_resource_block_types("aggressive")

        assert isinstance(result, set)
        assert "image" in result
        assert "font" in result
        assert "media" in result
        assert "stylesheet" in result
        assert "other" in result
        assert "websocket" in result
        assert "manifest" in result

    @_skip_if_missing
    def test_aggressive_has_more_types_than_default(self) -> None:
        """aggressive 프리셋은 default보다 더 많은 타입을 포함해야 한다."""
        assert _crawl_utils is not None
        aggressive = _crawl_utils.get_resource_block_types("aggressive")
        default = _crawl_utils.get_resource_block_types("default")

        assert len(aggressive) > len(default)

    @_skip_if_missing
    def test_no_arg_uses_default_preset(self) -> None:
        """인자 없이 호출하면 default 프리셋과 동일한 결과를 반환해야 한다."""
        assert _crawl_utils is not None
        result_no_arg = _crawl_utils.get_resource_block_types()
        result_default = _crawl_utils.get_resource_block_types("default")

        assert result_no_arg == result_default

    @_skip_if_missing
    def test_returns_set_type(self) -> None:
        """반환값은 반드시 set 타입이어야 한다."""
        assert _crawl_utils is not None
        for preset in ("default", "minimal", "aggressive"):
            result = _crawl_utils.get_resource_block_types(preset)
            assert isinstance(result, set), f"{preset} 프리셋의 반환 타입이 set이 아님"


# ────────────────────────────────────────────────────────
# html_to_markdown 테스트
# ────────────────────────────────────────────────────────


class TestHtmlToMarkdown:
    """html_to_markdown() 함수 테스트."""

    @_skip_if_missing
    def test_basic_heading_conversion(self) -> None:
        """h1 태그는 Markdown # 제목으로 변환되어야 한다."""
        assert _crawl_utils is not None
        html = "<h1>제목</h1>"

        result = _crawl_utils.html_to_markdown(html)

        assert "제목" in result
        assert "#" in result

    @_skip_if_missing
    def test_paragraph_conversion(self) -> None:
        """p 태그의 텍스트는 결과에 포함되어야 한다."""
        assert _crawl_utils is not None
        html = "<p>본문 텍스트입니다.</p>"

        result = _crawl_utils.html_to_markdown(html)

        assert "본문 텍스트입니다." in result

    @_skip_if_missing
    def test_anchor_tag_conversion(self) -> None:
        """a 태그는 Markdown 링크 형식으로 변환되어야 한다."""
        assert _crawl_utils is not None
        html = '<a href="https://example.com">링크</a>'

        result = _crawl_utils.html_to_markdown(html)

        assert "링크" in result
        assert "https://example.com" in result

    @_skip_if_missing
    def test_remove_noise_removes_script_tags(self) -> None:
        """remove_noise=True 시 script 태그 내용이 제거되어야 한다."""
        assert _crawl_utils is not None
        html = "<p>본문</p><script>alert('xss');</script>"

        result = _crawl_utils.html_to_markdown(html, remove_noise=True)

        assert "alert" not in result
        assert "본문" in result

    @_skip_if_missing
    def test_remove_noise_removes_style_tags(self) -> None:
        """remove_noise=True 시 style 태그 내용이 제거되어야 한다."""
        assert _crawl_utils is not None
        html = "<p>내용</p><style>.cls { color: red; }</style>"

        result = _crawl_utils.html_to_markdown(html, remove_noise=True)

        assert "color: red" not in result
        assert "내용" in result

    @_skip_if_missing
    def test_remove_noise_removes_noscript_tags(self) -> None:
        """remove_noise=True 시 noscript 태그 내용이 제거되어야 한다."""
        assert _crawl_utils is not None
        html = "<p>텍스트</p><noscript>JS 필요</noscript>"

        result = _crawl_utils.html_to_markdown(html, remove_noise=True)

        assert "JS 필요" not in result

    @_skip_if_missing
    def test_remove_noise_removes_svg_tags(self) -> None:
        """remove_noise=True 시 svg 태그 내용이 제거되어야 한다."""
        assert _crawl_utils is not None
        html = "<p>텍스트</p><svg><path d='M0 0'/></svg>"

        result = _crawl_utils.html_to_markdown(html, remove_noise=True)

        assert "<svg" not in result
        assert "<path" not in result

    @_skip_if_missing
    def test_remove_noise_false_keeps_script(self) -> None:
        """remove_noise=False 시 script 태그 내용이 유지될 수 있다."""
        assert _crawl_utils is not None
        html = "<p>본문</p><script>var x = 1;</script>"

        result = _crawl_utils.html_to_markdown(html, remove_noise=False)

        assert "var x = 1" in result

    @_skip_if_missing
    def test_no_excessive_blank_lines(self) -> None:
        """결과에 연속된 빈 줄이 3개 이상 이어지지 않아야 한다."""
        assert _crawl_utils is not None
        html = "<p>A</p><p>B</p><p>C</p>"

        result = _crawl_utils.html_to_markdown(html)

        # 연속 빈 줄 3개 이상 없어야 함
        assert "\n\n\n" not in result

    @_skip_if_missing
    def test_returns_string(self) -> None:
        """반환값은 str 타입이어야 한다."""
        assert _crawl_utils is not None
        result = _crawl_utils.html_to_markdown("<p>hello</p>")

        assert isinstance(result, str)


# ────────────────────────────────────────────────────────
# clean_html 테스트
# ────────────────────────────────────────────────────────


class TestCleanHtml:
    """clean_html() 함수 테스트."""

    @_skip_if_missing
    def test_removes_script_tags(self) -> None:
        """script 태그와 그 내용이 제거되어야 한다."""
        assert _crawl_utils is not None
        html = "<p>본문</p><script>evil();</script>"

        result = _crawl_utils.clean_html(html)

        assert "<script" not in result
        assert "evil()" not in result

    @_skip_if_missing
    def test_removes_style_tags(self) -> None:
        """style 태그와 그 내용이 제거되어야 한다."""
        assert _crawl_utils is not None
        html = "<p>내용</p><style>body { margin: 0; }</style>"

        result = _crawl_utils.clean_html(html)

        assert "<style" not in result
        assert "margin: 0" not in result

    @_skip_if_missing
    def test_removes_noscript_tags(self) -> None:
        """noscript 태그와 그 내용이 제거되어야 한다."""
        assert _crawl_utils is not None
        html = "<div><noscript>Please enable JS</noscript><p>본문</p></div>"

        result = _crawl_utils.clean_html(html)

        assert "<noscript" not in result
        assert "Please enable JS" not in result

    @_skip_if_missing
    def test_removes_svg_tags(self) -> None:
        """svg 태그와 그 내용이 제거되어야 한다."""
        assert _crawl_utils is not None
        html = "<div><svg><circle r='10'/></svg><p>텍스트</p></div>"

        result = _crawl_utils.clean_html(html)

        assert "<svg" not in result

    @_skip_if_missing
    def test_removes_onclick_attribute(self) -> None:
        """onclick 속성이 제거되어야 한다."""
        assert _crawl_utils is not None
        html = '<button onclick="doSomething()">Click</button>'

        result = _crawl_utils.clean_html(html)

        assert "onclick" not in result
        assert "Click" in result

    @_skip_if_missing
    def test_removes_style_attribute(self) -> None:
        """style 인라인 속성이 제거되어야 한다."""
        assert _crawl_utils is not None
        html = '<p style="color: red; font-size: 14px;">텍스트</p>'

        result = _crawl_utils.clean_html(html)

        assert 'style="' not in result
        assert "텍스트" in result

    @_skip_if_missing
    def test_preserves_text_content(self) -> None:
        """태그 제거 후 텍스트 내용은 보존되어야 한다."""
        assert _crawl_utils is not None
        html = "<div><p>중요한 내용입니다.</p></div>"

        result = _crawl_utils.clean_html(html)

        assert "중요한 내용입니다." in result

    @_skip_if_missing
    def test_preserves_href_attribute(self) -> None:
        """href 속성은 제거되지 않아야 한다."""
        assert _crawl_utils is not None
        html = '<a href="https://example.com">링크</a>'

        result = _crawl_utils.clean_html(html)

        assert "href" in result
        assert "https://example.com" in result

    @_skip_if_missing
    def test_returns_string(self) -> None:
        """반환값은 str 타입이어야 한다."""
        assert _crawl_utils is not None
        result = _crawl_utils.clean_html("<p>hello</p>")

        assert isinstance(result, str)

    @_skip_if_missing
    def test_empty_html_returns_string(self) -> None:
        """빈 HTML 입력도 str을 반환해야 한다."""
        assert _crawl_utils is not None
        result = _crawl_utils.clean_html("")

        assert isinstance(result, str)
