/**
 * Room storage on the shared `recflare` D1 database. Each room is a single JSON
 * blob in the `data` column; queryable fields (RoomId, Name, CreatorAccountId,
 * IsDorm) are SQLite generated (virtual) columns extracted from that JSON and
 * indexed. This keeps the room shape flexible while still allowing fast lookups
 * by id/name/creator — the same JSON-blob pattern `accounts-db` uses.
 *
 * `ROOM_SCHEMA_DDL` mirrors the head schema after all migrations (`0001_init.sql`
 * created the table as `rooms`; `0005_rename_room.sql` renamed it to `room`); the
 * room data is seeded from `apps/rooms/static/ImportRooms.json` by
 * `migrations/0002_import_rooms.sql`. Tests apply `ROOM_SCHEMA_DDL` then seed the
 * imported rooms directly.
 *
 * This module is the single source of truth for the helpers: the `rooms` worker
 * (which owns the schema/migrations) uses the read/write set; the `match` worker
 * uses the room lookups plus the dorm helpers; the `api` worker binds the same
 * database read-only and uses `getRoomById`. Each imports the subset it needs.
 */

import { Accessibility, Role } from './enums'
import { countPlayersByRoom } from './presence-db'

/** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
export const ROOM_SCHEMA_DDL: string[] = [
	`CREATE TABLE IF NOT EXISTS room (
		data TEXT NOT NULL,
		room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
		name TEXT GENERATED ALWAYS AS (json_extract(data, '$.Name')) VIRTUAL,
		name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
		creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
		is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL,
		-- Lifetime visit counter (migrations/0011_room_visits.sql, which appends it here):
		-- bumped once per successful matchmake into the room by {@link recordRoomVisit},
		-- and served as the room's \`Stats.VisitCount\`. A real column rather than a field
		-- in the blob so a visit is one atomic UPDATE that can't lose a concurrent
		-- read-modify-write of the whole room.
		visits INTEGER NOT NULL DEFAULT 0,
		-- The two flags every public feed filters on, alongside \`is_dorm\`
		-- (migrations/0014_room_listable.sql, which appends them here). Generated like the
		-- rest so the blob stays the only copy; they exist to be INDEXED — see
		-- {@link LISTABLE_WHERE}.
		accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL,
		exclude_from_lists INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ExcludeFromLists')) VIRTUAL
	)`,
	`CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_room_id ON room (room_id)`,
	`CREATE INDEX IF NOT EXISTS idx_rooms_name_lower ON room (name_lower)`,
	`CREATE INDEX IF NOT EXISTS idx_rooms_creator ON room (creator_account_id)`,
	// PARTIAL index over the public, non-dorm rooms — the only rooms any feed can serve,
	// and a small minority of the table (most rooms are dorms, one per account). Scanning
	// it visits those rooms alone instead of every room in the database; see
	// {@link LISTABLE_WHERE} for why the feeds select on it.
	//
	// Indexed on `room_id` because a partial index needs some column to key on and the
	// feeds all order by it eventually; the WHERE clause is the point, not the key.
	`CREATE INDEX IF NOT EXISTS idx_room_public ON room (room_id)
	 WHERE is_dorm IS NOT 1 AND accessibility = 1`,
	// A room's tags, one row per tag (migrations/0013_room_tag.sql). Modelled on the
	// `api` worker's `event_tag`, and the table is AUTHORITATIVE: `serializeRoom` strips
	// `Tags` from the blob and the reads re-attach it, the same arrangement `subroom` and
	// `subroom_save` already use, so the two can't drift.
	//
	// `tag` is stored lowercased and is the lookup key, which is what lets a tag-filtered
	// feed (a discovery category row, a `#tag` search) select in SQL instead of parsing
	// every room blob to ask. `type` is the client's tag-category int — 0 user, 2 the
	// auto-derived ones like `rro` — echoed back as stored.
	//
	// `is_primary_genre` (migrations/0015_room_tag_primary_genre.sql) flags the ONE tag
	// that is the room's genre, which the 2025 client sets with `primaryGenreTag=` and
	// draws differently from the rest. It is orthogonal to `type`: the flagged tag is
	// still an ordinary Type 0 user tag, and a room carries other tags alongside it.
	`CREATE TABLE IF NOT EXISTS room_tag (
		room_id INTEGER NOT NULL,
		tag TEXT NOT NULL,
		type INTEGER NOT NULL DEFAULT 0,
		is_primary_genre INTEGER NOT NULL DEFAULT 0,
		PRIMARY KEY (room_id, tag)
	)`,
	`CREATE INDEX IF NOT EXISTS idx_room_tag_tag ON room_tag (tag)`,
	// Per-player interaction state with a room (cheered/favorited + last visit).
	// One row per (player, room); cheer/favorite are toggled in place.
	`CREATE TABLE IF NOT EXISTS interaction (
		player_id INTEGER NOT NULL,
		room_id INTEGER NOT NULL,
		cheered INTEGER NOT NULL DEFAULT 0,
		favorited INTEGER NOT NULL DEFAULT 0,
		last_visited_at TEXT,
		PRIMARY KEY (player_id, room_id)
	)`,
	// Per-room player bans (migrations/0010_room_ban.sql). One row per (room, player),
	// so re-banning someone already banned updates their row rather than appending.
	// `ban_mask` is the client's `banMask` field kept verbatim — its meaning isn't known
	// yet (the client sends 0), so nothing interprets it.
	//
	// Deliberately NOT in the room's `data` blob: that blob is served to the client
	// verbatim as the room, and a ban list is not something every reader of a room
	// should receive.
	`CREATE TABLE IF NOT EXISTS room_ban (
		room_id INTEGER NOT NULL,
		banned_player_id INTEGER NOT NULL,
		ban_mask INTEGER NOT NULL DEFAULT 0,
		banned_by_account_id INTEGER NOT NULL,
		created_at TEXT NOT NULL,
		PRIMARY KEY (room_id, banned_player_id)
	)`,
	`CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id)`,
]

/**
 * Subroom schema DDL (mirror of migrations/0007_subrooms.sql). Subrooms are
 * first-class entities with their own globally-unique, autoincrementing id — the
 * original game mints `SubRoomId` from a single sequence, not per-room — so they
 * live in their own table rather than inside the room JSON blob. `data` holds the
 * rest of the subroom's client shape; `sub_room_id`/`room_id` are the authoritative
 * columns (re-injected over `data` on read). Rooms re-embed their `SubRooms` array
 * on read (see {@link getRoomById}); nothing persists SubRooms back into the room blob.
 */
export const SUBROOM_SCHEMA_DDL: string[] = [
	`CREATE TABLE IF NOT EXISTS subroom (
		sub_room_id INTEGER PRIMARY KEY AUTOINCREMENT,
		room_id INTEGER NOT NULL,
		data TEXT NOT NULL,
		current_save_id INTEGER,
		staged_save_id INTEGER
	)`,
	`CREATE INDEX IF NOT EXISTS idx_subroom_room ON subroom (room_id)`,
	// Room saves (migrations/0008_subroom_saves.sql). A save is its own entity with a
	// globally-unique, autoincrementing `SubRoomDataSaveId` — the same reason subrooms got
	// their own table in 0007. It HAS to be global because a subroom points at saves by
	// bare id: `current_save_id` is the live/published save the loader downloads,
	// `staged_save_id` the creator's unpublished one. Per-subroom numbering would make
	// every subroom's first save id 1 and those pointers ambiguous.
	//
	// `data` holds the save's client shape minus its two id fields; the columns are
	// authoritative and are re-injected on read, exactly how `subroom` treats its own ids.
	// A subroom's `CurrentSave` is inlined from `current_save_id` on every read and is
	// never stored in the subroom blob.
	//
	// Part of this DDL rather than its own export: reading a subroom joins this table, so
	// applying one without the other yields a schema that can't serve a room.
	`CREATE TABLE IF NOT EXISTS subroom_save (
		sub_room_data_save_id INTEGER PRIMARY KEY AUTOINCREMENT,
		sub_room_id INTEGER NOT NULL,
		data TEXT NOT NULL
	)`,
	`CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id)`,
	// Per-subroom permission overrides (migrations/0009_subroom_permissions.sql). The room
	// owner's permission table for one subroom, keyed by (permission, role) — that pair is
	// what the client's PUT addresses, and re-sending it overwrites the stored row rather
	// than appending a second one.
	//
	// A row IS an override, which is why the client's `Override` flag is not a column: it's
	// the checkbox next to the permission, so clearing it deletes the row and the pair falls
	// back to its default. `value` is the client's string, stored verbatim.
	//
	// Deliberately NOT in the subroom's `data` blob: that blob is served to the client
	// verbatim as part of the room, and these overrides are read on one path only
	// (`GET /photon_access_token`, where they overwrite the matching default entries).
	`CREATE TABLE IF NOT EXISTS subroom_permission (
		sub_room_id INTEGER NOT NULL,
		permission TEXT NOT NULL,
		role INTEGER NOT NULL,
		type INTEGER NOT NULL DEFAULT 0,
		value TEXT NOT NULL,
		PRIMARY KEY (sub_room_id, permission, role)
	)`,
]

/** A stored room — the parsed JSON blob (full client-facing room response). */
export type Room = Record<string, unknown>

/** A room role assignment (the client's RoomRole shape). */
interface RoomRole {
	AccountId: number
	Role: number
	LastChangedByAccountId: number | null
	InvitedRole: number
}

/**
 * Room roles that confer owner-level management of a room: Creator (255) and
 * CoOwner (30). The reference gates its room-admin actions on this set. (Host and
 * Moderator are lower tiers and are deliberately excluded.)
 */
const MANAGE_ROLES: ReadonlySet<number> = new Set([Role.Creator, Role.CoOwner])

/**
 * Whether an account may manage a room — its creator, or the holder of a
 * Creator/CoOwner role on the room's `Roles`. This is the owner-or-co-owner gate
 * the reference applies to room-admin actions (editing room data, viewing a room's
 * live instances). Shared so the `rooms` and `match` workers apply the same check
 * rather than each re-deriving the role set.
 */
export function canManageRoom(room: Room, accountId: number): boolean {
	if (room.CreatorAccountId === accountId) return true
	const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
	return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
}

/** A player banned from a room (a `room_ban` row). */
export interface RoomBan {
	RoomId: number
	BannedPlayerId: number
	/** The client's `banMask`, stored verbatim — its meaning isn't known yet. */
	BanMask: number
	BannedByAccountId: number
	CreatedAt: string
}

interface RoomBanRow {
	room_id: number
	banned_player_id: number
	ban_mask: number
	banned_by_account_id: number
	created_at: string
}

const toRoomBan = (row: RoomBanRow): RoomBan => ({
	RoomId: row.room_id,
	BannedPlayerId: row.banned_player_id,
	BanMask: row.ban_mask,
	BannedByAccountId: row.banned_by_account_id,
	CreatedAt: row.created_at,
})

/**
 * Ban a player from a room, returning the stored ban. One row per (room, player):
 * re-banning someone already banned rewrites their row with the new mask and issuer
 * rather than appending a second one, so the call is idempotent.
 */
export async function banPlayerFromRoom(
	db: D1Database,
	roomId: number,
	bannedPlayerId: number,
	banMask: number,
	bannedByAccountId: number
): Promise<RoomBan> {
	const row = await db
		.prepare(
			`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
			 VALUES (?1, ?2, ?3, ?4, ?5)
			 ON CONFLICT(room_id, banned_player_id) DO UPDATE SET
				 ban_mask = ?3, banned_by_account_id = ?4, created_at = ?5
			 RETURNING *`
		)
		.bind(roomId, bannedPlayerId, banMask, bannedByAccountId, new Date().toISOString())
		.first<RoomBanRow>()
	// RETURNING always yields the upserted row.
	return toRoomBan(row!)
}

/**
 * Lift a player's ban on a room, returning the ban that was removed — or null when
 * they weren't banned, which lets the caller tell a real unban from a no-op.
 */
export async function unbanPlayerFromRoom(
	db: D1Database,
	roomId: number,
	bannedPlayerId: number
): Promise<RoomBan | null> {
	const row = await db
		.prepare('DELETE FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2 RETURNING *')
		.bind(roomId, bannedPlayerId)
		.first<RoomBanRow>()
	return row ? toRoomBan(row) : null
}

/** Everyone banned from a room, most recently banned first. */
export async function getRoomBans(db: D1Database, roomId: number): Promise<RoomBan[]> {
	const { results } = await db
		.prepare('SELECT * FROM room_ban WHERE room_id = ?1 ORDER BY created_at DESC')
		.bind(roomId)
		.all<RoomBanRow>()
	return results.map(toRoomBan)
}

/** Whether a player is banned from a room. */
export async function isPlayerBannedFromRoom(
	db: D1Database,
	roomId: number,
	playerId: number
): Promise<boolean> {
	const row = await db
		.prepare('SELECT 1 AS hit FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2')
		.bind(roomId, playerId)
		.first<{ hit: number }>()
	return row !== null
}

/**
 * Clone an existing room into a new one owned by `accountId`. Copies the source
 * room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
 * name, and the new owner. The clone starts with an empty tag set — the source's
 * tags (including the `base` template tag) do not carry over, so the owner tags the
 * clone from scratch — `IsRRO` is cleared so the client doesn't render a virtual
 * "RRO" tag on it, and it starts PRIVATE rather than inheriting the source's
 * visibility. Returns the new room, or null when the source isn't in D1 or disallows
 * cloning.
 */
