<template>
	<MkStickyContainer>
		<template #header>
			<MkPageHeader :actions="headerActions" :tabs="[]" :display-back-button="true" />
		</template>
		<MkSpacer :content-max="1180">
			<div class="luaPage">
				<XAdServicePreroll v-if="!adDone" service="lua4frozen" @done="adDone = true" />
				<section v-else class="panel">
					<div class="head">
						<MkA to="/lua4frozen" class="brand">
							<i class="ph-code ph-bold ph-lg"></i>
							<span>Lua4Frozen</span>
						</MkA>
						<div class="actions">
							<MkButton primary :disabled="loading || !source" @click="run">Run</MkButton>
							<MkButton :disabled="!running" @click="stop">Stop</MkButton>
						</div>
					</div>
					<MkLoading v-if="loading" />
					<MkError v-else-if="error" @retry="fetchNote" />
					<div v-else-if="note" class="layout">
						<div class="stage">
							<canvas
								ref="canvasEl"
								class="canvas"
								width="800"
								height="450"
								tabindex="0"
								@keydown="onKeyDown"
								@keyup="onKeyUp"
								@mousemove="onMouseMove"
								@mousedown="onMouseDown"
								@mouseup="onMouseUp"
							></canvas>
							<div ref="svgEl" class="svgOut"></div>
							<div ref="htmlEl" class="htmlOut"></div>
							<div v-if="runtimeError" class="runtimeError">{{ runtimeError }}</div>
						</div>
						<aside class="side">
							<h1>{{ title }}</h1>
							<Mfm v-if="description" class="description" :text="description" :author="note.user" />
							<div class="fileChooser">
								<label>Lua file</label>
								<select v-model="selectedFileId" @change="loadSource">
									<option v-for="file in luaFiles" :key="file.id" :value="file.id">
										{{ file.name }}
									</option>
								</select>
							</div>
							<div class="log">
								<div v-for="(line, i) in logs" :key="i">{{ line }}</div>
							</div>
						</aside>
					</div>
				</section>
			</div>
		</MkSpacer>
	</MkStickyContainer>
</template>

<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, ref } from "vue";
import * as misskey from "iceshrimp-sdk";
import * as fengari from "fengari";
import MkButton from "@/components/MkButton.vue";
import XAdServicePreroll from "@/components/AdServicePreroll.vue";
import * as os from "@/os";
import { apiUrl } from "@/config";
import { $i } from "@/account";
import { instance } from "@/instance";
import { definePageMetadata } from "@/scripts/page-metadata";
import { uploadFile } from "@/scripts/upload";
import { useRouter } from "@/router.js";

const props = defineProps<{ noteId: string }>();

const { lua, lauxlib, lualib, to_luastring, to_jsstring } = fengari as any;

const router = useRouter();
const note = ref<misskey.entities.Note | null>(null);
const source = ref("");
const loading = ref(false);
const error = ref(false);
const running = ref(false);
const runtimeError = ref("");
const logs = ref<string[]>([]);
const selectedFileId = ref("");
const canvasEl = ref<HTMLCanvasElement | null>(null);
const svgEl = ref<HTMLElement | null>(null);
const htmlEl = ref<HTMLElement | null>(null);
const keys = new Set<string>();
const mouse = { x: 0, y: 0, down: false, button: 0 };
const attachedFiles = new Map<string, string>();
const driveFiles = new Map<string, string>();
const driveDatabaseFiles = new Set<string>();
const adDone = ref(false);
let driveReadState: "unknown" | "allowed" | "denied" = "unknown";
let animation = 0;
let currentLua: { L: any; updateRef: number; drawRef: number } | null = null;
let lastFrame = performance.now();
let runCountRequestedAt = 0;

