########################################################################################################################
# IMPORTS

import asyncio
import json
import logging
from datetime import timedelta
from random import randint
from types import TracebackType
from typing import Optional, Self, Sequence

import nodriver
from bs4 import BeautifulSoup
from nodriver.core.util import deconstruct_browser
from requests.exceptions import HTTPError, ProxyError
from tenacity import (
    before_sleep_log,
    retry,
    retry_if_exception_type,
    retry_if_not_exception_type,
    stop_after_attempt,
    stop_after_delay,
    wait_exponential,
)

from datamarket.exceptions import BadRequestError, EmptyResponseError, NotFoundError, RedirectionDetectedError
from datamarket.exceptions.main import IgnoredHTTPError
from datamarket.interfaces.proxy import ProxyInterface
from datamarket.utils.main import ban_sleep_async

########################################################################################################################
# SETUP LOGGER

logger = logging.getLogger(__name__)


########################################################################################################################
# RESPONSE WRAPPER


class NodriverResponse:
    """Minimal response wrapper compatible with PlaywrightCrawler's Response interface."""

    def __init__(self, status: int, url: str, original_url: str):
        self.status = status
        self.url = url
        self.request = self._RequestStub(original_url, url)

    class _RequestStub:
        def __init__(self, original_url: str, final_url: str):
            self.url = final_url
            self.redirected_from = self._RedirectStub(original_url, final_url) if original_url != final_url else None

        class _RedirectStub:
            def __init__(self, from_url: str, to_url: str):
                self.url = from_url
                self.redirected_to = type("_Dest", (), {"url": to_url})()


########################################################################################################################
# ASYNC HELPER FUNCTIONS


async def human_type(tab: nodriver.Tab, text: str, delay: int = 100):
    """Types text character-by-character with randomized delays using CDP Input events."""
    for char in text:
        await tab.send(nodriver.cdp.input_.dispatch_key_event(type_="char", text=char))
        await asyncio.sleep(randint(int(delay * 0.5), int(delay * 1.5)) / 1000)  # noqa: S311


async def human_press_key(
    tab: nodriver.Tab, key: str, count: int = 1, delay: int = 100, add_sleep: bool = True
) -> None:
    """Asynchronously presses a key with a random delay, optionally sleeping between presses."""
    for _ in range(count):
        await tab.send(nodriver.cdp.input_.dispatch_key_event(type_="keyDown", key=key))
        await asyncio.sleep(randint(int(delay * 0.5), int(delay * 1.5)) / 1000)  # noqa: S311
        await tab.send(nodriver.cdp.input_.dispatch_key_event(type_="keyUp", key=key))
        if add_sleep:
            await asyncio.sleep(randint(int(delay * 1.5), int(delay * 2.5)) / 1000)  # noqa: S311


########################################################################################################################
# BASE CLASS