export async function cloneRoom(
	db: D1Database,
	sourceRoomId: number,
	name: string,
	accountId: number
): Promise<Room | null> {
	const source = await getRoomById(db, sourceRoomId)
	if (!source || source.CloningAllowed === false) return null

	const row = await db
		.prepare('SELECT MAX(room_id) AS maxId FROM room')
		.first<{ maxId: number | null }>()
	const newRoomId = (row?.maxId ?? 0) + 1

	// Ownership is reset to the cloner — the source room's Roles (its creator and
	// any co-owners, e.g. the seeded base-room roles for accounts 1/2) must NOT
	// carry over, or the clone would still list the template's owner as owner.
	const roles: RoomRole[] = [
		{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 },
	]

	const cloned: Room = {
		...source,
		RoomId: newRoomId,
		Name: name,
		CreatorAccountId: accountId,
		IsDorm: false,
		// Start fresh: drop every tag the source carried (including `base`).
		Tags: [],
		// A user clone is not a Rec Room Original — clear the inherited flag, or the
		// client renders a virtual "RRO" tag on the clone.
		IsRRO: false,
		// A brand-new room is unpublished: the owner publishes it by setting the room's
		// accessibility. Inheriting the source's would put the clone straight into the
		// public feeds (hot/search/recommendations/similar all key on Accessibility === 1)
		// the moment it was made — every clone of a PUBLIC source, template or player room.
		Accessibility: Accessibility.Private,
		Roles: roles,
		// A fresh room has no engagement of its own — don't inherit the source's counters
		// (the derived ones are recomputed per read, but the clone is returned as-is here).
		Stats: storedStats(source.Stats),
		CreatedAt: new Date().toISOString(),
	}

	// serializeRoom drops the hydrated SubRooms from the blob; the clone's subrooms are
	// inserted into the subroom table below with fresh globally-unique ids.
	await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(cloned)).run()
	const sourceSubRooms = Array.isArray(source.SubRooms) ? (source.SubRooms as SubRoom[]) : []
	const clonedSubRooms: SubRoom[] = []
	for (const sub of sourceSubRooms) {
		clonedSubRooms.push(await insertSubRoom(db, newRoomId, { ...sub, CreatorAccountId: accountId }))
	}
	cloned.SubRooms = clonedSubRooms
	// Inherited from the (parsed) source in practice; defaulted here too so a clone is
	// never the one room shape missing them.
	attachRoomDtoDefaults(cloned)
	return cloned
}

/** Set a room's Description in place (the caller is responsible for the owner check). */
export async function setRoomDescription(
	db: D1Database,
	roomId: number,
	description: string
): Promise<void> {
	await db
		.prepare("UPDATE room SET data = json_set(data, '$.Description', ?2) WHERE room_id = ?1")
		.bind(roomId, description)
		.run()
}

/**
 * Set a room's Name in place (the caller checks ownership + name uniqueness first).
 *
 * Writes `FriendlyName` to the same string. That is the DISPLAY name — what the client
 * labels the room with — and it is only defaulted to `Name` on read
 * ({@link attachRoomDtoDefaults}), with `??=`, so a room whose blob has ever carried one
 * keeps it. Renaming without this leaves that room displaying its old name forever while
 * every name-keyed lookup uses the new one.
 *
 * The reference lets a creator set a display name apart from the unique `Name`; nothing
 * here exposes that, so the two are kept in step rather than allowed to diverge silently.
 */
export async function setRoomName(db: D1Database, roomId: number, name: string): Promise<void> {
	await db
		.prepare(
			"UPDATE room SET data = json_set(data, '$.Name', ?2, '$.FriendlyName', ?2) WHERE room_id = ?1"
		)
		.bind(roomId, name)
		.run()
}

/** Set a room's ImageName in place (the caller is responsible for the owner check). */
export async function setRoomImage(
	db: D1Database,
	roomId: number,
	imageName: string
): Promise<void> {
	await db
		.prepare("UPDATE room SET data = json_set(data, '$.ImageName', ?2) WHERE room_id = ?1")
		.bind(roomId, imageName)
		.run()
}

/**
 * Merge a set of top-level fields into a room's JSON blob and write it back. Used by
 * the room-settings mutations whose values include booleans (cloning, platform
 * restrictions) — rewriting the whole blob preserves proper JSON booleans, whereas a
 * `json_set` bind would store `true`/`false` as `1`/`0`. The caller supplies the
 * already-loaded, permission-checked room. Returns the updated room.
 */
export async function updateRoomFields(
	db: D1Database,
	roomId: number,
	room: Room,
	patch: Record<string, unknown>
): Promise<Room> {
	const updated: Room = { ...room, ...patch }
	await db
		.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
		.bind(roomId, serializeRoom(updated))
		.run()
	return updated
}

/**
 * Set a target account's room `Role` — updating their existing `Roles` entry or
 * appending a new one — and stamp `LastChangedByAccountId` with the editor. The
 * caller supplies the already-loaded room (after its owner/co-owner check) to avoid
 * a re-read; the whole room JSON is rewritten. Returns the updated room.
 */
export async function setRoomRole(
	db: D1Database,
	roomId: number,
	targetAccountId: number,
	role: number,
	changedByAccountId: number,
	room: Room
): Promise<Room> {
	const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
	const existing = roles.find((r) => r.AccountId === targetAccountId)
	if (existing) {
		existing.Role = role
		existing.LastChangedByAccountId = changedByAccountId
	} else {
		roles.push({
			AccountId: targetAccountId,
			Role: role,
			LastChangedByAccountId: changedByAccountId,
			InvitedRole: 0,
		})
	}
	const updated: Room = { ...room, Roles: roles }
	await db
		.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
		.bind(roomId, serializeRoom(updated))
		.run()
	return updated
}

/**
 * Mutually-exclusive "main" room tags. The 2023 UI presents these as radio buttons, so
 * toggling one on clears any other main tag. Compared case-insensitively.
 *
 * Only the TOGGLE body obeys this — the newer whole-set body says outright which tags the
 * room has, and its genre is the `IsPrimaryGenre` flag rather than membership of this set.
 */
const MAIN_TAGS = new Set(['pvp', 'quest', 'game', 'hangout', 'art'])

/**
 * A tag's `Type` — the client's tag CATEGORY, echoed back as stored.
 *
 * `user` is what a player types or picks. `auto` is what the client derives about the room
 * and posts as `autoTag` (`limitsv2`, `beta`). `derived` is this server's own (`rro`).
 * A tag's category is orthogonal to whether it is the room's primary genre.
 */
export const RoomTagType = {
	user: 0,
	auto: 1,
	derived: 2,
} as const

/**
 * The tag changes ONE `PUT /rooms/{id}/tags` request asks for. Every field is optional and
 * they compose: a single request may replace the user tags, add a derived one and move the
 * genre, and it is applied as one write.
 */
export interface RoomTagEdit {
	/**
	 * The 2023 single-tag TOGGLE: the tag is added when the room lacks it and removed when
	 * it has it, and adding one of {@link MAIN_TAGS} clears the others.
	 */
	toggle?: string
	/**
	 * The whole set of USER tags, replacing every `Type: 0` tag the room carries. The
	 * derived tags (`auto`, `derived`) are not the client's to send and are left alone.
	 */
	tags?: string[]
	/**
	 * Tags to ensure present at `Type: 1`. Additive — nothing here removes an auto tag,
	 * since the client posts the ones it wants rather than the full set. A tag already on
	 * the room is re-categorised rather than duplicated.
	 */
	autoTags?: string[]
	/**
	 * The tag to flag as the room's genre. Added (as a user tag) when the room lacks it;
	 * every other tag keeps its place and loses the flag.
	 */
	primaryGenre?: string
}

/** A tag's name, lowercased — every comparison in here is case-insensitive. */
const tagKey = (t: RoomTag): string => String(t?.Tag).toLowerCase()

/**
 * Apply one request's worth of tag changes to a room and store the result. The caller
 * supplies the already-loaded (owner-checked) room, so nothing is re-read.
 *
 * The changes are composed into ONE set and written once: a request naming tags, an auto
 * tag and a genre is a single state for the room, and applying it in three writes would
 * let a reader (or a failure) land between them.
 *
 * Only `room_tag` is written — the room blob carries no tags at all, so the room row is
 * left alone.
 */
export async function applyRoomTagEdit(
	db: D1Database,
	roomId: number,
	room: Room,
	edit: RoomTagEdit
): Promise<Room> {
	const current = Array.isArray(room.Tags) ? (room.Tags as RoomTag[]) : []
	// Copies throughout: the room handed in is answered to the client, and the steps below
	// mutate what they build.
	let next: RoomTag[] = current.map((t) => ({ ...t }))

	if (edit.tags !== undefined) {
		// A SET, not a merge. The posted list is exactly the room's user tags afterwards; a
		// tag already there keeps its row (and its genre flag, until the genre step below
		// says otherwise) rather than being deleted and re-added.
		const posted = new Set(edit.tags.map((t) => t.toLowerCase()))
		next = [
			...next.filter((t) => t.Type !== RoomTagType.user && !posted.has(tagKey(t))),
			...edit.tags.map(
				(tag) =>
					next.find((t) => tagKey(t) === tag.toLowerCase()) ?? { Tag: tag, Type: RoomTagType.user }
			),
		]
	} else if (edit.toggle !== undefined) {
		// The 2023 client has no delete/patch endpoint, so the same call toggles: remove the
		// tag if present, add it otherwise. Adding a main tag is a radio pick, so it also
		// clears any other main tag. Removing the flagged tag takes the genre with it, which
		// is right — the room's genre WAS that tag.
		const lower = edit.toggle.toLowerCase()
		const existing = next.findIndex((t) => tagKey(t) === lower)
		if (existing !== -1) {
			next = next.filter((_, i) => i !== existing)
		} else {
			const kept = MAIN_TAGS.has(lower) ? next.filter((t) => !MAIN_TAGS.has(tagKey(t))) : next
			next = [...kept, { Tag: edit.toggle, Type: RoomTagType.user }]
		}
	}

	for (const auto of edit.autoTags ?? []) {
		const existing = next.find((t) => tagKey(t) === auto.toLowerCase())
		// A tag the room already carries is re-categorised in place rather than duplicated —
		// `tag` is the table's key, so there is only ever one row per name anyway.
		if (existing) existing.Type = RoomTagType.auto
		else next.push({ Tag: auto, Type: RoomTagType.auto })
	}

	if (edit.primaryGenre !== undefined) {
		const lower = edit.primaryGenre.toLowerCase()
		for (const tag of next) delete tag.IsPrimaryGenre
		const chosen = next.find((t) => tagKey(t) === lower)
		// A tag the room already carries keeps its category and simply becomes the genre;
		// one it doesn't is added as an ordinary user tag.
		if (chosen) chosen.IsPrimaryGenre = true
		else next.push({ Tag: edit.primaryGenre, Type: RoomTagType.user, IsPrimaryGenre: true })
	}

	return storeRoomTags(db, roomId, room, next)
}

/**
 * Write a room's whole tag set and answer the room carrying it, lowercased the way the
 * table holds it — so the caller replies with exactly what a re-read would give, without
 * paying for the re-read. `IsPrimaryGenre` survives only where it was set, and stays
 * absent (not false) everywhere else.
 */
async function storeRoomTags(
	db: D1Database,
	roomId: number,
	room: Room,
	tags: RoomTag[]
): Promise<Room> {
	await setRoomTags(db, roomId, tags)
	return {
		...room,
		Tags: tags.map((t) => {
			const stored: RoomTag = { Tag: t.Tag.toLowerCase(), Type: t.Type }
			if (t.IsPrimaryGenre) stored.IsPrimaryGenre = true
			return stored
		}),
	}
}

/** Find a subroom (by SubRoomId) inside an already-hydrated room's `SubRooms`, or undefined. */
export function findSubRoom(room: Room, subRoomId: number): SubRoom | undefined {
	const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : []
	return subRooms.find((s) => s.SubRoomId === subRoomId)
}

/** Fields from the client's room-save POST body. */
export interface SaveSubRoomDataInput {
	/** Uploaded blob key for this subroom's scene data (becomes `CurrentSave.DataBlob`). */
	subRoomDataFilename?: string
	/** `SubRoomData.Hash` — echoed back as the save response's `dataBlobHash`. */
	subRoomDataHash?: string
	/** Uploaded blob key for the room-level METADATA blob (a separate upload). */
	roomDataFilename?: string
	/**
	 * The save comment — a description of THIS revision, typed into the client's save box.
	 * It belongs to the save (and shows up in the `…/saves` history); it is not the room's
	 * public description, which only `PUT /rooms/:id/description` sets.
	 */
	description?: string
	persistenceVersion?: number
	inventionUsage?: string
	/** Optional baked-asset id; emitted on the save only when present. */
	unityAssetId?: string
	/**
	 * The client's `AutoPublish`. True publishes the save outright (the author wants it
	 * live now); false/absent stages it for a manual `publish_save`. Dorms ignore this and
	 * always publish.
	 */
	autoPublish?: boolean
}

/**
 * A subroom's `CurrentSave` — the `SubRoomDataSave` the client reads to find the scene
 * data blob to download. The loader looks ONLY here: a subroom with no `CurrentSave`
 * loads nothing, no matter what the (legacy, flat) `DataBlob` field says.
 */
export type SubRoomDataSave = Record<string, unknown>

/**
 * The scene-data blob key the client should download for a subroom. Prefers the
 * authoritative `CurrentSave.DataBlob` and falls back to the flat `DataBlob` that
 * subrooms written before `CurrentSave` existed (and the `0001_init.sql` dorm seed)
 * still carry. Shared so the `match` and `auth` room-instance payloads resolve the
 * blob the same way the client's own loader does.
 */
export function subRoomDataBlob(sub: SubRoom | undefined | null): string {
	const save = sub?.CurrentSave
	if (save && typeof save === 'object') {
		const blob = (save as SubRoomDataSave).DataBlob
		if (typeof blob === 'string' && blob !== '') return blob
	}
	return typeof sub?.DataBlob === 'string' ? sub.DataBlob : ''
}

/** Fields that vary between a real save and one reconstructed from the legacy shape. */
interface BuildSaveInput {
	subRoomId: unknown
	dataBlob: string
	dataBlobHash: string | null
	persistenceVersion: number
	savedByAccountId: unknown
	description: string
	createdAt: string
	unityAssetId?: string
}

/**
 * Build a `SubRoomDataSave` in the shape the client parses — the reference's `MapSave`
 * projection. The four array fields are always empty (we neither resolve nor record
 * referenced Unity assets) but must be PRESENT, and `UnityAssetId` is emitted only when
 * the save actually carried one, exactly as the reference does. There is deliberately no
 * `DataBlobHash`: it is commented out of the reference DTO and absent from its output.
 *
 * `SavedOnPlatform`/`SavedOnDeviceClass` are 0 — the reference fills them from the saving
 * player's live platform/device, which the save request doesn't carry and we don't track.
 *
 * Shared by the save path and the legacy-shape reconstruction so the two can't drift.
 */
