# Copyright (c) 2025 CoReason, Inc.
#
# This software is proprietary and dual-licensed.
# Licensed under the Prosperity Public License 3.0 (the "License").
# A copy of the license is available at https://prosperitylicense.com/versions/3.0.0
# For details, see the LICENSE file.
# Commercial use beyond a 30-day trial requires a separate license.
#
# Source Code: https://github.com/CoReason-AI/coreason_navigator

import base64
import fnmatch
import urllib.parse
from functools import partial
from typing import Any, Literal, Optional, cast

import anyio
import httpx
import markdownify
from anyio.from_thread import BlockingPortal
from coreason_identity.models import UserContext
from playwright.async_api import Browser, BrowserContext, Page, Playwright, Route, async_playwright
from readability import Document

from coreason_navigator.exceptions import DomainNotAllowedError
from coreason_navigator.guardrails.pii import PIIGuard
from coreason_navigator.interface import Navigator
from coreason_navigator.perception.ax_tree import get_interactive_elements
from coreason_navigator.perception.overlay import draw_som_overlay
from coreason_navigator.types import (
    BrowserAction,
    BrowserState,
    ClickAction,
    GotoAction,
    ScreenshotAction,
    ScrollAction,
    TypeAction,
    WaitAction,
)
from coreason_navigator.utils.logger import logger
from coreason_navigator.utils.rate_limiter import RateLimiter

# Stealth constants
STEALTH_USER_AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
)

STEALTH_INIT_SCRIPT = """
Object.defineProperty(navigator, 'webdriver', {
    get: () => undefined
});
"""


