/**
 * Player-event storage on the shared `recflare` D1 database. Each event is a single
 * JSON blob in the `data` column; queryable fields (id, creator, club, start time)
 * are SQLite generated (virtual) columns extracted from that JSON — the same
 * JSON-blob pattern the image/invention/rooms/accounts tables use.
 *
 * The `api` worker owns this schema/migration (migrations/0006_event.sql and
 * 0007_event_attendee.sql, applied under its own `migrations_table` so they don't
 * clash with the other workers' migrations on the shared database).
 *
 * The stored record IS the DTO: every read endpoint serves the blob verbatim, so the
 * field set and casing here are exactly what the client parses. Timestamps are
 * normalized to `2020-11-29T22:00:00Z` (no fractional seconds) to match.
 *
 * RSVPs live alongside in `event_attendee`, one row per player per event. That one is
 * genuinely columnar (like the relationship/report tables), so it's a normal
 * relational table rather than a JSON blob.
 */

import {
	glyphLength,
	MAX_EVENT_DESCRIPTION_LENGTH,
	MAX_EVENT_DURATION_MS,
	MAX_EVENT_NAME_LENGTH,
} from '@repo/domain'

/**
 * Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
 * seed rows).
 */
export const SCHEMA_DDL: string[] = [
	`CREATE TABLE IF NOT EXISTS event (
		data TEXT NOT NULL,
		id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
		creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
		room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
		club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
		start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
		end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
	)`,
	`CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id)`,
	`CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id)`,
	`CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id)`,
	`CREATE INDEX IF NOT EXISTS idx_event_room ON event (room_id)`,
	`CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time)`,
	`CREATE TABLE IF NOT EXISTS event_attendee (
		event_id INTEGER NOT NULL,
		player_id INTEGER NOT NULL,
		status INTEGER NOT NULL,
		responded_at TEXT NOT NULL,
		PRIMARY KEY (event_id, player_id)
	)`,
	`CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id)`,
	`CREATE TABLE IF NOT EXISTS event_tag (
		event_id INTEGER NOT NULL,
		tag TEXT NOT NULL,
		type INTEGER NOT NULL DEFAULT 0,
		PRIMARY KEY (event_id, tag)
	)`,
	`CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag)`,
]

/**
 * How a player answered an event invitation — the `Type` on
 * `POST /api/playerevents/v1/respond`, stored as `event_attendee.status`.
 *
 * Only `going` counts toward an event's `AttendeeCount`: interested is a maybe, and
 * declining is recorded rather than deleted so the client can show the player their own
 * answer (and so changing your mind is an update, not an insert).
 */
export const EVENT_RESPONSE = {
	going: 0,
	interested: 1,
	cantGo: 2,
} as const

/** The response types, for validating an incoming `Type`. */
const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE)

/** Whether a number is one of the three response types. */
export function isEventResponseType(value: number): boolean {
	return EVENT_RESPONSE_VALUES.includes(value)
}

/**
 * One player's answer to one event.
 *
 * `id` is the row's SQLite `rowid` — the table has a composite primary key, so it's a
 * rowid table and the implicit id is free. It's what the RSVP list serves as
 * `PlayerEventResponseId`, and it's stable: a changed answer is an UPDATE through the
 * composite key (same rowid), and nothing ever deletes an RSVP row.
 */
export interface EventAttendeeRow {
	id: number
	event_id: number
	player_id: number
	status: number
	responded_at: string
}

/**
 * One RSVP as `GET /api/playerevents/v1/:eventId/responses` serves it — the PascalCase
 * projection of an `event_attendee` row.
 *
 * `CreatedAt` is the stored `responded_at`, so it's the time of the answer CURRENTLY
 * recorded, not of the player's first one: changing your mind updates the row in place
 * (one row per player per event), and the client shows the answer that stands.
 */
export interface PlayerEventResponse {
	PlayerEventResponseId: number
	PlayerEventId: number
	PlayerId: number
	CreatedAt: string
	Type: number
}

/** Project an RSVP row into the response the RSVP list serves. */
export function toEventResponse(row: EventAttendeeRow): PlayerEventResponse {
	return {
		PlayerEventResponseId: row.id,
		PlayerEventId: row.event_id,
		PlayerId: row.player_id,
		CreatedAt: row.responded_at,
		Type: row.status,
	}
}

/**
 * One tag on an event — the categories the browse screen's filter chips name
 * (`workshops`, `meetup`, …). `tag` is stored and matched lowercased; `type` is the
 * client's tag-category int, echoed back as sent (its enum isn't reversed yet).
 *
 * Tags live in their own table, NOT on the event blob: the blob is the DTO every read
 * serves verbatim, and tags surface only behind `includeDetails=True`.
 */
export interface EventTag {
	tag: string
	type: number
}

