"""Load pygitweb browser scripts in an embedded JavaScript engine."""

from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any, Protocol

_STATIC = Path(__file__).resolve().parents[1] / "static"
_SHIM = Path(__file__).resolve().parent / "dom_shim.js"
_SHOWDOWN = _STATIC / "vendor" / "showdown-2.1.0.min.js"
_IMPORT_RE = re.compile(
	r"""import\s+\{([^}]*)\}\s*from\s*["']([^"']+)["']\s*;?""",
	re.S,
)
_EXPORT_FN_RE = re.compile(r"^([ \t]*)export\s+function\s+(\w+)", re.M)


class JsError(RuntimeError):
	pass


class Engine(Protocol):
	def eval(self, source: str) -> Any: ...

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


def _import_bindings(block: str) -> str:
	parts: list[str] = []
	for raw in block.split(","):
		item = raw.strip()
		if not item:
			continue
		if " as " in item:
			src, dst = item.split(" as ", 1)
			parts.append(f"{src.strip()}: {dst.strip()}")
		else:
			parts.append(item)
	return "{ " + ", ".join(parts) + " }"


def _with_global_this(source: str) -> str:
	# Showdown's UMD prefers module.exports when an embedder defines module.
	return (
		"(function(){\n"
		"var module = undefined;\n"
		"var exports = undefined;\n"
		"var define = undefined;\n" + source + "\n}).call(globalThis)"
	)


def transform_module(source: str) -> str:
	exports: list[str] = []

	def replace_import(match: re.Match[str]) -> str:
		names = _import_bindings(match.group(1))
		spec = json.dumps(match.group(2))
		if names == "{ }":
			return f"__require({spec});"
		return f"const {names} = __require({spec});"

	transformed = _IMPORT_RE.sub(replace_import, source)

	def replace_export(match: re.Match[str]) -> str:
		exports.append(match.group(2))
		return f"{match.group(1)}function {match.group(2)}"

	transformed = _EXPORT_FN_RE.sub(replace_export, transformed)
	if re.search(r"^\s*export\s+", transformed, re.M):
		raise JsError("unsupported export syntax")
	tail = "\n".join(f"__exports.{name} = {name};" for name in exports)
	return transformed + ("\n" + tail if tail else "")


def available_engines() -> list[str]:
	found: list[str] = []
	try:
		import STPyV8  # type: ignore[import-not-found]  # noqa: F401
	except ImportError:
		pass
	else:
		found.append("stpyv8")
	try:
		import pythonmonkey  # type: ignore[import-not-found]  # noqa: F401
	except ImportError:
		pass
	else:
		found.append("pythonmonkey")
	return found


class _PythonMonkeyEngine:
	def __init__(self) -> None:
		import asyncio

		import pythonmonkey  # type: ignore[import-not-found]

		self._pm = pythonmonkey
		self._loop = asyncio.new_event_loop()
		asyncio.set_event_loop(self._loop)
		self.eval(_SHIM.read_text(encoding="utf-8"))
		self.eval(_with_global_this(_SHOWDOWN.read_text(encoding="utf-8")))
		self._register_modules()

	async def _eval_on_loop(self, source: str) -> Any:
		import asyncio

		try:
			value = self._pm.eval(source)
		except Exception as exc:
			raise JsError(str(exc)) from exc
		for _ in range(50):
			await asyncio.sleep(0)
		return value

	def eval(self, source: str) -> Any:
		return self._loop.run_until_complete(self._eval_on_loop(source))

	def flush(self) -> None:
		return None

	def _register_modules(self) -> None:
		for path in sorted(_STATIC.glob("*.js")):
			transformed = transform_module(path.read_text(encoding="utf-8"))
			self.eval(
				"__register(" + json.dumps(path.name) + ", function(__require, __exports) {\n" + transformed + "\n});"
			)

	def close(self) -> None:
		return None


