<template>
	<MkStickyContainer>
		<template #header>
			<MkPageHeader :actions="headerActions" :tabs="[]" :display-back-button="true" />
		</template>
		<MkSpacer :content-max="1220">
			<div class="luaTest">
				<section class="panel 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="!source.trim()" @click="run">
							<i class="ph-play ph-bold"></i>
							実行
						</MkButton>
						<MkButton :disabled="!running" @click="stop">
							<i class="ph-stop ph-bold"></i>
							停止
						</MkButton>
					</div>
				</section>

				<div class="layout">
					<section class="panel editorPanel">
						<h2>プログラム</h2>
						<div class="toolbar">
							<label>
								<span>サンプル</span>
								<select v-model="selectedSample" @change="loadSample">
									<option v-for="sample in samples" :key="sample.name" :value="sample.name">
										{{ sample.name }}
									</option>
								</select>
							</label>
							<MkButton inline @click="saveDraft">下書き保存</MkButton>
							<MkButton inline @click="downloadLua">.lua保存</MkButton>
						</div>
						<textarea
							v-model="source"
							class="editor"
							placeholder="ここにLuaプログラムを入力してください"
							spellcheck="false"
							@keydown.tab.prevent="insertTab"
						></textarea>
					</section>

					<section class="panel stagePanel">
						<h2>実行画面</h2>
						<div class="stage">
							<canvas
								ref="canvasEl"
								class="canvas"
								width="800"
								height="450"
								tabindex="0"
								@keydown="onKeyDown"
								@keyup="onKeyUp"
								@mousemove="onMouseMove"
								@mousedown="onMouseDown"
								@mouseup="onMouseUp"
								@mouseleave="onMouseUp"
							></canvas>
							<div ref="svgEl" class="svgOut"></div>
							<div ref="htmlEl" class="htmlOut"></div>
							<div v-if="runtimeError" class="runtimeError">{{ runtimeError }}</div>
							<div class="virtualKeyboard">
								<div class="keyboardToolbar">
									<label>
										<span>仮想キーボード</span>
										<select v-model="selectedKeyboardId">
											<option v-for="layout in allKeyboardLayouts" :key="layout.id" :value="layout.id">
												{{ layout.name }}
											</option>
										</select>
									</label>
									<MkButton inline @click="customLayoutJson = JSON.stringify(activeKeyboardLayout, null, 2)">
										選択中を編集
									</MkButton>
								</div>
								<div
									class="keyboard"
									:style="{
										gridTemplateColumns: `repeat(${activeKeyboardLayout.cols}, minmax(0, 1fr))`,
										gridTemplateRows: `repeat(${activeKeyboardGridRows}, minmax(17px, auto))`,
									}"
								>
									<button
										v-for="key in activeKeyboardKeys"
										:key="`${activeKeyboardLayout.id}-${key.y}-${key.x}-${key.label}`"
										class="keyboardKey"
										:class="{
											empty: !key.key && !key.text,
											latched: getModifierState(key) === 'latched',
											locked: getModifierState(key) === 'locked',
										}"
										:style="{ gridColumn: `${key.x} / span ${key.w ?? 1}`, gridRow: `${key.gridY} / span ${key.gridH}` }"
										type="button"
										@pointerdown.prevent="pressVirtualKey(key)"
										@pointerup.prevent="releaseVirtualKey(key)"
										@pointerleave.prevent="releaseVirtualKey(key)"
									>
										{{ key.label || " " }}
									</button>
								</div>
							</div>
						</div>
					</section>
				</div>

				<div class="layout bottom">
					<section class="panel">
						<div class="sectionTitle">
							<h2>Virtual files</h2>
							<MkButton inline @click="addVirtualFile">Add</MkButton>
						</div>
						<div class="files">
							<div v-for="file in fileRows" :key="file.id" class="fileRow">
								<input v-model="file.name" placeholder="name.txt" />
								<textarea v-model="file.content" placeholder="content"></textarea>
								<MkButton inline danger @click="removeVirtualFile(file.id)">Remove</MkButton>
							</div>
						</div>
					</section>

					<section class="panel">
						<div class="sectionTitle">
							<h2>Log</h2>
							<MkButton inline @click="logs = []">Clear</MkButton>
						</div>
						<div class="log">
							<div v-for="(line, i) in logs" :key="i">{{ line }}</div>
						</div>
					</section>
				</div>

				<section class="panel">
					<div class="sectionTitle">
						<h2>ユーザカスタムキーボード</h2>
						<div class="actions">
							<MkButton inline @click="saveCustomKeyboard">保存</MkButton>
							<MkButton inline danger @click="deleteCustomKeyboard">削除</MkButton>
						</div>
					</div>
					<textarea
						v-model="customLayoutJson"
						class="customLayoutEditor"
						spellcheck="false"
						placeholder="標準配列を元にJSONを編集して保存できます"
					></textarea>
				</section>
			</div>
		</MkSpacer>
	</MkStickyContainer>