/**
 * A scheduled player event (Rec Room's `PlayerEvent`) — a room, a window of time and
 * the settings the event runs under. Served verbatim by every read endpoint.
 *
 * `SubRoomId`/`ClubId`/`ImageName` are genuinely nullable: an event can name the room
 * without pinning a subroom, needn't belong to a club, and has no banner until one is
 * uploaded. The three `*Permissions`/`State`/`Accessibility` ints are stored as the
 * client sends them — their enums aren't reversed yet, so nothing here interprets
 * them beyond the defaults below.
 */
export interface PlayerEvent {
	PlayerEventId: number
	CreatorPlayerId: number
	ImageName: string | null
	RoomId: number
	SubRoomId: number | null
	ClubId: number | null
	Name: string
	Description: string
	/** ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`). */
	StartTime: string
	EndTime: string
	AttendeeCount: number
	State: number
	Accessibility: number
	IsMultiInstance: boolean
	SupportMultiInstanceRoomChat: boolean
	DefaultBroadcastPermissions: number
	CanRequestBroadcastPermissions: number
}

interface EventRow {
	data: string
}

/**
 * A tag as the 2023 build's `v2` envelope carries it: the PascalCase form of the stored
 * `{ tag, type }` pair. NOT the lowercase pair the v1 read serves — three casings of one
 * tag, and the client parses each in exactly one place.
 */
export interface PlayerEventEnvelopeTag {
	Tag: string
	Type: number
}

/**
 * The event as the `v2` envelope carries it: {@link PlayerEventBase} plus `Tags`. Defined
 * on top of the base rather than beside it, so the feed and the envelope cannot drift
 * apart on the fields they share.
 *
 * `Tags` is the one field whose shape depends on the caller's BUILD, because Rec Room
 * changed it under the same unversioned path rather than minting a `v3`: the 2023 build
 * parses `[{ Tag, Type }]` and the 2025 build parses `["celebration"]`. Serving either
 * one to the other build leaves the event's tag chips empty — the decoder drops what it
 * can't read rather than erroring. {@link toEventResult} picks; nothing else should.
 */
export interface PlayerEventEnvelope extends PlayerEventBase {
	Tags: string[] | PlayerEventEnvelopeTag[]
}

/**
 * The envelope the `v2` routes answer with — the event nested under a status, rather than
 * the bare record the `v1` reads serve. `Result` is 0 on success.
 *
 * `TagModifyResult` reports the tag edit that rides along with a write: its `Result` is 0
 * and its `Tags` echo the tags the event now carries, which is what the client redraws its
 * tag chips from. It is an OBJECT — it used to be served as null, back when no event tags
 * were stored.
 */
export interface PlayerEventResult {
	PlayerEvent: PlayerEventEnvelope
	Result: number
	TagModifyResult: { Result: number; Tags: string[] }
}

/**
 * Wrap a stored event and its tags in the `v2` envelope. `tags` are the event's stored
 * tags — pass what `getEventTags` returns, so the answer reflects what was actually
 * written rather than what was asked for.
 *
 * `legacyTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see
 * {@link PlayerEventEnvelope}): the 2023 pairs when set, the 2025 names when not. It
 * changes nothing else — `TagModifyResult.Tags` is a name list to both builds.
 */
export function toEventResult(
	event: PlayerEvent,
	tags: EventTag[] = [],
	legacyTags = false
): PlayerEventResult {
	const names = tags.map((t) => t.tag)
	const carried = legacyTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names
	return {
		PlayerEvent: { Tags: carried, ...toEventBase(event) },
		Result: 0,
		TagModifyResult: { Result: 0, Tags: names },
	}
}

/**
 * The envelope a DELETE answers with. Both payload fields are null: the reference reports
 * only that the delete happened, and the client reads nothing but `Result` — there is no
 * event left to redraw. Deliberately NOT {@link toEventResult}'s shape, even though both
 * are the v2 envelope.
 */
export interface PlayerEventDeletedResult {
	PlayerEvent: null
	Result: number
	TagModifyResult: null
}

/** The one value {@link PlayerEventDeletedResult} ever takes: a successful delete. */
export const EVENT_DELETED_RESULT: PlayerEventDeletedResult = {
	PlayerEvent: null,
	Result: 0,
	TagModifyResult: null,
}

/**
 * The projection of an event carried on a hub notification frame (`PlayerEventCreated`
 * and its siblings). Deliberately NOT the stored record, in three ways — don't unify
 * them:
 *
 * - it is camelCase, where the record and every read endpoint are PascalCase;
 * - it carries `tags` and `broadcastingRoomInstanceId`, which the record has no fields
 *   for (no event tags are stored, and nothing broadcasts an event yet, so both are
 *   empty/null), and drops `State`;
 * - its timestamps are padded to .NET tick precision (`…T19:00:00.0000000Z`) while the
 *   record stores them bare. That asymmetry is the reference server's: its notification
 *   frames carry the padded form and its event reads don't.
 */