function buildSubRoomSave(input: BuildSaveInput): SubRoomDataSave {
	const save: SubRoomDataSave = {
		UnitySubAssets: [],
		ReferencedUnityAssets: [],
		SubRoomId: input.subRoomId,
		DataBlob: input.dataBlob,
		// The client sends `SubRoomData.Hash` (usually null); the room-save response echoes
		// it as `dataBlobHash`. One observed room payload carries it on `CurrentSave` and
		// another omits it, so storing it and letting it ride along is the safe reading.
		DataBlobHash: input.dataBlobHash,
		ReferencedUnityAssetIds: [],
		PersistenceVersion: input.persistenceVersion,
		OMVersion: 0,
		UgcSubVersion: 0,
		SavedByAccountId: input.savedByAccountId,
		SavedOnPlatform: 0,
		SavedOnDeviceClass: 0,
		Description: input.description,
		Tags: [],
		ModerationState: 0,
		CreatedAt: input.createdAt,
	}
	if (input.unityAssetId) save.UnityAssetId = input.unityAssetId
	return save
}

/**
 * Build a save row from a subroom stored in the pre-`CurrentSave` shape, where the blob
 * key sat in the flat `DataBlob`/`DataSavedAt`/`PersistenceVersion` fields. Those
 * subrooms hold real saved content the client cannot see (it reads `CurrentSave` only),
 * so they get a save of their own rather than reading as never-saved. Mirrors backfill 2
 * of migration 0008 — keep the two in sync.
 *
 * Returns null when there is genuinely nothing saved, the honest answer for a fresh
 * subroom.
 */
function legacySubRoomSave(sub: SubRoom): SubRoomDataSave | null {
	const blob = sub.DataBlob
	if (typeof blob !== 'string' || blob === '') return null
	const savedAt = typeof sub.DataSavedAt === 'string' ? sub.DataSavedAt : new Date(0).toISOString()
	return buildSubRoomSave({
		subRoomId: sub.SubRoomId,
		dataBlob: blob,
		dataBlobHash: null,
		persistenceVersion: typeof sub.PersistenceVersion === 'number' ? sub.PersistenceVersion : 0,
		// The legacy shape never recorded who saved; the subroom's creator is the best
		// available answer (the save path is owner/co-owner gated).
		savedByAccountId: sub.CreatorAccountId ?? null,
		description: '',
		createdAt: savedAt,
	})
}

/**
 * Persist a room-save against a specific subroom. Everything the save carries belongs to
 * that subroom's revision — nothing is written to the room. Returns the updated
 * (hydrated) room AND the save that was just created — the route answers with both — or
 * null when the room or subroom doesn't exist.
 *
 * Whether the save goes live is the client's call: `AutoPublish: true` publishes it
 * outright, otherwise it becomes the subroom's `staged_save_id` with the live
 * `current_save_id` untouched, so what players load doesn't change until the room's
 * creator publishes (see {@link publishSubRoomSave}). Dorms always publish — they have
 * no publish flow in the client.
 */
export async function saveSubRoomData(
	db: D1Database,
	roomId: number,
	subRoomId: number,
	accountId: number,
	input: SaveSubRoomDataInput
): Promise<{ room: Room; save: SubRoomDataSave } | null> {
	const room = await getRoomById(db, roomId)
	if (!room) return null
	// Read off the already-hydrated room rather than re-querying the subroom and its
	// save — getRoomById has both, and this path is write-heavy enough already.
	const sub = findSubRoom(room, subRoomId)
	if (!sub) return null

	// Populate the subroom's creator on first save — it starts null, and the
	// client NREs on a null CreatorAccountId. Only the owner reaches this path.
	if (sub.CreatorAccountId == null) sub.CreatorAccountId = accountId

	// Append a new save row. The blob the loader downloads lives on the save — a subroom
	// whose current_save_id resolves to nothing loads nothing — so this never touches the
	// flat DataBlob field. Previous saves stay in the table as history.
	//
	// A staged save carries forward from the previous STAGED one when there is one, so a
	// creator's second edit builds on their first rather than on what's live.
	const staged =
		typeof sub.StagedSubRoomDataSaveId === 'number'
			? await getSubRoomSaveById(db, subRoomId, sub.StagedSubRoomDataSaveId)
			: null
	const previous =
		staged ??
		(sub.CurrentSave && typeof sub.CurrentSave === 'object'
			? (sub.CurrentSave as SubRoomDataSave)
			: undefined)
	const priorVersion = previous?.PersistenceVersion
	const priorBlob = previous?.DataBlob
	const save = await insertSubRoomSave(
		db,
		subRoomId,
		buildSubRoomSave({
			subRoomId,
			// A save that carries no new blob (e.g. a description-only save) keeps the one
			// the subroom already loads from.
			dataBlob: input.subRoomDataFilename ?? (typeof priorBlob === 'string' ? priorBlob : ''),
			dataBlobHash: input.subRoomDataHash ?? null,
			persistenceVersion:
				input.persistenceVersion ?? (typeof priorVersion === 'number' ? priorVersion : 0),
			savedByAccountId: accountId,
			// The save comment — empty string, not null, when the save carries none (the
			// reference's `roomDesc ?? ""`).
			description: input.description ?? '',
			createdAt: new Date().toISOString(),
			unityAssetId: input.unityAssetId,
		})
	)
	const saveId = Number(save.SubRoomDataSaveId)
	if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename
	sub.DataSavedAt = new Date().toISOString()
	if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion
	if (input.inventionUsage !== undefined) sub.InventionUsage = input.inventionUsage

	// Nothing here touches the ROOM. A room save is a revision of one SUBROOM, and every
	// field it carries describes that revision: `Description` is the save comment shown in
	// the `…/saves` history, `PersistenceVersion` and `InventionUsage` describe the scene
	// just saved. They used to be copied onto the room as well, which meant each save
	// silently replaced the room's public description with the save comment. The room's own
	// fields are edited through their own routes (`PUT /rooms/:id/description` and
	// friends), so the room row is not rewritten here at all.

	// Publish outright when the client asked to (`AutoPublish`), or for a dorm — a dorm is
	// the player's own private space with no publish step in the client, so staging one
	// would leave their edits permanently invisible. Otherwise stage it and wait for
	// `publish_save`. One round trip for the rest of the save.
	const publishNow = input.autoPublish === true || room.IsDorm === true
	await db.batch([
		publishNow
			? db
					.prepare(
						'UPDATE subroom SET current_save_id = ?2, staged_save_id = NULL WHERE sub_room_id = ?1'
					)
					.bind(subRoomId, saveId)
			: db
					.prepare('UPDATE subroom SET staged_save_id = ?2 WHERE sub_room_id = ?1')
					.bind(subRoomId, saveId),
		db
			.prepare('UPDATE subroom SET data = ?2 WHERE sub_room_id = ?1')
			.bind(subRoomId, serializeSubRoom(sub, roomId)),
	])

	// Re-hydrate so the returned room reflects the just-saved subroom.
	await attachSubRooms(db, [room])
	return { room, save }
}

/**
 * Publish one of a subroom's saves by id: make it the `current_save_id` players load.
 * This is the manual step every non-dorm room save waits on ({@link saveSubRoomData}
 * only stages). Because it takes an explicit id it doubles as restore-a-save — the id
 * can be any save in the subroom's history, not just the staged one.
 *
 * The staging slot is cleared only when the save being published IS the staged one, so
 * restoring an older version doesn't silently discard newer unpublished work.
 *
 * The id is looked up scoped to the subroom, so one subroom can't publish another's save
 * (ids are globally unique, so an unscoped lookup would happily resolve).
 *
 * Returns the updated (hydrated) room, or a reason: `not_found` (no such room/subroom) /
 * `unknown_save` (no such save on this subroom).
 */
export async function publishSubRoomSave(
	db: D1Database,
	roomId: number,
	subRoomId: number,
	saveId: number
): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'unknown_save' }> {
	const sub = await getSubRoom(db, roomId, subRoomId)
	if (!sub) return { ok: false, reason: 'not_found' }
	if (!(await getSubRoomSaveById(db, subRoomId, saveId))) {
		return { ok: false, reason: 'unknown_save' }
	}

	await db
		.prepare(
			`UPDATE subroom SET current_save_id = ?2,
			   staged_save_id = CASE WHEN staged_save_id = ?2 THEN NULL ELSE staged_save_id END
			 WHERE sub_room_id = ?1`
		)
		.bind(subRoomId, saveId)
		.run()

	const room = await getRoomById(db, roomId)
	if (!room) return { ok: false, reason: 'not_found' }
	return { ok: true, room }
}

/** Fields from the client's subroom `modify` form (each applied only when supplied). */
export interface ModifySubRoomInput {
	name?: string
	accessibility?: number
	maxPlayers?: number
}

/**
 * Modify a subroom's settings in place — its Name, Accessibility, and MaxPlayers
 * (the fields the client's subroom `modify` form carries). Only the supplied fields
 * are changed; the subroom row is updated in the `subroom` table. Returns the updated
 * (hydrated) room, or null when the room or subroom doesn't exist.
 */
export async function modifySubRoom(
	db: D1Database,
	roomId: number,
	subRoomId: number,
	input: ModifySubRoomInput
): Promise<Room | null> {
	const sub = await getSubRoom(db, roomId, subRoomId)
	if (!sub) return null

	if (input.name !== undefined) sub.Name = input.name
	if (input.accessibility !== undefined) sub.Accessibility = input.accessibility
	if (input.maxPlayers !== undefined) sub.MaxPlayers = input.maxPlayers
	await updateSubRoom(db, sub)

	return getRoomById(db, roomId)
}

/**
 * Clone an existing subroom into a new subroom of the same room, owned by
 * `accountId`. The copy keeps the source's scene/settings (and its saved data
 * blobs, so it loads identical content) but gets a fresh globally-unique SubRoomId
 * minted from the `subroom` table's autoincrement sequence. Returns the updated
 * (hydrated) room and the new subroom, or null when the room or source subroom
 * doesn't exist.
 */
export async function cloneSubRoom(
	db: D1Database,
	roomId: number,
	subRoomId: number,
	accountId: number
): Promise<{ room: Room; subRoom: SubRoom } | null> {
	const source = await getSubRoom(db, roomId, subRoomId)
	if (!source) return null

	const subRoom = await insertSubRoom(db, roomId, { ...source, CreatorAccountId: accountId })
	const room = await getRoomById(db, roomId)
	if (!room) return null
	return { room, subRoom }
}

/** Fallback scene, used only when a room has no existing subroom to inherit from. */
const DEFAULT_SUBROOM_SCENE = '76d98498-60a1-430c-ab76-b54a29b7a163'

/**
 * The scene a brand-new subroom inherits: the room's own first (existing) subroom —
 * lowest SubRoomId — read from the subroom table. Falls back to the base sandbox scene
 * only when the room has no subrooms yet.
 */
async function baseSubRoomScene(db: D1Database, roomId: number): Promise<string> {
	const row = await db
		.prepare('SELECT data FROM subroom WHERE room_id = ?1 ORDER BY sub_room_id LIMIT 1')
		.bind(roomId)
		.first<{ data: string }>()
	const scene = row ? (JSON.parse(row.data) as SubRoom).UnitySceneId : undefined
	return typeof scene === 'string' ? scene : DEFAULT_SUBROOM_SCENE
}

/**
 * Create a new (empty) subroom in a room, owned by `accountId` and named `name`. It
 * inherits the room's existing subroom scene (see {@link baseSubRoomScene}) with a clean
 * save, and gets a fresh globally-unique SubRoomId. Returns the updated (hydrated) room
 * and the new subroom, or null when the room doesn't exist.
 */
export async function createSubRoom(
	db: D1Database,
	roomId: number,
	accountId: number,
	name: string
): Promise<{ room: Room; subRoom: SubRoom } | null> {
	const room = await getRoomById(db, roomId)
	if (!room) return null

	const subRoom = await insertSubRoom(db, roomId, {
		Name: name,
		CreatorAccountId: accountId,
		UnitySceneId: await baseSubRoomScene(db, roomId),
		MaxPlayers: 4,
		Accessibility: Accessibility.Unlisted,
		IsSandbox: true,
		LastModeratedSaveModerationState: 0,
		ShouldAutoStageSaves: true,
		// Nothing saved yet — the first room save mints one and points current_save_id
		// at it. Until then the subroom reads with `CurrentSave: null`.
	})
	// Refresh the hydrated SubRooms so the returned room includes the one just inserted.
	await attachSubRooms(db, [room])
	return { room, subRoom }
}

/**
 * Delete a subroom from a room. Refuses to remove a room's only subroom (that would
 * leave it with no scene to load). Any saved-data blob the subroom pointed at is left in
 * R2 (like {@link deleteRoom} leaves a room's images). Returns the updated (hydrated)
 * room on success, or a reason: `not_found` (no such subroom) / `last_subroom`.
 */
export async function deleteSubRoom(
	db: D1Database,
	roomId: number,
	subRoomId: number
): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'last_subroom' }> {
	const subRooms = await getSubRooms(db, roomId)
	if (!subRooms.some((s) => s.SubRoomId === subRoomId)) return { ok: false, reason: 'not_found' }
	if (subRooms.length <= 1) return { ok: false, reason: 'last_subroom' }

	await db.batch([
		db
			.prepare('DELETE FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2')
			.bind(roomId, subRoomId),
		// The saves go with it — nothing can reference them once the subroom is gone.
		// The blobs they point at are left in R2, like a deleted room's images.
		db.prepare('DELETE FROM subroom_save WHERE sub_room_id = ?1').bind(subRoomId),
		// So do its permission overrides — subroom ids are minted from one global
		// sequence, but leaving orphans would still be dead rows nothing can reach.
		db.prepare('DELETE FROM subroom_permission WHERE sub_room_id = ?1').bind(subRoomId),
	])

	const room = await getRoomById(db, roomId)
	if (!room) return { ok: false, reason: 'not_found' }
	return { ok: true, room }
}

interface RoomRow {
	data: string
	visits: number
}

/**
 * The columns every room read selects. `visits` is authoritative for the room's
 * `Stats.VisitCount` (the blob keeps it at 0 — see {@link storedStats}), so it has to
 * come back with the blob on every read; a join aliases them (`r.data AS data`).
 */
const ROOM_COLUMNS = 'data, visits'

