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

import json
import logging
import time
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 import Camoufox
from playwright.sync_api import Browser, BrowserContext, Page, Response
from playwright.sync_api import (
    Error as PlaywrightError,
)
from playwright.sync_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

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

logger = logging.getLogger(__name__)


########################################################################################################################
# HELPER FUNCTIONS


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


def human_press_key(page: Page, key: str, count: int = 1, delay: int = 100, add_sleep: bool = True) -> None:
    """Presses a key with a random delay, optionally sleeping between presses."""
    for _ in range(count):
        page.keyboard.press(key, delay=randint(int(delay * 0.5), int(delay * 1.5)))  # noqa: S311
        if add_sleep:
            time.sleep(randint(int(delay * 1.5), int(delay * 2.5)) / 1000)  # noqa: S311


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


class _BaseCrawler:
    """Base class for sync 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.pw: Optional[Any] = None
        self.browser: Optional[Browser] = None
        self.context: Optional[BrowserContext] = None
        self.page: Optional[Page] = None

    def __enter__(self) -> Self:
        self.init_context()
        return self

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

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

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

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

        host, port, user, pwd = self.proxy_interface.get_proxies(raw=True, use_auth=True)
        proxy_url = f"http://{host}:{port}"
        proxy_cfg: dict = {"server": proxy_url}
        if user and pwd:
            proxy_cfg.update({"username": user, "password": pwd})

        logger.info(f"Starting browser with proxy: {proxy_url}")
        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,
    )
    def init_context(self) -> Self:
        """Initializes a new browser instance and context."""
        try:
            proxy_cfg: Optional[dict] = self._build_proxy_config()
            self._launch_browser(proxy_cfg)
        except Exception as e:
            logger.error(f"Failed to initialize browser context: {e}")
            self._close_browser(type(e), e, e.__traceback__)
            raise
        return self

    def restart_context(self) -> None:
        """Closes the current browser instance and initializes a new one."""
        logger.info("Restarting browser context...")
        self._close_browser(None, None, None)
        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),
        before=lambda rs: rs.args[0].restart_context() if rs.attempt_number > 1 else None,
        reraise=True,
    )
    def _goto_with_retry(self, url: str, timeout: int = 30_000) -> Response:
        """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.")
            self.restart_context()

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

    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...")
            self.init_context()
        return 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,
    )
    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 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 = self.goto(url, max_proxy_delay, timeout)
        ban_sleep(*sleep)
        body_content = 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_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}")


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


class CamoufoxCrawler(_BaseCrawler):
    """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."
        )

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

    def _close_browser(
        self,
        exc_type: Optional[type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> None:
        if self.pw:
            try:
                self.pw.__exit__(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):
    """Playwright crawler using Patchright (Chromium) with stealth patching."""

    def _launch_browser(self, proxy_cfg: Optional[dict]) -> None:
        from patchright.sync_api import sync_playwright

        self.pw = sync_playwright().start()
        launch_args: dict = {"headless": self.headless}
        if proxy_cfg:
            launch_args["proxy"] = proxy_cfg
        self.browser = self.pw.chromium.launch(**launch_args)
        self.context = self.browser.new_context()
        self.page = self.context.new_page()

    def _close_browser(
        self,
        exc_type: Optional[type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> None:
        try:
            if self.browser:
                self.browser.close()
            if self.pw:
                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):
    """Sync Playwright crawler. Selects Firefox (Camoufox) or Chrome (Patchright) via `browser`."""

    def __init__(
        self,
        proxy_interface: Optional[ProxyInterface] = None,
        headless: bool = True,
        browser: str = "firefox",
        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).
            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'.")
        super().__init__(proxy_interface, headless)
        self._browser_engine = browser
        self.camoufox_version = camoufox_version

    _ensure_version = CamoufoxCrawler._ensure_version

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

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