export interface PlayerEventNotification {
	tags: Array<{ tag: string; type: number }>
	playerEventId: number
	creatorPlayerId: number
	roomId: number
	subRoomId: number | null
	clubId: number | null
	name: string
	description: string
	imageName: string
	startTime: string
	endTime: string
	attendeeCount: number
	accessibility: number
	isMultiInstance: boolean
	supportMultiInstanceRoomChat: boolean
	defaultBroadcastPermissions: number
	canRequestBroadcastPermissions: number
	broadcastingRoomInstanceId: number | null
}

/**
 * The client's BASE event — the 17-key shape the browse feed (`GET /api/playerevents/v1`)
 * serves, and the same thing the v2 envelope carries once `Tags` is added. PascalCase like
 * the stored record, but not identical to it — don't unify them:
 *
 * - it drops `State`, which neither the feed nor the envelope carries;
 * - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing
 *   broadcasts an event yet, so it is always null);
 * - its `ImageName` is a string: an event with no image reads `""`, where the record holds
 *   null.
 *
 * The by-id / bulk / search reads serve the stored RECORD verbatim instead, `State` and
 * nullable `ImageName` included. Two shapes; keep them apart.
 */
export interface PlayerEventBase extends Omit<PlayerEvent, 'State' | 'ImageName'> {
	ImageName: string
	BroadcastingRoomInstanceId: number | null
}

/** Project a stored event into the base shape the feed serves and the envelope wraps. */
export function toEventBase(event: PlayerEvent): PlayerEventBase {
	const { State: _State, ...rest } = event
	return { ...rest, ImageName: event.ImageName ?? '', BroadcastingRoomInstanceId: null }
}

/** Pad a stored timestamp out to .NET tick precision (seven fractional digits). */
function toTickPrecision(iso: string): string {
	const match = /^(.*?)(?:\.(\d+))?Z$/.exec(iso)
	if (match === null) return iso
	return `${match[1]}.${(match[2] ?? '').padEnd(7, '0').slice(0, 7)}Z`
}

/**
 * Project a stored event into its notification frame. `imageName` becomes an empty
 * string rather than null when the event has no banner: the frame carries `""`, and a
 * null wouldn't survive the trip anyway — the hub drops null values from `Msg`.
 *
 * `tags` are passed in rather than read from the event: they live in their own table,
 * and the callers that have them already looked them up.
 */
export function toEventNotification(
	event: PlayerEvent,
	tags: EventTag[] = []
): PlayerEventNotification {
	return {
		tags,
		playerEventId: event.PlayerEventId,
		creatorPlayerId: event.CreatorPlayerId,
		roomId: event.RoomId,
		subRoomId: event.SubRoomId,
		clubId: event.ClubId,
		name: event.Name,
		description: event.Description,
		imageName: event.ImageName ?? '',
		startTime: toTickPrecision(event.StartTime),
		endTime: toTickPrecision(event.EndTime),
		attendeeCount: event.AttendeeCount,
		accessibility: event.Accessibility,
		isMultiInstance: event.IsMultiInstance,
		supportMultiInstanceRoomChat: event.SupportMultiInstanceRoomChat,
		defaultBroadcastPermissions: event.DefaultBroadcastPermissions,
		canRequestBroadcastPermissions: event.CanRequestBroadcastPermissions,
		broadcastingRoomInstanceId: null,
	}
}

/**
 * Normalize a timestamp to the form the client sends and reads back —
 * `2020-11-29T22:00:00Z`, with no fractional seconds. `toISOString()` always emits
 * milliseconds, which the samples never carry, so they're trimmed.
 */
function eventTime(ms: number): string {
	return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
}

/**
 * Normalize one posted timestamp into the stored form, or undefined when it isn't a
 * usable date.
 *
 * Exported for the single-field time edit (`PUT …/v2/{id}/time`), which has to tell an
 * ABSENT bound — leave the stored one alone — from an unusable one, which it refuses.
 * {@link parseEventBody} collapses the two, since a create/update posting rubbish for a
 * time is better off defaulting than failing.
 */
export function parseEventTime(raw: unknown): string | undefined {
	if (typeof raw !== 'string') return undefined
	const parsed = Date.parse(raw)
	return Number.isNaN(parsed) ? undefined : eventTime(parsed)
}

/** An event's tags, alphabetical so a list read is stable. */
export async function getEventTags(db: D1Database, eventId: number): Promise<EventTag[]> {
	const { results } = await db
		.prepare('SELECT tag, type FROM event_tag WHERE event_id = ?1 ORDER BY tag')
		.bind(eventId)
		.all<EventTag>()
	return results
}

/**
 * Replace an event's tags with the given set — the tag edit that rides along with a
 * create or update. A replace, not a merge: the client posts the whole set it wants,
 * so an untagging is a post with the tag left out.
 */