/**
 * The rooms a public feed may consider, as a SQL predicate: public, not a dorm, and not
 * opted out of lists — the same test {@link isListable} makes in memory, pushed down so
 * the blobs of the rooms that fail it never cross the wire. `IS NOT 1` rather than `= 0`
 * because a blob missing the key extracts as NULL, which the JS `!== true` accepts.
 *
 * The feeds all scanned the whole table and threw most of it away: a server's rooms are
 * mostly DORMS (one per account, private by construction), so a scan read megabytes of
 * blob to rank a hundred rooms. `idx_room_public` covers the first two terms, so this
 * visits only the rooms that can actually be served.
 *
 * The in-memory filter STAYS wherever this is used. It costs nothing once the set is
 * small, and it — not the SQL — remains the definition of listable: a blob with a
 * surprising type in one of these fields (`"1"` for `Accessibility`, say) would satisfy
 * the column's integer affinity while failing `=== 1` in JS, and the feeds must agree
 * with {@link isListable} rather than with SQLite.
 */
const LISTABLE_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_lists IS NOT 1'

/**
 * The wider half of {@link LISTABLE_WHERE}: public and not a dorm, without the
 * `ExcludeFromLists` term. What SEARCH considers — a room can opt out of the browse feeds
 * and still be findable by name — so the two searching reads select on this instead.
 */
const PUBLIC_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1'

/**
 * Keys on the client's room DTO that nothing here stores, defaulted on every read so the
 * key is PRESENT rather than absent — the seed blobs and every room written since predate
 * them, so they can't come from the data:
 *
 * - `BoostCount` — how many boosts the room is carrying. No boost feature exists here, so
 *   it is 0 for every room.
 * - `CurrentSnapshotId` — the room's published snapshot. Nothing takes snapshots, so it is
 *   null, which is also what the reference serves for a room that has none.
 * - `FriendlyName` — the display name, which the reference lets a creator set apart from
 *   the unique `Name`. Nothing sets one here, so it falls back to `Name`; it must never be
 *   null, because the client labels a room from it and renders nothing for a room without
 *   one.
 * - `CCU` — concurrent users. No live-population counter exists here, so it is null, which
 *   is what the reference serves when it has no number rather than 0 (a 0 reads as "nobody
 *   is in here" in the browse feeds).
 *
 * Defaulted rather than assigned, so a stored value wins if any is ever really written
 * (a blob keeps whatever `serializeRoom` last put in it).
 */
function attachRoomDtoDefaults(room: Room): void {
	room.BoostCount ??= 0
	room.CurrentSnapshotId ??= null
	room.FriendlyName ??= room.Name
	room.CCU ??= null
}

/**
 * Parse a room row: the stored blob with the counters the columns own folded back in.
 * `visits` is a real column, so a room read straight from the DB carries the live count.
 */
const parseRow = (row: RoomRow): Room => {
	const room = JSON.parse(row.data) as Room
	room.Stats = { ...storedStats(room.Stats), VisitCount: row.visits ?? 0 }
	attachRoomDtoDefaults(room)
	return room
}

const parseOne = (row: RoomRow | null): Room | null => (row ? parseRow(row) : null)
const parseAll = (rows: RoomRow[]): Room[] => rows.map(parseRow)

/**
 * D1 caps a prepared statement at 100 bound parameters — binding more fails outright with
 * "variable number must be between ?1 and ?100". Every `IN (…)` list built from a caller's
 * array has to respect this, which is easy to miss: a seeded dev database has fewer than a
 * hundred rooms, so an unchunked query works right up until it meets a real one.
 */
const MAX_BOUND_PARAMS = 100

/**
 * Split values into chunks that fit {@link MAX_BOUND_PARAMS}, for the reads whose rows are
 * too heavy to fetch wholesale (subroom and save blobs) and so have to page through an
 * `IN (…)` rather than scan.
 */
function chunkForBinds<T>(values: T[]): T[][] {
	const chunks: T[][] = []
	for (let i = 0; i < values.length; i += MAX_BOUND_PARAMS) {
		chunks.push(values.slice(i, i + MAX_BOUND_PARAMS))
	}
	return chunks
}

/**
 * Run one `… IN (…)` query per chunk and concatenate the rows. `sql` is handed the
 * placeholder list for its chunk (`?1,?2,…`), which always restarts at `?1` because each
 * chunk is its own statement.
 *
 * Rows come back in chunk order, and each chunk is ordered by whatever `sql` says. Callers
 * that group by a key stay correct as long as a key's rows can't straddle two chunks —
 * true for both callers here, which chunk BY that key.
 */
async function selectInChunks<Row>(
	db: D1Database,
	ids: number[],
	sql: (placeholders: string) => string
): Promise<Row[]> {
	const pages = await Promise.all(
		chunkForBinds(ids).map((chunk) =>
			db
				.prepare(sql(chunk.map((_, i) => `?${i + 1}`).join(',')))
				.bind(...chunk)
				.all<Row>()
		)
	)
	return pages.flatMap((page) => page.results)
}

// ---- Subrooms -------------------------------------------------------------
// Subrooms are their own table (globally-unique autoincrement `sub_room_id`); a
// room's `SubRooms` array is reconstructed on read and never stored in the room blob.

/** A stored subroom — the parsed JSON blob (its client shape). */
export type SubRoom = Record<string, unknown>

interface SubRoomRow {
	sub_room_id: number
	room_id: number
	data: string
	current_save_id: number | null
	staged_save_id: number | null
}

/** The columns every subroom read needs — the blob plus its two save pointers. */
const SUBROOM_COLUMNS = 'sub_room_id, room_id, data, current_save_id, staged_save_id'

/**
 * Materialize a subroom row into its client shape, with the columns authoritative.
 * `CurrentSave` is left undefined here and filled in by {@link attachCurrentSaves} — it
 * lives in `subroom_save`, and resolving it per row would be a query each. Callers must
 * go through the helpers below so the key is never missing: the client reads the scene
 * blob from `CurrentSave` and nowhere else, so a subroom without one loads nothing.
 */
const parseSubRoomRow = (row: SubRoomRow): SubRoom => ({
	...(JSON.parse(row.data) as SubRoom),
	SubRoomId: row.sub_room_id,
	RoomId: row.room_id,
	// Served from the column, not the blob — the creator's unpublished save (unused for
	// now, but the client expects the key present).
	StagedSubRoomDataSaveId: row.staged_save_id,
})

/**
 * Serialize a subroom for storage — drop the id/room columns and the save fields that
 * are columns or their own table, so the blob never holds a stale copy of either.
 */
const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
	const {
		SubRoomId: _id,
		RoomId: _room,
		CurrentSave: _save,
		StagedSubRoomDataSaveId: _staged,
		...rest
	} = sub
	return JSON.stringify({ ...rest, RoomId: roomId })
}

/**
 * Serialize a room for a full-blob write, dropping the parts that belong to another table
 * so the blob can never hold a stale copy: hydrated `SubRooms` (the `subroom` table's job),
 * `Tags` (the `room_tag` table's job — see {@link setRoomTags}), and the derived engagement
 * counters, which are zeroed rather than dropped so the key stays present (the
 * `interaction` table's job — see {@link attachStats}).
 *
 * Because `Tags` is dropped here, a write that means to CHANGE a room's tags has to write
 * the table itself; passing a room with a new `Tags` array through this silently discards
 * it.
 */
const serializeRoom = (room: Room): string => {
	const { SubRooms: _subRooms, Tags: _tags, Stats: stats, ...rest } = room
	return JSON.stringify({ ...rest, Stats: storedStats(stats) })
}

/**
 * Fill in each subroom's `CurrentSave` from `subroom_save`, in ONE query for the whole
 * batch. Every subroom ends up with the key present — null when it points at no save
 * (never saved) or the pointer dangles — because the client's loader reads it directly.
 *
 * `rows` must line up with `subs` positionally; the pointer lives on the row, not the
 * parsed blob.
 */
async function attachCurrentSaves(
	db: D1Database,
	subs: SubRoom[],
	rows: SubRoomRow[]
): Promise<void> {
	const saveIds = [...new Set(rows.map((r) => r.current_save_id).filter((id) => id != null))]
	const byId = new Map<number, SubRoomDataSave>()
	if (saveIds.length > 0) {
		// Chunked: a save row carries a whole scene, so these are fetched by id rather than
		// scanned, and the id list can exceed D1's bound-parameter cap once enough rooms are
		// hydrated at once.
		const results = await selectInChunks<SubRoomSaveRow>(
			db,
			saveIds,
			(placeholders) =>
				`SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save
				 WHERE sub_room_data_save_id IN (${placeholders})`
		)
		for (const r of results) byId.set(r.sub_room_data_save_id, parseSubRoomSaveRow(r))
	}
	subs.forEach((sub, i) => {
		const id = rows[i]!.current_save_id
		sub.CurrentSave = id == null ? null : (byId.get(id) ?? null)
	})
}

// ---- Room tags ------------------------------------------------------------
// A room's tags live in `room_tag`, not in the room blob (see ROOM_SCHEMA_DDL). The blob
// is stripped on write and the array is re-attached on read, so there is exactly one
// place a tag is stored — and a tag lookup is an indexed query rather than a scan that
// parses every room to ask.

/**
 * One of a room's tags, as the client's room DTO carries it.
 *
 * `IsPrimaryGenre` is PRESENT ONLY on the one tag that is the room's genre — the key is
 * left off the others rather than sent as false, which is the shape the client sends and
 * reads back. At most one tag in an array carries it; see {@link setPrimaryGenreTag}.
 */
export interface RoomTag {
	Tag: string
	Type: number
	IsPrimaryGenre?: boolean
}

interface RoomTagRow {
	room_id: number
	tag: string
	type: number
	is_primary_genre: number
}

/** Project a stored tag row, adding `IsPrimaryGenre` only when the row is flagged. */
function toRoomTag(row: RoomTagRow): RoomTag {
	const tag: RoomTag = { Tag: row.tag, Type: row.type }
	if (row.is_primary_genre) tag.IsPrimaryGenre = true
	return tag
}

/** Group tag rows by RoomId, preserving the order they arrived in (alphabetical by tag). */
function groupTags(rows: RoomTagRow[]): Map<number, RoomTag[]> {
	const byRoom = new Map<number, RoomTag[]>()
	for (const row of rows) {
		const list = byRoom.get(row.room_id) ?? []
		list.push(toRoomTag(row))
		byRoom.set(row.room_id, list)
	}
	return byRoom
}

/**
 * Every tag of the given rooms, keyed by RoomId, in ONE query however many rooms are
 * asked about. An empty `roomIds` reads nothing rather than every tag in the table — an
 * empty `IN ()` isn't valid SQL, and "no rooms asked about" must not come to mean "all of
 * them".
 *
 * Two shapes, because the callers are two different questions. A page slice fits D1's
 * 100-parameter cap and is fetched by id. Attaching tags to EVERY room does not fit — and
 * chunking it would mean ceil(n/100) round trips to answer what one unfiltered read
 * answers, on a table of three small columns. So past the cap this reads the whole table
 * and narrows in memory.
 *
 * This is what the D1 error "variable number must be between ?1 and ?100" was: the hot feed
 * attaches tags to every room, so the bound list grew with the database and the query blew
 * up the moment a server had more than a hundred rooms.
 *
 * Tags come back alphabetical, so a room's array is stable between reads.
 */
async function tagsByRoom(db: D1Database, roomIds: number[]): Promise<Map<number, RoomTag[]>> {
	const ids = [...new Set(roomIds)]
	if (ids.length === 0) return new Map()

	if (ids.length > MAX_BOUND_PARAMS) {
		const { results } = await db
			.prepare('SELECT room_id, tag, type, is_primary_genre FROM room_tag ORDER BY tag')
			.all<RoomTagRow>()
		const wanted = new Set(ids)
		return groupTags(results.filter((row) => wanted.has(row.room_id)))
	}

	const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
	const { results } = await db
		.prepare(
			`SELECT room_id, tag, type, is_primary_genre FROM room_tag
			 WHERE room_id IN (${placeholders}) ORDER BY tag`
		)
		.bind(...ids)
		.all<RoomTagRow>()
	return groupTags(results)
}

/**
 * Fill in each room's `Tags` from `room_tag`. Every room ends up with the key PRESENT —
 * an empty array when it carries none — because the client's DTO has a non-nullable
 * `Tags` and the blob no longer supplies one.
 */
async function attachTags(db: D1Database, rooms: Room[]): Promise<void> {
	const byRoom = await tagsByRoom(db, [...new Set(rooms.map(roomIdOf))])
	for (const room of rooms) room.Tags = byRoom.get(roomIdOf(room)) ?? []
}

/**
 * Parse room rows AND attach their tags — the read every scan-then-rank feed starts from.
 *
 * Those feeds filter and sort BEFORE they hydrate (ranking a room doesn't need its
 * subrooms), but several of them rank ON tags, so the tags have to be present earlier than
 * {@link hydrateRooms} would put them. One extra query for the whole batch.
 */
async function parseAllWithTags(db: D1Database, rows: RoomRow[]): Promise<Room[]> {
	const rooms = parseAll(rows)
	await attachTags(db, rooms)
	return rooms
}

/**
 * Replace a room's tags with the given set, in one batch. A replace and not a merge: the
 * only writer ({@link toggleRoomTag}) computes the whole set it wants, so a removed tag is
 * a write with that tag left out.
 *
 * Tags are stored LOWERCASED, which is what makes the index a usable lookup key — every
 * comparison in this module was already case-insensitive, so nothing downstream can tell
 * the difference. A room tagged `Horror` reads back `horror`.
 */
export async function setRoomTags(db: D1Database, roomId: number, tags: RoomTag[]): Promise<void> {
	const statements = [db.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(roomId)]
	for (const { Tag, Type, IsPrimaryGenre } of tags) {
		statements.push(
			db
				.prepare(
					`INSERT INTO room_tag (room_id, tag, type, is_primary_genre) VALUES (?1, ?2, ?3, ?4)
					 ON CONFLICT (room_id, tag) DO UPDATE SET type = ?3, is_primary_genre = ?4`
				)
				.bind(roomId, String(Tag).toLowerCase(), Number(Type) || 0, IsPrimaryGenre ? 1 : 0)
		)
	}
	await db.batch(statements)
}