class _StPyV8Engine:
	def __init__(self) -> None:
		import STPyV8  # type: ignore[import-not-found]

		self._STPyV8 = STPyV8
		self._ctx = STPyV8.JSContext()
		self._ctx.enter()
		self.eval(_SHIM.read_text(encoding="utf-8"))
		self.eval(_with_global_this(_SHOWDOWN.read_text(encoding="utf-8")))
		self._register_modules()

	def eval(self, source: str) -> Any:
		try:
			value = self._ctx.eval(source)
		except Exception as exc:
			raise JsError(str(exc)) from exc
		if value is None:
			return None
		if isinstance(value, str | int | float | bool):
			return value
		text = str(value)
		return text

	def _register_modules(self) -> None:
		for path in sorted(_STATIC.glob("*.js")):
			transformed = transform_module(path.read_text(encoding="utf-8"))
			self.eval(
				"__register(" + json.dumps(path.name) + ", function(__require, __exports) {\n" + transformed + "\n});"
			)

	def close(self) -> None:
		self._ctx.leave()


_ENGINES: dict[str, Engine] = {}


def _engine(name: str) -> Engine:
	cached = _ENGINES.get(name)
	if cached is not None:
		return cached
	if name == "pythonmonkey":
		engine: Engine = _PythonMonkeyEngine()
	elif name == "stpyv8":
		engine = _StPyV8Engine()
	else:
		raise JsError(f"unknown JS engine {name}")
	_ENGINES[name] = engine
	return engine


class JsPage:
	def __init__(self, engine_name: str) -> None:
		self.engine_name = engine_name
		self._engine = _engine(engine_name)
		self._engine.eval("__reset()")
		self._engine.eval(_with_global_this(_SHOWDOWN.read_text(encoding="utf-8")))

	def close(self) -> None:
		return None

	def run(self, source: str) -> None:
		self._engine.eval(source)
		self._drain()

	def _drain(self) -> None:
		if getattr(self._engine, "flush", None) is not None:
			return
		self._engine.eval("(function(){var n=0;function hop(){if(++n>40)return;Promise.resolve().then(hop);}hop();})()")

	def value(self, expression: str) -> Any:
		raw = self._engine.eval(
			"(function(){ var __v = (" + expression + "); return __v === undefined ? 'null' : JSON.stringify(__v); })()"
		)
		if raw is None:
			return None
		return json.loads(str(raw))

	def settle(self, expression: str) -> Any:
		self.run(
			"globalThis.__settled = null; Promise.resolve("
			+ expression
			+ ").then(function(v){ globalThis.__settled = v === undefined ? null : v; },"
			+ " function(e){ globalThis.__settled = {__error: String(e && e.message || e)}; });"
		)
		return self.value("globalThis.__settled")

	def set_html(self, html: str) -> None:
		self.run("__setHtml(" + json.dumps(html) + ")")

	def set_location(self, href: str) -> None:
		self.run("__setLocation(" + json.dumps(href) + ")")

	def load(self, name: str) -> None:
		self.run("__load(" + json.dumps(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.run("__routes.push(" + json.dumps(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.run("__fulfill(" + json.dumps(url) + ", " + json.dumps(spec) + ")")

	def tick(self) -> None:
		self.run("__tick()")

	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.run("document.querySelector(" + json.dumps(selector) + ").click()")

	def dispatch(self, selector: str, event: str, **fields: object) -> None:
		init: dict[str, object] = {"bubbles": True, "cancelable": True}
		init.update(fields)
		self.run(
			"document.querySelector("
			+ json.dumps(selector)
			+ ").dispatchEvent(new Event("
			+ json.dumps(event)
			+ ", "
			+ json.dumps(init)
			+ "))"
		)

	def dispatch_window(self, event: str) -> None:
		self.run("window.dispatchEvent(new Event(" + json.dumps(event) + ", {bubbles:true}))")

	def set_prop(self, selector: str, name: str, value: object) -> None:
		self.run(
			"document.querySelector(" + json.dumps(selector) + ")[" + json.dumps(name) + "] = " + json.dumps(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) + "]")