export async function setEventTags(
	db: D1Database,
	eventId: number,
	tags: EventTag[]
): Promise<void> {
	const statements = [db.prepare('DELETE FROM event_tag WHERE event_id = ?1').bind(eventId)]
	for (const { tag, type } of tags) {
		statements.push(
			db
				.prepare(
					`INSERT INTO event_tag (event_id, tag, type) VALUES (?1, ?2, ?3)
					 ON CONFLICT (event_id, tag) DO UPDATE SET type = ?3`
				)
				.bind(eventId, tag, type)
		)
	}
	await db.batch(statements)
}

/**
 * Fields a create or update supplies, camelCased. Every one is optional: create
 * defaults what's missing, and update leaves anything absent at its stored value —
 * which is why the nullable ids are `number | null` rather than merely absent, so a
 * posted `"ClubId": null` can genuinely clear a club.
 */
export interface EventInput {
	/** The whole tag set to store; absent leaves the event's tags alone. */
	tags?: EventTag[]
	imageName?: string | null
	roomId?: number
	subRoomId?: number | null
	clubId?: number | null
	name?: string
	description?: string
	startTime?: string
	endTime?: string
	state?: number
	accessibility?: number
	isMultiInstance?: boolean
	supportMultiInstanceRoomChat?: boolean
	defaultBroadcastPermissions?: number
	canRequestBroadcastPermissions?: number
}

/** Read a value as an integer, or undefined when absent / not a number. */
function asInt(value: unknown): number | undefined {
	if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value)
	if (typeof value === 'string') {
		const n = Number.parseInt(value, 10)
		if (!Number.isNaN(n)) return n
	}
	return undefined
}

/**
 * The window a write would end up storing, resolved the way {@link createEvent} and
 * {@link updateEvent} resolve it: a bound the body carries wins, otherwise the stored one
 * (an edit), otherwise the create defaults — now, and an hour later.
 *
 * Exists so the duration rule below and the writes themselves can't drift apart on what
 * "the event's window" means for a body that moves only one bound.
 */
function resolvedWindow(
	input: EventInput,
	existing?: PlayerEvent,
	now = Date.now()
): { start: number; end: number } {
	const start = Date.parse(input.startTime ?? existing?.StartTime ?? eventTime(now))
	const stored = input.endTime ?? existing?.EndTime
	return { start, end: stored === undefined ? start + DEFAULT_DURATION_MS : Date.parse(stored) }
}

/**
 * Why a parsed event body can't be stored, or `null` when it's fine.
 *
 * Two rules: the stored lengths, and the window.
 *
 * Lengths are a cap, not a charset — an event name is a title, not an identifier
 * ("Building a Better Room Using Trigonometry" is a real one), so the alphanumeric rule
 * the account and room names carry would be wrong here. Absent fields are skipped: an
 * update posts only what it changes, and create defaults a missing name rather than
 * refusing it. The name is measured AFTER trimming, matching what the writes store.
 *
 * The window is checked on what the write RESOLVES to rather than on the fields the body
 * carries, which is why `existing` is passed for an edit: moving the start alone still
 * has to leave a window that ends after it and runs no longer than
 * {@link MAX_EVENT_DURATION_MS}. A create resolves against the same defaults
 * {@link createEvent} applies, so a body naming neither bound — or only a start — can
 * never fail this.
 *
 * A backwards window is refused here too. It isn't a duration rule as such, but it's the
 * hole in one: `end - start` on a window running a month backwards is negative, which
 * would sail past a "no longer than a day" check.
 */
export function eventInputRejection(input: EventInput, existing?: PlayerEvent): string | null {
	const name = input.name?.trim()
	if (name !== undefined && glyphLength(name) > MAX_EVENT_NAME_LENGTH) {
		return `Event names can be at most ${MAX_EVENT_NAME_LENGTH} characters.`
	}
	if (
		input.description !== undefined &&
		glyphLength(input.description) > MAX_EVENT_DESCRIPTION_LENGTH
	) {
		return `Event descriptions can be at most ${MAX_EVENT_DESCRIPTION_LENGTH} characters.`
	}

	const { start, end } = resolvedWindow(input, existing)
	// Unparseable can't happen from `parseEventBody` (it drops what it can't read) but can
	// from a stored blob edited by hand; skip the rule rather than refusing an edit that
	// says nothing about the times.
	if (Number.isNaN(start) || Number.isNaN(end)) return null
	if (end < start) return 'An event cannot end before it starts.'
	if (end - start > MAX_EVENT_DURATION_MS) {
		return `An event can run for at most ${MAX_EVENT_DURATION_MS / (60 * 60 * 1000)} hours.`
	}
	return null
}