/**
 * A room read narrowed to the rooms carrying EVERY one of `tagSets` — one set per tag the
 * caller requires, and a room matches a set by carrying ANY tag in it (which is how a
 * term's aliases work: `#recroomoriginal` accepts `rro`). An empty `tagSets` reads every
 * room.
 *
 * A JOIN driven from `room_tag`, not a `WHERE EXISTS`, and the difference is the whole
 * point of the table. EXPLAIN QUERY PLAN on the seeded database:
 *
 *   WHERE EXISTS …   SCAN room · SEARCH room_tag USING COVERING INDEX (room_id=? AND tag=?)
 *   JOIN from tags   SEARCH room_tag USING INDEX idx_room_tag_tag (tag=?)
 *                    SEARCH room USING INDEX idx_rooms_room_id (room_id=?)
 *
 * The EXISTS form still walks every room and probes the index once per room, so it costs
 * what the in-memory filter it replaced cost. The join searches the tag index FIRST and
 * then looks up only the rooms that matched, which is what makes a category row cheap.
 */
function roomsByTagsQuery(tagSets: string[][], where = ''): { sql: string; binds: string[] } {
	// `where` is the caller's row filter ({@link LISTABLE_WHERE} or {@link PUBLIC_WHERE}) —
	// unqualified, which is unambiguous under either shape below. It matters most when
	// `tagSets` is EMPTY: that branch is the full scan every pseudo-tag feed still runs.
	const filter = where === '' ? '' : ` WHERE ${where}`
	if (tagSets.length === 0) return { sql: `SELECT ${ROOM_COLUMNS} FROM room${filter}`, binds: [] }

	const binds: string[] = []
	const joins = tagSets.map((tags, i) => {
		const placeholders = tags.map((_, j) => `?${binds.length + j + 1}`).join(', ')
		binds.push(...tags)
		return `JOIN (SELECT DISTINCT room_id FROM room_tag WHERE tag IN (${placeholders})) f${i}
		         ON f${i}.room_id = r.room_id`
	})
	// `data`/`visits` are unqualified but unambiguous: the joined subqueries expose only
	// `room_id`.
	return { sql: `SELECT ${ROOM_COLUMNS} FROM room r ${joins.join(' ')}${filter}`, binds }
}

/** Parse subroom rows and resolve their `CurrentSave` in one batched query. */
async function parseSubRoomRows(db: D1Database, rows: SubRoomRow[]): Promise<SubRoom[]> {
	const subs = rows.map(parseSubRoomRow)
	await attachCurrentSaves(db, subs, rows)
	return subs
}

/** Attach each room's `SubRooms` array from the subroom table (one batched query). */
async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
	const ids = rooms.map((r) => Number(r.RoomId)).filter((n) => Number.isFinite(n))
	if (ids.length === 0) {
		for (const room of rooms) room.SubRooms = []
		return
	}
	// Chunked by ROOM, so all of a room's subrooms land in one chunk and stay ordered
	// relative to each other — the grouping below depends on that.
	const results = await selectInChunks<SubRoomRow>(
		db,
		ids,
		(placeholders) =>
			`SELECT ${SUBROOM_COLUMNS} FROM subroom
			 WHERE room_id IN (${placeholders}) ORDER BY sub_room_id`
	)
	const subs = await parseSubRoomRows(db, results)
	const byRoom = new Map<number, SubRoom[]>()
	results.forEach((r, i) => {
		const list = byRoom.get(r.room_id) ?? []
		list.push(subs[i]!)
		byRoom.set(r.room_id, list)
	})
	for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? []
}

// ---- Room stats -----------------------------------------------------------
// A room's cheer/favorite counters are DERIVED from the `interaction` table rather than
// stored: they're recomputed on every read, so a cheer shows up immediately and the
// counts can't drift from the per-player rows they're made of. The blob keeps them at 0
// (see {@link serializeRoom}).
//
// `VisitCount` is neither stored in the blob nor derived: it's the `room.visits` column,
// incremented by {@link recordRoomVisit} on each matchmake and read back with the blob
// (see {@link parseRow}). It can't be derived the way cheers are — a visit leaves no
// per-player row to count — and it can't live in the blob, where a read-modify-write of
// the whole room would drop concurrent visits. `VisitorCount` (distinct visitors) is
// still left as the blob has it: `interaction.last_visited_at` is only stamped by the
// cheer/favorite toggles, so counting those rows would report cheerers as visitors.

/** One room's derived engagement counters (the aggregate maps below key these by RoomId). */
export interface RoomStats {
	CheerCount: number
	FavoriteCount: number
}

interface RoomStatsRow {
	room_id: number
	cheers: number
	favorites: number
}

/** The counters a room starts life with (and the shape the client expects). */
const ZERO_STATS = { CheerCount: 0, FavoriteCount: 0, VisitorCount: 0, VisitCount: 0 }

/** D1 caps a query at 100 bound parameters, and a feed page can carry more ids than that. */
const STATS_ID_LIMIT = 90

/** A room's RoomId, or 0 for a blob without one. */
const roomIdOf = (room: Room): number => (typeof room.RoomId === 'number' ? room.RoomId : 0)

/**
 * The `Stats` object to persist: whatever the room carried, with the counters the
 * columns/tables own back at 0 so the blob never holds a stale copy of them.
 */
function storedStats(stats: unknown): Record<string, unknown> {
	const stored =
		typeof stats === 'object' && stats !== null ? (stats as Record<string, unknown>) : {}
	return { ...ZERO_STATS, ...stored, CheerCount: 0, FavoriteCount: 0, VisitCount: 0 }
}

/**
 * Count one visit to a room — the `match` worker calls this on every successful
 * matchmake (see its `enterRoom`), which is the only way a player ever lands in a room.
 * A blind `visits = visits + 1` UPDATE: it's the whole write, so simultaneous visitors
 * can't clobber each other, and an unknown room id simply matches nothing.
 */
export async function recordRoomVisit(db: D1Database, roomId: number): Promise<void> {
	await db.prepare('UPDATE room SET visits = visits + 1 WHERE room_id = ?1').bind(roomId).run()
}

/**
 * Cheer/favorite counts per room, aggregated from `interaction` in ONE grouped query.
 * Restricted to `roomIds` when given (a feed page), otherwise covering every room —
 * which is also what a page too large to bind gets, since scanning the whole table is
 * cheaper than splitting the query. Rooms nobody has interacted with are absent.
 */
export async function getRoomStats(
	db: D1Database,
	roomIds?: number[]
): Promise<Map<number, RoomStats>> {
	const byRoom = new Map<number, RoomStats>()
	if (roomIds && roomIds.length === 0) return byRoom
	const ids = roomIds && roomIds.length <= STATS_ID_LIMIT ? roomIds : []
	const where =
		ids.length > 0 ? `WHERE room_id IN (${ids.map((_, i) => `?${i + 1}`).join(',')})` : ''
	const { results } = await db
		.prepare(
			`SELECT room_id, SUM(cheered) AS cheers, SUM(favorited) AS favorites
			 FROM interaction ${where} GROUP BY room_id`
		)
		.bind(...ids)
		.all<RoomStatsRow>()
	for (const r of results) {
		byRoom.set(r.room_id, { CheerCount: r.cheers ?? 0, FavoriteCount: r.favorites ?? 0 })
	}
	return byRoom
}

/**
 * Overwrite each room's derived counters from the interaction table, in one query for
 * the whole batch. Callers that already aggregated (the feeds rank by these counts, so
 * they need them before paging) pass their map in rather than paying for a second query.
 */
async function attachStats(
	db: D1Database,
	rooms: Room[],
	stats?: Map<number, RoomStats>
): Promise<void> {
	if (rooms.length === 0) return
	const byRoom = stats ?? (await getRoomStats(db, [...new Set(rooms.map(roomIdOf))]))
	for (const room of rooms) {
		const counts = byRoom.get(roomIdOf(room))
		// `storedStats` zeroes VisitCount (the blob doesn't own it), so carry over the
		// value `parseRow` folded in from the `visits` column rather than losing it here.
		const stats = (room.Stats ?? {}) as Record<string, unknown>
		room.Stats = {
			...storedStats(stats),
			VisitCount: typeof stats.VisitCount === 'number' ? stats.VisitCount : 0,
			CheerCount: counts?.CheerCount ?? 0,
			FavoriteCount: counts?.FavoriteCount ?? 0,
		}
	}
}

/** Hydrate a single room's `SubRooms` and derived `Stats` (no-op for null). */
async function hydrateRoom(db: D1Database, room: Room | null): Promise<Room | null> {
	if (room) await hydrateRooms(db, [room])
	return room
}

/**
 * Hydrate many rooms' `SubRooms`, `Tags` and derived `Stats` (one batched query each).
 *
 * `Tags` is re-attached even for the feeds that already did so before ranking
 * ({@link parseAllWithTags}) — it is one query for the page slice and it guarantees the key
 * is present on every room this module hands out, whichever path produced it.
 */
async function hydrateRooms(
	db: D1Database,
	rooms: Room[],
	stats?: Map<number, RoomStats>
): Promise<Room[]> {
	await Promise.all([
		attachSubRooms(db, rooms),
		attachTags(db, rooms),
		attachStats(db, rooms, stats),
	])
	return rooms
}

/** A single subroom of a room (columns authoritative), or null if it doesn't exist. */
export async function getSubRoom(
	db: D1Database,
	roomId: number,
	subRoomId: number
): Promise<SubRoom | null> {
	const row = await db
		.prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2`)
		.bind(roomId, subRoomId)
		.first<SubRoomRow>()
	if (!row) return null
	return (await parseSubRoomRows(db, [row]))[0]!
}

/** All of a room's subrooms, ordered by SubRoomId. */
export async function getSubRooms(db: D1Database, roomId: number): Promise<SubRoom[]> {
	const { results } = await db
		.prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 ORDER BY sub_room_id`)
		.bind(roomId)
		.all<SubRoomRow>()
	return parseSubRoomRows(db, results)
}

// ---- Subroom saves --------------------------------------------------------

interface SubRoomSaveRow {
	sub_room_data_save_id: number
	sub_room_id: number
	data: string
}

/** Materialize a save row, with its two id columns authoritative over the blob. */
const parseSubRoomSaveRow = (row: SubRoomSaveRow): SubRoomDataSave => ({
	...(JSON.parse(row.data) as SubRoomDataSave),
	SubRoomDataSaveId: row.sub_room_data_save_id,
	SubRoomId: row.sub_room_id,
})

/** Serialize a save for storage — the id columns own those two fields, not the blob. */
const serializeSubRoomSave = (save: SubRoomDataSave): string => {
	const { SubRoomDataSaveId: _id, SubRoomId: _sub, ...rest } = save
	return JSON.stringify(rest)
}

/**
 * Insert a save for a subroom, minting a fresh globally-unique `SubRoomDataSaveId` from
 * the table's autoincrement sequence. Returns the stored save with its new id.
 */
async function insertSubRoomSave(
	db: D1Database,
	subRoomId: number,
	save: SubRoomDataSave
): Promise<SubRoomDataSave> {
	const row = await db
		.prepare(
			'INSERT INTO subroom_save (sub_room_id, data) VALUES (?1, ?2) RETURNING sub_room_data_save_id'
		)
		.bind(subRoomId, serializeSubRoomSave(save))
		.first<{ sub_room_data_save_id: number }>()
	return { ...save, SubRoomDataSaveId: row!.sub_room_data_save_id, SubRoomId: subRoomId }
}

/**
 * A subroom's save history, newest first. Unlike the old inline model this is real
 * history: every save is its own row and none are overwritten.
 */
export async function getSubRoomSaves(
	db: D1Database,
	subRoomId: number
): Promise<SubRoomDataSave[]> {
	const { results } = await db
		.prepare(
			`SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save
			 WHERE sub_room_id = ?1 ORDER BY sub_room_data_save_id DESC`
		)
		.bind(subRoomId)
		.all<SubRoomSaveRow>()
	return results.map(parseSubRoomSaveRow)
}

/**
 * A single save by its globally-unique id, scoped to the subroom that owns it (the
 * restore-a-save lookup). Null when the id is unknown or belongs to another subroom.
 */
export async function getSubRoomSaveById(
	db: D1Database,
	subRoomId: number,
	saveId: number
): Promise<SubRoomDataSave | null> {
	const row = await db
		.prepare(
			`SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save
			 WHERE sub_room_data_save_id = ?1 AND sub_room_id = ?2`
		)
		.bind(saveId, subRoomId)
		.first<SubRoomSaveRow>()
	return row ? parseSubRoomSaveRow(row) : null
}

// ---- Subroom permissions --------------------------------------------------

/**
 * One entry of a subroom's permission table, in the client's own shape. `Value` is a
 * STRING, not a boolean — usually `"True"`/`"False"`, but a permission whose UI isn't a
 * True/False picker carries something else, so it is stored and served verbatim. `Role`
 * is the tier the entry applies to (0 = everyone, 30 = co-owner, …). `Permission` + `Role`
 * identify an entry: the client PUTs the pair it wants changed, and the same pair
 * overwrites the matching default in the photon access token's table.
 *
 * `Override` is the row's own existence, not data: the client's UI is a checkbox ("is
 * this permission overridden in this subroom?") plus a True/False picker for the value.
 * Unchecking it means "fall back to the default", so an entry arriving with
 * `Override: false` DELETES the stored row rather than storing anything. Every stored
 * entry is therefore an override, and reads always serve `Override: true`.
 */
export interface RoomPermission {
	Permission: string
	Role: number
	Override: boolean
	Type: number
	Value: string
}

interface RoomPermissionRow {
	permission: string
	role: number
	type: number
	value: string
}

const toRoomPermission = (row: RoomPermissionRow): RoomPermission => ({
	// A stored row IS the override — the table holds nothing else (see RoomPermission).
	Override: true,
	Permission: row.permission,
	Role: row.role,
	Type: row.type,
	Value: row.value,
})

/** The permission columns, in the order the read/copy statements use. */
const PERMISSION_COLUMNS = 'permission, role, type, value'

/**
 * A subroom's stored permission overrides, in the order they were first set. Empty for a
 * subroom whose owner has never overridden a permission — the photon access token then
 * serves its defaults untouched.
 */
export async function getSubRoomPermissions(
	db: D1Database,
	subRoomId: number
): Promise<RoomPermission[]> {
	const { results } = await db
		.prepare(
			`SELECT ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1 ORDER BY rowid`
		)
		.bind(subRoomId)
		.all<RoomPermissionRow>()
	return results.map(toRoomPermission)
}

/**
 * Apply permission changes to a subroom, keyed by (`Permission`, `Role`). Only the pairs
 * supplied are touched; every other stored entry is left alone.
 *
 * `Override` decides which way an entry goes, mirroring the checkbox the client draws
 * next to each permission: true STORES the `Value` for that pair (overwriting whatever
 * was there), false CLEARS it, so the pair falls back to the photon access token's
 * default. Clearing a pair that was never overridden is a no-op.
 */
