"""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) + "]")