"""
scrylo/scrapers/browser.py
---------------------------
Patchright browser wrapper — sync API in a background thread.

Why sync, not async
-------------------
Patchright on Windows drives Chromium via a subprocess. The async API wraps
this with asyncio, but asyncio.CancelledError / wait_for timeout does NOT
stop the subprocess — Chromium keeps navigating, tries to write results to a
closing pipe, and raises ERR_ABORTED / TargetClosedError that block the next
fetch. The sync API has no event loop involvement so none of this happens.

Pattern (from patchright docs + working reference script):
  - sync_playwright().start()      ← called once, in background thread
  - launch_persistent_context     ← one browser, one context, persistent
  - one shared page               ← reused across all fetches (faster)
  - ThreadPoolExecutor(1)         ← all sync calls routed here from async code

Public interface (async, drop-in):
  await browser_fetch(url, timeout_ms)  → str | None
  await browser_close()
  patchright_available()                → bool
"""

from __future__ import annotations

import asyncio
import logging
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Optional

log = logging.getLogger("scrylo.browser")

# These sites always block headless browsers — skip before opening a tab
_SKIP_URL_PATTERNS = [
    "linkedin.com",
    "google.com",
    "facebook.com",
    "twitter.com",
    "x.com",
]

def is_patchright_installed() -> bool:
    """Check if the patchright package is installed."""
    try:
        import patchright  # noqa: F401
        return True
    except ImportError:
        return False

def is_browser_downloaded() -> bool:
    """Check if the Chromium browser is actually downloaded via filesystem only."""
    import os
    import sys
    
    # 1. Check if user set a custom path
    custom_path = os.environ.get("BROWSER_PATH")
    if custom_path and os.path.exists(custom_path):
        return True

    # 2. Check standard Playwright/Patchright locations
    base = os.environ.get("PLAYWRIGHT_BROWSERS_PATH")
    if not base:
        home = os.path.expanduser("~")
        if os.name == "nt":
            local_app_data = os.environ.get("LOCALAPPDATA", "")
            if not local_app_data:
                local_app_data = os.path.join(home, "AppData", "Local")
            base = os.path.join(local_app_data, "ms-playwright")
        elif sys.platform == "darwin":
            base = os.path.join(home, "Library", "Caches", "ms-playwright")
        else:
            # Linux and others
            base = os.path.join(home, ".cache", "ms-playwright")
    
    if os.path.exists(base):
        try:
            items = os.listdir(base)
            for item in items:
                if item.startswith("chromium-"):
                    search_dir = os.path.join(base, item)
                    
                    # OS-specific executable paths
                    if os.name == "nt":
                        # Windows: check win64 then win
                        for sub in ["chrome-win64", "chrome-win"]:
                            if os.path.exists(os.path.join(search_dir, sub, "chrome.exe")):
                                return True
                    elif sys.platform == "darwin":
                        # macOS: chromium-XXXX/chrome-mac/Contents/MacOS/Chromium
                        if os.path.exists(os.path.join(search_dir, "chrome-mac", "Contents", "MacOS", "Chromium")):
                            return True
                    else:
                        # Linux: chromium-XXXX/chrome-linux/chrome
                        if os.path.exists(os.path.join(search_dir, "chrome-linux", "chrome")):
                            return True
                    
                    # Recursive fallback just in case versioning changes
                    import glob
                    exe_name = "chrome.exe" if os.name == "nt" else "chrome"
                    if sys.platform == "darwin": exe_name = "Chromium"
                    
                    found = glob.glob(os.path.join(search_dir, "**", exe_name), recursive=True)
                    if found:
                        return True
        except Exception:
            pass
            
    return False

_PATCHRIGHT_AVAILABLE = is_patchright_installed()
_BROWSER_DOWNLOADED   = is_browser_downloaded()

def recheck_patchright():
    """Update global flags — called by web UI after an installation."""
    global _PATCHRIGHT_AVAILABLE, _BROWSER_DOWNLOADED
    _PATCHRIGHT_AVAILABLE = is_patchright_installed()
    _BROWSER_DOWNLOADED   = is_browser_downloaded()

def patchright_available() -> bool:
    """True only if both package AND browser are ready."""
    return is_patchright_installed() and is_browser_downloaded()

_STEALTH_AVAILABLE = False
try:
    from playwright_stealth import stealth_sync
    _STEALTH_AVAILABLE = True
except ImportError:
    pass


import threading

# ── Thread-local browser manager (one browser per thread) ─────────────────