export async function setSubRoomPermissions(
	db: D1Database,
	subRoomId: number,
	permissions: RoomPermission[]
): Promise<void> {
	if (permissions.length === 0) return
	const upsert = db.prepare(
		`INSERT INTO subroom_permission (sub_room_id, ${PERMISSION_COLUMNS})
		 VALUES (?1, ?2, ?3, ?4, ?5)
		 ON CONFLICT (sub_room_id, permission, role)
		 DO UPDATE SET type = excluded.type, value = excluded.value`
	)
	const clear = db.prepare(
		'DELETE FROM subroom_permission WHERE sub_room_id = ?1 AND permission = ?2 AND role = ?3'
	)
	await db.batch(
		permissions.map((p) =>
			p.Override
				? upsert.bind(subRoomId, p.Permission, p.Role, p.Type, p.Value)
				: clear.bind(subRoomId, p.Permission, p.Role)
		)
	)
}

/**
 * Copy a subroom's permission overrides onto another subroom — a clone inherits the
 * source's permission table along with its scene and settings. Replaces any entry the
 * destination already holds for the same (permission, role).
 */
async function copySubRoomPermissions(
	db: D1Database,
	fromSubRoomId: number,
	toSubRoomId: number
): Promise<void> {
	await db
		.prepare(
			`INSERT OR REPLACE INTO subroom_permission (sub_room_id, ${PERMISSION_COLUMNS})
			 SELECT ?2, ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1`
		)
		.bind(fromSubRoomId, toSubRoomId)
		.run()
}

/**
 * Insert a subroom for a room, minting a fresh globally-unique SubRoomId from the
 * table's autoincrement sequence. Returns the created subroom (with its new id).
 */
export async function insertSubRoom(
	db: D1Database,
	roomId: number,
	sub: SubRoom
): Promise<SubRoom> {
	const row = await db
		.prepare('INSERT INTO subroom (room_id, data) VALUES (?1, ?2) RETURNING sub_room_id')
		.bind(roomId, serializeSubRoom(sub, roomId))
		.first<{ sub_room_id: number }>()
	const subRoomId = row!.sub_room_id
	const created: SubRoom = {
		...sub,
		SubRoomId: subRoomId,
		RoomId: roomId,
		CurrentSave: null,
		StagedSubRoomDataSaveId: null,
	}
	// The permission overrides follow the copy too — they live in their own table (keyed by
	// the id the caller is cloning FROM), so unlike the rest of the settings they aren't
	// carried by the blob. A fresh subroom (`createSubRoom`) passes no id and copies nothing.
	if (typeof sub.SubRoomId === 'number') {
		await copySubRoomPermissions(db, sub.SubRoomId, subRoomId)
	}
	// A copied subroom (room clone, subroom clone) carries the source's save. It gets its
	// OWN row — a save belongs to exactly one subroom, so sharing the source's id would
	// make the copy's content follow the source's future saves.
	if (sub.CurrentSave && typeof sub.CurrentSave === 'object') {
		const copy = await insertSubRoomSave(db, subRoomId, sub.CurrentSave as SubRoomDataSave)
		await setCurrentSave(db, subRoomId, Number(copy.SubRoomDataSaveId))
		created.CurrentSave = copy
	}
	return created
}

/** Overwrite a subroom's stored data blob in place. */
async function updateSubRoom(db: D1Database, sub: SubRoom): Promise<void> {
	await db
		.prepare('UPDATE subroom SET data = ?2 WHERE sub_room_id = ?1')
		.bind(sub.SubRoomId, serializeSubRoom(sub, Number(sub.RoomId)))
		.run()
}

/** Point a subroom at its live/published save, clearing any staged one. */
async function setCurrentSave(db: D1Database, subRoomId: number, saveId: number): Promise<void> {
	await db
		.prepare(
			'UPDATE subroom SET current_save_id = ?2, staged_save_id = NULL WHERE sub_room_id = ?1'
		)
		.bind(subRoomId, saveId)
		.run()
}

/**
 * Seed a room together with its subrooms — inserts the room (SubRooms stripped from the
 * blob) and each embedded subroom into the `subroom` table, preserving explicit ids. Any
 * subroom carrying a `CurrentSave` gets it inserted into `subroom_save` and pointed at,
 * mirroring 0008's backfill the way this mirrors 0007's.
 */
export async function seedRoomWithSubRooms(db: D1Database, room: Room): Promise<void> {
	const roomId = Number(room.RoomId)
	const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : []
	await db.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run()
	// `serializeRoom` drops `Tags`, so a seeded room's tags have to go to their own table or
	// they'd vanish — the same step the migration's backfill takes for the imported rooms.
	if (Array.isArray(room.Tags) && room.Tags.length > 0) {
		await setRoomTags(db, roomId, room.Tags as RoomTag[])
	}
	for (const sub of subRooms) {
		const subRoomId = Number(sub.SubRoomId)
		await db
			.prepare('INSERT INTO subroom (sub_room_id, room_id, data) VALUES (?1, ?2, ?3)')
			.bind(subRoomId, roomId, serializeSubRoom(sub, roomId))
			.run()
		const seeded = sub.CurrentSave ?? legacySubRoomSave(sub)
		if (seeded && typeof seeded === 'object') {
			const save = await insertSubRoomSave(db, subRoomId, seeded as SubRoomDataSave)
			await setCurrentSave(db, subRoomId, Number(save.SubRoomDataSaveId))
		}
	}
}

/** Look up a single room by its RoomId. */
export async function getRoomById(db: D1Database, roomId: number): Promise<Room | null> {
	return hydrateRoom(
		db,
		parseOne(
			await db
				.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id = ?1`)
				.bind(roomId)
				.first<RoomRow>()
		)
	)
}

/**
 * Delete a room and every player's interaction (cheer/favorite/visit) with it, in one
 * batch. Deliberately leaves transient `room_instance`/`presence` rows (they expire on
 * their own) and any images taken in the room (those live in the api/img world and
 * outlast the room). Authorization and removing the room image from the CDN bucket are
 * the caller's responsibility (see the DELETE /rooms/:id route).
 */
export async function deleteRoom(db: D1Database, roomId: number): Promise<void> {
	await db.batch([
		db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId),
		db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId),
		// Tag rows outlive the blob otherwise, and would keep answering `#tag` searches and
		// category rows for a room nobody can open.
		db.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(roomId),
		// Saves and permission overrides first — both are keyed by subroom, so they'd be
		// unreachable once the subrooms themselves are gone.
		db
			.prepare(
				'DELETE FROM subroom_save WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)'
			)
			.bind(roomId),
		db
			.prepare(
				'DELETE FROM subroom_permission WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)'
			)
			.bind(roomId),
		db.prepare('DELETE FROM subroom WHERE room_id = ?1').bind(roomId),
	])
}

