"""Managed Browser: penegakan batas tab, filter URL, dan persetujuan download lewat Chrome DevTools Protocol.

Browser (Chrome/Edge) harus dijalankan lewat peluncur 'Browser Sekolah' (--remote-debugging-port + profil khusus).
Browser yang dibuka tanpa itu dimatikan oleh Watcher dan digantikan dengan browser terkelola.
"""
from __future__ import annotations

import asyncio
import logging
import os
import re
import shutil
import subprocess
import time
import urllib.parse
from dataclasses import dataclass, field
from pathlib import Path
from typing import Awaitable, Callable

from .cdp import CDP, CDPError, fetch_ws_url
from .policy_engine import host_of, url_verdict

log = logging.getLogger("agent.browser")
INTERNAL_PREFIXES = ("devtools://", "chrome-extension://", "chrome-untrusted://", "chrome://inspect")
IS_WIN = os.name == "nt"

BLOCK_PAGE = ("<html><head><meta charset=utf-8><title>Situs diblokir</title></head><body style=\"font-family:Segoe UI,Arial;background:#0b1f3a;color:#fff;"
              "display:flex;align-items:center;justify-content:center;height:100vh;margin:0\"><div style=\"text-align:center;max-width:560px\">"
              "<h1 style=\"font-size:56px;margin:0\">&#128274;</h1><h2>Situs ini diblokir oleh sekolah</h2><p style=\"opacity:.8\">{reason}: <b>{host}</b></p>"
              "<p style=\"opacity:.6;font-size:14px\">Hubungi guru jika menurut Anda ini keliru.</p></div></body></html>")


def block_page_url(host: str, reason: str) -> str:
    html = BLOCK_PAGE.format(host=urllib.parse.quote(host, safe=".-:"), reason=reason.replace("<", ""))
    return "data:text/html;charset=utf-8," + urllib.parse.quote(html)


def safe_filename(name: str) -> str:
    name = os.path.basename(name.replace("\\", "/")) or "unduhan"
    return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name)[:150]


def unique_path(dir_: Path, name: str) -> Path:
    p = dir_ / name
    i = 1
    while p.exists():
        p = dir_ / f"{p.stem.split(' (')[0]} ({i}){Path(name).suffix}"
        i += 1
    return p


@dataclass
class Approval:
    request_id: int
    url: str
    file_name: str
    valid_until: float
    used: bool = False

    def matches(self, url: str, name: str) -> bool:
        if url == self.url:
            return True
        if url.split("#")[0].split("?")[0] == self.url.split("#")[0].split("?")[0] and not url.startswith(("blob:", "data:")):
            return True
        return url.startswith(("blob:", "data:")) and safe_filename(name) == safe_filename(self.file_name)


@dataclass
class Hooks:
    """Callback ke core agent."""
    on_blocked_site: Callable[[str, str, str], Awaitable[None]]  # kind, domain, reason
    on_tab_limit: Callable[[str, int], Awaitable[None]]
    on_download_blocked: Callable[[str, str, str], Awaitable[None]]  # kind, url, filename
    on_download_used: Callable[[int], Awaitable[None]]
    on_toast: Callable[[str], Awaitable[None]]


