"""
crawl_utils.py — 크롤링 유틸리티 모음.

ProxyRotator, is_proxy_error, fetch_with_retry,
get_resource_block_types, html_to_markdown, clean_html 제공.
"""

import random
import re
import time
from typing import Any, Optional

import lxml.html
import markdownify
from bs4 import BeautifulSoup

# ────────────────────────────────────────────────────────
# ProxyRotator
# ────────────────────────────────────────────────────────


class ProxyRotator:
    """프록시 목록을 순환하는 클래스.

    Args:
        proxies: 프록시 URL 리스트.
        strategy: "round_robin" 또는 "random".
    """

    def __init__(self, proxies: list[str], strategy: str = "round_robin") -> None:
        self._proxies: list[str] = list(proxies)
        self._strategy = strategy
        self._index: int = 0

    def get_next(self) -> Optional[str]:
        """다음 프록시를 반환한다. 빈 리스트면 None을 반환한다."""
        if not self._proxies:
            return None
        if self._strategy == "random":
            return random.choice(self._proxies)
        # round_robin
        proxy = self._proxies[self._index % len(self._proxies)]
        self._index += 1
        return proxy

    def remove(self, proxy: str) -> None:
        """특정 프록시를 목록에서 제거한다. 없으면 무시한다."""
        try:
            self._proxies.remove(proxy)
            # 인덱스가 범위를 벗어나지 않도록 조정
            if self._proxies and self._index >= len(self._proxies):
                self._index = self._index % len(self._proxies)
            else:
                self._index = 0
        except ValueError:
            pass

    def __len__(self) -> int:
        """남은 프록시 수를 반환한다."""
        return len(self._proxies)


# ────────────────────────────────────────────────────────
# is_proxy_error
# ────────────────────────────────────────────────────────


def is_proxy_error(error: Exception) -> bool:
    """프록시 관련 오류 여부를 판별한다.

    - response.status_code 속성이 있으면 HTTP 오류로 간주 → False
    - OSError / ConnectionError / TimeoutError 계열 → True
    - 그 외 → False
    """
    # HTTP 응답이 있는 오류는 프록시 오류가 아님
    response = getattr(error, "response", None)
    if response is not None and hasattr(response, "status_code"):
        return False
    return isinstance(error, OSError)


# ────────────────────────────────────────────────────────
# fetch_with_retry
# ────────────────────────────────────────────────────────


def fetch_with_retry(
    url: str,
    max_retries: int = 3,
    proxy_rotator: Optional[Any] = None,
    fetcher_class: Optional[Any] = None,
) -> Any:
    """재시도 로직을 포함한 URL 페치 함수.

    Args:
        url: 가져올 URL.
        max_retries: 최대 재시도 횟수.
        proxy_rotator: ProxyRotator 인스턴스 (선택).
        fetcher_class: 페처 클래스. None이면 scrapling.Fetcher 사용.

    Returns:
        페처의 fetch() 반환값.

    Raises:
        마지막 시도까지 실패하면 마지막 예외를 raise.
    """
    if fetcher_class is None:
        import scrapling

        fetcher_class = scrapling.Fetcher

    fetcher = fetcher_class()

    last_exc: Optional[Exception] = None
    current_proxy: Optional[str] = None

    for attempt in range(max_retries):
        # 프록시 회전: 첫 시도도 포함하여 rotator가 있으면 프록시 가져옴
        if proxy_rotator is not None:
            current_proxy = proxy_rotator.get_next()

        try:
            if current_proxy is not None:
                result = fetcher.fetch(url, proxy=current_proxy)
            else:
                result = fetcher.fetch(url)
            return result
        except Exception as exc:
            last_exc = exc
            if is_proxy_error(exc) and proxy_rotator is not None and current_proxy is not None:
                proxy_rotator.remove(current_proxy)
                current_proxy = None
            if attempt < max_retries - 1:
                time.sleep(2**attempt)

    assert last_exc is not None
    raise last_exc


# ────────────────────────────────────────────────────────
# get_resource_block_types
# ────────────────────────────────────────────────────────

_RESOURCE_BLOCK_PRESETS: dict[str, set[str]] = {
    "default": {"image", "font", "media", "stylesheet", "other"},
    "minimal": {"image", "font", "media"},
    "aggressive": {
        "image",
        "font",
        "media",
        "stylesheet",
        "other",
        "websocket",
        "manifest",
    },
}