const luaFiles = computed(() =>
	(note.value?.files ?? []).filter((file) => file.name.toLowerCase().endsWith(".lua")),
);
const title = computed(() => splitText(note.value)[0]?.trim() || "(no title)");
const description = computed(() => splitText(note.value).slice(1).join("\n").trim());
const headerActions = computed(() => [
	{
		icon: "ph-note ph-bold ph-lg",
		text: "Open note",
		handler: () => router.push(`/notes/${props.noteId}`),
	},
]);

function splitText(target: misskey.entities.Note | null): string[] {
	return (target?.text ?? "").replace(/\r\n/g, "\n").split("\n");
}

function log(line: string) {
	logs.value.push(line);
	if (logs.value.length > 200) logs.value.shift();
}

async function fetchNote() {
	loading.value = true;
	error.value = false;
	try {
		const res = (await os.api("notes/show", { noteId: props.noteId })) as misskey.entities.Note;
		const tags = (res.tags ?? []).map((tag) => tag.toLowerCase());
		if (!tags.includes("lua4frozen")) throw new Error("Missing #Lua4Frozen");
		if (!res.files.some((file) => file.name.toLowerCase().endsWith(".lua"))) {
			throw new Error("No .lua file");
		}
		note.value = res;
		selectedFileId.value = luaFiles.value[0]?.id ?? "";
		await preloadAttachedFiles();
		await loadSource();
	} catch (err) {
		console.error(err);
		error.value = true;
	} finally {
		loading.value = false;
	}
}

async function loadSource() {
	const file = luaFiles.value.find((item) => item.id === selectedFileId.value);
	if (!file) return;
	source.value = await fetch(file.url).then((res) => res.text());
	attachedFiles.set(file.name, source.value);
}

async function preloadAttachedFiles() {
	attachedFiles.clear();
	await Promise.all(
		(note.value?.files ?? [])
			.filter((file) => file.size <= 512 * 1024 && /\.(lua|txt|json|csv)$/i.test(file.name))
			.map(async (file) => {
				try {
					attachedFiles.set(file.name, await fetch(file.url).then((res) => res.text()));
				} catch {
					// Ignore unreadable attached files.
				}
			}),
	);
}

function loadDriveFilesSyncWithConfirmation(): boolean {
	if (driveReadState === "allowed") return true;
	if (driveReadState === "denied") return false;
	driveFiles.clear();
	if (!$i) return false;
	if (!window.confirm("This Lua4Frozen program wants to read small text files from your Drive. Allow Drive reads for this run?")) {
		driveReadState = "denied";
		log("Drive read denied.");
		return false;
	}
	driveReadState = "allowed";
	const files = readDriveFileListSync();
	for (const file of files.filter((file) => file.size <= 512 * 1024 && isLuaReadableFile(file.name))) {
		try {
			driveFiles.set(file.name, readTextSync(file.url));
			if ((file as any).isDatabase || isDatabaseFile(file.name)) driveDatabaseFiles.add(file.name);
		} catch {
			// Ignore unreadable drive files.
		}
	}
	log("Drive read allowed.");
	return true;
}

function readDriveFileListSync(): misskey.entities.DriveFile[] {
	const xhr = new XMLHttpRequest();
	xhr.open("POST", `${apiUrl}/drive/files`, false);
	if ($i?.token) xhr.setRequestHeader("authorization", `Bearer ${$i.token}`);
	xhr.send(JSON.stringify({ limit: 100 }));
	if (xhr.status !== 200) return [];
	return JSON.parse(xhr.responseText);
}

function readTextSync(url: string): string {
	const xhr = new XMLHttpRequest();
	xhr.open("GET", url, false);
	xhr.send();
	if (xhr.status < 200 || xhr.status >= 300) throw new Error(`HTTP ${xhr.status}`);
	return xhr.responseText;
}

function confirmDriveWrite(name: string): boolean {
	if (!$i) {
		log(`Drive write skipped: login required (${name})`);
		return false;
	}
	return window.confirm(`Allow this Lua4Frozen program to save "${name}" to your Drive?`);
}