class PlaywrightNavigatorAsync(Navigator):
    """
    Playwright implementation of the Navigator interface.

    Manages the browser lifecycle, navigation, and interaction execution using Playwright.
    Handles stealth measures, rate limiting, and domain restrictions.

    Attributes:
        headless (bool): Whether to run the browser in headless mode.
        rate_limiter (RateLimiter): The rate limiter instance.
        allowed_domains (list[str]): List of allowed domain patterns.
        pii_guard (PIIGuard): Guardrail for PII detection.
    """

    def __init__(
        self,
        headless: bool = True,
        rate_limit: float = 2.0,
        allowed_domains: Optional[list[str]] = None,
        pii_guard: Optional[PIIGuard] = None,
        client: Optional[httpx.AsyncClient] = None,
    ):
        """
        Initializes the PlaywrightNavigatorAsync.

        Args:
            headless: Whether to run the browser in headless mode. Defaults to True.
            rate_limit: Minimum time interval (in seconds) between actions. Defaults to 2.0.
            allowed_domains: List of allowed domain patterns (glob style).
                             If None, all domains are allowed.
            pii_guard: Optional PII guardrail to check input text for sensitive data.
            client: Optional httpx.AsyncClient for external session management.
        """
        self.headless = headless
        self.rate_limiter = RateLimiter(rate_limit=rate_limit)
        self.allowed_domains = allowed_domains
        self.pii_guard = pii_guard

        self._internal_client = client is None
        self._client = client or httpx.AsyncClient()

        self.playwright: Playwright | None = None
        self.browser: Browser | None = None
        self.context: BrowserContext | None = None
        self.page: Page | None = None

    async def __aenter__(self) -> "PlaywrightNavigatorAsync":
        return self

    async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
        await self.close()
        if self._internal_client:
            await self._client.aclose()

    async def launch(self) -> None:
        """
        Starts the browser context and initializes the page.

        Sets up the stealth environment (User-Agent, navigator.webdriver masking)
        and configures route interception for domain allowlisting.
        """
        if self.playwright is None:
            self.playwright = await async_playwright().start()

        if self.browser is None:
            # We'll default to chromium as confirmed
            self.browser = await self.playwright.chromium.launch(headless=self.headless)

        if self.context is None:
            self.context = await self.browser.new_context(
                viewport={"width": 1280, "height": 800},  # Standardized resolution as per PRD
                user_agent=STEALTH_USER_AGENT,  # AUC-6: Stealth User-Agent
            )
            # AUC-6: Inject stealth scripts to mask automation traces
            await self.context.add_init_script(STEALTH_INIT_SCRIPT)

            # AUC-10.2: Runtime Interception for clicks/redirects
            if self.allowed_domains:
                await self.context.route("**", self._handle_route)

        if self.page is None:
            self.page = await self.context.new_page()

    async def _get_state(self) -> BrowserState:
        """
        Captures the current state of the browser (Internal).

        Fetches the screenshot, interactive elements (Accessibility Tree),
        and generates the Set-of-Marks overlay.

        Returns:
            BrowserState: The current visual and structural state of the page.

        Raises:
            RuntimeError: If the browser has not been launched.
        """
        if not self.page:
            raise RuntimeError("Browser not launched. Call launch() first.")

        # Capture screenshot as JPEG with reduced quality for VLM efficiency
        screenshot_bytes = await self.page.screenshot(type="jpeg", quality=75)
        screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8")

        # Extract interactive elements using CDP via Perception module (AUC-3)
        interactive_elements = await get_interactive_elements(self.page)

        # Generate SoM overlay (AUC-4)
        som_screenshot_base64 = draw_som_overlay(screenshot_base64, interactive_elements)

        return BrowserState(
            url=self.page.url,
            title=await self.page.title(),
            screenshot_base64=screenshot_base64,
            som_screenshot_base64=som_screenshot_base64,
            interactive_elements=interactive_elements,
        )

    async def navigate(self, url: str, *, context: UserContext) -> BrowserState:
        """
        Navigates to the specified URL and returns the visual state.

        Args:
            url: The URL to navigate to.
            context: The identity context for the operation.

        Returns:
            BrowserState: The state of the browser after navigation.

        Raises:
            DomainNotAllowedError: If the URL is not in the allowed domains list.
            ValueError: If context is None.
        """
        if context is None:
            raise ValueError("UserContext is required for navigation")

        logger.info(
            "Navigating to URL",
            user_id=context.sub,
            url=url,
        )

        # AUC-10.1: Check if URL is allowed
        if not self._is_url_allowed(url):
            raise DomainNotAllowedError(url, self.allowed_domains or [])

        await self.rate_limiter.wait()

        if not self.page:
            await self.launch()

        if self.page:  # pragma: no branch
            await self.page.goto(url, wait_until="domcontentloaded")

        return await self._get_state()

    def _is_url_allowed(self, url: str) -> bool:
        """
        Checks if the URL is allowed based on the allowed_domains list.

        Args:
            url: The URL to check.

        Returns:
            bool: True if the URL is allowed or if no restrictions are set; False otherwise.
        """
        if not self.allowed_domains:
            return True

        try:
            parsed = urllib.parse.urlparse(url)
            hostname = parsed.hostname
            if not hostname:
                return False
        except Exception:  # pragma: no cover
            return False

        for domain_pattern in self.allowed_domains:
            # Check for wildcard support using fnmatch
            # example.com matches example.com
            # *.example.com matches sub.example.com
            # Normalize to lowercase for case-insensitive matching
            if fnmatch.fnmatch(hostname.lower(), domain_pattern.lower()):
                return True

        return False

    async def _handle_route(self, route: Route) -> None:
        """
        Intercepts requests to enforce the domain allowlist.

        Args:
            route: The Playwright Route object to handle.
        """
        request = route.request

        # Only block top-level navigations to disallowed domains
        if request.is_navigation_request() and request.frame.parent_frame is None:
            if not self._is_url_allowed(request.url):
                logger.warning(f"Blocked navigation to disallowed domain: {request.url}")
                try:
                    await route.abort("blockedbyclient")
                except Exception:  # pragma: no cover
                    pass
                return

        await route.continue_()

    async def perform_action(self, action: BrowserAction, *, context: UserContext) -> BrowserState:
        """
        Executes a browser action (click, type, scroll, etc.) and returns the new state.

        Args:
            action: The BrowserAction to perform.
            context: The identity context for the operation.

        Returns:
            BrowserState: The updated state of the browser.

        Raises:
            ValueError: If the action type is unknown or invalid, or if context is missing.
            RuntimeError: If the browser fails to launch.
        """
        if context is None:
            raise ValueError("UserContext is required for interaction")

        logger.debug(
            "Executing browser interaction",
            user_id=context.sub,
            action=type(action).__name__,
        )

        await self.rate_limiter.wait()

        if not self.page:
            await self.launch()
            if not self.page:  # pragma: no cover
                raise RuntimeError("Failed to launch browser")

        if isinstance(action, ClickAction):
            return await self._handle_click(action)
        elif isinstance(action, TypeAction):
            return await self._handle_type(action)
        elif isinstance(action, ScrollAction):
            return await self._handle_scroll(action)
        elif isinstance(action, GotoAction):
            return await self._handle_goto(action, context=context)
        elif isinstance(action, WaitAction):
            return await self._handle_wait(action)
        elif isinstance(action, ScreenshotAction):
            return await self._handle_screenshot(action)
        else:
            raise ValueError(f"Unknown action type: {action}")  # pragma: no cover

    async def _handle_click(self, action: ClickAction) -> BrowserState:
        """
        Handles click action.

        Args:
            action: The ClickAction to perform.

        Returns:
            BrowserState: The new state.

        Raises:
            ValueError: If target_id is invalid or coordinates are missing.
        """
        x, y = action.x, action.y

        # Page is guaranteed by perform_action
        assert self.page is not None

        if x is None or y is None:
            if action.target_id:
                # AUC-5b: Stateless Re-fetch
                # We need to find the element by ID and click its center
                elements = await get_interactive_elements(self.page)
                target = next((e for e in elements if e.element_id == action.target_id), None)

                if not target:
                    raise ValueError(f"Element with ID {action.target_id} not found")

                # Calculate center
                x = target.x + (target.width / 2)
                y = target.y + (target.height / 2)
            else:
                # We strictly require coordinates if no target_id
                raise ValueError("Click action requires x and y coordinates or target_id")

        # Ensure x and y are not None for typing
        assert x is not None and y is not None

        await self.page.mouse.click(x, y)

        return await self._get_state()

    async def _handle_type(self, action: TypeAction) -> BrowserState:
        """
        Handles type action.

        Args:
            action: The TypeAction to perform.

        Returns:
            BrowserState: The new state.

        Raises:
            PIIViolationError: If PII is detected in the input text.
        """
        # AUC-12: PII Protection
        if self.pii_guard:
            await self.pii_guard.check(action.text)

        # Page is guaranteed by perform_action
        assert self.page is not None
        await self.page.keyboard.type(action.text)
        if action.press_enter:
            await self.page.keyboard.press("Enter")

        return await self._get_state()

    async def _handle_scroll(self, action: ScrollAction) -> BrowserState:
        """
        Handles scroll action.

        Args:
            action: The ScrollAction to perform.

        Returns:
            BrowserState: The new state.
        """
        # Page is guaranteed by perform_action
        assert self.page is not None

        delta_x = 0
        delta_y = 0

        if action.direction == "down":
            delta_y = action.amount
        elif action.direction == "up":
            delta_y = -action.amount
        elif action.direction == "left":
            delta_x = -action.amount
        elif action.direction == "right":
            delta_x = action.amount

        await self.page.mouse.wheel(delta_x, delta_y)

        return await self._get_state()

    async def _handle_goto(self, action: GotoAction, context: UserContext) -> BrowserState:
        """
        Handles goto action.

        Args:
            action: The GotoAction to perform.
            context: The user context.

        Returns:
            BrowserState: The new state.
        """
        return await self.navigate(action.url, context=context)

    async def _handle_wait(self, action: WaitAction) -> BrowserState:
        """
        Handles wait action.

        Args:
            action: The WaitAction to perform.

        Returns:
            BrowserState: The new state.
        """
        # Page is guaranteed by perform_action
        assert self.page is not None
        await self.page.wait_for_load_state(action.state)
        return await self._get_state()

    async def _handle_screenshot(self, action: ScreenshotAction) -> BrowserState:
        """
        Handles screenshot action (idempotent state capture).

        Args:
            action: The ScreenshotAction to perform.

        Returns:
            BrowserState: The new state.
        """
        return await self._get_state()

    async def extract_content(self, format: Literal["markdown", "html"], *, context: UserContext) -> str:
        """
        Converts the page content to an LLM-readable format.

        Args:
            format: The desired output format ("markdown" or "html").
            context: The identity context for the operation.

        Returns:
            str: The extracted content.

        Raises:
            ValueError: If the format is not supported or context is missing.
        """
        if context is None:
            raise ValueError("UserContext is required for content extraction")  # pragma: no cover

        if not self.page:
            await self.launch()
            assert self.page is not None

        try:
            # Get raw HTML
            html_content = await self.page.content()

            # Parse with readability
            doc = Document(html_content)
            summary_html = doc.summary()

            if format == "html":
                return str(summary_html)
            elif format == "markdown":
                return str(markdownify.markdownify(summary_html, heading_style="ATX"))
            else:
                raise ValueError(f"Unsupported format: {format}")  # pragma: no cover

        except Exception:
            # Fallback to simple text extraction if readability fails
            # This ensures robustness against malformed HTML or parsing errors
            assert self.page is not None
            return str(await self.page.inner_text("body"))

    async def close(self) -> None:
        """
        Closes the browser resources (context, browser, playwright).
        """
        if self.context:
            await self.context.close()
            self.context = None
        if self.browser:
            await self.browser.close()
            self.browser = None
        if self.playwright:
            await self.playwright.stop()
            self.playwright = None


