from __future__ import annotations

import asyncio
import dataclasses
import itertools
import platform
import random
import signal
import time
import typing
import urllib.parse
from collections import deque

import bs4
import curl_cffi.requests
from playwright.async_api import Browser
from playwright.async_api import BrowserContext
from playwright.async_api import Page
from playwright.async_api import ProxySettings
from playwright.async_api import TimeoutError as PlaywrightTimeout
from playwright.async_api import _context_manager
from playwright.async_api import async_playwright

from chitragupt.core.logger import GuptLogger
from chitragupt.core.logger import LogType


@dataclasses.dataclass(frozen=True, slots=True)
class CrawlerConfig:
    user_agents: typing.Tuple[str, ...] = dataclasses.field(
        default=(
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
            "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3_1) AppleWebKit/605.1.15 "
            "(KHTML, like Gecko) Version/17.2 Safari/605.1.15",
        )
    )
    proxy_list: typing.Tuple[str, ...] = dataclasses.field(default=())
    browser_profile: curl_cffi.requests.BrowserType = (
        curl_cffi.requests.BrowserType.chrome120
    )
    max_depth: int = 5
    max_concurrency: int = 100
    request_timeout: int = 30
    js_timeout: int = 30000
    base_rate: int = 10
    politeness_delay: typing.Tuple[float, float] = (1.0, 3.0)
    headless: bool = True
    enable_js: bool = True
    obey_robots: bool = False
    max_retries: int = 3
    fingerprint_threshold: float = 0.85
    playwright_browser_type: str = "chromium"


@dataclasses.dataclass(slots=True)
class PageData:
    url: str
    title: typing.Optional[str]
    text_content: typing.List[str]
    headings: typing.List[str]
    images: typing.List[str]
    links: typing.List[str]
    metadata: typing.Dict[str, str]
    screenshot: typing.Optional[bytes] = None
    network_logs: typing.List[typing.Dict[str, typing.Any]] = (
        dataclasses.field(default_factory=list)
    )


def _normalize_links(
    links: typing.List[bs4.element.Tag],
    base_url: urllib.parse.ParseResult,
) -> typing.List[str]:
    normalized = []
    for link in links:
        href = link["href"]
        assert isinstance(href, str)
        href = href.strip()
        if href.startswith(("javascript:", "mailto:", "tel:")):
            continue
        absolute = urllib.parse.urljoin(base_url.geturl(), href)
        parsed = urllib.parse.urlparse(absolute)
        normalized.append(parsed._replace(fragment="", query="").geturl())
    return normalized


def _extract_metadata(soup: bs4.BeautifulSoup) -> typing.Dict[str, str]:
    return {
        meta.get("name", meta.get("property", "")): meta.get("content", "")
        for meta in soup.find_all("meta")
        if meta.has_attr("content")
        and (meta.has_attr("name") or meta.has_attr("property"))
    }


def _extract_structured_data(content: str, base_url: str) -> PageData:
    soup = bs4.BeautifulSoup(content, "lxml")
    parsed_url = urllib.parse.urlparse(base_url)

    return PageData(
        url=base_url,
        title=soup.title.string if soup.title else None,
        text_content=[p.get_text().strip() for p in soup.find_all("p")],
        headings=[
            h.get_text().strip() for h in soup.find_all(["h1", "h2", "h3"])
        ],
        images=[img["src"] for img in soup.find_all("img", src=True)],
        links=_normalize_links(soup.find_all("a", href=True), parsed_url),
        metadata=_extract_metadata(soup),
    )


