import { bigint, boolean, foreignKey, index, integer, pgTable, text } from "drizzle-orm/pg-core";
import { and, asc, eq, isNull, sql } from "drizzle-orm";
//#region src/errors.ts
const hasTag = (e, tag) => typeof e === "object" && e !== null && e._tag === tag;
var MenuAuthorizationError = class extends Error {
	actorId;
	action;
	_tag = "MenuAuthorizationError";
	name = "MenuAuthorizationError";
	constructor(actorId, action) {
		super(`actor ${actorId} cannot ${action}`);
		this.actorId = actorId;
		this.action = action;
	}
};
var MenuNotFoundError = class extends Error {
	kind;
	id;
	_tag = "MenuNotFoundError";
	name = "MenuNotFoundError";
	constructor(kind, id) {
		super(`${kind} not found: ${id}`);
		this.kind = kind;
		this.id = id;
	}
};
var MenuValidationError = class extends Error {
	field;
	detail;
	_tag = "MenuValidationError";
	name = "MenuValidationError";
	constructor(field, detail) {
		super(`menu invalid: ${field} — ${detail}`);
		this.field = field;
		this.detail = detail;
	}
};
var MenuDepthExceededError = class extends Error {
	maxDepth;
	_tag = "MenuDepthExceededError";
	name = "MenuDepthExceededError";
	constructor(maxDepth) {
		super(`menu depth exceeds max ${maxDepth}`);
		this.maxDepth = maxDepth;
	}
};
var MenuLimitExceededError = class extends Error {
	maxItems;
	_tag = "MenuLimitExceededError";
	name = "MenuLimitExceededError";
	constructor(maxItems) {
		super(`menu item count exceeds max ${maxItems}`);
		this.maxItems = maxItems;
	}
};
var MenuCycleError = class extends Error {
	itemId;
	_tag = "MenuCycleError";
	name = "MenuCycleError";
	constructor(itemId) {
		super(`menu cycle detected at item ${itemId}`);
		this.itemId = itemId;
	}
};
var MenuUrlSchemeError = class extends Error {
	url;
	_tag = "MenuUrlSchemeError";
	name = "MenuUrlSchemeError";
	constructor(url) {
		super(`menu url scheme not allowed: ${url}`);
		this.url = url;
	}
};
const isMenuAuthorizationError = (e) => hasTag(e, "MenuAuthorizationError") && "actorId" in e && "action" in e;
const isMenuNotFoundError = (e) => hasTag(e, "MenuNotFoundError") && "kind" in e && "id" in e;
const isMenuValidationError = (e) => hasTag(e, "MenuValidationError") && "field" in e && "detail" in e;
const isMenuDepthExceededError = (e) => hasTag(e, "MenuDepthExceededError") && "maxDepth" in e;
const isMenuLimitExceededError = (e) => hasTag(e, "MenuLimitExceededError") && "maxItems" in e;
const isMenuCycleError = (e) => hasTag(e, "MenuCycleError") && "itemId" in e;
const isMenuUrlSchemeError = (e) => hasTag(e, "MenuUrlSchemeError") && "url" in e;
//#endregion
//#region src/authz.ts
function assertCanManageMenus(actor) {
	if (!actor.canManageMenus) throw new MenuAuthorizationError(actor.id, "manageMenus");
}
//#endregion
//#region src/limits.ts
const MAX_MENU_DEPTH = 5;
const MAX_ITEMS_PER_MENU = 200;
//#endregion
//#region src/schema.ts
const MENU_KEY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const menus = pgTable("menus", {
	id: text("id").primaryKey(),
	key: text("key").notNull().unique(),
	label: text("label").notNull(),
	createdAtMs: bigint("created_at_ms", { mode: "number" }).notNull(),
	updatedAtMs: bigint("updated_at_ms", { mode: "number" }).notNull()
});
const menuItems = pgTable("menu_items", {
	id: text("id").primaryKey(),
	menuId: text("menu_id").notNull().references(() => menus.id, { onDelete: "cascade" }),
	parentId: text("parent_id"),
	position: integer("position").notNull(),
	depth: integer("depth").notNull(),
	label: text("label").notNull(),
	targetKind: text("target_kind").notNull(),
	url: text("url"),
	targetEntityType: text("target_entity_type"),
	targetEntityId: text("target_entity_id"),
	openInNew: boolean("open_in_new").notNull().default(false),
	createdAtMs: bigint("created_at_ms", { mode: "number" }).notNull(),
	updatedAtMs: bigint("updated_at_ms", { mode: "number" }).notNull()
}, (t) => [foreignKey({
	columns: [t.parentId],
	foreignColumns: [t.id],
	name: "menu_items_parent_id_menu_items_id_fk"
}).onDelete("cascade"), index("menu_items_menu_parent_position_idx").on(t.menuId, t.parentId, t.position)]);
const menusSchema = {
	menus,
	menuItems
};
/** Idempotent additive DDL for menus tables. */
const menusMigrationSql = () => `
CREATE TABLE IF NOT EXISTS menus (
  id            text PRIMARY KEY,
  key           text NOT NULL UNIQUE,
  label         text NOT NULL,
  created_at_ms bigint NOT NULL,
  updated_at_ms bigint NOT NULL
);
CREATE TABLE IF NOT EXISTS menu_items (
  id                  text PRIMARY KEY,
  menu_id             text NOT NULL REFERENCES menus(id) ON DELETE CASCADE,
  parent_id           text REFERENCES menu_items(id) ON DELETE CASCADE,
  position            integer NOT NULL,
  depth               integer NOT NULL,
  label               text NOT NULL,
  target_kind         text NOT NULL,
  url                 text,
  target_entity_type  text,
  target_entity_id    text,
  open_in_new         boolean NOT NULL DEFAULT false,
  created_at_ms       bigint NOT NULL,
  updated_at_ms       bigint NOT NULL
);
CREATE INDEX IF NOT EXISTS menu_items_menu_parent_position_idx
  ON menu_items (menu_id, parent_id, position);
`.trim();
//#endregion
//#region src/menus.ts
function nowMs$1() {
	return Date.now();
}
function newId$1() {
	return crypto.randomUUID();
}
function rowToMenu$1(row) {
	return {
		id: row.id,
		key: row.key,
		label: row.label,
		createdAtMs: row.createdAtMs,
		updatedAtMs: row.updatedAtMs
	};
}
function validateMenuKey(key) {
	if (!MENU_KEY_RE.test(key)) throw new MenuValidationError("key", "invalid menu key");
}
async function createMenu(db, actor, input) {
	assertCanManageMenus(actor);
	validateMenuKey(input.key);
	if ((await db.select().from(menus).where(eq(menus.key, input.key)).limit(1)).length > 0) throw new MenuValidationError("key", "menu key already exists");
	const ts = nowMs$1();
	const row = {
		id: newId$1(),
		key: input.key,
		label: input.label,
		createdAtMs: ts,
		updatedAtMs: ts
	};
	await db.insert(menus).values(row);
	return rowToMenu$1(row);
}
async function updateMenu(db, actor, menuId, patch) {
	assertCanManageMenus(actor);
	const [existing] = await db.select().from(menus).where(eq(menus.id, menuId)).limit(1);
	if (!existing) throw new MenuNotFoundError("menu", menuId);
	const updatedAtMs = nowMs$1();
	const next = {
		label: patch.label ?? existing.label,
		updatedAtMs
	};
	await db.update(menus).set(next).where(eq(menus.id, menuId));
	return rowToMenu$1({
		...existing,
		...next
	});
}
async function deleteMenu(db, actor, menuId) {
	assertCanManageMenus(actor);
	const [existing] = await db.select().from(menus).where(eq(menus.id, menuId)).limit(1);
	if (!existing) throw new MenuNotFoundError("menu", menuId);
	await db.delete(menus).where(eq(menus.id, menuId));
}
//#endregion
//#region src/url-scheme.ts
const ALLOWED_SCHEMES = /* @__PURE__ */ new Set([
	"http",
	"https",
	"mailto"
]);
/**
* Accept http/https/mailto/path-relative URLs; reject dangerous schemes before any write.
* Path-relative means a single leading `/` NOT followed by `/` or `\`: protocol-relative
* `//host` and backslash-authority `/\host` both resolve cross-host in browsers (WHATWG
* slash-normalization for special schemes) and are rejected as open-redirect/phishing vectors.
* The host should re-use this guard at render, never re-author it.
*/
function assertAllowedMenuUrl(url) {
	const trimmed = url.replace(/[\u0000-\u001F\u007F]/g, "").trim();
	if (trimmed.startsWith("/")) {
		const next = trimmed.charCodeAt(1);
		if (next === 47 || next === 92) throw new MenuUrlSchemeError(url);
		return trimmed;
	}
	const colon = trimmed.indexOf(":");
	if (colon === -1) throw new MenuUrlSchemeError(url);
	const scheme = trimmed.slice(0, colon).toLowerCase();
	if (ALLOWED_SCHEMES.has(scheme)) return trimmed;
	throw new MenuUrlSchemeError(url);
}
//#endregion
//#region src/items.ts
function nowMs() {
	return Date.now();
}
function newId() {
	return crypto.randomUUID();
}
function rowToItem$1(row) {
	return {
		id: row.id,
		menuId: row.menuId,
		parentId: row.parentId,
		position: row.position,
		depth: row.depth,
		label: row.label,
		targetKind: row.targetKind,
		url: row.url,
		targetEntityType: row.targetEntityType,
		targetEntityId: row.targetEntityId,
		openInNew: row.openInNew,
		createdAtMs: row.createdAtMs,
		updatedAtMs: row.updatedAtMs
	};
}
function validateTarget(target) {
	if (target.kind === "url") {
		if (!target.url) throw new MenuValidationError("url", "url target requires url");
		target.url = assertAllowedMenuUrl(target.url);
		return;
	}
	if (!target.entityType || !target.entityId) throw new MenuValidationError("target", "entity target requires entityType and entityId");
}
function targetColumns(target, openInNew) {
	if (target.kind === "url") return {
		targetKind: "url",
		url: assertAllowedMenuUrl(target.url),
		targetEntityType: null,
		targetEntityId: null,
		openInNew: target.openInNew ?? openInNew ?? false
	};
	return {
		targetKind: "entity",
		url: null,
		targetEntityType: target.entityType,
		targetEntityId: target.entityId,
		openInNew: target.openInNew ?? openInNew ?? false
	};
}
async function getItem(db, itemId) {
	const [row] = await db.select().from(menuItems).where(eq(menuItems.id, itemId)).limit(1);
	if (!row) throw new MenuNotFoundError("item", itemId);
	return row;
}
async function assertMenuExists(db, menuId) {
	const [row] = await db.select({ id: menus.id }).from(menus).where(eq(menus.id, menuId)).limit(1);
	if (!row) throw new MenuNotFoundError("menu", menuId);
}
async function countItemsInMenu(db, menuId) {
	const [row] = await db.select({ count: sql`count(*)::int` }).from(menuItems).where(eq(menuItems.menuId, menuId));
	return row?.count ?? 0;
}
async function getSiblings(db, menuId, parentId, excludeId) {
	const rows = await db.select().from(menuItems).where(and(eq(menuItems.menuId, menuId), parentId === null ? isNull(menuItems.parentId) : eq(menuItems.parentId, parentId))).orderBy(asc(menuItems.position));
	return excludeId ? rows.filter((r) => r.id !== excludeId) : rows;
}
async function resequenceSiblings(db, menuId, parentId, orderedIds) {
	const ts = nowMs();
	for (let position = 0; position < orderedIds.length; position++) await db.update(menuItems).set({
		position,
		updatedAtMs: ts
	}).where(and(eq(menuItems.id, orderedIds[position]), eq(menuItems.menuId, menuId)));
}
async function assertNoCycle(db, movedItemId, newParentId) {
	if (newParentId === null) return;
	if (newParentId === movedItemId) throw new MenuCycleError(movedItemId);
	let current = newParentId;
	const visited = /* @__PURE__ */ new Set();
	while (current) {
		if (current === movedItemId) throw new MenuCycleError(movedItemId);
		if (visited.has(current)) break;
		visited.add(current);
		const [parent] = await db.select().from(menuItems).where(eq(menuItems.id, current)).limit(1);
		current = parent?.parentId ?? null;
	}
}
async function maxDescendantDepth(db, itemId) {
	const item = await getItem(db, itemId);
	const all = await db.select().from(menuItems).where(eq(menuItems.menuId, item.menuId));
	const byParent = /* @__PURE__ */ new Map();
	for (const row of all) {
		const key = row.parentId;
		const list = byParent.get(key) ?? [];
		list.push(row);
		byParent.set(key, list);
	}
	let maxExtra = 0;
	const walk = (id, extra) => {
		maxExtra = Math.max(maxExtra, extra);
		for (const child of byParent.get(id) ?? []) walk(child.id, extra + 1);
	};
	walk(itemId, 0);
	return maxExtra;
}
function assertDepthAllowed(depth) {
	if (depth >= 5) throw new MenuDepthExceededError(5);
}
function hasPgSqlState(err, code) {
	if (typeof err !== "object" || err === null) return false;
	const rec = err;
	if (rec.code === code) return true;
	if (rec.cause !== void 0) return hasPgSqlState(rec.cause, code);
	return false;
}
/** True when `err` (or a nested `cause`) carries Postgres SQLSTATE `23503` (FK violation). */
function isForeignKeyViolation(err) {
	return hasPgSqlState(err, "23503");
}
async function addMenuItem(db, actor, menuId, input) {
	assertCanManageMenus(actor);
	validateTarget(input.target);
	await assertMenuExists(db, menuId);
	if (await countItemsInMenu(db, menuId) >= 200) throw new MenuLimitExceededError(200);
	let depth = 0;
	const parentId = input.parentId ?? null;
	if (parentId) {
		const parent = await getItem(db, parentId);
		if (parent.menuId !== menuId) throw new MenuNotFoundError("item", parentId);
		depth = parent.depth + 1;
	}
	assertDepthAllowed(depth);
	const position = (await getSiblings(db, menuId, parentId)).length;
	const ts = nowMs();
	const row = {
		id: newId(),
		menuId,
		parentId,
		position,
		depth,
		label: input.label,
		...targetColumns(input.target),
		createdAtMs: ts,
		updatedAtMs: ts
	};
	try {
		await db.insert(menuItems).values(row);
	} catch (err) {
		if (isForeignKeyViolation(err)) throw new MenuNotFoundError("menu", menuId);
		throw err;
	}
	return rowToItem$1(row);
}
async function updateMenuItem(db, actor, itemId, patch) {
	assertCanManageMenus(actor);
	if (patch.target) validateTarget(patch.target);
	const existing = await getItem(db, itemId);
	const updatedAtMs = nowMs();
	const next = {
		label: patch.label ?? existing.label,
		...patch.target ? targetColumns(patch.target, patch.openInNew) : { openInNew: patch.openInNew ?? existing.openInNew },
		updatedAtMs
	};
	await db.update(menuItems).set(next).where(eq(menuItems.id, itemId));
	return rowToItem$1({
		...existing,
		...next
	});
}
async function removeMenuItem(db, actor, itemId) {
	assertCanManageMenus(actor);
	const existing = await getItem(db, itemId);
	await db.delete(menuItems).where(eq(menuItems.id, itemId));
	const siblings = await getSiblings(db, existing.menuId, existing.parentId);
	await resequenceSiblings(db, existing.menuId, existing.parentId, siblings.map((s) => s.id));
}
async function moveMenuItem(db, actor, itemId, input) {
	assertCanManageMenus(actor);
	const item = await getItem(db, itemId);
	const { newParentId, newPosition } = input;
	if (newParentId) {
		if ((await getItem(db, newParentId)).menuId !== item.menuId) throw new MenuNotFoundError("item", newParentId);
	}
	await assertNoCycle(db, itemId, newParentId);
	const subtreeExtra = await maxDescendantDepth(db, itemId);
	const newDepth = newParentId ? (await getItem(db, newParentId)).depth + 1 : 0;
	assertDepthAllowed(newDepth);
	if (newDepth + subtreeExtra >= 5) throw new MenuDepthExceededError(5);
	const oldParentId = item.parentId;
	const oldSiblings = (await getSiblings(db, item.menuId, oldParentId, itemId)).map((s) => s.id);
	await resequenceSiblings(db, item.menuId, oldParentId, oldSiblings);
	const newSiblings = (await getSiblings(db, item.menuId, newParentId, itemId)).map((s) => s.id);
	const clamped = Math.max(0, Math.min(newPosition, newSiblings.length));
	newSiblings.splice(clamped, 0, itemId);
	await resequenceSiblings(db, item.menuId, newParentId, newSiblings);
	const depthDelta = newDepth - item.depth;
	const ts = nowMs();
	await db.update(menuItems).set({
		parentId: newParentId,
		depth: newDepth,
		position: clamped,
		updatedAtMs: ts
	}).where(eq(menuItems.id, itemId));
	if (depthDelta !== 0) {
		const all = await db.select().from(menuItems).where(eq(menuItems.menuId, item.menuId));
		const childrenByParent = /* @__PURE__ */ new Map();
		for (const row of all) if (row.parentId) {
			const list = childrenByParent.get(row.parentId) ?? [];
			list.push(row);
			childrenByParent.set(row.parentId, list);
		}
		const stack = [...childrenByParent.get(itemId) ?? []];
		while (stack.length) {
			const current = stack.pop();
			const nextDepth = current.depth + depthDelta;
			assertDepthAllowed(nextDepth);
			await db.update(menuItems).set({
				depth: nextDepth,
				updatedAtMs: ts
			}).where(eq(menuItems.id, current.id));
			stack.push(...childrenByParent.get(current.id) ?? []);
		}
	}
	return rowToItem$1({
		...item,
		parentId: newParentId,
		depth: newDepth,
		position: clamped,
		updatedAtMs: ts
	});
}
async function reorderMenuItems(db, actor, menuId, parentId, orderedIds) {
	assertCanManageMenus(actor);
	const siblings = await getSiblings(db, menuId, parentId);
	const siblingIds = new Set(siblings.map((s) => s.id));
	if (orderedIds.length !== siblingIds.size || new Set(orderedIds).size !== orderedIds.length) throw new MenuValidationError("orderedIds", "must include every sibling exactly once");
	for (const id of orderedIds) if (!siblingIds.has(id)) throw new MenuValidationError("orderedIds", "unknown sibling id");
	await resequenceSiblings(db, menuId, parentId, orderedIds);
}
//#endregion
//#region src/read.ts
function rowToMenu(row) {
	return {
		id: row.id,
		key: row.key,
		label: row.label,
		createdAtMs: row.createdAtMs,
		updatedAtMs: row.updatedAtMs
	};
}
function rowToItem(row) {
	return {
		id: row.id,
		menuId: row.menuId,
		parentId: row.parentId,
		position: row.position,
		depth: row.depth,
		label: row.label,
		targetKind: row.targetKind,
		url: row.url,
		targetEntityType: row.targetEntityType,
		targetEntityId: row.targetEntityId,
		openInNew: row.openInNew,
		createdAtMs: row.createdAtMs,
		updatedAtMs: row.updatedAtMs
	};
}
function buildChildren(rows, parentId, visited) {
	const nodes = [];
	for (const row of rows.filter((r) => r.parentId === parentId).sort((a, b) => a.position - b.position)) {
		if (visited.has(row.id)) continue;
		visited.add(row.id);
		nodes.push({
			...row,
			children: buildChildren(rows, row.id, visited)
		});
	}
	return nodes;
}
function assembleTree(rows) {
	const visited = /* @__PURE__ */ new Set();
	const itemIds = new Set(rows.map((r) => r.id));
	const roots = rows.filter((r) => r.parentId === null || !itemIds.has(r.parentId)).sort((a, b) => a.position - b.position);
	const tree = [];
	for (const root of roots) {
		if (visited.has(root.id)) continue;
		visited.add(root.id);
		tree.push({
			...root,
			children: buildChildren(rows, root.id, visited)
		});
	}
	for (const row of rows.sort((a, b) => a.position - b.position)) {
		if (visited.has(row.id)) continue;
		visited.add(row.id);
		tree.push({
			...row,
			children: buildChildren(rows, row.id, visited)
		});
	}
	return tree.sort((a, b) => a.position - b.position);
}
async function listMenus(db) {
	return (await db.select().from(menus).orderBy(asc(menus.key))).map(rowToMenu);
}
/**
* Load a menu and its item tree by stable key. Returns null when the key is missing.
*
* Returns UNRESOLVED opaque targets exactly as stored — entity targets are NOT
* visibility-filtered; this core never resolves entity ids and cannot know whether
* a target points at a draft, private, or trashed entity. The caller MUST resolve
* each entity target through its own visibility/authorization filter at render time
* and drop anything not publicly visible before exposing the tree to an end user.
*/
async function getMenuTree(db, key) {
	const [menu] = await db.select().from(menus).where(eq(menus.key, key)).limit(1);
	if (!menu) return null;
	const rows = await db.select().from(menuItems).where(eq(menuItems.menuId, menu.id)).orderBy(asc(menuItems.position));
	return {
		menu: rowToMenu(menu),
		items: assembleTree(rows.map(rowToItem))
	};
}
//#endregion
export { MAX_ITEMS_PER_MENU, MAX_MENU_DEPTH, MENU_KEY_RE, MenuAuthorizationError, MenuCycleError, MenuDepthExceededError, MenuLimitExceededError, MenuNotFoundError, MenuUrlSchemeError, MenuValidationError, addMenuItem, assertAllowedMenuUrl, assertCanManageMenus, createMenu, deleteMenu, getMenuTree, isMenuAuthorizationError, isMenuCycleError, isMenuDepthExceededError, isMenuLimitExceededError, isMenuNotFoundError, isMenuUrlSchemeError, isMenuValidationError, listMenus, menuItems, menus, menusMigrationSql, menusSchema, moveMenuItem, removeMenuItem, reorderMenuItems, updateMenu, updateMenuItem };