/**
 * Read the `Tags` a create/update body carries, or undefined when it carries none (an
 * update that says nothing about tags leaves them alone; `[]` genuinely clears them).
 *
 * Both forms in circulation are accepted — a bare string (`"workshops"`) and the
 * `{ tag, type }` object the notification frame carries — since the browse chips are
 * plain names while the client's own event model pairs each with a category int. Tags
 * are lowercased (the search matches them lowercased, and `#Workshops` and `#workshops`
 * are the same chip), a leading `#` is stripped, and blanks/duplicates are dropped.
 */
export function parseEventTags(raw: unknown): EventTag[] | undefined {
	if (!Array.isArray(raw)) return undefined
	const byTag = new Map<string, EventTag>()
	for (const entry of raw) {
		const source = (typeof entry === 'object' && entry !== null ? entry : {}) as Record<
			string,
			unknown
		>
		const name = typeof entry === 'string' ? entry : (source.tag ?? source.Tag)
		if (typeof name !== 'string') continue
		const tag = name.trim().replace(/^#/, '').toLowerCase()
		if (tag === '') continue
		byTag.set(tag, { tag, type: asInt(source.type ?? source.Type) ?? 0 })
	}
	return [...byTag.values()]
}

/**
 * Parse a posted event body into an {@link EventInput}.
 *
 * Accepts the event's fields either at the top level or nested under `PlayerEvent`:
 * the client posts the same envelope it reads back, and both forms are in circulation.
 * A field the body doesn't carry stays undefined (create defaults it, update keeps the
 * stored value); an explicit `null` on one of the nullable ids is preserved so it can
 * clear the value. Timestamps are normalized here, so an unparseable one is dropped
 * rather than stored.
 */
export function parseEventBody(body: unknown): EventInput {
	const outer = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
	const nested = outer.PlayerEvent
	const obj = (typeof nested === 'object' && nested !== null ? nested : outer) as Record<
		string,
		unknown
	>

	const has = (key: string): boolean => Object.hasOwn(obj, key)
	// A nullable id: absent leaves it alone, an explicit null clears it.
	const nullableInt = (key: string): number | null | undefined => {
		if (!has(key)) return undefined
		return obj[key] === null ? null : asInt(obj[key])
	}
	const time = (key: string): string | undefined => parseEventTime(obj[key])
	const bool = (key: string): boolean | undefined => {
		const raw = obj[key]
		if (typeof raw === 'boolean') return raw
		if (raw === 'true') return true
		if (raw === 'false') return false
		return undefined
	}
	// The banner name: same absent/null distinction as the nullable ids.
	const nullableString = (key: string): string | null | undefined => {
		if (!has(key)) return undefined
		if (obj[key] === null) return null
		return typeof obj[key] === 'string' ? (obj[key] as string) : undefined
	}

	return {
		tags: parseEventTags(obj.Tags ?? obj.tags),
		imageName: nullableString('ImageName'),
		roomId: asInt(obj.RoomId),
		subRoomId: nullableInt('SubRoomId'),
		clubId: nullableInt('ClubId'),
		name: typeof obj.Name === 'string' ? obj.Name : undefined,
		description: typeof obj.Description === 'string' ? obj.Description : undefined,
		startTime: time('StartTime'),
		endTime: time('EndTime'),
		state: asInt(obj.State),
		accessibility: asInt(obj.Accessibility),
		isMultiInstance: bool('IsMultiInstance'),
		supportMultiInstanceRoomChat: bool('SupportMultiInstanceRoomChat'),
		defaultBroadcastPermissions: asInt(obj.DefaultBroadcastPermissions),
		canRequestBroadcastPermissions: asInt(obj.CanRequestBroadcastPermissions),
	}
}

/** How long an event runs when the body names a start but no end. */
const DEFAULT_DURATION_MS = 60 * 60 * 1000

/**
 * Insert a new event, returning the stored record.
 *
 * Lenient about what the body carries, like the other writes here: an event with no
 * name or no time window is defaulted rather than rejected, because a rejection the
 * client can't render is worse than a placeholder the creator can edit. `State` starts
 * at 0 (scheduled). The creator comes from the bearer token, never the body.
 *
 * The creator is recorded as Going in `event_attendee`, which is what makes
 * `AttendeeCount` start at 1: the count is derived from that table, so the creator
 * needs a row there for the number to stay right once other players respond.
 */
export async function createEvent(
	db: D1Database,
	creatorPlayerId: number,
	input: EventInput
): Promise<PlayerEvent> {
	// Sequential id: one past the current max (the table starts empty).
	const row = await db
		.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM event')
		.first<{ next: number }>()
	const now = Date.now()
	const startTime = input.startTime ?? eventTime(now)
	const event: PlayerEvent = {
		PlayerEventId: row?.next ?? 1,
		CreatorPlayerId: creatorPlayerId,
		ImageName: input.imageName ?? null,
		RoomId: input.roomId ?? 0,
		SubRoomId: input.subRoomId ?? null,
		ClubId: input.clubId ?? null,
		Name: input.name?.trim() || 'Untitled Event',
		Description: input.description ?? '',
		StartTime: startTime,
		EndTime: input.endTime ?? eventTime(Date.parse(startTime) + DEFAULT_DURATION_MS),
		AttendeeCount: 1,
		State: input.state ?? 0,
		Accessibility: input.accessibility ?? 1,
		IsMultiInstance: input.isMultiInstance ?? false,
		SupportMultiInstanceRoomChat: input.supportMultiInstanceRoomChat ?? false,
		DefaultBroadcastPermissions: input.defaultBroadcastPermissions ?? 0,
		CanRequestBroadcastPermissions: input.canRequestBroadcastPermissions ?? 0,
	}
	await db.batch([
		db.prepare('INSERT INTO event (data) VALUES (?1)').bind(JSON.stringify(event)),
		db
			.prepare(
				`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
				 VALUES (?1, ?2, ?3, ?4)`
			)
			.bind(event.PlayerEventId, creatorPlayerId, EVENT_RESPONSE.going, eventTime(now)),
	])
	// Tags ride along with the write but live in their own table — they are not part of
	// the stored blob, since that blob is the DTO every read serves verbatim.
	if (input.tags !== undefined) await setEventTags(db, event.PlayerEventId, input.tags)
	return event
}

/**
 * Record a player's answer to an event, replacing whatever they said before — one row
 * per player per event, so changing your mind is an update rather than a second RSVP.
 * The event's `AttendeeCount` is recomputed from the table afterwards.
 *
 * Returns the updated event, or null when there's no such event. Anyone who can see an
 * event may respond to it, the creator included (they're already Going from create, and
 * nothing stops them declining their own event).
 */
export async function setEventResponse(
	db: D1Database,
	eventId: number,
	playerId: number,
	status: number
): Promise<PlayerEvent | null> {
	const event = await getEventById(db, eventId)
	if (event === null) return null

	await db
		.prepare(
			`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
			 VALUES (?1, ?2, ?3, ?4)
			 ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4`
		)
		.bind(eventId, playerId, status, eventTime(Date.now()))
		.run()

	const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
	await writeEvent(db, updated)
	return updated
}

/**
 * Add invited players to an event as Going — the bulk invite. Returns the updated
 * event (with its recounted `AttendeeCount`) and the rows actually created, or null
 * when there's no such event.
 *
 * An invite only ever INSERTS: a player who already has a row keeps the answer they
 * gave, so being invited can't flip a decline back to Going, and re-inviting the same
 * player is a no-op rather than a reset. Since the rows land as Going, the invited
 * count toward `AttendeeCount` from the moment they're invited — see the route.
 *
 * `added` is what `RETURNING` gave back, so it holds exactly the new rows: a conflict
 * inserts nothing and returns nothing. That's what the route notifies on — a player
 * whose existing answer was left alone gets no frame, because nothing changed for them.
 *
 * Ids are deduplicated by the composite primary key; an empty list is a no-op that
 * still returns the event.
 */
export async function inviteToEvent(
	db: D1Database,
	eventId: number,
	playerIds: number[]
): Promise<{ event: PlayerEvent; added: EventAttendeeRow[] } | null> {
	const event = await getEventById(db, eventId)
	if (event === null) return null
	if (playerIds.length === 0) return { event, added: [] }

	const at = eventTime(Date.now())
	const inserts = await db.batch<EventAttendeeRow>(
		playerIds.map((playerId) =>
			db
				.prepare(
					`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
					 VALUES (?1, ?2, ?3, ?4)
					 ON CONFLICT (event_id, player_id) DO NOTHING
					 RETURNING rowid AS id, *`
				)
				.bind(eventId, playerId, EVENT_RESPONSE.going, at)
		)
	)
	const added = inserts.flatMap((r) => r.results)

	const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
	await writeEvent(db, updated)
	return { event: updated, added }
}

/** How many players said they're Going — an event's `AttendeeCount`. */
export async function countGoing(db: D1Database, eventId: number): Promise<number> {
	const row = await db
		.prepare('SELECT COUNT(*) AS going FROM event_attendee WHERE event_id = ?1 AND status = ?2')
		.bind(eventId, EVENT_RESPONSE.going)
		.first<{ going: number }>()
	return row?.going ?? 0
}

/** One player's answer to one event, or null when they haven't responded. */
export async function getEventResponse(
	db: D1Database,
	eventId: number,
	playerId: number
): Promise<EventAttendeeRow | null> {
	return db
		.prepare('SELECT rowid AS id, * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2')
		.bind(eventId, playerId)
		.first<EventAttendeeRow>()
}

/**
 * Everyone who answered an event, in the order they responded — the guest list behind
 * `GET /api/playerevents/v1/:eventId/responses`. Ties on the timestamp (the creator's
 * own Going row shares its second with a fast first RSVP) break on the player id, so
 * the order is stable.
 */
export async function getEventAttendees(
	db: D1Database,
	eventId: number
): Promise<EventAttendeeRow[]> {
	const { results } = await db
		.prepare(
			`SELECT rowid AS id, * FROM event_attendee
			 WHERE event_id = ?1 ORDER BY responded_at, player_id`
		)
		.bind(eventId)
		.all<EventAttendeeRow>()
	return results
}

/** Overwrite an event's stored blob in place. */
async function writeEvent(db: D1Database, event: PlayerEvent): Promise<void> {
	await db
		.prepare('UPDATE event SET data = ?1 WHERE id = ?2')
		.bind(JSON.stringify(event), event.PlayerEventId)
		.run()
}

/**
 * Apply an edit to an event. Only the fields the body carried change; everything else
 * keeps its stored value, so a partial post can't blank out the rest of the event.
 * The id, the creator and the attendee count are not editable — ownership doesn't
 * transfer and RSVPs aren't set by hand. Returns the updated event, or null when
 * there's no such row.
 */
export async function updateEvent(
	db: D1Database,
	eventId: number,
	input: EventInput
): Promise<PlayerEvent | null> {
	const event = await getEventById(db, eventId)
	if (event === null) return null

	const updated: PlayerEvent = {
		...event,
		ImageName: input.imageName === undefined ? event.ImageName : input.imageName,
		RoomId: input.roomId ?? event.RoomId,
		SubRoomId: input.subRoomId === undefined ? event.SubRoomId : input.subRoomId,
		ClubId: input.clubId === undefined ? event.ClubId : input.clubId,
		Name: input.name?.trim() || event.Name,
		Description: input.description ?? event.Description,
		StartTime: input.startTime ?? event.StartTime,
		EndTime: input.endTime ?? event.EndTime,
		State: input.state ?? event.State,
		Accessibility: input.accessibility ?? event.Accessibility,
		IsMultiInstance: input.isMultiInstance ?? event.IsMultiInstance,
		SupportMultiInstanceRoomChat:
			input.supportMultiInstanceRoomChat ?? event.SupportMultiInstanceRoomChat,
		DefaultBroadcastPermissions:
			input.defaultBroadcastPermissions ?? event.DefaultBroadcastPermissions,
		CanRequestBroadcastPermissions:
			input.canRequestBroadcastPermissions ?? event.CanRequestBroadcastPermissions,
	}
	await writeEvent(db, updated)
	// A body that says nothing about tags leaves them alone, like every other field
	// here; an explicit `[]` clears them.
	if (input.tags !== undefined) await setEventTags(db, eventId, input.tags)
	return updated
}

/**
 * Delete an event and everything hanging off it — its RSVPs (`event_attendee`) and its tags
 * (`event_tag`) — in one batch, so a cancelled event can't leave rows behind that the
 * attendee counts and the `#tag` search would still find. Event ids are assigned in
 * sequence and never reused, but orphan rows would still be counted against whatever id
 * they name.
 *
 * Answers the event as it was, so the caller can report what it deleted; `null` when there
 * was no such event.
 */
export async function deleteEvent(db: D1Database, eventId: number): Promise<PlayerEvent | null> {
	const event = await getEventById(db, eventId)
	if (event === null) return null
	await db.batch([
		db.prepare('DELETE FROM event_attendee WHERE event_id = ?1').bind(eventId),
		db.prepare('DELETE FROM event_tag WHERE event_id = ?1').bind(eventId),
		db.prepare('DELETE FROM event WHERE id = ?1').bind(eventId),
	])
	return event
}

/** One event by id, or null when there's no such row. */
export async function getEventById(db: D1Database, eventId: number): Promise<PlayerEvent | null> {
	const row = await db
		.prepare('SELECT data FROM event WHERE id = ?1')
		.bind(eventId)
		.first<EventRow>()
	return row ? (JSON.parse(row.data) as PlayerEvent) : null
}

/**
 * Several events by id — the bulk fetch. Answers in the order the ids were asked for
 * (the client renders them in the order it requested), skipping ids with no row rather
 * than leaving a hole. Duplicated ids resolve to the same event.
 */
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
	if (ids.length === 0) return []
	const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
	const { results } = await db
		.prepare(`SELECT data FROM event WHERE id IN (${placeholders})`)
		.bind(...ids)
		.all<EventRow>()
	const byId = new Map<number, PlayerEvent>()
	for (const r of results) {
		const event = JSON.parse(r.data) as PlayerEvent
		byId.set(event.PlayerEventId, event)
	}
	return ids.map((id) => byId.get(id)).filter((e): e is PlayerEvent => e !== undefined)
}

