"""Run pygitweb browser scripts in Chromium."""

from __future__ import annotations

import json
import re
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse

_STATIC = Path(__file__).resolve().parents[1] / "static"
_IMPORT_FROM = re.compile(r"""from\s+["'](\./[^"']+)["']""")
_HARNESS_JS = Path(__file__).resolve().parent / "harness.js"
_PAGE = """<!DOCTYPE html>
<html><head>
<script src="/harness.js"></script>
<script src="/static/vendor/showdown-2.1.0.min.js"></script>
</head><body></body></html>
"""


class JsError(RuntimeError):
    pass


class _Handler(BaseHTTPRequestHandler):
    def log_message(self, format: str, *args: object) -> None:
        return

    def do_GET(self) -> None:
        path = unquote(urlparse(self.path).path)
        if path in ("/", "/index.html"):
            self._send(200, "text/html; charset=utf-8", _PAGE.encode())
            return
        if path == "/harness.js":
            self._send(200, "text/javascript; charset=utf-8", _HARNESS_JS.read_bytes())
            return
        if path.startswith("/static/"):
            rel = path.removeprefix("/static/").lstrip("/")
            file = (_STATIC / rel).resolve()
            if file.is_relative_to(_STATIC.resolve()) and file.is_file():
                mime = "text/javascript; charset=utf-8" if file.suffix == ".js" else "application/octet-stream"
                self._send(200, mime, file.read_bytes())
                return
        self._send(404, "text/plain; charset=utf-8", b"not found")

    def _send(self, status: int, content_type: str, body: bytes) -> None:
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)


class StaticServer:
    def __init__(self) -> None:
        self._httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
        self.base_url = f"http://127.0.0.1:{self._httpd.server_address[1]}"
        self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)

    def start(self) -> None:
        self._thread.start()

    def close(self) -> None:
        self._httpd.shutdown()
        self._httpd.server_close()


class _NavGuard:
    def __init__(self) -> None:
        self.locked = False
        self.current = ""
        self.events: list[tuple[str, str]] = []

    def handle(self, route: Any) -> None:
        request = route.request
        if request.is_navigation_request() and self.locked:
            target = urlparse(request.url)
            current = urlparse(self.current)
            same = (target.path, target.query) == (current.path, current.query)
            rel = target.path or "/"
            if target.query:
                rel += "?" + target.query
            self.events.append(("reload" if same else "assign", rel))
            route.fulfill(status=204, body=b"")
            return
        route.fallback()


def _module_names(name: str) -> list[str]:
    seen: list[str] = []

    def walk(module: str) -> None:
        if module in seen:
            return
        seen.append(module)
        text = (_STATIC / module).read_text(encoding="utf-8")
        for match in _IMPORT_FROM.finditer(text):
            walk(match.group(1).removeprefix("./"))

    walk(name)
    return seen


def _same_origin_path(href: str) -> str:
    if href.startswith("#") or href.startswith("/") or href.startswith("?"):
        return href
    parsed = urlparse(href)
    path = parsed.path or "/"
    if parsed.query:
        path += "?" + parsed.query
    if parsed.fragment:
        path += "#" + parsed.fragment
    return path