class Kundalini:
    __slots__ = (
        "_lock",
        "_pw_context",
        "_shutdown_flag",
        "_stats",
        "_tasks",
        "_ua_rotator",
        "browser",
        "config",
        "domain_limiter",
        "frontier",
        "logger",
        "playwright",
        "proxy_rotator",
        "rate_limiter",
        "session",
    )

    def __init__(self, config: CrawlerConfig, logger: GuptLogger) -> None:
        self.logger: GuptLogger = logger
        self.config: typing.Final[CrawlerConfig] = config
        self.rate_limiter: AdaptiveRateLimiter = AdaptiveRateLimiter(
            config.base_rate, logger
        )
        self.proxy_rotator: ProxyRotator = ProxyRotator(
            config.proxy_list, logger
        )
        self.domain_limiter: DomainLimiter = DomainLimiter()
        self.frontier: typing.Deque[FrontierItem] = deque()
        self.session: typing.Optional[curl_cffi.requests.AsyncSession] = None
        self.playwright: typing.Optional[
            _context_manager.PlaywrightContextManager
        ] = None
        self.browser: typing.Optional[Browser] = None
        self._pw_context: typing.Optional[BrowserContext] = None
        self._tasks: typing.Set[asyncio.Task] = set()
        self._shutdown_flag: bool = False
        self._lock: asyncio.Lock = asyncio.Lock()
        self._ua_rotator: typing.Iterator[str] = itertools.cycle(
            config.user_agents
        )
        self._stats: CrawlerStats = CrawlerStats()

    async def __aenter__(self) -> Kundalini:
        await self._initialize_resources()
        return self

    async def __aexit__(self, *exc_info: typing.Any) -> None:
        await self._shutdown_resources()

    async def _initialize_resources(self) -> None:
        self.session = curl_cffi.requests.AsyncSession(
            impersonate=self.config.browser_profile.name,
            timeout=self.config.request_timeout,
            verify=False,
        )
        if self.config.enable_js:
            self.playwright = async_playwright()
            pw = await self.playwright.__aenter__()
            next_proxy = self.proxy_rotator.next_proxy()
            if isinstance(next_proxy, str) and self.config.proxy_list:
                self.browser = await pw[
                    self.config.playwright_browser_type
                ].launch(
                    headless=self.config.headless,
                    proxy=ProxySettings(server=next_proxy),
                    args=["--no-sandbox", "--disable-setuid-sandbox"],
                )
            else:
                self.browser = await pw[
                    self.config.playwright_browser_type
                ].launch(
                    headless=self.config.headless,
                    args=["--no-sandbox", "--disable-setuid-sandbox"],
                )
            ProxySettings()
            self._pw_context = await self.browser.new_context(
                user_agent=next(self._ua_rotator), ignore_https_errors=True
            )

    def _handle_system_signals(self) -> None:
        if platform.system() != "Windows":
            loop = asyncio.get_running_loop()
            for sig in (signal.SIGINT, signal.SIGTERM):
                loop.add_signal_handler(sig, self._initiate_graceful_shutdown)

    def _initiate_graceful_shutdown(self) -> None:
        self.logger.log("Initiating system signal shutdown...", LogType.INFO)
        self._shutdown_flag = True

    async def run(
        self, seed_urls: typing.Union[str, typing.List[str]]
    ) -> CrawlerStats:
        self._handle_system_signals()
        seed_url_list = (
            [seed_urls] if isinstance(seed_urls, str) else seed_urls
        )
        async with self:
            for url in seed_url_list:
                self.frontier.append(
                    FrontierItem(
                        url=url, depth=0, priority=1.0, last_access=0.0
                    )
                )

            workers = [
                asyncio.create_task(self._crawl_task_worker())
                for _ in range(self.config.max_concurrency)
            ]
            await asyncio.gather(*workers)

            return self._stats

    async def _shutdown_resources(self) -> None:
        if self.session:
            await self.session.close()
        if self.browser:
            await self.browser.close()
        if self.playwright:
            await self.playwright.__aexit__(None, None, None)
        self._shutdown_flag = True

    async def _render_javascript(
        self, url: str
    ) -> typing.Optional[
        typing.Tuple[
            str,
            typing.List[typing.Dict[str, typing.Any]],
            typing.Optional[bytes],
        ]
    ]:
        if not self._pw_context:
            return None

        page: Page = await self._pw_context.new_page()
        network_logs: typing.List[typing.Dict[str, typing.Any]] = []
        screenshot: typing.Optional[bytes] = None

        def log_request(request: typing.Any) -> None:
            network_logs.append(
                {
                    "url": request.url,
                    "method": request.method,
                    "headers": dict(request.headers),
                    "post_data": request.post_data,
                }
            )

        page.on("request", log_request)

        try:
            await page.goto(url, timeout=self.config.js_timeout)
            content: str = await page.content()
            screenshot = await page.screenshot(full_page=True)

            self._stats = dataclasses.replace(
                self._stats,
                js_rendered=self._stats.js_rendered + 1,
                screenshots_taken=self._stats.screenshots_taken
                + (1 if screenshot else 0),
            )
            return content, network_logs, screenshot
        except PlaywrightTimeout:
            self.logger.log(f"Playwright timeout for {url}", LogType.WARNING)
            return None
        except Exception as exc:
            self.logger.log(f"Playwright error: {exc}", LogType.ERROR)
            return None
        finally:
            await page.close()

    async def store_data(self, url: str, data: PageData) -> None:
        # TODO
        # To be implemented.
        pass

    def _enqueue_links(
        self, links: typing.List[str], current_depth: int
    ) -> None:
        if current_depth >= self.config.max_depth:
            return

    async def _process_crawled_page(
        self,
        item: FrontierItem,
        content: str,
        network_logs: typing.List[typing.Dict[str, typing.Any]],
        screenshot: typing.Optional[bytes],
    ) -> None:
        data = _extract_structured_data(content, item.url)
        data.network_logs = network_logs
        data.screenshot = screenshot
        await self._store_data(url=item.url, data=data)
        self._enqueue_links(data.links, item.depth)

    def _generate_headers(self) -> typing.Dict[str, str]:
        return {
            "User-Agent": next(self._ua_rotator),
            "Accept-Language": "en-US,en;q=0.9",
            "Referer": "https://www.google.com/",
        }

    async def _fetch_page(self, url: str) -> typing.Optional[str]:
        while not self._shutdown_flag:
            for _ in range(self.config.max_retries):
                proxy = self.proxy_rotator.next_proxy()
                try:
                    response = await self.session.get(  # type: ignore
                        url,
                        headers=self._generate_headers(),
                        proxy=proxy,
                        allow_redirects=True,
                    )
                    self.rate_limiter.adjust(response.status_code)
                    self._stats = dataclasses.replace(
                        self._stats, fetched=self._stats.fetched + 1
                    )
                    return response.text
                except curl_cffi.requests.errors.CurlError as exc:
                    self.logger.log(
                        f"Curl error occurred: {exc}", LogType.WARNING
                    )
                    if proxy:
                        self.proxy_rotator.mark_bad(proxy)
                    continue
                except Exception as exc:
                    self.logger.log(
                        f"Unexpected error fetching page: {exc}", LogType.ERROR
                    )
                    self._stats = dataclasses.replace(
                        self._stats, errors=self._stats.errors + 1
                    )
                    return None
        return None

    async def _crawl_task_worker(self) -> None:
        while not self._shutdown_flag and self.frontier:
            item = self.frontier.popleft()
            parsed_url = urllib.parse.urlparse(item.url)

            async with self._lock:
                delay = random.uniform(*self.config.politeness_delay)
                elapsed = time.monotonic() - item.last_access
                if elapsed < delay:
                    await asyncio.sleep(delay - elapsed)

            content: typing.Optional[str] = await self._fetch_page(item.url)
            js_content: typing.Optional[
                typing.Tuple[
                    str,
                    typing.List[typing.Dict[str, typing.Any]],
                    typing.Optional[bytes],
                ]
            ] = None

            if not content and self.config.enable_js:
                js_content = await self._render_javascript(item.url)

            if js_content:
                content, network_logs, screenshot = js_content
                await self._process_crawled_page(
                    item, content, network_logs, screenshot
                )
                self.domain_limiter.update_domain(
                    parsed_url.netloc, success=True
                )
                self._stats = dataclasses.replace(
                    self._stats,
                    domains_crawled=self._stats.domains_crawled + 1,
                )

            await asyncio.sleep(self.rate_limiter.current_delay)