class BrowserController:
    def __init__(self, kind: str, port: int, hooks: Hooks, downloads_dir: Callable[[], Path], quarantine_dir: Path, get_policy: Callable[[], dict]):
        self.kind, self.port, self.hooks, self.get_policy = kind, port, hooks, get_policy
        self.downloads_dir, self.quarantine = downloads_dir, quarantine_dir
        self.cdp: CDP | None = None
        self.pages: dict[str, dict] = {}
        self._order = 0
        self.approvals: list[Approval] = []
        self.active_guid: dict[str, Approval] = {}
        self._mode = None
        self._stop = False
        self.connected = False

    # ---- siklus hidup -------------------------------------------------------------------------------------------
    async def run(self) -> None:
        while not self._stop:
            ws = await asyncio.to_thread(fetch_ws_url, self.port)
            if not ws:
                self.connected = False
                await asyncio.sleep(1.0)
                continue
            self.cdp = CDP(ws, self._on_event)
            try:
                await self.cdp.connect()
                self.connected = True
                self.pages.clear()
                self._mode = None
                await self.cdp.send("Target.setDiscoverTargets", {"discover": True})
                await self._sync_download_mode(force=True)
                log.info("%s terkelola tersambung (port %s)", self.kind, self.port)
                await self.cdp.closed.wait()
            except Exception as e:
                log.warning("%s: koneksi CDP gagal: %s", self.kind, e)
            self.connected = False
            await asyncio.sleep(1.0)

    def stop(self) -> None:
        self._stop = True

    # ---- event ------------------------------------------------------------------------------------------------------
    async def _on_event(self, method: str, p: dict, session: str | None) -> None:
        if method in ("Target.targetCreated", "Target.targetInfoChanged"):
            info = p["targetInfo"]
            if info.get("type") != "page":
                return
            tid = info["targetId"]
            if tid not in self.pages:
                self._order += 1
                self.pages[tid] = {"order": self._order}
            self.pages[tid].update(url=info.get("url", ""), title=info.get("title", ""))
            if any(info.get("url", "").startswith(x) for x in INTERNAL_PREFIXES):
                self.pages.pop(tid, None)
                return
            if method == "Target.targetCreated":
                await self.enforce_tabs()
            if tid in self.pages:
                await self._check_url(tid)
        elif method == "Target.targetDestroyed":
            self.pages.pop(p.get("targetId"), None)
        elif method == "Browser.downloadWillBegin":
            await self._on_download_begin(p)
        elif method == "Browser.downloadProgress":
            await self._on_download_progress(p)

    # ---- batas tab ---------------------------------------------------------------------------------------------------
    async def enforce_tabs(self) -> None:
        limit = int(self.get_policy().get("browser", {}).get("max_tabs") or 0)
        if limit <= 0:
            return
        while len(self.pages) > limit:
            newest = max(self.pages.items(), key=lambda kv: kv[1]["order"])[0]
            self.pages.pop(newest, None)
            try:
                await self.cdp.send("Target.closeTarget", {"targetId": newest})
            except CDPError as e:
                log.debug("closeTarget: %s", e)
            await self.hooks.on_tab_limit(self.kind, limit)

    # ---- filter URL ----------------------------------------------------------------------------------------------------
    async def _check_url(self, tid: str) -> None:
        url = self.pages[tid].get("url", "")
        pol = self.get_policy()
        ok, reason = url_verdict(url, pol.get("internet", {}))
        if ok:
            return
        host = host_of(url)
        await self._navigate(tid, block_page_url(host, reason))
        await self.hooks.on_blocked_site(self.kind, host, reason)

    async def _navigate(self, tid: str, url: str) -> None:
        try:
            s = await self.cdp.send("Target.attachToTarget", {"targetId": tid, "flatten": True})
            sid = s["sessionId"]
            await self.cdp.send("Page.navigate", {"url": url}, session_id=sid)
            await self.cdp.send("Target.detachFromTarget", {"sessionId": sid})
        except CDPError as e:
            log.debug("navigate: %s", e)
            try:
                await self.cdp.send("Target.closeTarget", {"targetId": tid})
            except CDPError:
                pass

    async def recheck_all(self) -> None:
        """Dipanggil setelah kebijakan berubah: periksa ulang semua tab yang terbuka."""
        if not self.connected:
            return
        try:
            res = await self.cdp.send("Target.getTargets")
        except CDPError:
            return
        self.pages = {k: v for k, v in self.pages.items()}
        for t in res.get("targetInfos", []):
            if t.get("type") == "page" and not any(t["url"].startswith(x) for x in INTERNAL_PREFIXES):
                if t["targetId"] not in self.pages:
                    self._order += 1
                    self.pages[t["targetId"]] = {"order": self._order}
                self.pages[t["targetId"]].update(url=t["url"], title=t.get("title", ""))
        await self.enforce_tabs()
        for tid in list(self.pages):
            await self._check_url(tid)
        await self._sync_download_mode()

    # ---- download ---------------------------------------------------------------------------------------------------------
    def _want_mode(self) -> str:
        if not self.get_policy().get("browser", {}).get("downloads_require_approval", True):
            return "allow"
        now = time.time()
        self.approvals = [a for a in self.approvals if a.valid_until > now and not a.used] if not self.active_guid else self.approvals
        return "window" if any(a.valid_until > now and not a.used for a in self.approvals) else "deny"

    async def _sync_download_mode(self, force: bool = False) -> None:
        if not self.connected:
            return
        mode = self._want_mode()
        if mode == self._mode and not force:
            return
        self.quarantine.mkdir(parents=True, exist_ok=True)
        self.downloads_dir().mkdir(parents=True, exist_ok=True)
        if mode == "allow":
            params = {"behavior": "allow", "downloadPath": str(self.downloads_dir()), "eventsEnabled": True}
        elif mode == "window":
            params = {"behavior": "allowAndName", "downloadPath": str(self.quarantine), "eventsEnabled": True}
        else:
            params = {"behavior": "deny", "eventsEnabled": True}
        try:
            await self.cdp.send("Browser.setDownloadBehavior", params)
            self._mode = mode
        except CDPError as e:
            log.warning("setDownloadBehavior: %s", e)

    async def add_approval(self, request_id: int, url: str, file_name: str, minutes: int) -> None:
        self.approvals.append(Approval(request_id, url, file_name, time.time() + minutes * 60))
        await self._sync_download_mode()

    async def _on_download_begin(self, p: dict) -> None:
        url, name, guid = p.get("url", ""), p.get("suggestedFilename", ""), p.get("guid", "")
        if self._mode == "allow":
            return
        now = time.time()
        ap = next((a for a in self.approvals if not a.used and a.valid_until > now and a.matches(url, name)), None) if self._mode == "window" else None
        if ap:
            ap.used = True
            self.active_guid[guid] = ap
            await self.hooks.on_download_used(ap.request_id)
            return
        if self._mode == "window":  # download lain saat jendela izin terbuka -> batalkan & minta persetujuan
            try:
                await self.cdp.send("Browser.cancelDownload", {"guid": guid})
            except CDPError:
                pass
            asyncio.get_running_loop().call_later(1.5, lambda: self._rm_quarantine(guid))
        await self.hooks.on_download_blocked(self.kind, url, name)

    def _rm_quarantine(self, guid: str) -> None:
        for f in self.quarantine.glob(f"{guid}*"):
            try:
                f.unlink()
            except OSError:
                pass

    async def _on_download_progress(self, p: dict) -> None:
        guid, state = p.get("guid"), p.get("state")
        ap = self.active_guid.get(guid)
        if not ap or state not in ("completed", "canceled"):
            return
        self.active_guid.pop(guid, None)
        if state == "completed":
            src = self.quarantine / guid
            if src.exists():
                dst = unique_path(self.downloads_dir(), safe_filename(ap.file_name))
                shutil.move(str(src), dst)
                await self.hooks.on_toast(f"Download disetujui & selesai: {dst.name}")
        else:
            self._rm_quarantine(guid)
        await self._sync_download_mode()

    async def tick(self) -> None:
        await self._sync_download_mode()

    # ---- perintah -----------------------------------------------------------------------------------------------------------
    async def close_all(self) -> bool:
        if not self.connected:
            return False
        try:
            await self.cdp.send("Browser.close")
        except CDPError:
            pass
        return True

    def info(self) -> dict | None:
        if not self.connected:
            return None
        hosts = sorted({host_of(p["url"]) for p in self.pages.values() if p.get("url", "").startswith("http")})
        return {"name": self.kind, "tabs": len(self.pages), "hosts": hosts[:10]}