async function saveToDrive(name: string, content: string) {
	const safeName = name.replace(/[\\/:*?"<>|]/g, "_").slice(0, 120) || "lua4frozen.txt";
	const database = isDatabaseFile(safeName);
	if (database && !hasDatabaseQuotaFor(safeName, content)) {
		log(`Database write blocked: quota exceeded (${safeName})`);
		return;
	}
	const file = new File([content], safeName, { type: database ? "application/json" : "text/plain" });
	await uploadFile(file, undefined, safeName, true, database);
	log(`${database ? "Database" : "Drive"} write: ${safeName}`);
}

function stop() {
	running.value = false;
	if (animation) cancelAnimationFrame(animation);
	animation = 0;
	currentLua = null;
}

async function run() {
	stop();
	countRun();
	await nextTick();
	runtimeError.value = "";
	logs.value = [];
	driveReadState = "unknown";
	driveFiles.clear();
	const canvas = canvasEl.value;
	if (!canvas) return;
	canvas.focus();
	const ctx = canvas.getContext("2d");
	if (!ctx) return;
	ctx.clearRect(0, 0, canvas.width, canvas.height);
	if (svgEl.value) svgEl.value.innerHTML = "";
	if (htmlEl.value) htmlEl.value.textContent = "";

	try {
		const L = lauxlib.luaL_newstate();
		openSafeLibraries(L);
		removeGlobal(L, "io");
		removeGlobal(L, "os");
		removeGlobal(L, "package");
		removeGlobal(L, "require");
		removeGlobal(L, "debug");
		removeGlobal(L, "dofile");
		removeGlobal(L, "loadfile");
		removeGlobal(L, "load");
		installApi(L, ctx);
		const status = lauxlib.luaL_loadstring(L, to_luastring(source.value));
		if (status !== lua.LUA_OK) throw new Error(luaToString(L, -1));
		callLua(L, 0, 0, 250000);
		currentLua = {
			L,
			updateRef: getRegistryRef(L, "__ffy_update_ref"),
			drawRef: getRegistryRef(L, "__ffy_draw_ref"),
		};
		running.value = true;
		lastFrame = performance.now();
		frame();
	} catch (err: any) {
		runtimeError.value = err?.message ?? String(err);
		log(runtimeError.value);
	}
}

function countRun() {
	const now = Date.now();
	if (now - runCountRequestedAt < 1000) return;
	runCountRequestedAt = now;
	os.api("notes/increment-service-view" as any, {
		noteId: props.noteId,
		service: "lua4frozen",
	}).then((res: any) => {
		if (res?.counted && note.value) note.value.viewCount = (note.value.viewCount ?? 0) + 1;
	}).catch(() => undefined);
}

function frame() {
	if (!running.value || !currentLua) return;
	const now = performance.now();
	const dt = Math.min((now - lastFrame) / 1000, 0.1);
	lastFrame = now;
	try {
		if (currentLua.updateRef !== lauxlib.LUA_NOREF) {
			lua.lua_rawgeti(currentLua.L, lua.LUA_REGISTRYINDEX, currentLua.updateRef);
			lua.lua_pushnumber(currentLua.L, dt);
			callLua(currentLua.L, 1, 0, 40000);
		}
		if (currentLua.drawRef !== lauxlib.LUA_NOREF) {
			lua.lua_rawgeti(currentLua.L, lua.LUA_REGISTRYINDEX, currentLua.drawRef);
			callLua(currentLua.L, 0, 0, 40000);
		}
	} catch (err: any) {
		runtimeError.value = err?.message ?? String(err);
		log(runtimeError.value);
		stop();
		return;
	}
	animation = requestAnimationFrame(frame);
}

function removeGlobal(L: any, name: string) {
	lua.lua_pushnil(L);
	lua.lua_setglobal(L, to_luastring(name));
}

function openSafeLibraries(L: any) {
	const libs = [
		["_G", lualib.luaopen_base],
		[lualib.LUA_COLIBNAME, lualib.luaopen_coroutine],
		[lualib.LUA_TABLIBNAME, lualib.luaopen_table],
		[lualib.LUA_STRLIBNAME, lualib.luaopen_string],
		[lualib.LUA_UTF8LIBNAME, lualib.luaopen_utf8],
		[lualib.LUA_MATHLIBNAME, lualib.luaopen_math],
	] as const;
	for (const [name, open] of libs) {
		lauxlib.luaL_requiref(L, to_luastring(name), open, 1);
		lua.lua_pop(L, 1);
	}
}

function setFieldFunction(L: any, name: string, fn: (L: any) => number) {
	lua.lua_pushjsfunction(L, fn);
	lua.lua_setfield(L, -2, to_luastring(name));
}

function installApi(L: any, ctx: CanvasRenderingContext2D) {
	const printFn = (L: any) => {
		const count = lua.lua_gettop(L);
		const parts: string[] = [];
		for (let i = 1; i <= count; i++) parts.push(luaToString(L, i));
		log(parts.join("\t"));
		return 0;
	};
	lua.lua_pushjsfunction(L, printFn);
	lua.lua_setglobal(L, to_luastring("print"));

	lua.lua_newtable(L);
	setFieldFunction(L, "print", printFn);
	setFieldFunction(L, "on_update", (L) => storeCallback(L, "__ffy_update_ref"));
	setFieldFunction(L, "on_draw", (L) => storeCallback(L, "__ffy_draw_ref"));
	installInputApi(L);
	installGfxApi(L, ctx);
	installSvgApi(L);
	installHtmlApi(L);
	installAudioApi(L);
	installFsApi(L);
	installDbApi(L);
	installSqlApi(L);
	lua.lua_setglobal(L, to_luastring("ffy"));
}

function storeCallback(L: any, key: string): number {
	if (!lua.lua_isfunction(L, 1)) return 0;
	lua.lua_pushvalue(L, 1);
	const ref = lauxlib.luaL_ref(L, lua.LUA_REGISTRYINDEX);
	lua.lua_pushinteger(L, ref);
	lua.lua_setglobal(L, to_luastring(key));
	return 0;
}

function installInputApi(L: any) {
	lua.lua_newtable(L);
	setFieldFunction(L, "key", (L) => {
		lua.lua_pushboolean(L, keys.has(luaToString(L, 1).toLowerCase()));
		return 1;
	});
	setFieldFunction(L, "mouse", (L) => {
		lua.lua_newtable(L);
		pushTableNumber(L, "x", mouse.x);
		pushTableNumber(L, "y", mouse.y);
		pushTableBoolean(L, "down", mouse.down);
		pushTableNumber(L, "button", mouse.button);
		return 1;
	});
	lua.lua_setfield(L, -2, to_luastring("input"));
}

function installGfxApi(L: any, ctx: CanvasRenderingContext2D) {
	lua.lua_newtable(L);
	setFieldFunction(L, "size", (L) => {
		lua.lua_pushinteger(L, ctx.canvas.width);
		lua.lua_pushinteger(L, ctx.canvas.height);
		return 2;
	});
	setFieldFunction(L, "color", (L) => {
		const color = luaToString(L, 1) || "#ffffff";
		ctx.fillStyle = color;
		ctx.strokeStyle = color;
		return 0;
	});
	setFieldFunction(L, "clear", (L) => {
		ctx.fillStyle = lua.lua_gettop(L) >= 1 ? luaToString(L, 1) : "transparent";
		ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
		if (ctx.fillStyle !== "transparent") ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
		return 0;
	});
	setFieldFunction(L, "rect", (L) => {
		const x = lua.lua_tonumber(L, 1);
		const y = lua.lua_tonumber(L, 2);
		const w = lua.lua_tonumber(L, 3);
		const h = lua.lua_tonumber(L, 4);
		lua.lua_toboolean(L, 5) || lua.lua_gettop(L) < 5 ? ctx.fillRect(x, y, w, h) : ctx.strokeRect(x, y, w, h);
		return 0;
	});
	setFieldFunction(L, "circle", (L) => {
		ctx.beginPath();
		ctx.arc(lua.lua_tonumber(L, 1), lua.lua_tonumber(L, 2), lua.lua_tonumber(L, 3), 0, Math.PI * 2);
		lua.lua_toboolean(L, 4) || lua.lua_gettop(L) < 4 ? ctx.fill() : ctx.stroke();
		return 0;
	});
	setFieldFunction(L, "line", (L) => {
		ctx.beginPath();
		ctx.moveTo(lua.lua_tonumber(L, 1), lua.lua_tonumber(L, 2));
		ctx.lineTo(lua.lua_tonumber(L, 3), lua.lua_tonumber(L, 4));
		ctx.stroke();
		return 0;
	});
	setFieldFunction(L, "text", (L) => {
		ctx.font = `${lua.lua_tonumber(L, 4) || 18}px sans-serif`;
		ctx.fillText(luaToString(L, 1), lua.lua_tonumber(L, 2), lua.lua_tonumber(L, 3));
		return 0;
	});
	lua.lua_setfield(L, -2, to_luastring("gfx"));
}

function installSvgApi(L: any) {
	lua.lua_newtable(L);
	setFieldFunction(L, "set", (L) => {
		if (svgEl.value) svgEl.value.innerHTML = sanitizeSvg(luaToString(L, 1));
		return 0;
	});
	setFieldFunction(L, "clear", () => {
		if (svgEl.value) svgEl.value.innerHTML = "";
		return 0;
	});
	lua.lua_setfield(L, -2, to_luastring("svg"));
}

function installHtmlApi(L: any) {
	lua.lua_newtable(L);
	setFieldFunction(L, "set", (L) => {
		if (htmlEl.value) htmlEl.value.innerHTML = sanitizeHtml(luaToString(L, 1));
		return 0;
	});
	lua.lua_setfield(L, -2, to_luastring("html"));
}

function installAudioApi(L: any) {
	lua.lua_newtable(L);
	setFieldFunction(L, "beep", (L) => {
		const audio = new AudioContext();
		const osc = audio.createOscillator();
		const gain = audio.createGain();
		osc.frequency.value = lua.lua_tonumber(L, 1) || 440;
		gain.gain.value = 0.08;
		osc.connect(gain).connect(audio.destination);
		osc.start();
		osc.stop(audio.currentTime + Math.min(lua.lua_tonumber(L, 2) || 0.12, 2));
		return 0;
	});
	lua.lua_setfield(L, -2, to_luastring("audio"));
}

function installFsApi(L: any) {
	lua.lua_newtable(L);
	setFieldFunction(L, "read", (L) => {
		const name = luaToString(L, 1);
		if (!attachedFiles.has(name) && !driveFiles.has(name)) loadDriveFilesSyncWithConfirmation();
		lua.lua_pushstring(L, to_luastring(driveFiles.get(name) ?? attachedFiles.get(name) ?? ""));
		return 1;
	});
	setFieldFunction(L, "write", (L) => {
		const name = luaToString(L, 1);
		const content = luaToString(L, 2);
		if (!confirmDriveWrite(name)) {
			log(`Drive write denied: ${name}`);
			return 0;
		}
		driveFiles.set(name, content);
		if (isDatabaseFile(name)) driveDatabaseFiles.add(name);
		saveToDrive(name, content).catch((err) => log(`Drive write failed: ${err}`));
		return 0;
	});
	setFieldFunction(L, "list", (L) => {
		lua.lua_newtable(L);
		let i = 1;
		for (const name of new Set([...attachedFiles.keys(), ...driveFiles.keys()])) {
			lua.lua_pushinteger(L, i++);
			lua.lua_pushstring(L, to_luastring(name));
			lua.lua_settable(L, -3);
		}
		return 1;
	});
	lua.lua_setfield(L, -2, to_luastring("fs"));
}

function installDbApi(L: any) {
	lua.lua_newtable(L);
	setFieldFunction(L, "get", (L) => {
		lua.lua_pushstring(L, to_luastring(readDatabaseValue(luaToString(L, 1), luaToString(L, 2))));
		return 1;
	});
	setFieldFunction(L, "set", (L) => {
		const name = luaToString(L, 1);
		const key = luaToString(L, 2);
		const value = luaToString(L, 3);
		writeDatabaseValue(name, key, value);
		return 0;
	});
	setFieldFunction(L, "delete", (L) => {
		deleteDatabaseValue(luaToString(L, 1), luaToString(L, 2));
		return 0;
	});
	setFieldFunction(L, "list", (L) => {
		const db = loadDatabase(luaToString(L, 1));
		lua.lua_newtable(L);
		let i = 1;
		for (const key of Object.keys(db.records)) {
			lua.lua_pushinteger(L, i++);
			lua.lua_pushstring(L, to_luastring(key));
			lua.lua_settable(L, -3);
		}
		return 1;
	});
	lua.lua_setfield(L, -2, to_luastring("db"));
}

function installSqlApi(L: any) {
	lua.lua_newtable(L);
	setFieldFunction(L, "exec", (L) => {
		lua.lua_pushstring(L, to_luastring(execSimpleSql(luaToString(L, 1), luaToString(L, 2))));
		return 1;
	});
	lua.lua_setfield(L, -2, to_luastring("sql"));
}

type LuaDatabase = {
	records: Record<string, string>;
};

function isDatabaseFile(name: string): boolean {
	return /\.(jsondb|ffdb|db\.json)$/i.test(name);
}

function isLuaReadableFile(name: string): boolean {
	return /\.(lua|txt|json|csv|jsondb|ffdb|db\.json)$/i.test(name);
}

function normalizeDatabaseName(name: string): string {
	const safe = name.replace(/[\\/:*?"<>|]/g, "_").slice(0, 120);
	return isDatabaseFile(safe) ? safe : `${safe || "lua4frozen"}.jsondb`;
}

function loadDatabase(name: string): LuaDatabase {
	const dbName = normalizeDatabaseName(name);
	if (!driveFiles.has(dbName)) loadDriveFilesSyncWithConfirmation();
	const raw = driveFiles.get(dbName);
	if (!raw) return { records: {} };
	try {
		const parsed = JSON.parse(raw);
		return {
			records: parsed && typeof parsed.records === "object" && parsed.records != null
				? Object.fromEntries(
						Object.entries(parsed.records).map(([key, value]) => [key, String(value)]),
				  )
				: {},
		};
	} catch {
		return { records: {} };
	}
}

function saveDatabase(name: string, db: LuaDatabase) {
	const dbName = normalizeDatabaseName(name);
	const content = JSON.stringify({ records: db.records });
	if (!hasDatabaseQuotaFor(dbName, content)) {
		log(`Database write blocked: quota exceeded (${dbName})`);
		return;
	}
	driveFiles.set(dbName, content);
	driveDatabaseFiles.add(dbName);
	saveToDrive(dbName, content).catch((err) => log(`Database write failed: ${err}`));
}

function readDatabaseValue(name: string, key: string): string {
	return loadDatabase(name).records[key] ?? "";
}

function writeDatabaseValue(name: string, key: string, value: string) {
	const db = loadDatabase(name);
	db.records[key] = value;
	saveDatabase(name, db);
}

function deleteDatabaseValue(name: string, key: string) {
	const db = loadDatabase(name);
	delete db.records[key];
	saveDatabase(name, db);
}

function hasDatabaseQuotaFor(name: string, content: string): boolean {
	const limit = Math.max(0, Number(instance.lua4frozenDatabaseCapacityMb ?? 16)) * 1024 * 1024;
	const encoder = new TextEncoder();
	let usage = 0;
	for (const dbName of driveDatabaseFiles) {
		if (dbName === name) continue;
		usage += encoder.encode(driveFiles.get(dbName) ?? "").byteLength;
	}
	return usage + encoder.encode(content).byteLength <= limit;
}

function execSimpleSql(name: string, sql: string): string {
	const text = sql.trim();
	if (/^create\s+table\b/i.test(text)) return "ok";

	const insert = text.match(/^insert\s+into\s+\w+\s*\(\s*key\s*,\s*value\s*\)\s*values\s*\(\s*(['"])(.*?)\1\s*,\s*(['"])(.*?)\3\s*\)\s*;?$/i);
	if (insert) {
		writeDatabaseValue(name, insert[2], insert[4]);
		return "ok";
	}

	const selectOne = text.match(/^select\s+value\s+from\s+\w+\s+where\s+key\s*=\s*(['"])(.*?)\1\s*;?$/i);
	if (selectOne) return readDatabaseValue(name, selectOne[2]);

	if (/^select\s+\*\s+from\s+\w+\s*;?$/i.test(text)) {
		const db = loadDatabase(name);
		return JSON.stringify(Object.entries(db.records).map(([key, value]) => ({ key, value })));
	}

	const del = text.match(/^delete\s+from\s+\w+\s+where\s+key\s*=\s*(['"])(.*?)\1\s*;?$/i);
	if (del) {
		deleteDatabaseValue(name, del[2]);
		return "ok";
	}

	return "unsupported sql";
}

function callLua(L: any, args: number, results: number, limit: number) {
	let instructions = 0;
	lua.lua_sethook(
		L,
		(hookL: any) => {
			instructions += 1000;
			if (instructions > limit) {
				lua.lua_pushstring(hookL, to_luastring("Lua instruction limit exceeded"));
				lua.lua_error(hookL);
			}
		},
		lua.LUA_MASKCOUNT,
		1000,
	);
	const status = lua.lua_pcall(L, args, results, 0);
	lua.lua_sethook(L, null, 0, 0);
	if (status !== lua.LUA_OK) throw new Error(luaToString(L, -1));
}

function getRegistryRef(L: any, key: string): number {
	lua.lua_getglobal(L, to_luastring(key));
	const ref = lua.lua_isinteger(L, -1) ? lua.lua_tointeger(L, -1) : lauxlib.LUA_NOREF;
	lua.lua_pop(L, 1);
	return ref;
}

function luaToString(L: any, index: number): string {
	lauxlib.luaL_tolstring(L, index, null);
	const value = to_jsstring(lua.lua_tolstring(L, -1));
	lua.lua_pop(L, 1);
	return value;
}

function pushTableNumber(L: any, key: string, value: number) {
	lua.lua_pushnumber(L, value);
	lua.lua_setfield(L, -2, to_luastring(key));
}

function pushTableBoolean(L: any, key: string, value: boolean) {
	lua.lua_pushboolean(L, value);
	lua.lua_setfield(L, -2, to_luastring(key));
}

function sanitizeHtml(input: string): string {
	const doc = new DOMParser().parseFromString(input, "text/html");
	for (const node of doc.querySelectorAll("script, iframe, object, embed, link, meta, form")) node.remove();
	for (const el of doc.querySelectorAll("*")) {
		for (const attr of [...el.attributes]) {
			const name = attr.name.toLowerCase();
			const value = attr.value.trim().toLowerCase();
			if (name.startsWith("on")) el.removeAttribute(attr.name);
			if ((name === "src" || name === "href") && /^(https?:)?\/\//.test(value)) {
				el.removeAttribute(attr.name);
			}
		}
	}
	return doc.body.innerHTML;
}

function sanitizeSvg(input: string): string {
	const wrapped = input.trim().startsWith("<svg")
		? input
		: `<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg">${input}</svg>`;
	const doc = new DOMParser().parseFromString(wrapped, "image/svg+xml");
	if (doc.querySelector("parsererror")) return "";
	for (const node of doc.querySelectorAll("script, foreignObject, iframe, object, embed, link, meta")) node.remove();
	for (const el of doc.querySelectorAll("*")) {
		for (const attr of [...el.attributes]) {
			const name = attr.name.toLowerCase();
			const value = attr.value.trim().toLowerCase();
			if (name.startsWith("on")) el.removeAttribute(attr.name);
			if ((name === "href" || name.endsWith(":href")) && (/^(https?:)?\/\//.test(value) || value.startsWith("javascript:"))) {
				el.removeAttribute(attr.name);
			}
		}
	}
	const svg = doc.documentElement;
	if (svg.tagName.toLowerCase() !== "svg") return "";
	if (!svg.getAttribute("viewBox")) svg.setAttribute("viewBox", "0 0 800 450");
	svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
	return new XMLSerializer().serializeToString(svg);
}

function onKeyDown(ev: KeyboardEvent) {
	keys.add(ev.key.toLowerCase());
}

function onKeyUp(ev: KeyboardEvent) {
	keys.delete(ev.key.toLowerCase());
}

function onMouseMove(ev: MouseEvent) {
	const rect = (ev.currentTarget as HTMLCanvasElement).getBoundingClientRect();
	mouse.x = ((ev.clientX - rect.left) / rect.width) * 800;
	mouse.y = ((ev.clientY - rect.top) / rect.height) * 450;
}

function onMouseDown(ev: MouseEvent) {
	mouse.down = true;
	mouse.button = ev.button;
}

function onMouseUp() {
	mouse.down = false;
}

onBeforeUnmount(stop);
fetchNote();

definePageMetadata(
	computed(() => ({
		title: "Lua4Frozen",
		icon: "ph-code ph-bold ph-lg",
	})),
);
</script>

<style lang="scss" scoped>
.luaPage,
.layout {
	display: grid;
	gap: 16px;
}

.panel {
	padding: 16px;
	border-radius: 8px;
	background: var(--panel);
	border: solid 1px var(--divider);
}

.head {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 12px;
	margin-bottom: 16px;
}

.brand,
.actions {
	display: inline-flex;
	align-items: center;
	gap: 8px;
}

.brand {
	color: var(--accent);
	font-weight: 700;
}

.layout {
	grid-template-columns: minmax(0, 1fr) 320px;
	align-items: start;
}

.stage {
	position: relative;
	display: grid;
	gap: 12px;
}

.canvas {
	width: 100%;
	aspect-ratio: 16 / 9;
	border-radius: 8px;
	background: #101010;
	border: solid 1px var(--divider);
	outline: none;
}

.svgOut {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	aspect-ratio: 16 / 9;
	pointer-events: none;
	overflow: hidden;
	border-radius: 8px;
}

.svgOut :deep(svg) {
	display: block;
	width: 100%;
	height: 100%;
}

.htmlOut {
	min-height: 80px;
	padding: 12px;
	border-radius: 8px;
	background: var(--bg);
	border: solid 1px var(--divider);
	overflow: auto;
}

.runtimeError {
	padding: 10px 12px;
	border-radius: 8px;
	color: #ffb4b4;
	background: rgba(255, 0, 0, 0.12);
}

.side {
	display: grid;
	gap: 12px;
}

h1 {
	margin: 0;
	font-size: 1.2rem;
}

.description {
	color: var(--fg);
}

.fileChooser {
	display: grid;
	gap: 6px;
}

.fileChooser > label {
	color: var(--fgTransparentWeak);
	font-size: 0.9em;
}

.fileChooser > select {
	width: 100%;
	padding: 8px;
	border-radius: 8px;
	color: var(--fg);
	background: var(--bg);
	border: solid 1px var(--divider);
}

.log {
	min-height: 180px;
	max-height: 320px;
	overflow: auto;
	padding: 10px;
	border-radius: 8px;
	background: var(--bg);
	border: solid 1px var(--divider);
	font-family: monospace;
	font-size: 0.9em;
	white-space: pre-wrap;
}

@media (max-width: 900px) {
	.layout {
		grid-template-columns: 1fr;
	}
}
</style>