class _BaseCrawler:
    """Base class for async nodriver-based crawlers with proxy support and retry logic."""

    def __init__(self, proxy_interface: Optional[ProxyInterface] = None, headless: bool = True):
        self.proxy_interface = proxy_interface
        self.headless = headless
        self.browser: Optional[nodriver.Browser] = None
        self.tab: Optional[nodriver.Tab] = None
        self._proxy_user: Optional[str] = None
        self._proxy_pwd: Optional[str] = None

    async def __aenter__(self) -> Self:
        await self.init_context()
        return self

    async def __aexit__(
        self,
        exc_type: Optional[type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> None:
        await self._close_browser(exc_type, exc_val, exc_tb)

    async def _build_proxy_config(self) -> tuple[Optional[str], Optional[str], Optional[str]]:
        """Returns (proxy_url, username, password) or (None, None, None)."""
        if not self.proxy_interface:
            logger.info("Starting browser without proxy.")
            return None, None, None

        host, port, user, pwd, schema = await asyncio.to_thread(self.proxy_interface.get_proxies, raw=True, use_auth=True)
        proxy_url = f"{schema}://{host}:{port}"
        logger.info(f"Starting browser with proxy: {proxy_url}")
        return proxy_url, user, pwd

    async def _launch_browser(
        self,
        proxy_url: Optional[str],
        proxy_user: Optional[str],
        proxy_pwd: Optional[str],
    ) -> None:
        browser_args = []
        if proxy_url:
            browser_args.append(f"--proxy-server={proxy_url}")
            browser_args.append("--webrtc-ip-handling-policy=disable_non_proxied_udp")
            browser_args.append("--force-webrtc-ip-handling-policy")

        self.browser = await nodriver.start(
            headless=self.headless,
            browser_args=browser_args or None,
        )
        self.tab = self.browser.main_tab
        self._proxy_user = proxy_user
        self._proxy_pwd = proxy_pwd

        if proxy_user and proxy_pwd:
            await self._setup_proxy_auth()

    async def _setup_proxy_auth(self) -> None:
        """Enables CDP Fetch domain to handle proxy authentication challenges."""
        await self.tab.send(nodriver.cdp.fetch.enable(handle_auth_requests=True))

        async def _on_auth_required(event: nodriver.cdp.fetch.AuthRequired):
            await self.tab.send(
                nodriver.cdp.fetch.continue_with_auth(
                    request_id=event.request_id,
                    auth_challenge_response=nodriver.cdp.fetch.AuthChallengeResponse(
                        response="ProvideCredentials",
                        username=self._proxy_user,
                        password=self._proxy_pwd,
                    ),
                )
            )

        self.tab.add_handler(nodriver.cdp.fetch.AuthRequired, _on_auth_required)

    async def _close_browser(
        self,
        exc_type: Optional[type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> None:
        if self.browser:
            try:
                # deconstruct_browser calls stop() internally and then
                # shutil.rmtree on the temp profile dir, so each browser
                # instance gets its profile deleted immediately.
                await deconstruct_browser(self.browser)
            except Exception as e:
                logger.warning(f"Error closing browser: {e}")
            finally:
                self.browser = None
                self.tab = None

    async def _get_response_info(self, original_url: str) -> NodriverResponse:
        """Extracts HTTP status and redirect info via the Performance Navigation Timing API."""
        status = await self.tab.evaluate(
            "(() => {"
            "  const e = performance.getEntriesByType('navigation');"
            "  return e.length > 0 && e[0].responseStatus ? e[0].responseStatus : 200;"
            "})()",
            return_by_value=True,
        )
        current_url = self.tab.target.url if self.tab.target else original_url
        return NodriverResponse(status=int(status or 200), url=current_url, original_url=original_url)

    @retry(
        wait=wait_exponential(exp_base=2, multiplier=3, max=90),
        stop=stop_after_delay(timedelta(minutes=10)),
        before_sleep=before_sleep_log(logger, logging.INFO),
        reraise=True,
    )
    async def init_context(self) -> Self:
        """Initializes a new browser instance."""
        try:
            proxy_url, proxy_user, proxy_pwd = await self._build_proxy_config()
            await self._launch_browser(proxy_url, proxy_user, proxy_pwd)
        except Exception as e:
            logger.error(f"Failed to initialize browser context: {e}")
            await self._close_browser(type(e), e, e.__traceback__)
            raise
        return self

    async def restart_context(self) -> None:
        """Closes the current browser and initializes a new one."""
        logger.info("Restarting browser context...")
        await self._close_browser(None, None, None)
        await self.init_context()

    @retry(
        retry=retry_if_exception_type((asyncio.TimeoutError, ConnectionError, OSError)),
        wait=wait_exponential(exp_base=2, multiplier=3, max=90),
        stop=stop_after_delay(timedelta(minutes=10)),
        before_sleep=before_sleep_log(logger, logging.INFO),
        reraise=True,
    )
    async def _goto_with_retry(self, url: str, timeout: int = 30_000) -> NodriverResponse:
        """Navigates to a URL with retries for common browser errors."""
        if not self.tab:
            logger.warning("Tab is not available. Restarting context.")
            await self.restart_context()

        self.tab = await asyncio.wait_for(
            self.browser.get(url),
            timeout=timeout / 1000,
        )
        return await self._get_response_info(url)

    async def goto(
        self, url: str, max_proxy_delay: timedelta = timedelta(minutes=10), timeout: int = 30_000
    ) -> NodriverResponse:
        """Ensures the browser is initialized and navigates to the given URL."""
        if not self.tab:
            logger.info("Browser context not found, initializing now...")
            await self.init_context()
        return await self._goto_with_retry.retry_with(stop=stop_after_delay(max_proxy_delay))(self, url, timeout)

    def _handle_http_error(
        self, status_code: int, url: str, response: NodriverResponse, allow_redirects: bool = True
    ) -> None:
        if not allow_redirects and response.request.redirected_from:
            raise RedirectionDetectedError(
                message=f"HTTP redirect detected from {response.request.redirected_from.url} to {response.request.redirected_from.redirected_to.url}",
                response=response,
            )

        error_handlers = {
            404: lambda: NotFoundError(message=f"404 Not Found error for {url}", response=response),
            410: lambda: NotFoundError(message=f"410 Gone error for {url}", response=response),
            400: lambda: BadRequestError(message=f"400 Bad Request error for {url}", response=response),
        }

        if status_code in error_handlers:
            raise error_handlers[status_code]()

        if status_code >= 400:
            raise HTTPError(f"Navigation failed: {status_code} - {url}", response=response)

    @retry(
        retry=retry_if_not_exception_type(
            (IgnoredHTTPError, NotFoundError, BadRequestError, RedirectionDetectedError, ProxyError)
        ),
        wait=wait_exponential(exp_base=3, multiplier=3, max=60),
        stop=stop_after_attempt(5),
        before_sleep=before_sleep_log(logger, logging.WARNING),
        reraise=False,
        retry_error_callback=lambda rs: None,
    )
    async def get_data(
        self,
        url: str,
        output: str = "json",
        sleep: tuple = (6, 3),
        max_proxy_delay: timedelta = timedelta(minutes=10),
        ignored_status_codes: Sequence[int] = (),
        timeout: int = 30_000,
        **kwargs,
    ):
        """Crawls a given URL using nodriver and attempts to parse its body content."""
        params = kwargs.copy()
        allow_redirects = params.get("allow_redirects", True)

        logger.info(f"Fetching data from {url} ...")
        r = await self.goto(url, max_proxy_delay, timeout)
        await ban_sleep_async(*sleep)
        body_content = await self.tab.evaluate("document.body.innerText", return_by_value=True)

        if r.status in ignored_status_codes:
            raise IgnoredHTTPError(message=f"Status {r.status} in ignored_status_codes for URL {url}", response=r)

        self._handle_http_error(r.status, url, r, allow_redirects)

        if not body_content:
            raise EmptyResponseError(message=f"Empty body received from {url} (status {r.status})", response=r)

        output_format = {
            "json": lambda: json.loads(body_content),
            "text": lambda: body_content,
            "soup": lambda: BeautifulSoup(body_content, "html.parser"),
            "response": lambda: r,
        }

        if output in output_format:
            return output_format[output]()

        raise ValueError(f"Unsupported output format: {output}")


########################################################################################################################
# PUBLIC CLASS


class SeleniumCrawler(_BaseCrawler):
    """Async nodriver-based crawler. Drop-in async replacement for PlaywrightCrawler using Chrome via nodriver."""

    pass