/**
 * The events a player created — their "my events" list, soonest first. Uses the
 * creator_player_id index; the per-player set is small, so ordering is done in memory.
 */
export async function getEventsByCreator(
	db: D1Database,
	creatorPlayerId: number
): Promise<PlayerEvent[]> {
	const { results } = await db
		.prepare('SELECT data FROM event WHERE creator_player_id = ?1')
		.bind(creatorPlayerId)
		.all<EventRow>()
	return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}

/**
 * The events belonging to a set of clubs — the events shelf on a club's page, soonest
 * first. Selected on the indexed club_id column. An empty id list is an empty shelf
 * rather than every event.
 */
export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promise<PlayerEvent[]> {
	if (clubIds.length === 0) return []
	const placeholders = clubIds.map((_, i) => `?${i + 1}`).join(', ')
	const { results } = await db
		.prepare(`SELECT data FROM event WHERE club_id IN (${placeholders})`)
		.bind(...clubIds)
		.all<EventRow>()
	return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}

/**
 * A room's events — what is happening in this room and what is coming up, soonest first.
 * Backs the room's event shelf (`GET /api/playerevents/v1/room/{roomId}`).
 *
 * FINISHED events are left out, like the browse feed's: this answers "what can I still turn
 * up to in this room", and an event that ended last month is not that. Running events count
 * as current — the filter is on the END time, so an event stays listed until it is over
 * rather than disappearing the moment it starts.
 *
 * Selected on the indexed room_id column, with the time bound in SQL too: end_time is a
 * generated column of an ISO-8601 UTC string, so it compares lexicographically.
 */