class JsPage:
    def __init__(self, page: Any, guard: _NavGuard) -> None:
        self._page = page
        self._guard = guard

    def close(self) -> None:
        self._page.close()

    def _evaluate(self, expression: str, arg: object | None = None) -> Any:
        self._flush_nav()
        try:
            result = self._page.evaluate(expression) if arg is None else self._page.evaluate(expression, arg)
        except Exception as exc:
            raise JsError(str(exc)) from exc
        self._flush_nav()
        return result

    def _flush_nav(self) -> None:
        self._page.evaluate("() => 0")
        self._publish()

    def _publish(self) -> None:
        events = self._guard.events
        self._guard.events = []
        for kind, url in events:
            if kind == "reload":
                self._page.evaluate("() => { globalThis.__reloaded = true; }")
            else:
                self._page.evaluate("(url) => { globalThis.__assigned = url; }", url)

    def run(self, source: str) -> None:
        self._evaluate("source => globalThis.__run(source)", source)

    def value(self, expression: str) -> Any:
        return self._evaluate("expression => globalThis.__value(expression)", expression)

    def settle(self, expression: str) -> Any:
        return self._evaluate("expression => globalThis.__settle(expression)", expression)

    def set_html(self, html: str) -> None:
        self._evaluate("html => globalThis.__setHtml(html)", html)

    def set_location(self, href: str) -> None:
        self._evaluate("path => globalThis.__setLocation(path)", _same_origin_path(href))
        self._guard.current = self._page.url

    def load(self, name: str) -> None:
        self._evaluate("names => globalThis.__load(names)", _module_names(name))

    def call(self, module: str, fn: str, *args: object) -> Any:
        joined = ", ".join(json.dumps(arg) for arg in args)
        return self.value(f"__loaded[{json.dumps(module)}][{json.dumps(fn)}]({joined})")

    def route(
        self,
        url: str,
        *,
        status: int = 200,
        body: object | None = None,
        text: str | None = None,
        hang: bool = False,
        json_error: bool = False,
        status_text: str = "",
        error: str | None = None,
    ) -> None:
        spec: dict[str, object] = {
            "url": url,
            "status": status,
            "hang": hang,
            "jsonError": json_error,
            "statusText": status_text,
        }
        if error is not None:
            spec["reject"] = error
        if text is not None:
            spec["text"] = text
        elif body is not None:
            spec["json"] = body
        self._evaluate("spec => { globalThis.__routes.push(spec); }", spec)

    def fulfill(self, url: str, *, status: int = 200, body: object | None = None, text: str | None = None) -> None:
        spec: dict[str, object] = {"status": status}
        if text is not None:
            spec["text"] = text
        elif body is not None:
            spec["json"] = body
        self._evaluate(
            "payload => { globalThis.__fulfill(payload.url, payload.spec); return globalThis.__drain(); }",
            {"url": url, "spec": spec},
        )

    def tick(self) -> None:
        self._evaluate("() => { globalThis.__tick(); return globalThis.__drain(); }")

    def text(self, selector: str) -> str:
        found = self.value("document.querySelector(" + json.dumps(selector) + ").textContent")
        return "" if found is None else str(found)

    def texts(self, selector: str) -> list[str]:
        found = self.value(
            "Array.from(document.querySelectorAll("
            + json.dumps(selector)
            + ")).map(function(el){return el.textContent;})"
        )
        return [str(item) for item in found or []]

    def attr(self, selector: str, name: str) -> str | None:
        value = self.value(
            "(document.querySelector(" + json.dumps(selector) + ")||{}).getAttribute(" + json.dumps(name) + ")"
        )
        return None if value is None else str(value)

    def attrs(self, selector: str, name: str) -> list[str | None]:
        found = self.value(
            "Array.from(document.querySelectorAll("
            + json.dumps(selector)
            + ")).map(function(el){return el.getAttribute("
            + json.dumps(name)
            + ");})"
        )
        return list(found or [])

    def count(self, selector: str) -> int:
        value = self.value("document.querySelectorAll(" + json.dumps(selector) + ").length")
        return int(value or 0)

    def classes(self, selector: str) -> list[str]:
        value = self.value(
            "(document.querySelector("
            + json.dumps(selector)
            + ").getAttribute('class')||'').split(/\\s+/).filter(Boolean)"
        )
        return list(value or [])

    def click(self, selector: str) -> None:
        self._evaluate("selector => globalThis.__click(selector)", selector)

    def dispatch(self, selector: str, event: str, **fields: object) -> None:
        self._evaluate(
            "payload => globalThis.__dispatch(payload.selector, payload.type, payload.fields)",
            {"selector": selector, "type": event, "fields": fields},
        )

    def dispatch_window(self, event: str) -> None:
        self._evaluate("type => globalThis.__dispatchWindow(type)", event)

    def set_prop(self, selector: str, name: str, value: object) -> None:
        self._evaluate(
            "payload => globalThis.__setProp(payload.selector, payload.name, payload.value)",
            {"selector": selector, "name": name, "value": value},
        )

    def requests(self) -> list[dict[str, Any]]:
        found = self.value("__requests")
        return [] if not isinstance(found, list) else found

    def prop(self, selector: str, name: str) -> Any:
        return self.value("document.querySelector(" + json.dumps(selector) + ")[" + json.dumps(name) + "]")