class _SyncBrowserManager:
    """
    One Patchright browser, one reusable page, sync API.
    Instances are thread-local to allow parallel fetches without state collision.
    """

    def __init__(self):
        self._pw        = None
        self._context   = None
        self._page      = None
        self._ready     = False
        # Persistent user data dir — unique per thread
        self._user_data_dir = tempfile.mkdtemp(prefix=f"scrylo_chrome_{threading.get_ident()}_")

    def start(self) -> bool:
        """Start browser. Returns True if ready."""
        if not _PATCHRIGHT_AVAILABLE:
            return False
        try:
            from patchright.sync_api import sync_playwright
            self._pw = sync_playwright().start()

            self._context = self._pw.chromium.launch_persistent_context(
                user_data_dir=self._user_data_dir,
                headless=True,
                args=[
                    "--disable-blink-features=AutomationControlled",
                    "--disable-dev-shm-usage",
                    "--no-sandbox",
                    "--disable-gpu",
                    "--no-first-run",
                    "--no-default-browser-check",
                    "--window-size=1280,720",
                ],
            )

            pages = self._context.pages
            self._page = pages[0] if pages else self._context.new_page()
            
            if _STEALTH_AVAILABLE:
                stealth_sync(self._page)

            # Block images/fonts
            self._page.route(
                "**/*.{png,jpg,jpeg,gif,webp,svg,woff,woff2,ttf,eot,ico}",
                lambda route: route.abort()
            )

            self._ready = True
            log.debug("Patchright thread %d started", threading.get_ident())
            return True

        except Exception as e:
            log.warning("Patchright thread %d failed to start: %s", threading.get_ident(), e)
            return False

    def fetch(self, url: str, timeout_ms: int = 8000) -> Optional[str]:
        if not self._ready:
            if not self.start():
                return None

        if any(pat in url for pat in _SKIP_URL_PATTERNS):
            return None

        try:
            self._page.goto(url, timeout=timeout_ms, wait_until="domcontentloaded")
            time.sleep(0.3)
            self._dismiss_consent()
            html = self._page.content()
            return html

        except Exception as e:
            log.debug("fetch error %s: %s", url, type(e).__name__)
            self._recover_page()
            return None

    def _recover_page(self):
        try:
            if self._page:
                self._page.close()
        except Exception:
            pass
        try:
            self._page = self._context.new_page()
            if _STEALTH_AVAILABLE:
                stealth_sync(self._page)
            self._page.route(
                "**/*.{png,jpg,jpeg,gif,webp,svg,woff,woff2,ttf,eot,ico}",
                lambda route: route.abort()
            )
        except Exception:
            self._ready = False

    def _dismiss_consent(self):
        for sel in [
            "button:has-text('Accept all')",
            "button:has-text('Accept All')",
            "button:has-text('Accept cookies')",
            "button:has-text('Accept')",
            "button:has-text('I agree')",
            "[id*='accept']",
        ]:
            try:
                btn = self._page.locator(sel).first
                if btn.is_visible(timeout=150):
                    btn.click(timeout=150)
                    return
            except Exception:
                continue

    def close(self):
        for obj, method in [
            (self, "_page"),
            (self, "_context"),
            (self, "_pw"),
        ]:
            attr = getattr(obj, method, None)
            if attr:
                try:
                    attr.close() if method != "_pw" else attr.stop()
                except Exception:
                    pass
        self._ready = False


# ── Thread-local storage for browser managers ─────────────────────────────
_thread_local = threading.local()

def _get_manager() -> _SyncBrowserManager:
    if not hasattr(_thread_local, 'manager'):
        _thread_local.manager = _SyncBrowserManager()
    return _thread_local.manager

_executor = ThreadPoolExecutor(max_workers=5, thread_name_prefix="patchright")


def _sync_fetch(url: str, timeout_ms: int) -> Optional[str]:
    return _get_manager().fetch(url, timeout_ms)


def _sync_close():
    # This only closes the manager for the thread that calls it.
    # We rely on ThreadPoolExecutor shutdown or process exit for full cleanup.
    if hasattr(_thread_local, 'manager'):
        _thread_local.manager.close()


# ── Public async interface ────────────────────────────────────────────────

async def browser_fetch(url: str, timeout_ms: int = 8000) -> Optional[str]:
    if not _PATCHRIGHT_AVAILABLE:
        return None
    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(_executor, _sync_fetch, url, timeout_ms)


async def browser_close() -> None:
    # Note: browser_close in this architecture only closes the manager in 
    # the executor thread that picks up the task. For 100% cleanup, 
    # we'd need to broadcast to all threads.
    loop = asyncio.get_event_loop()
    await loop.run_in_executor(_executor, _sync_close)