def get_resource_block_types(preset: str = "default") -> set[str]:
    """리소스 차단 타입 집합을 반환한다.

    Args:
        preset: "default", "minimal", "aggressive" 중 하나.

    Returns:
        차단할 리소스 타입 집합.
    """
    return set(_RESOURCE_BLOCK_PRESETS[preset])


# ────────────────────────────────────────────────────────
# html_to_markdown
# ────────────────────────────────────────────────────────

_NOISE_TAGS = {"script", "style", "noscript", "svg"}


def _remove_noise_tags_bs4(html: str) -> str:
    """BeautifulSoup을 사용하여 script/style/noscript/svg 태그와 내용을 제거한다."""
    soup = BeautifulSoup(html, "html.parser")
    for tag in _NOISE_TAGS:
        for elem in soup.find_all(tag):
            elem.decompose()
    return str(soup)


def _expand_script_tags_to_text(html: str) -> str:
    """BeautifulSoup을 사용하여 script 태그를 텍스트 노드로 교체한다.

    markdownify는 script 태그 내용을 무시하므로, remove_noise=False일 때
    script 태그를 텍스트로 변환하여 내용을 보존한다.
    """
    soup = BeautifulSoup(html, "html.parser")
    for script_tag in soup.find_all("script"):
        text = script_tag.get_text()
        script_tag.replace_with(soup.new_string(text))
    return str(soup)


def html_to_markdown(html: str, remove_noise: bool = True) -> str:
    """HTML을 Markdown으로 변환한다.

    Args:
        html: 변환할 HTML 문자열.
        remove_noise: True이면 script/style/noscript/svg를 먼저 제거.

    Returns:
        변환된 Markdown 문자열.
    """
    if remove_noise:
        html = _remove_noise_tags_bs4(html)
    else:
        # script 태그 내용을 텍스트 노드로 교체하여 markdownify가 처리하도록 함
        html = _expand_script_tags_to_text(html)

    md: str = markdownify.markdownify(html, heading_style="atx")

    # 연속 3개 이상의 빈 줄을 2개로 축소
    md = re.sub(r"\n{3,}", "\n\n", md)
    return md


# ────────────────────────────────────────────────────────
# clean_html
# ────────────────────────────────────────────────────────

_EVENT_HANDLER_PATTERN = re.compile(
    r'\s+on\w+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]*)',
    re.IGNORECASE,
)
_STYLE_ATTR_PATTERN = re.compile(
    r'\s+style\s*=\s*(?:"[^"]*"|\'[^\']*\')',
    re.IGNORECASE,
)

_CLEAN_TAGS = {"script", "style", "noscript", "svg"}


def clean_html(html: str) -> str:
    """HTML에서 노이즈 태그와 위험 속성을 제거한다.

    - script/style/noscript/svg 태그 + 내용 제거
    - onclick, onload, onmouseover 등 이벤트 핸들러 속성 제거
    - style 인라인 속성 제거
    - href, src, class, id 등 유용한 속성은 유지

    Args:
        html: 정리할 HTML 문자열.

    Returns:
        정리된 HTML 문자열.
    """
    if not html:
        return ""

    try:
        # lxml.html.fromstring은 UTF-8 문자열을 올바르게 처리함
        doc = lxml.html.fromstring(html)

        # 노이즈 태그 제거
        for tag in _CLEAN_TAGS:
            for elem in doc.xpath(f"//{tag}"):
                parent = elem.getparent()
                if parent is not None:
                    parent.remove(elem)

        # 모든 요소에서 이벤트 핸들러 속성 및 style 속성 제거
        for elem in doc.iter():
            attribs_to_remove = [
                attr for attr in elem.attrib if attr.lower().startswith("on") or attr.lower() == "style"
            ]
            for attr in attribs_to_remove:
                del elem.attrib[attr]

        raw = lxml.html.tostring(doc, encoding="unicode")
        result: str = raw if isinstance(raw, str) else raw.decode("utf-8")
        return result
    except Exception:
        # lxml 파싱 실패 시 정규식 fallback
        for tag in _CLEAN_TAGS:
            html = re.sub(
                rf"<{tag}[\s\S]*?</{tag}>",
                "",
                html,
                flags=re.IGNORECASE | re.DOTALL,
            )
        html = _EVENT_HANDLER_PATTERN.sub("", html)
        html = _STYLE_ATTR_PATTERN.sub("", html)
        return html