export async function getEventsByRoom(
	db: D1Database,
	roomId: number,
	now = Date.now()
): Promise<PlayerEvent[]> {
	const { results } = await db
		.prepare('SELECT data FROM event WHERE room_id = ?1 AND end_time >= ?2')
		.bind(roomId, eventTime(now))
		.all<EventRow>()
	return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}

/**
 * The events happening right now — started and not yet finished. Backs the "happening
 * now" browse query. Both bounds compare lexicographically on the generated ISO-8601
 * columns, so the whole filter stays in SQL.
 */
export async function getLiveEvents(db: D1Database, now = Date.now()): Promise<PlayerEvent[]> {
	const at = eventTime(now)
	const { results } = await db
		.prepare('SELECT data FROM event WHERE start_time <= ?1 AND end_time >= ?1')
		.bind(at)
		.all<EventRow>()
	return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}

/** Soonest start first; ties broken by id so paging is stable. */
function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
	return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId
}

/**
 * Event search — the browse query on the player-events screen. Term by term, an empty
 * query browsing everything upcoming; paginated via skip/take, soonest first.
 *
 * A term is matched one of two ways, and the `#` decides which:
 *
 * - `#workshops` is a TAG term — it matches only an event tagged `workshops`, and never
 *   the word appearing in a name or description. That's what the browse screen's filter
 *   chips send.
 * - `workshops` is a TEXT term, matched case-insensitively against the name and the
 *   description, as before.
 *
 * Every term has to match, and the two kinds combine: `#workshops trigonometry` is the
 * workshops-tagged events whose text also mentions trigonometry.
 *
 * Events that have already finished are excluded: this backs a browse screen, where a
 * name match on something that ended last month is noise. The per-event history a
 * creator wants comes from `getEventsByCreator`, which keeps them.
 */