</template>

<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, ref } from "vue";
import * as fengari from "fengari";
import MkButton from "@/components/MkButton.vue";
import { $i } from "@/account";
import { apiUrl } from "@/config";
import { instance } from "@/instance";
import { definePageMetadata } from "@/scripts/page-metadata";
import { uploadFile } from "@/scripts/upload";
import keyboardLayoutDefinitions from "./lua4frozen-keyboards.json";

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

const samples = [
	{
		name: "Bouncing ball",
		code: `x = 120
y = 80
vx = 170
vy = 120
r = 24

function update(dt)
  w, h = ffy.gfx.size()
  x = x + vx * dt
  y = y + vy * dt
  if x < r or x > w - r then vx = -vx end
  if y < r or y > h - r then vy = -vy end
end

function draw()
  ffy.gfx.clear("#111827")
  ffy.gfx.color("#7dd3fc")
  ffy.gfx.circle(x, y, r)
  ffy.gfx.color("#ffffff")
  ffy.gfx.text("Lua4Frozen", 24, 42, 24)
end

ffy.on_update(update)
ffy.on_draw(draw)`,
	},
	{
		name: "Keyboard and mouse",
		code: `x = 400
y = 225

function update(dt)
  speed = 220 * dt
  if ffy.input.key("arrowleft") then x = x - speed end
  if ffy.input.key("arrowright") then x = x + speed end
  if ffy.input.key("arrowup") then y = y - speed end
  if ffy.input.key("arrowdown") then y = y + speed end
end

function draw()
  mouse = ffy.input.mouse()
  ffy.gfx.clear("#172554")
  ffy.gfx.color("#bef264")
  ffy.gfx.rect(x - 20, y - 20, 40, 40)
  ffy.gfx.color("#fb7185")
  ffy.gfx.circle(mouse.x, mouse.y, mouse.down and 18 or 8)
  ffy.html.set("<b>Arrow keys</b> move the square. Mouse draws the red point.")
end

ffy.on_update(update)
ffy.on_draw(draw)`,
	},
	{
		name: "Files and audio",
		code: `count = tonumber(ffy.fs.read("count.txt")) or 0
count = count + 1
ffy.fs.write("count.txt", tostring(count))
ffy.audio.beep(523.25, 0.1)

function draw()
  ffy.gfx.clear("#052e16")
  ffy.gfx.color("#dcfce7")
  ffy.gfx.text("Run count: " .. count, 40, 80, 30)
  ffy.gfx.text("Files:", 40, 135, 22)
  files = ffy.fs.list()
  for i, name in ipairs(files) do
    ffy.gfx.text("- " .. name, 60, 135 + i * 28, 18)
  end
end

ffy.on_draw(draw)`,
	},
];