/** Look up a single room by name (case-insensitive exact match). */
export async function getRoomByName(db: D1Database, name: string): Promise<Room | null> {
	return hydrateRoom(
		db,
		parseOne(
			await db
				.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE name_lower = ?1`)
				.bind(name.toLowerCase())
				.first<RoomRow>()
		)
	)
}

/**
 * Look up multiple rooms by RoomId.
 *
 * Every id is bound into one query, so the CALLER must keep the list within D1's cap of 100
 * bound parameters — `/rooms/bulk` rejects a longer request with a 400 rather than have this
 * split it, since a client asking about more than a hundred rooms at once is asking the
 * wrong question.
 */
export async function getRoomsByIds(db: D1Database, ids: number[]): Promise<Room[]> {
	if (ids.length === 0) return []
	const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
	const { results } = await db
		.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id IN (${placeholders})`)
		.bind(...ids)
		.all<RoomRow>()
	return hydrateRooms(db, parseAll(results))
}

/** All rooms created by an account (e.g. their dorm). */
export async function getRoomsByCreator(db: D1Database, accountId: number): Promise<Room[]> {
	const { results } = await db
		.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE creator_account_id = ?1`)
		.bind(accountId)
		.all<RoomRow>()
	return hydrateRooms(db, parseAll(results))
}

/**
 * How many rooms an account has made, for the per-account room cap. Dorms don't
 * count: every player gets one auto-provisioned, so counting it would silently cost
 * them a slot they never asked for.
 */
export async function countRoomsByCreator(db: D1Database, accountId: number): Promise<number> {
	const row = await db
		.prepare(
			`SELECT COUNT(*) AS n FROM room
			 WHERE creator_account_id = ?1
			   AND COALESCE(is_dorm, 0) = 0`
		)
		.bind(accountId)
		.first<{ n: number }>()
	return row?.n ?? 0
}

/**
 * Every room an account works on: the ones it CREATED plus the ones whose `Roles` name it
 * (Host, Moderator or CoOwner). Every role tier counts, unlike {@link canManageRoom}'s
 * owner-or-co-owner gate: this is "you have a job in this room", not "you may administer
 * it".
 *
 * The creator half used to be excluded — a room's `Roles` carries its creator too, and the
 * client shows "rooms you own" and "rooms you contribute to" as separate lists, so the
 * exclusion kept this from repeating `createdby/me`. It also made the list EMPTY for every
 * account that had only ever built its own rooms, which is most of them, so the screen
 * behind it showed nothing at all. Repeating `createdby/me` is the better failure, and
 * overlap is what a client that renders one list wants anyway.
 *
 * The dorm stays out, on `ownedby/me`'s reasoning: it is auto-provisioned rather than a
 * room the player made. A room matching BOTH halves appears once — the roles half is an
 * EXISTS, not a join.
 *
 * Roles live inside the room blob rather than in a table of their own, so the match is a
 * `json_each` over `$.Roles`. A room with no `Roles` key (or a null one) simply yields no
 * rows there rather than erroring, so it only reaches the list if the account created it.
 */
export async function getContributedRooms(db: D1Database, accountId: number): Promise<Room[]> {
	const { results } = await db
		.prepare(
			`SELECT ${ROOM_COLUMNS} FROM room
			 WHERE creator_account_id = ?1
			    OR EXISTS (
			     SELECT 1 FROM json_each(room.data, '$.Roles') AS role
			     WHERE json_extract(role.value, '$.AccountId') = ?1
			   )`
		)
		.bind(accountId)
		.all<RoomRow>()
	return (await hydrateRooms(db, parseAll(results))).filter((r) => r.IsDorm !== true)
}

/**
 * An account's public, non-dorm rooms — the publicly viewable "rooms owned by
 * <player>" list (excludes private rooms, dorms, and list-excluded rooms).
 */
export async function getPublicRoomsByCreator(db: D1Database, accountId: number): Promise<Room[]> {
	return (await getRoomsByCreator(db, accountId)).filter(isListable)
}

/**
 * Rooms the player has favorited (interaction.favorited = 1), most recently
 * interacted first. Joins the `interaction` table to `rooms`, so a favorited room
 * no longer in D1 is simply absent. Paginated via skip/take; returns a bare array
 * of rooms (the client's room-source loaders expect a plain list).
 */
export async function getFavoritedRooms(
	db: D1Database,
	playerId: number,
	skip: number,
	take: number
): Promise<Room[]> {
	const { results } = await db
		.prepare(
			`SELECT r.data AS data, r.visits AS visits
			 FROM interaction i
			 JOIN room r ON r.room_id = i.room_id
			 WHERE i.player_id = ?1 AND i.favorited = 1
			 ORDER BY i.last_visited_at DESC`
		)
		.bind(playerId)
		.all<RoomRow>()
	return hydrateRooms(db, parseAll(results).slice(skip, skip + take))
}

/**
 * Rooms the player has visited (an interaction row with a `last_visited_at`),
 * most recent first. Like favorites, it joins `interaction` to `rooms`, so a
 * visited room no longer in D1 is simply absent. Paginated via skip/take; returns
 * a bare array of rooms (the client's room-source loaders expect a plain list).
 */
export async function getVisitedRooms(
	db: D1Database,
	playerId: number,
	skip: number,
	take: number
): Promise<Room[]> {
	const { results } = await db
		.prepare(
			`SELECT r.data AS data, r.visits AS visits
			 FROM interaction i
			 JOIN room r ON r.room_id = i.room_id
			 WHERE i.player_id = ?1 AND i.last_visited_at IS NOT NULL
			 ORDER BY i.last_visited_at DESC`
		)
		.bind(playerId)
		.all<RoomRow>()
	return hydrateRooms(db, parseAll(results).slice(skip, skip + take))
}

/** A player's interaction state with a room. */
export interface Interaction {
	Cheered: boolean
	Favorited: boolean
}

interface InteractionRow {
	cheered: number
	favorited: number
}

const toInteraction = (row: InteractionRow | null): Interaction => ({
	Cheered: row?.cheered === 1,
	Favorited: row?.favorited === 1,
})

/** Read a player's interaction with a room (defaults to all-false if none). */
export async function getInteraction(
	db: D1Database,
	playerId: number,
	roomId: number
): Promise<Interaction> {
	return toInteraction(
		await db
			.prepare('SELECT cheered, favorited FROM interaction WHERE player_id = ?1 AND room_id = ?2')
			.bind(playerId, roomId)
			.first<InteractionRow>()
	)
}

/** Upsert+toggle a single boolean column, returning the resulting interaction. */
async function toggleInteraction(
	db: D1Database,
	playerId: number,
	roomId: number,
	column: 'cheered' | 'favorited'
): Promise<Interaction> {
	const now = new Date().toISOString()
	// First interaction defaults the toggled column to 1; subsequent calls flip it.
	return toInteraction(
		await db
			.prepare(
				`INSERT INTO interaction (player_id, room_id, ${column}, last_visited_at)
				 VALUES (?1, ?2, 1, ?3)
				 ON CONFLICT(player_id, room_id)
				 DO UPDATE SET ${column} = NOT ${column}, last_visited_at = ?3
				 RETURNING cheered, favorited`
			)
			.bind(playerId, roomId, now)
			.first<InteractionRow>()
	)
}

/** Toggle the player's cheer on a room, returning the resulting interaction. */
export async function toggleCheer(
	db: D1Database,
	playerId: number,
	roomId: number
): Promise<Interaction> {
	return toggleInteraction(db, playerId, roomId, 'cheered')
}

/** Toggle the player's favorite on a room, returning the resulting interaction. */
export async function toggleFavorite(
	db: D1Database,
	playerId: number,
	roomId: number
): Promise<Interaction> {
	return toggleInteraction(db, playerId, roomId, 'favorited')
}

/**
 * Explicitly clear a single interaction flag on a room (the DELETE counterpart to
 * the cheer/favorite toggles). Idempotent: only clears an existing interaction row
 * and never creates one, so clearing a flag on a room the player never interacted
 * with doesn't add a spurious visited/favorited entry. Returns the interaction.
 */
async function clearInteraction(
	db: D1Database,
	playerId: number,
	roomId: number,
	column: 'cheered' | 'favorited'
): Promise<Interaction> {
	await db
		.prepare(`UPDATE interaction SET ${column} = 0 WHERE player_id = ?1 AND room_id = ?2`)
		.bind(playerId, roomId)
		.run()
	return getInteraction(db, playerId, roomId)
}

/** Clear the player's cheer on a room (DELETE cheer), returning the interaction. */
export async function removeCheer(
	db: D1Database,
	playerId: number,
	roomId: number
): Promise<Interaction> {
	return clearInteraction(db, playerId, roomId, 'cheered')
}

/** Clear the player's favorite on a room (DELETE favorite), returning the interaction. */
export async function removeFavorite(
	db: D1Database,
	playerId: number,
	roomId: number
): Promise<Interaction> {
	return clearInteraction(db, playerId, roomId, 'favorited')
}

/**
 * Search-tag aliases: a queried `#tag` also matches these stored tag names.
 * The client's pinned filters don't always match how rooms are tagged (e.g. it
 * searches `recroomoriginal`, but rooms are tagged `rro`).
 */
const TAG_ALIASES: Record<string, string[]> = {
	recroomoriginal: ['rro'],
}

/** A room's tag names, lowercased (empty when it has no Tags array). */
function roomTags(room: Room): string[] {
	const tags = room.Tags
	if (!Array.isArray(tags)) return []
	return tags
		.map((t) => (t as Record<string, unknown> | null)?.Tag)
		.filter((v): v is string => typeof v === 'string')
		.map((v) => v.toLowerCase())
}

/** True if the room carries any of the given (lowercased) tags. */
function roomHasAnyTag(room: Room, tags: Set<string>): boolean {
	return roomTags(room).some((t) => tags.has(t))
}

/**
 * Search public, non-dorm rooms. The query is split into terms (space/`+`):
 * `#tag` terms match the room's Tags; plain terms match the room name
 * (substring). All terms must match. Returns a paginated `{ Results, TotalResults }`.
 * The rows narrow in SQL — public, non-dorm ({@link PUBLIC_WHERE}) — and the name terms
 * match in memory over what comes back.
 *
 * `#community` is the one tag term that isn't a tag lookup — see {@link COMMUNITY_TAG}.
 */
export async function searchRooms(
	db: D1Database,
	query: string,
	skip: number,
	take: number
): Promise<{ Results: Room[]; TotalResults: number }> {
	const q = query.trim().toLowerCase()
	if (q === '') return { Results: [], TotalResults: 0 }
	const terms = q.split(/[\s+]+/).filter(Boolean)

	// The `#tag` terms narrow in SQL — one EXISTS per term, because a room has to carry
	// EVERY tag asked for, and each term expands to its aliases (`#recroomoriginal` accepts
	// `rro`). Only the rooms that survive have their blobs read, which is what `room_tag` is
	// for: a tag search no longer parses every room in the database to ask.
	//
	// `#community` is held out of that query: no room CARRIES the tag (the browse chip posts
	// it to the hot feed as a pseudo-tag, and the search box sends the same term), so asking
	// `room_tag` for it matches nothing and the whole search comes back empty. It filters on
	// who MADE the room instead, below.
	const tagTerms = terms.filter((t) => t.startsWith('#')).map((t) => t.slice(1))
	const communityOnly = tagTerms.includes(COMMUNITY_TAG)
	const tagSets = tagTerms
		.filter((tag) => tag !== COMMUNITY_TAG)
		.map((tag) => [tag, ...(TAG_ALIASES[tag] ?? [])])
	const { sql, binds } = roomsByTagsQuery(tagSets, PUBLIC_WHERE)
	const { results } = await db
		.prepare(sql)
		.bind(...binds)
		.all<RoomRow>()
	let rooms = parseAll(results).filter((r) => r.IsDorm !== true && r.Accessibility === 1)

	// The same test the hot feed's `community` chip applies: every room a player made, which
	// is every room the Coach account doesn't own. It narrows the other terms rather than
	// replacing them, so `#community horror` is still a name search within player-made rooms.
	if (communityOnly) rooms = rooms.filter(isPlayerMade)

	// The plain terms still match in memory: they are substring matches on the name, which
	// no index helps with.
	for (const term of terms) {
		if (term.startsWith('#')) continue
		rooms = rooms.filter((r) => typeof r.Name === 'string' && r.Name.toLowerCase().includes(term))
	}

	return {
		Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
		TotalResults: rooms.length,
	}
}

/**
 * Search suggestions for the box the player is typing in
 * (`GET /rooms/autocomplete_search`) — a list of plain STRINGS, not rooms.
 *
 * Everything suggested is something the follow-up `/rooms/search` will actually find,
 * which is the whole point of the endpoint: a suggestion that returns nothing is worse
 * than no suggestion. So the candidates are drawn from the two things that search matches
 * — room NAMES for a plain term, and TAGS for a `#tag` term — over the same public,
 * non-dorm rooms search itself considers. A tag comes back with its `#` so submitting the
 * suggestion verbatim searches by tag rather than for a room called "horror".
 *
 * A query starting with `#` is asking for tags, so only tags are suggested. Otherwise
 * names come first (the likelier intent), then tags, and within each, matches that START
 * with the query come before ones that merely contain it. Ties break alphabetically, so
 * the same query always suggests the same things in the same order.
 *
 * Matching is case-insensitive and suggestions are de-duplicated case-insensitively, but
 * each is returned in its stored casing — search doesn't care, and the player reads these.
 * Narrowed in SQL to the same rooms {@link searchRooms} considers; the matching itself is
 * in memory, like search's.
 */
export async function autocompleteRoomSearch(
	db: D1Database,
	query: string,
	take: number
): Promise<string[]> {
	const q = query.trim().toLowerCase()
	if (q === '' || take <= 0) return []

	const { results } = await db
		.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${PUBLIC_WHERE}`)
		.all<RoomRow>()
	// Tags attached up front: suggestions are drawn from them, and this reads every candidate
	// room for its NAME regardless, so the tags cost one extra query rather than a second scan.
	const rooms = (await parseAllWithTags(db, results)).filter(
		(r) => r.IsDorm !== true && r.Accessibility === 1
	)

	const tagQuery = q.startsWith('#')
	const term = tagQuery ? q.slice(1) : q
	if (term === '') return []

	// Lowercased suggestion → [rank, the casing to serve it in]. Lower rank sorts first.
	const found = new Map<string, [number, string]>()
	const offer = (value: string, rank: number) => {
		const key = value.toLowerCase()
		const existing = found.get(key)
		if (existing === undefined || existing[0] > rank) found.set(key, [rank, value])
	}

	for (const room of rooms) {
		if (!tagQuery && typeof room.Name === 'string') {
			const name = room.Name.toLowerCase()
			if (name.startsWith(term)) offer(room.Name, 0)
			else if (name.includes(term)) offer(room.Name, 1)
		}
		for (const tag of roomTags(room)) {
			if (tag.startsWith(term)) offer(`#${tag}`, tagQuery ? 0 : 2)
			else if (tag.includes(term)) offer(`#${tag}`, tagQuery ? 1 : 3)
		}
	}

	return [...found.entries()]
		.sort(([aKey, [aRank]], [bKey, [bRank]]) => aRank - bRank || aKey.localeCompare(bKey))
		.slice(0, take)
		.map(([, [, value]]) => value)
}

/**
 * Engagement score used to order the hot feed (cheers weigh most, then favorites).
 * Cheers/favorites come from the caller's aggregated {@link getRoomStats} map — ranking
 * happens before hydration, so the room blob's copies are still zero at this point.
 */
function hotScore(room: Room, stats: Map<number, RoomStats>): number {
	const counts = stats.get(roomIdOf(room))
	const stored = room.Stats as Record<string, unknown> | null | undefined
	const visitors = typeof stored?.VisitorCount === 'number' ? stored.VisitorCount : 0
	return (counts?.CheerCount ?? 0) * 3 + (counts?.FavoriteCount ?? 0) * 2 + visitors
}

/**
 * The browse screen's "New" chip posts `tag=new` to the hot feed, but `new` is a
 * PSEUDO-tag: no room carries it. It means "recently created by a player", so it
 * selects the non-RRO rooms and orders them newest-first instead of by population.
 */
const NEW_TAG = 'new'

/**
 * The browse screen's "Community" chip posts `tag=community`, another PSEUDO-tag no
 * room carries. It means "made by a player", which here is every room whose creator
 * isn't the Coach account — the system account that owns the seeded Rec Room rooms.
 * Unlike {@link NEW_TAG} it only filters: the page keeps the feed's normal
 * live-population ordering.
 *
 * The chip reaches {@link searchRooms} too, as the tag term `#community` — the search box
 * carries the same word — so both feeds have to know it names no tag.
 */
const COMMUNITY_TAG = 'community'

/** The system account (`Coach`) that owns the seeded first-party rooms. */
const COACH_ACCOUNT_ID = 1

/**
 * True if the room is a Rec Room Original. `IsRRO` is the flag the client renders a
 * virtual "RRO" tag from; the auto-derived `rro` tag is checked too so a room that only
 * carries the tag isn't mistaken for player-made.
 */
function isRRO(room: Room): boolean {
	return room.IsRRO === true || roomHasAnyTag(room, new Set(['rro']))
}

/**
 * True when the room may appear in a browse or discovery feed at all: public, not a dorm,
 * and not opted out of lists. Every feed below starts from this, so a room that opts out
 * cannot come back through a row that forgot to check.
 */
function isListable(room: Room): boolean {
	return room.IsDorm !== true && room.Accessibility === 1 && room.ExcludeFromLists !== true
}

/**
 * True when the room is a PLAYER's rather than one of this server's stock ones — the same
 * test {@link COMMUNITY_TAG} applies, since the Coach account owns every seeded room.
 *
 * A different question from {@link isRRO}, which asks whether a room is a Rec Room
 * Original. The two agree on the data as it stands (every seeded room is Coach-owned AND
 * flagged `rro`), but the discovery rows ask this one: a stock room that was never flagged
 * still isn't something a player built.
 */
function isPlayerMade(room: Room): boolean {
	return room.CreatorAccountId !== COACH_ACCOUNT_ID
}

/** A room's CreatedAt as epoch millis; 0 (i.e. oldest) when it's missing or unparseable. */
function createdAt(room: Room): number {
	const ts = typeof room.CreatedAt === 'string' ? Date.parse(room.CreatedAt) : NaN
	return Number.isNaN(ts) ? 0 : ts
}

/**
 * The "hot" rooms feed: public, non-dorm rooms not excluded from lists, ordered
 * by how many players are in them RIGHT NOW (live presence summed across the
 * room's instances), and optionally filtered to a single `tag` (with the same
 * aliases as search). "Hot" is a live-population feed, so current players lead;
 * rooms nobody is in — and the all-zero seed data — fall back to the stored
 * engagement score, then to RoomId order so paging stays stable. Paginated via
 * skip/take; returns `{ Results, TotalResults }` like search. The listable filter runs in
 * SQL ({@link LISTABLE_WHERE}); the ranking is in memory.
 *
 * `tag=new` and `tag=community` are the filters that aren't tag lookups — see
 * {@link NEW_TAG} and {@link COMMUNITY_TAG}.
 */
export async function getHotRooms(
	db: D1Database,
	tag: string,
	skip: number,
	take: number
): Promise<{ Results: Room[]; TotalResults: number }> {
	const t = tag.trim().toLowerCase()

	// A REAL tag narrows in SQL — this is the query `room_tag` exists for, and the one a
	// discovery category row runs: only the rooms carrying the tag have their blobs read.
	// The two pseudo-tags below name no tag at all, so they still scan.
	const isPseudo = t === '' || t === NEW_TAG || t === COMMUNITY_TAG
	const { sql, binds } = roomsByTagsQuery(
		isPseudo ? [] : [[t, ...(TAG_ALIASES[t] ?? [])]],
		LISTABLE_WHERE
	)
	const { results } = await db
		.prepare(sql)
		.bind(...binds)
		.all<RoomRow>()
	let rooms = (await parseAllWithTags(db, results)).filter(isListable)

	if (t === NEW_TAG) {
		// Newest player-made rooms first; RoomId (which is minted in creation order)
		// breaks ties so rooms created in the same instant still page stably.
		const fresh = rooms
			.filter((r) => !isRRO(r))
			.sort((a, b) => createdAt(b) - createdAt(a) || roomIdOf(b) - roomIdOf(a))
		return {
			Results: await hydrateRooms(db, fresh.slice(skip, skip + take)),
			TotalResults: fresh.length,
		}
	}

	// `community` is a pseudo-tag: it filters on who MADE the room rather than on any tag,
	// so it can't be pushed into the tag query above. A real tag already narrowed there.
	if (t === COMMUNITY_TAG) {
		rooms = rooms.filter((r) => r.CreatorAccountId !== COACH_ACCOUNT_ID)
	}

	const players = await countPlayersByRoom(db)
	const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0
	const stats = await getRoomStats(db)
	rooms.sort(
		(a, b) =>
			playerCount(b) - playerCount(a) ||
			hotScore(b, stats) - hotScore(a, stats) ||
			roomIdOf(a) - roomIdOf(b)
	)
	return {
		Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
		TotalResults: rooms.length,
	}
}

/**
 * When each room's live scene was last PUBLISHED, as epoch millis keyed by RoomId: the
 * newest `CurrentSave.CreatedAt` across the room's subrooms.
 *
 * Published, not merely saved. A staged save bumps the subroom's `DataSavedAt` but changes
 * nothing anyone else can load, so ordering by that would float rooms whose visible content
 * never moved — only `current_save_id`, what the loader actually serves, counts here.
 *
 * A room with no published save is ABSENT from the map rather than mapped to 0, so the
 * caller can tell "never published" from "published at the epoch" and choose its own
 * fallback.
 */