export async function searchEvents(
	db: D1Database,
	query: string,
	skip: number,
	take: number
): Promise<PlayerEvent[]> {
	const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
	// A `#` prefix makes a term a tag; the rest are matched against the text. A bare `#`
	// is dropped rather than treated as a tag nothing can carry.
	const tags = terms.filter((t) => t.startsWith('#')).map((t) => t.slice(1))
	const textTerms = terms.filter((t) => !t.startsWith('#'))

	// end_time is a generated column of an ISO-8601 UTC string, so it compares
	// lexicographically — that filter stays in SQL, and so does the tag one: an event
	// has to carry EVERY tag asked for, which is the count of matching tag rows.
	const wanted = tags.filter(Boolean)
	const sql =
		wanted.length === 0
			? 'SELECT data FROM event WHERE end_time >= ?1'
			: `SELECT data FROM event WHERE end_time >= ?1 AND (
					SELECT COUNT(DISTINCT tag) FROM event_tag
					WHERE event_tag.event_id = event.id
					  AND tag IN (${wanted.map((_, i) => `?${i + 2}`).join(', ')})
				) = ${wanted.length}`
	const { results } = await db
		.prepare(sql)
		.bind(eventTime(Date.now()), ...wanted)
		.all<EventRow>()
	let events = results.map((r) => JSON.parse(r.data) as PlayerEvent)

	for (const term of textTerms) {
		events = events.filter(
			(e) => e.Name.toLowerCase().includes(term) || e.Description.toLowerCase().includes(term)
		)
	}

	return events.sort(bySoonest).slice(skip, skip + take)
}
