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

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

from bs4 import BeautifulSoup
from camoufox.async_api import AsyncCamoufox as Camoufox
from playwright.async_api import Browser, BrowserContext, Page, Response
from playwright.async_api import (
    Error as PlaywrightError,
)
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
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
from datamarket.utils.playwright.socks5_bridge import Socks5Bridge

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

logger = logging.getLogger(__name__)

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


async def human_type(page: Page, text: str, delay: int = 100):
    for char in text:
        await page.keyboard.type(char, delay=randint(int(delay * 0.5), int(delay * 1.5)))  # noqa: S311


async def human_press_key(page: Page, 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 page.keyboard.press(key, delay=randint(int(delay * 0.5), int(delay * 1.5)))  # noqa: S311
        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 Playwright-based crawlers with proxy support and retry logic."""

    _REDIRECT_STATUS_CODES = set(range(300, 309))

    def __init__(self, proxy_interface: Optional[ProxyInterface] = None, headless: bool = True):
        self.proxy_interface = proxy_interface
        self.headless = headless
        self.cdp_url: Optional[str] = None
        self.pw: Optional[Any] = None
        self.browser: Optional[Browser] = None
        self.context: Optional[BrowserContext] = None
        self.page: Optional[Page] = None
        self._socks5_bridge: Optional[Socks5Bridge] = 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)
        if self._socks5_bridge:
            self._socks5_bridge.stop()
            self._socks5_bridge = None

    async def _launch_browser(self, proxy_cfg: Optional[dict]) -> None:
        raise NotImplementedError

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

    async def _build_proxy_config(self) -> Optional[dict]:
        """Builds the proxy configuration dictionary."""
        if self.cdp_url:
            logger.info("Connecting via CDP; skipping proxy configuration.")
            return None
        if not self.proxy_interface:
            logger.info("Starting browser without proxy.")
            return None

        host, port, user, pwd, schema = await asyncio.to_thread(
            self.proxy_interface.get_proxies, raw=True, use_auth=True
        )
        if schema == "socks5" and user and pwd:
            # Playwright's Firefox does not support SOCKS5 authentication in any form.
            # Start a local HTTP CONNECT bridge that forwards to the authenticated SOCKS5 proxy.
            if self._socks5_bridge:
                self._socks5_bridge.stop()
            self._socks5_bridge = Socks5Bridge(host, port, user, pwd)
            local_port = await asyncio.to_thread(self._socks5_bridge.start)
            proxy_cfg: dict = {"server": f"socks5://127.0.0.1:{local_port}"}
        elif schema == "socks5":
            proxy_cfg = {"server": f"socks5://{host}:{port}"}
        else:
            proxy_cfg = {"server": f"{schema}://{host}:{port}"}
            if user and pwd:
                proxy_cfg.update({"username": user, "password": pwd})

        logger.info(f"Starting browser with proxy: {schema}://{host}:{port}")
        return proxy_cfg

    @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 async browser instance and context."""
        try:
            proxy_cfg: Optional[dict] = await self._build_proxy_config()
            await self._launch_browser(proxy_cfg)
        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 instance 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((PlaywrightTimeoutError, PlaywrightError)),
        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) -> Response:
        """Asynchronously navigates to a URL with retries for common Playwright errors."""
        if not (self.page and not self.page.is_closed()):
            logger.warning("Page is not available or closed. Restarting context.")
            await self.restart_context()

        response = await self.page.goto(url, timeout=timeout, wait_until="domcontentloaded")
        return response

    async def goto(
        self, url: str, max_proxy_delay: timedelta = timedelta(minutes=10), timeout: int = 30_000
    ) -> Response:
        """Ensures the browser is initialized and navigates to the given URL."""
        if not self.page:
            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, allow_redirects: bool = True) -> None:
        if not allow_redirects and response.request.redirected_from:  # noqa: F841
            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,
    ):
        """Asynchronously crawls a given URL using Playwright 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.page.content()
        body_text = await self.page.eval_on_selector("body", "body => body.innerText")

        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_text:
            raise EmptyResponseError(message=f"Empty body received from {url} (status {r.status})", response=r)

        output_format = {
            "json": lambda: json.loads(body_text),
            "text": lambda: body_text,
            "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}")


########################################################################################################################
# CAMOUFOX CRAWLER (Firefox)


class CamoufoxCrawler(_BaseCrawler):
    """Async Playwright crawler using Camoufox (Firefox) with fingerprint spoofing."""

    def __init__(
        self,
        proxy_interface: Optional[ProxyInterface] = None,
        headless: bool = True,
        # camoufox_version: Optional[str] = None,
    ):
        """
        Args:
            proxy_interface: Provider used to fetch proxy credentials.
            headless: Whether to run the browser in headless mode. Defaults to True.
            camoufox_version: Camoufox version specifier to ensure is installed and active,
                e.g. "official/prerelease/146.0.1-alpha.25". Uses the currently active version if None.
        """
        super().__init__(proxy_interface, headless)
        # self.camoufox_version = camoufox_version

    # def _ensure_version(self) -> None:
    #     """Ensures the requested camoufox_version is installed and set as active."""
    #     if not self.camoufox_version:
    #         return

    #     from camoufox.__main__ import CamoufoxUpdate
    #     from camoufox.multiversion import INSTALL_DIR, find_installed_version, load_repo_cache, set_active
    #     from camoufox.pkgman import AvailableVersion, RepoConfig, Version

    #     parts = self.camoufox_version.split("/")
    #     if len(parts) == 3:
    #         repo_name, _, ver_str = parts
    #     elif len(parts) == 2:
    #         repo_name, ver_str = parts
    #     else:
    #         raise ValueError(
    #             f"Invalid camoufox_version: {self.camoufox_version!r}. "
    #             "Expected 'repo/version' or 'repo/channel/version'."
    #         )
    #     ver_str = ver_str.lstrip("v")

    #     installed_path = find_installed_version(ver_str)
    #     if installed_path:
    #         set_active(str(installed_path.relative_to(INSTALL_DIR)))
    #         logger.info(f"Camoufox {ver_str} already installed, set as active.")
    #         return

    #     logger.info(f"Camoufox {ver_str} not found locally. Installing...")
    #     cache = load_repo_cache()
    #     for repo_data in cache.get("repos", []):
    #         if repo_data["name"].lower() != repo_name.lower():
    #             continue
    #         for v in repo_data["versions"]:
    #             if f"{v['version']}-{v['build']}" == ver_str:
    #                 av = AvailableVersion(
    #                     version=Version(v["build"], v["version"]),
    #                     url=v["url"],
    #                     is_prerelease=v.get("is_prerelease", False),
    #                 )
    #                 repo_config = RepoConfig.find_by_name(repo_data["name"])
    #                 CamoufoxUpdate(repo_config=repo_config, selected_version=av).update()
    #                 return

    #     raise ValueError(
    #         f"Camoufox version '{ver_str}' not found in cache. "
    #         "Run `python -m camoufox sync` to refresh available versions."
    #     )

    async def _launch_browser(self, proxy_cfg: Optional[dict]) -> None:
        # await asyncio.to_thread(self._ensure_version)
        self.pw = Camoufox(headless=self.headless, geoip=True, humanize=True, proxy=proxy_cfg)
        self.browser = await self.pw.__aenter__()
        self.context = await self.browser.new_context()
        self.page = await self.context.new_page()

    async def _close_browser(
        self,
        exc_type: Optional[type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> None:
        if self.pw:
            try:
                await self.pw.__aexit__(exc_type, exc_val, exc_tb)
            except Exception as e:
                logger.warning(f"Error closing browser: {e}")
            finally:
                self.pw = None
                self.browser = None
                self.context = None
                self.page = None


########################################################################################################################
# PATCHRIGHT CRAWLER (Chrome)


class PatchrightCrawler(_BaseCrawler):
    """Async Playwright crawler using Patchright (Chromium) with stealth patching."""

    async def _launch_browser(self, proxy_cfg: Optional[dict]) -> None:
        from patchright.async_api import async_playwright

        self.pw = await async_playwright().start()
        if self.cdp_url:
            logger.info(f"Connecting to remote browser via CDP: {self.cdp_url}")
            self.browser = await self.pw.chromium.connect_over_cdp(self.cdp_url)
            self.context = self.browser.contexts[0] if self.browser.contexts else await self.browser.new_context()
        else:
            launch_args: dict = {
                "headless": self.headless,
                "args": ["--webrtc-ip-handling-policy=disable_non_proxied_udp", "--force-webrtc-ip-handling-policy"],
            }
            if proxy_cfg:
                launch_args["proxy"] = proxy_cfg
            self.browser = await self.pw.chromium.launch(**launch_args)
            self.context = await self.browser.new_context()
        self.page = self.context.pages[0] if self.context.pages else await self.context.new_page()

    async def _close_browser(
        self,
        exc_type: Optional[type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> None:
        try:
            if self.browser:
                await self.browser.close()
            if self.pw:
                await self.pw.stop()
        except Exception as e:
            logger.warning(f"Error closing browser: {e}")
        finally:
            self.pw = None
            self.browser = None
            self.context = None
            self.page = None


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


class PlaywrightCrawler(_BaseCrawler):
    """Async Playwright crawler. Selects Firefox (Camoufox) or Chrome (Patchright) via `browser`."""

    def __init__(
        self,
        proxy_interface: Optional[ProxyInterface] = None,
        headless: bool = True,
        browser: str = "firefox",
        cdp_url: Optional[str] = None,
        # camoufox_version: Optional[str] = None,
    ):
        """
        Args:
            proxy_interface: Provider used to fetch proxy credentials.
            headless: Whether to run the browser in headless mode. Defaults to True.
            browser: Browser to use. Either "firefox" (Camoufox, default) or "chrome" (Patchright).
            cdp_url: CDP WebSocket URL to connect to a remote Chrome/Chromium instance
                (e.g. "http://localhost:9222"). Only valid when browser="chrome".
            camoufox_version: Camoufox version specifier (only used when browser="firefox"),
                e.g. "official/prerelease/146.0.1-alpha.25".
        """
        if browser not in ("firefox", "chrome"):
            raise ValueError(f"Unknown browser: {browser!r}. Choose 'firefox' or 'chrome'.")
        if cdp_url and browser != "chrome":
            raise ValueError("cdp_url is only supported when browser='chrome'.")
        super().__init__(proxy_interface, headless)
        self._browser_engine = browser
        self.cdp_url = cdp_url
        # self.camoufox_version = camoufox_version

    # _ensure_version = CamoufoxCrawler._ensure_version

    async def _launch_browser(self, proxy_cfg: Optional[dict]) -> None:
        if self._browser_engine == "firefox":
            await CamoufoxCrawler._launch_browser(self, proxy_cfg)
        else:
            await PatchrightCrawler._launch_browser(self, proxy_cfg)

    async def _close_browser(
        self,
        exc_type: Optional[type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> None:
        if self._browser_engine == "firefox":
            await CamoufoxCrawler._close_browser(self, exc_type, exc_val, exc_tb)
        else:
            await PatchrightCrawler._close_browser(self, exc_type, exc_val, exc_tb)