const selectedSample = ref(samples[0].name);
const source = ref(localStorage.getItem("lua4frozen:test:source") ?? samples[0].code);
const running = ref(false);
const runtimeError = ref("");
const logs = ref<string[]>([]);
const canvasEl = ref<HTMLCanvasElement | null>(null);
const svgEl = ref<HTMLElement | null>(null);
const htmlEl = ref<HTMLElement | null>(null);
const keys = new Set<string>();
const virtualPressedKeys = new Set<string>();
const modifierTapCounts = ref<Record<string, number>>({});
const mouse = { x: 0, y: 0, down: false, button: 0 };
const fileRows = ref([
	{ id: makeLocalId(), name: "count.txt", content: "0" },
	{ id: makeLocalId(), name: "readme.txt", content: "Hello from virtual file system" },
]);
const virtualFiles = new Map<string, string>();
const driveFiles = new Map<string, string>();
const driveDatabaseFiles = new Set<string>();
let driveReadState: "unknown" | "allowed" | "denied" = "unknown";
let animation = 0;
let currentLua: { L: any; updateRef: number; drawRef: number } | null = null;
let lastFrame = performance.now();

type VirtualKey = {
	label: string;
	key?: string;
	text?: string;
	w?: number;
	h?: number;
};

type PositionedVirtualKey = VirtualKey & {
	x: number;
	y: number;
	gridY: number;
	gridH: number;
};

type KeyboardLayout = {
	id: string;
	name: string;
	cols: number;
	rows: VirtualKey[][];
	source: "server" | "user" | "program";
};

const standardKeyboardLayouts = makeStandardKeyboardLayouts();
const userKeyboardLayouts = ref<KeyboardLayout[]>(loadUserKeyboardLayouts());
const programKeyboardLayouts = ref<KeyboardLayout[]>([]);
const selectedKeyboardId = ref("jis");
const customLayoutJson = ref("");

const allKeyboardLayouts = computed(() => [
	...standardKeyboardLayouts,
	...userKeyboardLayouts.value,
	...programKeyboardLayouts.value,
]);

const activeKeyboardLayout = computed(() =>
	allKeyboardLayouts.value.find((layout) => layout.id === selectedKeyboardId.value) ??
	standardKeyboardLayouts[0],
);

const activeKeyboardKeys = computed(() => positionKeyboardKeys(activeKeyboardLayout.value));
const activeKeyboardGridRows = computed(() =>
	Math.max(
		activeKeyboardLayout.value.rows.length * 2,
		...activeKeyboardKeys.value.map((key) => key.gridY + key.gridH - 1),
	),
);

const headerActions = computed(() => [
	{
		icon: "ph-book-open ph-bold ph-lg",
		text: "仕様書",
		handler: () => {
			location.href = "/lua4frozen/spec";
		},
	},
]);

function loadSample() {
	const sample = samples.find((item) => item.name === selectedSample.value);
	if (sample) source.value = sample.code;
}

function saveDraft() {
	localStorage.setItem("lua4frozen:test:source", source.value);
	log("Draft saved in this browser.");
}

function downloadLua() {
	const blob = new Blob([source.value], { type: "text/x-lua" });
	const url = URL.createObjectURL(blob);
	const a = document.createElement("a");
	a.href = url;
	a.download = "lua4frozen.lua";
	a.click();
	URL.revokeObjectURL(url);
}

function insertTab(ev: KeyboardEvent) {
	const textarea = ev.currentTarget as HTMLTextAreaElement;
	const start = textarea.selectionStart;
	const end = textarea.selectionEnd;
	source.value = `${source.value.slice(0, start)}  ${source.value.slice(end)}`;
	nextTick(() => {
		textarea.selectionStart = textarea.selectionEnd = start + 2;
	});
}

function addVirtualFile() {
	fileRows.value.push({ id: makeLocalId(), name: "file.txt", content: "" });
}

function removeVirtualFile(id: string) {
	fileRows.value = fileRows.value.filter((file) => file.id !== id);
}