class PlaywrightNavigator:
    """
    Synchronous Facade for PlaywrightNavigatorAsync.
    Wraps all async methods using a background event loop via BlockingPortal.

    Attributes:
        _async (PlaywrightNavigatorAsync): The underlying async navigator instance.
    """

    def __init__(
        self,
        headless: bool = True,
        rate_limit: float = 2.0,
        allowed_domains: Optional[list[str]] = None,
        pii_guard: Optional[PIIGuard] = None,
        client: Optional[httpx.AsyncClient] = None,
    ):
        """
        Initializes the PlaywrightNavigator sync facade.

        Args:
            headless: Whether to run the browser in headless mode. Defaults to True.
            rate_limit: Minimum time interval (in seconds) between actions. Defaults to 2.0.
            allowed_domains: List of allowed domain patterns (glob style).
            pii_guard: Optional PII guardrail.
            client: Optional httpx.AsyncClient.
        """
        self._async = PlaywrightNavigatorAsync(
            headless=headless,
            rate_limit=rate_limit,
            allowed_domains=allowed_domains,
            pii_guard=pii_guard,
            client=client,
        )
        self._portal: Optional[BlockingPortal] = None
        self._portal_cm: Any = None

    def __enter__(self) -> "PlaywrightNavigator":
        self._portal_cm = anyio.from_thread.start_blocking_portal()
        self._portal = self._portal_cm.__enter__()
        self._get_portal().call(self._async.__aenter__)
        return self

    def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
        try:
            if self._portal:
                self._portal.call(self._async.__aexit__, exc_type, exc_val, exc_tb)
        finally:
            if self._portal_cm:
                self._portal_cm.__exit__(exc_type, exc_val, exc_tb)
            self._portal = None
            self._portal_cm = None

    def _get_portal(self) -> BlockingPortal:
        if not self._portal:
            raise RuntimeError("PlaywrightNavigator must be used within a context manager (with statement).")
        return self._portal

    def launch(self) -> None:
        """Starts the browser context."""
        self._get_portal().call(self._async.launch)

    def navigate(self, url: str, *, context: UserContext) -> BrowserState:
        """Navigates to the specified URL."""
        return cast(BrowserState, self._get_portal().call(partial(self._async.navigate, url, context=context)))

    def perform_action(self, action: BrowserAction, *, context: UserContext) -> BrowserState:
        """Executes a browser action."""
        return cast(
            BrowserState,
            self._get_portal().call(partial(self._async.perform_action, action, context=context)),
        )

    def extract_content(self, format: Literal["markdown", "html"], *, context: UserContext) -> str:
        """Extracts page content."""
        return cast(str, self._get_portal().call(partial(self._async.extract_content, format, context=context)))

    def close(self) -> None:
        """Closes the browser resources."""
        if self._portal:
            self._portal.call(self._async.close)