async function lastPublishedAtByRoom(db: D1Database): Promise<Map<number, number>> {
	// `json_extract` rather than parsing the row: a save blob carries the whole scene and
	// only its timestamp is wanted, so the DataBlob never has to cross the wire.
	const { results } = await db
		.prepare(
			`SELECT s.room_id AS room_id, json_extract(sv.data, '$.CreatedAt') AS created_at
			 FROM subroom s JOIN subroom_save sv ON sv.sub_room_data_save_id = s.current_save_id`
		)
		.all<{ room_id: number; created_at: string | null }>()

	const latest = new Map<number, number>()
	for (const row of results) {
		const ts = typeof row.created_at === 'string' ? Date.parse(row.created_at) : NaN
		if (Number.isNaN(ts)) continue
		const seen = latest.get(row.room_id)
		if (seen === undefined || ts > seen) latest.set(row.room_id, ts)
	}
	return latest
}

/**
 * The "recently updated" discovery row: listable, player-made rooms ordered by when their
 * live scene was last PUBLISHED, newest first.
 *
 * The Coach account's rooms are left out for the reason {@link COMMUNITY_TAG} leaves them
 * out — they are this server's stock rooms, and a row about what people have been building
 * should not be a row about the seed data.
 *
 * A room that has never published a save falls back to its own `CreatedAt`: creating a room
 * IS its first update, and on a fresh server that is the only timestamp any room has, so
 * dropping them would leave the row empty. RoomId — minted in creation order — breaks ties
 * newest-first so paging stays stable.
 *
 * Paginated via skip/take, `{ Results, TotalResults }` like the hot feed. The listable
 * filter runs in SQL ({@link LISTABLE_WHERE}); the ranking is in memory.
 */
export async function getRecentlyUpdatedRooms(
	db: D1Database,
	skip: number,
	take: number
): Promise<{ Results: Room[]; TotalResults: number }> {
	const { results } = await db
		.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
		.all<RoomRow>()
	const rooms = parseAll(results).filter((r) => isListable(r) && isPlayerMade(r))

	const published = await lastPublishedAtByRoom(db)
	const updatedAt = (r: Room): number => published.get(roomIdOf(r)) ?? createdAt(r)
	rooms.sort((a, b) => updatedAt(b) - updatedAt(a) || roomIdOf(b) - roomIdOf(a))

	return {
		Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
		TotalResults: rooms.length,
	}
}

/**
 * The "new" discovery row: listable, player-made rooms newest FIRST by creation time.
 *
 * Close to the browse screen's `tag=new` chip (see {@link NEW_TAG}) but not the same test:
 * the chip drops Rec Room Originals, this drops the Coach account's rooms. Both mean
 * "player-made" and agree on the data as it stands — the discovery rows deliberately all
 * use ownership ({@link isPlayerMade}) so one row cannot include a room its sibling row
 * excludes.
 *
 * Paginated via skip/take, `{ Results, TotalResults }` like the hot feed. RoomId breaks
 * ties, newest first, so rooms created in the same instant still page stably.
 */
export async function getNewRooms(
	db: D1Database,
	skip: number,
	take: number
): Promise<{ Results: Room[]; TotalResults: number }> {
	const { results } = await db
		.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
		.all<RoomRow>()
	const rooms = parseAll(results)
		.filter((r) => isListable(r) && isPlayerMade(r))
		.sort((a, b) => createdAt(b) - createdAt(a) || roomIdOf(b) - roomIdOf(a))

	return {
		Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
		TotalResults: rooms.length,
	}
}

/**
 * Recommended rooms feed: public, non-dorm rooms not excluded from lists, ranked
 * by engagement (same score as the hot feed). Unlike the hot feed this returns a
 * bare array — the client's recommendation room-source loader expects a plain
 * list, like the other `*by/me`/base sources. The `splitTest*` A/B params the
 * client passes don't change the result. Paginated via skip/take; the listable filter runs
 * in SQL ({@link LISTABLE_WHERE}); the ranking is in memory.
 */
export async function getRecommendedRooms(
	db: D1Database,
	skip: number,
	take: number
): Promise<Room[]> {
	const { results } = await db
		.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
		.all<RoomRow>()
	const stats = await getRoomStats(db)
	return hydrateRooms(
		db,
		parseAll(results)
			.filter(isListable)
			.sort((a, b) => hotScore(b, stats) - hotScore(a, stats) || roomIdOf(a) - roomIdOf(b))
			.slice(skip, skip + take),
		stats
	)
}

/**
 * Trending ("rising") rooms — the listable rooms someone is standing in RIGHT NOW, busiest
 * first. What the `rising` carousel is filled from.
 *
 * This is the one feed where live presence FILTERS rather than merely ranks: the hot feed
 * sorts by head-count but still lists the empty rooms underneath it, and a carousel of
 * rooms nobody is in is not trending. So a quiet server serves an EMPTY carousel rather
 * than falling back to stored engagement — a room with no one in it has not risen.
 *
 * Ties break the way the hot feed's do (stored engagement, then RoomId), so equally busy
 * rooms page stably.
 */
export async function getTrendingRooms(
	db: D1Database,
	skip: number,
	take: number
): Promise<{ Results: Room[]; TotalResults: number }> {
	const players = await countPlayersByRoom(db)
	// Nobody anywhere: nothing can be trending, and the room table needn't be read at all.
	if (players.size === 0) return { Results: [], TotalResults: 0 }

	const { results } = await db
		.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
		.all<RoomRow>()
	const stats = await getRoomStats(db)
	const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0
	const rooms = parseAll(results)
		.filter((r) => isListable(r) && playerCount(r) > 0)
		.sort(
			(a, b) =>
				playerCount(b) - playerCount(a) ||
				hotScore(b, stats) - hotScore(a, stats) ||
				roomIdOf(a) - roomIdOf(b)
		)

	return {
		Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
		TotalResults: rooms.length,
	}
}

/** Compact room projection carried by a featured-room group. */
export interface FeaturedRoom {
	RoomId: number
	RoomName: string
	ImageName: string
	IsRecRoomApproved: boolean
	ExcludeFromLists: boolean
	ExcludeFromSearch: boolean
}

/** A time-boxed group of featured rooms, as returned by `/featuredrooms/current`. */
export interface FeaturedRoomGroup {
	FeaturedRoomGroupId: number
	name: string
	StartAt: string
	EndAt: string
	Rooms: FeaturedRoom[]
}

/** How many rooms one featured group carries. See {@link getFeaturedRooms}. */
export const FEATURED_ROOM_LIMIT = 10

/**
 * Featured rooms group: public, non-dorm rooms not excluded from lists, in random
 * order. There's no editorial curation behind this yet, so "featured" is just a
 * random shuffle of the eligible rooms wrapped in a single always-active group.
 * Eligibility is filtered in SQL ({@link LISTABLE_WHERE}); the rest is in memory.
 *
 * At most {@link FEATURED_ROOM_LIMIT} rooms — a featured group is a short editorial
 * selection, not the whole room list. The cap is applied AFTER the shuffle, so it is a
 * random SAMPLE that varies between requests; a `LIMIT` in the SQL would instead pin the
 * same handful of rooms forever and make the shuffle cosmetic.
 */
export async function getFeaturedRooms(db: D1Database): Promise<FeaturedRoomGroup> {
	const { results } = await db
		.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
		.all<RoomRow>()
	const rooms = parseAll(results).filter(isListable)
	// Fisher–Yates shuffle so the feed varies between requests.
	for (let i = rooms.length - 1; i > 0; i--) {
		const j = Math.floor(Math.random() * (i + 1))
		;[rooms[i], rooms[j]] = [rooms[j], rooms[i]]
	}
	rooms.length = Math.min(rooms.length, FEATURED_ROOM_LIMIT)

	const str = (v: unknown): string => (typeof v === 'string' ? v : '')
	const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
	return {
		FeaturedRoomGroupId: 1,
		name: 'Featured Rooms',
		StartAt: '2025-12-01T11:01:00Z',
		EndAt: '9999-12-08T11:00:00Z',
		Rooms: rooms.map((r) => ({
			RoomId: num(r.RoomId),
			RoomName: str(r.Name),
			ImageName: str(r.ImageName),
			IsRecRoomApproved: r.IsRecRoomApproved === true,
			ExcludeFromLists: r.ExcludeFromLists === true,
			ExcludeFromSearch: r.ExcludeFromSearch === true,
		})),
	}
}

/**
 * Rooms similar to a target room: public, non-dorm rooms (excluding the target)
 * that share at least one tag with it, ranked by shared-tag count then
 * engagement. Returns a paginated `{ Results, TotalResults }` (the client's
 * RoomSimilarity source expects an object, not a bare array); empty if the target
 * isn't in D1 or is untagged. Eligibility is filtered in SQL ({@link LISTABLE_WHERE}); the
 * tag ranking is in memory.
 */
export async function getSimilarRooms(
	db: D1Database,
	roomId: number,
	skip: number,
	take: number
): Promise<{ Results: Room[]; TotalResults: number }> {
	const empty = { Results: [] as Room[], TotalResults: 0 }
	const target = await getRoomById(db, roomId)
	if (!target) return empty
	const targetTags = new Set(roomTags(target))
	if (targetTags.size === 0) return empty

	const { results } = await db
		.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
		.all<RoomRow>()
	const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length
	const stats = await getRoomStats(db)

	// Ranking is by SHARED TAG COUNT, so every candidate needs its tags before the sort —
	// not a filter one tag can narrow, since "shares any tag with the target" is the whole
	// candidate set.
	const scored = (await parseAllWithTags(db, results))
		.filter(
			(r) =>
				roomIdOf(r) !== roomId &&
				r.IsDorm !== true &&
				r.Accessibility === 1 &&
				r.ExcludeFromLists !== true
		)
		.map((room) => ({ room, shared: sharedCount(room) }))
		.filter((x) => x.shared > 0)

	scored.sort(
		(a, b) =>
			b.shared - a.shared ||
			hotScore(b.room, stats) - hotScore(a.room, stats) ||
			roomIdOf(a.room) - roomIdOf(b.room)
	)
	const rooms = scored.map((x) => x.room)
	return {
		Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
		TotalResults: rooms.length,
	}
}

/**
 * "Base" rooms — the template rooms tagged `base` that the client offers as
 * starting points when creating a room. Unlike the public feeds these are
 * returned regardless of accessibility (most base rooms aren't publicly listed).
 * Ordered by RoomId for stable paging. Paginated via skip/take; returns a bare
 * array. Small dataset, so done in memory.
 */
export async function getBaseRooms(db: D1Database, skip: number, take: number): Promise<Room[]> {
	// Selected in SQL off the tag index — a handful of rooms out of the whole table, so this
	// is the clearest case for narrowing before the blobs are read.
	const { sql, binds } = roomsByTagsQuery([['base']])
	const { results } = await db
		.prepare(sql)
		.bind(...binds)
		.all<RoomRow>()
	return hydrateRooms(
		db,
		parseAll(results)
			.sort((a, b) => roomIdOf(a) - roomIdOf(b))
			.slice(skip, skip + take)
	)
}

/** The seeded template dorm (RoomId 1) that personal dorms are cloned from. */
const DORM_TEMPLATE_ROOM_ID = 1

/** A player's username from the shared accounts table (for naming their dorm), or null. */
export async function getUsername(db: D1Database, accountId: number): Promise<string | null> {
	const row = await db
		.prepare('SELECT data FROM account WHERE account_id = ?1')
		.bind(accountId)
		.first<{ data: string }>()
	if (!row) return null
	const account = JSON.parse(row.data) as { username?: string }
	return typeof account.username === 'string' ? account.username : null
}

/** A player's personal dorm room (owned by them, IsDorm), or null if none yet. */
export async function getDormRoom(db: D1Database, accountId: number): Promise<Room | null> {
	return hydrateRoom(
		db,
		parseOne(
			await db
				.prepare(
					`SELECT ${ROOM_COLUMNS} FROM room WHERE creator_account_id = ?1 AND is_dorm = 1 LIMIT 1`
				)
				.bind(accountId)
				.first<RoomRow>()
		)
	)
}

/**
 * The player's personal dorm room, created on first access. Cloned from the
 * seeded template dorm (RoomId 1) but owned by the player and flagged IsDorm — so
 * matchmaking routes them into their own dorm and they can save it via the
 * owner-gated room-save. Idempotent: returns the existing dorm once created.
 *
 * NOTE: this is the one place the match worker writes to the rooms table (the
 * `rooms` worker otherwise owns the schema).
 */
export async function getOrCreateDormRoom(db: D1Database, accountId: number): Promise<Room> {
	const existing = await getDormRoom(db, accountId)
	if (existing) return existing

	const template = await getRoomById(db, DORM_TEMPLATE_ROOM_ID)
	const idRow = await db
		.prepare('SELECT COALESCE(MAX(room_id), 1) + 1 AS next FROM room')
		.first<{ next: number }>()
	const roomId = idRow?.next ?? 2

	// Reuse the template's subroom (scene/capacity), owned by the player, starting
	// from a clean save. Fall back to the base dorm scene if the template is absent.
	const templateSub =
		template && Array.isArray(template.SubRooms) && template.SubRooms.length > 0
			? (template.SubRooms[0] as Record<string, unknown>)
			: { SubRoomId: 1, UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163', MaxPlayers: 4 }

	// Named after the owner: `@<username>'s Dorm` (falls back to the account id).
	const username = (await getUsername(db, accountId)) ?? `Player${accountId}`

	const room: Room = {
		...(template ?? { Accessibility: Accessibility.Unlisted }),
		RoomId: roomId,
		Name: `@${username}'s Dorm`,
		CreatorAccountId: accountId,
		IsDorm: true,
		Roles: [
			{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 },
		],
		// Counters start at zero rather than inheriting the template dorm's (see cloneRoom).
		Stats: storedStats(template?.Stats),
		CreatedAt: new Date().toISOString(),
	}
	// serializeRoom drops any SubRooms carried over from the template; the dorm's own
	// subroom is inserted into the subroom table below with a fresh globally-unique id.
	await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run()
	const subRoom = await insertSubRoom(db, roomId, { ...templateSub, CreatorAccountId: accountId })
	room.SubRooms = [subRoom]
	// The template carries these (it was parsed), but a dorm minted without one wouldn't.
	attachRoomDtoDefaults(room)
	return room
}