@typing.final
class AdaptiveRateLimiter:
    __slots__ = (
        "_base",
        "_current",
        "_error_history",
        "_last_adjusted",
        "logger",
    )

    def __init__(self, base_rate: int, logger: GuptLogger) -> None:
        self._base: float = float(base_rate)
        self._current: float = float(base_rate)
        self._error_history: typing.Deque[int] = deque(maxlen=100)
        self._last_adjusted: float = time.monotonic()

    def adjust(self, status_code: int) -> None:
        self._error_history.append(status_code)
        error_rate = sum(s >= 400 for s in self._error_history) / len(
            self._error_history
        )

        now = time.monotonic()
        if status_code == 429 or error_rate > 0.2:
            self._current = max(self._base * 0.5, self._current * 0.7)
            self._last_adjusted = now
            self.logger.log(
                f"Adaptive rate limiting activated: {self._current} req/s",
                LogType.WARNING,
            )
        elif now - self._last_adjusted > 60.0:
            self._current = min(self._base, self._current * 1.2)
            self._last_adjusted = now

    @property
    def current_delay(self) -> float:
        return 1.0 / self._current if self._current > 0 else 0.0


@typing.final
class ProxyRotator:
    __slots__ = ("_blacklist", "_current", "_proxies", "logger")

    def __init__(
        self, proxies: typing.Tuple[str, ...], logger: GuptLogger
    ) -> None:
        self._proxies: typing.Deque[str] = deque(proxies)
        self._blacklist: typing.Set[str] = set()
        self._current: typing.Optional[str] = None

    def next_proxy(self) -> typing.Optional[str]:
        while self._proxies:
            candidate = self._proxies[0]
            if candidate not in self._blacklist:
                self._current = candidate
                return candidate
            self._proxies.rotate(-1)
        return None

    def mark_bad(self, proxy: str) -> None:
        self._blacklist.add(proxy)
        self.logger.log(f"Proxy added to blacklist: {proxy}", LogType.WARNING)
        if proxy == self._current:
            self._current = None


@dataclasses.dataclass(frozen=True, slots=True)
class CrawlerStats:
    fetched: int = 0
    errors: int = 0
    js_rendered: int = 0
    domains_crawled: int = 0
    screenshots_taken: int = 0


@dataclasses.dataclass(frozen=False, slots=True)
class FrontierItem:
    url: str
    depth: int
    priority: float
    last_access: float = 0.0


class DomainLimiter:
    __slots__ = ("_delays", "_last_access")

    def __init__(self) -> None:
        self._delays: typing.Dict[str, float] = {}
        self._last_access: typing.Dict[str, float] = {}

    def get_delay(self, domain: str) -> float:
        return self._delays.get(domain, 1.0)

    def update_domain(self, domain: str, success: bool) -> None:
        current = self._delays.get(domain, 1.0)
        self._delays[domain] = current * (0.9 if success else 1.1)
        self._last_access[domain] = time.monotonic()

    def should_delay(self, domain: str) -> bool:
        last_access = self._last_access.get(domain, 0.0)
        return time.monotonic() - last_access < self._delays.get(domain, 1.0)

    def reset(self) -> None:
        self._delays.clear()
        self._last_access.clear()