# ---- Peluncur browser terkelola ---------------------------------------------------------------------------------------------
def find_browser(kind: str) -> str | None:
    cands = {
        "chrome": [r"C:\Program Files\Google\Chrome\Application\chrome.exe", r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
                   "/usr/bin/google-chrome"],
        "edge": [r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", r"C:\Program Files\Microsoft\Edge\Application\msedge.exe"],
    }[kind]
    return next((c for c in cands if Path(c).exists()), None)


def profile_dir(kind: str) -> str:
    """Jalur tetap (bukan %LOCALAPPDATA%) karena agent berjalan sebagai SYSTEM. Installer memberi hak tulis pada folder ini untuk Users."""
    from config import agent_home
    return str(agent_home() / "profiles" / kind)


def launch_args(kind: str, port: int, url: str | None = None, headless: bool = False) -> list[str]:
    exe = find_browser(kind)
    if not exe:
        raise FileNotFoundError(f"Browser {kind} tidak terpasang")
    args = [exe, f"--user-data-dir={profile_dir(kind)}", f"--remote-debugging-port={port}", "--remote-debugging-address=127.0.0.1",
            "--no-first-run", "--no-default-browser-check", "--disable-sync"]
    if headless:
        args += ["--headless=new", "--no-sandbox"]
    if url and re.match(r"^https?://", url):
        args.append(url)
    return args


def launch(kind: str, port: int, url: str | None = None) -> subprocess.Popen:
    return subprocess.Popen(launch_args(kind, port, url), close_fds=True)