function syncVirtualFilesFromRows() {
	virtualFiles.clear();
	for (const file of fileRows.value) {
		const name = file.name.trim();
		if (name) virtualFiles.set(name, file.content);
	}
}

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.isDatabase || isDatabaseFile(file.name)) driveDatabaseFiles.add(file.name);
		} catch {
			// Ignore unreadable drive files.
		}
	}
	log("Drive read allowed.");
	return true;
}

function readDriveFileListSync(): any[] {
	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 makeLocalId(): string {
	return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}

function makeStandardKeyboardLayouts(): KeyboardLayout[] {
	return (keyboardLayoutDefinitions as any[]).map((layout) => normalizeLayout(layout, "server"));
}

function positionKeyboardKeys(layout: KeyboardLayout): PositionedVirtualKey[] {
	const occupied = new Set<string>();
	const positioned: PositionedVirtualKey[] = [];

	for (let rowIndex = 0; rowIndex < layout.rows.length; rowIndex++) {
		let column = 1;
		const y = rowIndex + 1;
		const gridY = rowIndex * 2 + 1;

		for (const key of layout.rows[rowIndex]) {
			const w = key.w ?? 1;
			const h = key.h ?? 1;
			const gridH = Math.max(1, Math.round(h * 2));
			while (occupied.has(`${gridY}:${column}`)) column++;

			const x = column;
			positioned.push({ ...key, x, y, gridY, gridH, w, h });

			for (let dy = 0; dy < gridH; dy++) {
				for (let dx = 0; dx < w; dx++) {
					occupied.add(`${gridY + dy}:${x + dx}`);
				}
			}

			column += w;
		}
	}

	return positioned;
}

function displayLabel(label: string): string {
	const special: Record<string, string> = {
		Esc: "⎋",
		Backspace: "⌫",
		Tab: "⇥",
		Enter: "↵",
		Shift: "⇧",
		Ctrl: "⌃",
		Alt: "⌥",
		AltGr: "⌥R",
		Meta: "⌘",
		CapsLock: "⇪",
		Delete: "⌦",
		Space: "␣",
		ArrowLeft: "←",
		ArrowRight: "→",
		ArrowUp: "↑",
		ArrowDown: "↓",
	};
	return special[label] ?? label;
}

function keyName(label: string): string {
	const special: Record<string, string> = {
		Esc: "escape",
		"⎋": "escape",
		Backspace: "backspace",
		"⌫": "backspace",
		Tab: "tab",
		"⇥": "tab",
		Enter: "enter",
		"↵": "enter",
		Shift: "shift",
		"⇧": "shift",
		Ctrl: "control",
		"⌃": "control",
		Alt: "alt",
		"⌥": "alt",
		AltGr: "altgraph",
		"⌥R": "altgraph",
		AltGraph: "altgraph",
		Meta: "meta",
		"⌘": "meta",
		CapsLock: "capslock",
		"⇪": "capslock",
		Delete: "delete",
		"⌦": "delete",
		Space: " ",
		"␣": " ",
		ArrowLeft: "arrowleft",
		"←": "arrowleft",
		ArrowRight: "arrowright",
		"→": "arrowright",
		ArrowUp: "arrowup",
		"↑": "arrowup",
		ArrowDown: "arrowdown",
		"↓": "arrowdown",
	};
	return special[label] ?? label.toLowerCase();
}

function normalizeLayout(input: any, source: KeyboardLayout["source"]): KeyboardLayout {
	const cols = Math.max(1, Math.min(24, Number(input.cols) || 12));
	const inputRows = Array.isArray(input.rows)
		? input.rows
		: legacyRowsFromKeys(Array.isArray(input.keys) ? input.keys : [], cols);
	return {
		id: String(input.id || `${source}-${makeLocalId()}`),
		name: String(input.name || "Custom keyboard"),
		cols,
		source,
		rows: inputRows.map((row: any) =>
			(Array.isArray(row) ? row : []).map((key: any) => normalizeKey(key)),
		),
	};
}

function legacyRowsFromKeys(keys: any[], cols: number): any[][] {
	const rows: any[][] = [];
	for (let i = 0; i < keys.length; i += cols) {
		rows.push(keys.slice(i, i + cols));
	}
	return rows.length > 0 ? rows : [[]];
}

function normalizeKey(key: any): VirtualKey {
	const input = typeof key === "string" ? { label: key } : key;
	const rawLabel = String(input?.label ?? "");
	const normalizedKey = input?.key == null ? keyName(rawLabel) : String(input.key).toLowerCase();
	return {
		label: displayLabel(rawLabel),
		key: rawLabel === "" && input?.key == null ? undefined : normalizedKey,
		text: input?.text == null ? (rawLabel.length === 1 ? rawLabel : undefined) : String(input.text),
		w: Math.max(1, Math.min(6, Number(input?.w) || 1)),
		h: Math.max(0.5, Math.min(4, Number(input?.h) || 1)),
	};
}

function loadUserKeyboardLayouts(): KeyboardLayout[] {
	try {
		const raw = JSON.parse(localStorage.getItem("lua4frozen:userKeyboards") || "[]");
		return Array.isArray(raw) ? raw.map((layout) => normalizeLayout(layout, "user")) : [];
	} catch {
		return [];
	}
}

function persistUserKeyboardLayouts() {
	localStorage.setItem("lua4frozen:userKeyboards", JSON.stringify(userKeyboardLayouts.value));
}

function saveCustomKeyboard() {
	try {
		const layout = normalizeLayout(JSON.parse(customLayoutJson.value), "user");
		const index = userKeyboardLayouts.value.findIndex((item) => item.id === layout.id);
		if (index >= 0) userKeyboardLayouts.value[index] = layout;
		else userKeyboardLayouts.value.push(layout);
		persistUserKeyboardLayouts();
		selectedKeyboardId.value = layout.id;
		log(`Keyboard saved: ${layout.name}`);
	} catch (err: any) {
		log(`Keyboard save failed: ${err?.message ?? err}`);
	}
}

function deleteCustomKeyboard() {
	userKeyboardLayouts.value = userKeyboardLayouts.value.filter((layout) => layout.id !== selectedKeyboardId.value);
	persistUserKeyboardLayouts();
	selectedKeyboardId.value = "jis";
}

const modifierKeys = new Set(["shift", "control", "alt", "altgraph", "meta"]);

function isModifierKey(value: string): boolean {
	return modifierKeys.has(value);
}

function getModifierState(key: VirtualKey): "off" | "latched" | "locked" {
	const value = (key.key || "").toLowerCase();
	if (!isModifierKey(value)) return "off";
	const count = modifierTapCounts.value[value] ?? 0;
	return count === 1 ? "latched" : count === 2 ? "locked" : "off";
}

function pressVirtualKey(key: VirtualKey) {
	const value = (key.key || key.text || "").toLowerCase();
	if (!value) return;
	if (isModifierKey(value)) {
		const count = ((modifierTapCounts.value[value] ?? 0) + 1) % 3;
		modifierTapCounts.value = {
			...modifierTapCounts.value,
			[value]: count,
		};
		if (count === 0) {
			keys.delete(value);
		} else {
			keys.add(value);
		}
		return;
	}
	virtualPressedKeys.add(value);
	keys.add(value);
	if (key.text) insertTextToFocusedEditor(key.text);
}

function releaseVirtualKey(key: VirtualKey) {
	const value = (key.key || key.text || "").toLowerCase();
	if (!value) return;
	if (isModifierKey(value)) return;
	virtualPressedKeys.delete(value);
	if (!virtualPressedKeys.has(value)) keys.delete(value);
	consumeLatchedModifiers();
}

function consumeLatchedModifiers() {
	const next = { ...modifierTapCounts.value };
	let changed = false;
	for (const value of modifierKeys) {
		if (next[value] === 1) {
			next[value] = 0;
			keys.delete(value);
			changed = true;
		}
	}
	if (changed) modifierTapCounts.value = next;
}

function insertTextToFocusedEditor(text: string) {
	const active = document.activeElement;
	if (!(active instanceof HTMLTextAreaElement) || !active.classList.contains("editor")) return;
	const start = active.selectionStart;
	const end = active.selectionEnd;
	source.value = `${source.value.slice(0, start)}${text}${source.value.slice(end)}`;
	nextTick(() => {
		active.selectionStart = active.selectionEnd = start + text.length;
	});
}

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

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

async function run() {
	stop();
	await nextTick();
	runtimeError.value = "";
	logs.value = [];
	syncVirtualFilesFromRows();
	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 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);
	installKeyboardApi(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 = window.AudioContext || (window as any).webkitAudioContext;
		if (!Audio) return 0;
		const audio = new Audio();
		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 (!virtualFiles.has(name) && !driveFiles.has(name)) loadDriveFilesSyncWithConfirmation();
		lua.lua_pushstring(L, to_luastring(driveFiles.get(name) ?? virtualFiles.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([...virtualFiles.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) => {
		writeDatabaseValue(luaToString(L, 1), luaToString(L, 2), luaToString(L, 3));
		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 installKeyboardApi(L: any) {
	lua.lua_newtable(L);
	setFieldFunction(L, "set_layout_json", (L) => {
		try {
			const name = luaToString(L, 1) || "Program keyboard";
			const json = luaToString(L, 2);
			const layout = normalizeLayout(JSON.parse(json), "program");
			layout.id = `program-${layout.id}`;
			layout.name = name;
			const index = programKeyboardLayouts.value.findIndex((item) => item.id === layout.id);
			if (index >= 0) programKeyboardLayouts.value[index] = layout;
			else programKeyboardLayouts.value.push(layout);
			selectedKeyboardId.value = layout.id;
			log(`Program keyboard loaded: ${layout.name}`);
		} catch (err: any) {
			log(`Program keyboard failed: ${err?.message ?? err}`);
		}
		return 0;
	});
	setFieldFunction(L, "select", (L) => {
		const id = luaToString(L, 1);
		if (allKeyboardLayouts.value.some((layout) => layout.id === id)) selectedKeyboardId.value = id;
		return 0;
	});
	lua.lua_setfield(L, -2, to_luastring("keyboard"));
}

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);

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

<style lang="scss" scoped>
.luaTest {
	display: grid;
	gap: 16px;
	max-width: 100%;
	overflow-x: hidden;
	box-sizing: border-box;
}

.panel {
	min-width: 0;
	max-width: 100%;
	box-sizing: border-box;
	padding: 16px;
	border-radius: 8px;
	background: var(--panel);
	border: solid 1px var(--divider);
}

.head,
.toolbar,
.sectionTitle,
.brand,
.actions {
	display: flex;
	align-items: center;
	gap: 10px;
	min-width: 0;
}

.head,
.sectionTitle {
	justify-content: space-between;
	flex-wrap: wrap;
}

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

.layout {
	display: grid;
	grid-template-columns: minmax(0, 1fr) minmax(0, 0.8fr);
	gap: 16px;
	align-items: start;
	min-width: 0;
	max-width: 100%;
}

.bottom {
	grid-template-columns: minmax(0, 0.95fr) minmax(0, 0.7fr);
}

.editorPanel,
.stagePanel,
.files {
	display: grid;
	gap: 12px;
	min-width: 0;
}

.stage {
	position: relative;
	display: grid;
	gap: 12px;
	min-width: 0;
}

.editorPanel > h2,
.stagePanel > h2 {
	margin: 0;
	font-size: 1.05rem;
}

.toolbar {
	justify-content: space-between;
	flex-wrap: wrap;
}

.toolbar > label {
	display: inline-flex;
	align-items: center;
	gap: 8px;
	color: var(--fgTransparentWeak);
	flex: 1 1 220px;
	min-width: 0;
}

select,
input,
textarea {
	box-sizing: border-box;
	max-width: 100%;
	color: var(--fg);
	background: var(--bg);
	border: solid 1px var(--divider);
	border-radius: 8px;
}

select,
input {
	padding: 8px;
}

select {
	min-width: 0;
	width: 100%;
}

.editor {
	width: 100%;
	max-width: 100%;
	min-height: 520px;
	padding: 12px;
	resize: vertical;
	font-family: monospace;
	font-size: 0.95rem;
	line-height: 1.5;
	tab-size: 2;
}

.canvas {
	width: 100%;
	max-width: 100%;
	box-sizing: border-box;
	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: 90px;
	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);
}

.virtualKeyboard {
	display: grid;
	gap: 10px;
	min-width: 0;
}

.keyboardToolbar {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 10px;
	flex-wrap: wrap;
	min-width: 0;
}

.keyboardToolbar > label {
	display: inline-flex;
	align-items: center;
	gap: 8px;
	flex: 1 1 220px;
	min-width: 0;
	color: var(--fgTransparentWeak);
}

.keyboard {
	display: grid;
	gap: 4px;
	max-width: 100%;
	overflow: auto;
	padding: 8px;
	border-radius: 8px;
	background: var(--bg);
	border: solid 1px var(--divider);
	touch-action: manipulation;
}

.keyboardKey {
	min-width: 0;
	min-height: 34px;
	padding: 5px 4px;
	border-radius: 6px;
	color: var(--fg);
	background: var(--panel);
	border: solid 1px var(--divider);
	font-size: 0.78rem;
	line-height: 1.1;
	overflow: hidden;
	white-space: normal;
	word-break: keep-all;
	user-select: none;
	touch-action: none;
}

.keyboardKey:active {
	color: var(--accent);
	background: var(--accentedBg);
}

.keyboardKey.latched {
	color: var(--accent);
	background: var(--accentedBg);
	border-color: var(--accent);
}

.keyboardKey.locked {
	color: var(--accent);
	background: var(--accentedBg);
	border-color: var(--accent);
	box-shadow: inset 0 0 0 2px var(--accent);
}

.keyboardKey.empty {
	opacity: 0.35;
}

h2 {
	margin: 0;
	font-size: 1.05rem;
}

.fileRow {
	display: grid;
	grid-template-columns: minmax(120px, 180px) minmax(0, 1fr) auto;
	gap: 8px;
	align-items: start;
	min-width: 0;
}

.fileRow > textarea {
	width: 100%;
	min-height: 76px;
	padding: 8px;
	resize: vertical;
	font-family: monospace;
}

.customLayoutEditor {
	width: 100%;
	min-height: 180px;
	padding: 10px;
	resize: vertical;
	font-family: monospace;
	font-size: 0.9rem;
	line-height: 1.45;
}

.log {
	box-sizing: border-box;
	max-width: 100%;
	min-height: 220px;
	max-height: 360px;
	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,
	.bottom {
		grid-template-columns: 1fr;
	}
}

@media (max-width: 640px) {
	.panel {
		padding: 12px;
	}

	.actions {
		width: 100%;
		flex-wrap: wrap;
	}

	.actions > * {
		flex: 1 1 120px;
	}

	.editor {
		min-height: 360px;
		font-size: 0.9rem;
	}

	.keyboard {
		gap: 3px;
		padding: 6px;
	}

	.keyboardKey {
		min-height: 30px;
		font-size: 0.68rem;
		padding: 4px 2px;
	}

	.fileRow {
		grid-template-columns: 1fr;
	}
}
</style>
