import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, describe, expect, it } from 'vitest'

import '../../chat.app'

import { NotificationType } from '../../../../notify/src/notification-types'
import {
	ChatModerationState,
	getMessage,
	getThreadMessages,
	insertMessage,
	SCHEMA_DDL,
} from '../../message-db'
import {
	createThread,
	findThreadWithMembers,
	getThreadForPlayer,
	getThreadsForPlayer,
	isThreadMember,
	leftChatContents,
	markThreadRead,
	postMessage,
	removeThreadMember,
	setThreadFavorited,
	startedChatContents,
	SYSTEM_SENDER_ID,
	THREAD_SCHEMA_DDL,
} from '../../thread-db'

import type { Env } from '../../context'
import type { ChatMessage } from '../../message-db'

declare module 'cloudflare:test' {
	interface ProvidedEnv extends Env {}
}

const ORIGIN = 'https://example.com'

// Mint a token the way the `auth` worker does, signing with the shared test key seeded
// into the JWT_SECRET store.
const TEST_SECRET = 'test-signing-key'

function b64url(input: ArrayBuffer | string): string {
	const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
	let binary = ''
	for (const byte of bytes) binary += String.fromCharCode(byte)
	return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

async function bearer(sub: number): Promise<Record<string, string>> {
	const now = Math.floor(Date.now() / 1000)
	const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
		JSON.stringify({ sub: String(sub), exp: now + 3600 })
	)}`
	const key = await crypto.subtle.importKey(
		'raw',
		new TextEncoder().encode(TEST_SECRET),
		{ name: 'HMAC', hash: 'SHA-256' },
		false,
		['sign']
	)
	const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
	return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}

beforeAll(async () => {
	// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
	await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
	for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
	for (const stmt of THREAD_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})

describe('chat endpoints', () => {
	it('GET / reports service status', async () => {
		const res = await SELF.fetch(`${ORIGIN}/`)
		expect(res.status).toBe(200)
		expect(await res.json()).toEqual({ service: 'chat', status: 'ok' })
	})

	it('GET /thread 401s without a token', async () => {
		const res = await SELF.fetch(`${ORIGIN}/thread`)
		expect(res.status).toBe(401)
	})

	it('GET /thread serves the caller their own threads', async () => {
		const player = 881001
		const thread = await createThread(env.DB, [player, 881002])
		const latest = await postMessage(env.DB, {
			chatThreadId: thread,
			senderPlayerId: 881002,
			timeSent: '2022-02-21T18:08:56.0362822',
			contents: '{"Type":0,"Version":1,"Data":"hi"}',
		})

		const res = await SELF.fetch(`${ORIGIN}/thread?MessageCount=16&Mode=0`, {
			headers: await bearer(player),
		})
		expect(res.status).toBe(200)
		expect(await res.json()).toEqual([
			{
				latestMessage: latest,
				chatThreadId: thread,
				playerIds: [player, 881002],
				lastReadMessageId: 0,
				chatThreadName: '',
				chatThreadType: 0,
				snoozedUntil: null,
				isFavorited: false,
			},
		])
	})

	it('GET /thread/:id/message serves the thread newest first', async () => {
		const player = 881003
		const thread = await createThread(env.DB, [player, 881004])
		const older = await postMessage(env.DB, {
			chatThreadId: thread,
			senderPlayerId: 881004,
			timeSent: '2022-02-19T22:13:56.7224503',
			contents: '{"Type":0,"Version":1,"Data":"on discord?"}',
		})
		const newer = await postMessage(env.DB, {
			chatThreadId: thread,
			senderPlayerId: 881004,
			timeSent: '2022-02-21T18:08:56.0362822',
			contents: '{"Type":0,"Version":1,"Data":"hi"}',
		})

		const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message?MessageCount=16&Mode=0`, {
			headers: await bearer(player),
		})
		expect(res.status).toBe(200)
		expect(await res.json()).toEqual([newer, older])
	})

	it('GET /thread/:id/message honours MessageCount', async () => {
		const player = 881005
		const thread = await createThread(env.DB, [player])
		for (const data of ['one', 'two', 'three']) {
			await postMessage(env.DB, {
				chatThreadId: thread,
				senderPlayerId: player,
				contents: JSON.stringify({ Type: 0, Version: 1, Data: data }),
			})
		}

		const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message?MessageCount=2`, {
			headers: await bearer(player),
		})
		expect(await res.json()).toHaveLength(2)
	})

	// A thread's existence is itself private, so a non-member gets the same 404 as for a
	// thread that never existed — not a 403 that confirms it's there.
	it('GET /thread/:id/message hides threads the caller is not in', async () => {
		const thread = await createThread(env.DB, [881006, 881007])
		const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message`, {
			headers: await bearer(881008),
		})
		expect(res.status).toBe(404)

		const missing = await SELF.fetch(`${ORIGIN}/thread/999999/message`, {
			headers: await bearer(881008),
		})
		expect(missing.status).toBe(404)
	})
})

describe('message storage', () => {
	// A real message as the client sends it, kept verbatim (including the JSON-in-a-string
	// `contents` envelope) so the round-trip is tested against the actual payload shape.
	const CONTENTS =
		'{"Type":0,"Version":1,"Data":"This is jordanparki7 from your Oculus friends. We\'re friends in Rec Room now!"}'

	it('round-trips a message, assigning an id', async () => {
		const stored = await insertMessage(env.DB, {
			chatThreadId: 116181128,
			senderPlayerId: 10441985,
			timeSent: '2022-05-22T12:47:03.6536656',
			contents: CONTENTS,
		})
		expect(stored.chatMessageId).toBeGreaterThan(0)
		expect(stored).toEqual({
			chatMessageId: stored.chatMessageId,
			chatThreadId: 116181128,
			senderPlayerId: 10441985,
			timeSent: '2022-05-22T12:47:03.6536656',
			contents: CONTENTS,
			moderationState: ChatModerationState.None,
		})
		expect(await getMessage(env.DB, stored.chatMessageId)).toEqual(stored)
	})

	it('defaults timeSent to now', async () => {
		const stored = await insertMessage(env.DB, {
			chatThreadId: 999,
			senderPlayerId: 42,
			contents: CONTENTS,
		})
		expect(Date.parse(stored.timeSent)).toBeGreaterThan(Date.now() - 60_000)
	})

	it('lists a thread newest first and pages backwards', async () => {
		const thread = 116181129
		const first = await insertMessage(env.DB, {
			chatThreadId: thread,
			senderPlayerId: 1,
			contents: CONTENTS,
		})
		const second = await insertMessage(env.DB, {
			chatThreadId: thread,
			senderPlayerId: 2,
			contents: CONTENTS,
		})

		const page = await getThreadMessages(env.DB, thread)
		expect(page.map((m) => m.chatMessageId)).toEqual([second.chatMessageId, first.chatMessageId])

		const older = await getThreadMessages(env.DB, thread, { before: second.chatMessageId })
		expect(older.map((m) => m.chatMessageId)).toEqual([first.chatMessageId])

		// Messages from other threads never leak into a thread's listing.
		expect(await getThreadMessages(env.DB, 404)).toEqual([])
	})
})

describe('thread storage', () => {
	// The viewing player from the captured thread-list response.
	const VIEWER = 10441985

	function contents(data: string): string {
		return JSON.stringify({ Type: 0, Version: 1, Data: data })
	}

	it('renders the thread list in the shape the client expects', async () => {
		const dm = await createThread(env.DB, [9489959, VIEWER])
		const latest = await postMessage(env.DB, {
			chatThreadId: dm,
			senderPlayerId: VIEWER,
			timeSent: '2022-05-22T12:47:03.6536656',
			contents: contents(
				"This is jordanparki7 from your Oculus friends. We're friends in Rec Room now!"
			),
		})
		await markThreadRead(env.DB, dm, VIEWER, latest.chatMessageId)

		const [thread] = await getThreadsForPlayer(env.DB, VIEWER)
		expect(thread).toEqual({
			latestMessage: latest,
			chatThreadId: dm,
			playerIds: [9489959, VIEWER],
			lastReadMessageId: latest.chatMessageId,
			chatThreadName: '',
			chatThreadType: 0,
			snoozedUntil: null,
			isFavorited: false,
		})
	})

	it('keeps a named group thread with all its members', async () => {
		const members = [VIEWER, 10452682, 12534039, 12535328, 12631702]
		const group = await createThread(env.DB, members, 'Group Chat =]')
		await postMessage(env.DB, {
			chatThreadId: group,
			senderPlayerId: VIEWER,
			contents: contents('sussy baka'),
		})

		const thread = await getThreadForPlayer(env.DB, group, VIEWER)
		expect(thread?.chatThreadName).toBe('Group Chat =]')
		expect(thread?.playerIds).toEqual(members.sort((a, b) => a - b))
	})

	// System notices ("Player <@U…> started a chat") and player messages both carry
	// markup the server must not touch — the mention token, and HTML entities the
	// client escaped itself. Stored and served back byte-for-byte.
	it('stores message contents verbatim, markup and all', async () => {
		const thread = await createThread(env.DB, [VIEWER, 29565301])
		const notice = await postMessage(env.DB, {
			chatThreadId: thread,
			senderPlayerId: 29565301,
			contents: contents('Player <@U29565301> started a chat'),
		})
		const escaped = await postMessage(env.DB, {
			chatThreadId: thread,
			senderPlayerId: 29563053,
			contents: contents('Ly2 bae &lt;&lt;&lt;333'),
		})

		expect(await getMessage(env.DB, notice.chatMessageId)).toEqual(notice)
		expect((await getMessage(env.DB, escaped.chatMessageId))?.contents).toBe(escaped.contents)
	})

	it('orders threads newest first and honours the page size', async () => {
		const viewer = 777001
		const older = await createThread(env.DB, [viewer, 1])
		const newer = await createThread(env.DB, [viewer, 2])
		await postMessage(env.DB, {
			chatThreadId: older,
			senderPlayerId: viewer,
			contents: contents('first'),
		})
		await postMessage(env.DB, {
			chatThreadId: newer,
			senderPlayerId: viewer,
			contents: contents('second'),
		})

		const threads = await getThreadsForPlayer(env.DB, viewer)
		expect(threads.map((t) => t.chatThreadId)).toEqual([newer, older])
		expect(await getThreadsForPlayer(env.DB, viewer, { limit: 1 })).toHaveLength(1)
	})

	it('gates reads on membership', async () => {
		const thread = await createThread(env.DB, [777002, 777003])
		expect(await isThreadMember(env.DB, thread, 777002)).toBe(true)
		expect(await isThreadMember(env.DB, thread, 777004)).toBe(false)
		// A non-member sees neither the thread nor its place in their own list.
		expect(await getThreadForPlayer(env.DB, thread, 777004)).toBeNull()
		expect(await getThreadsForPlayer(env.DB, 777004)).toEqual([])
	})

	it('keeps read state, favorites, and snoozes per viewer', async () => {
		const a = 777005
		const b = 777006
		const thread = await createThread(env.DB, [a, b])
		const first = await postMessage(env.DB, {
			chatThreadId: thread,
			senderPlayerId: a,
			contents: contents('one'),
		})
		const second = await postMessage(env.DB, {
			chatThreadId: thread,
			senderPlayerId: b,
			contents: contents('two'),
		})

		await markThreadRead(env.DB, thread, a, second.chatMessageId)
		await setThreadFavorited(env.DB, thread, a, true)
		await markThreadRead(env.DB, thread, b, first.chatMessageId)

		const forA = await getThreadForPlayer(env.DB, thread, a)
		const forB = await getThreadForPlayer(env.DB, thread, b)
		expect(forA?.lastReadMessageId).toBe(second.chatMessageId)
		expect(forA?.isFavorited).toBe(true)
		expect(forB?.lastReadMessageId).toBe(first.chatMessageId)
		expect(forB?.isFavorited).toBe(false)

		// A late ack from a second client can't walk the thread back to unread.
		await markThreadRead(env.DB, thread, a, first.chatMessageId)
		expect((await getThreadForPlayer(env.DB, thread, a))?.lastReadMessageId).toBe(
			second.chatMessageId
		)
	})

	it('leaves an empty thread with no latest message', async () => {
		const thread = await createThread(env.DB, [777007])
		expect(await getThreadForPlayer(env.DB, thread, 777007)).toMatchObject({
			latestMessage: null,
			lastReadMessageId: 0,
			playerIds: [777007],
		})
	})

	it('drops a removed member from the roster but keeps the thread', async () => {
		const thread = await createThread(env.DB, [777008, 777009])
		await postMessage(env.DB, {
			chatThreadId: thread,
			senderPlayerId: 777008,
			contents: contents('mellon'),
		})
		await removeThreadMember(env.DB, thread, 777009)

		expect(await getThreadForPlayer(env.DB, thread, 777009)).toBeNull()
		expect((await getThreadForPlayer(env.DB, thread, 777008))?.playerIds).toEqual([777008])
	})
})

// The party thread is a stub until its real shape is observed off a live client; these
// pin down only what the stub promises — auth, and an object body rather than a 404.
describe('GET /settings/partyinvite', () => {
	it('answers the invite-link lifetime as a bare single-key object', async () => {
		const res = await SELF.fetch(`${ORIGIN}/settings/partyinvite`, {
			headers: await bearer(885001),
		})
		expect(res.status).toBe(200)
		// One key, no `{ success, error, value }` envelope around it.
		expect(await res.json()).toEqual({ InviteLinkLifetimeInMinutes: 60 })
	})

	it('401s without a token', async () => {
		const res = await SELF.fetch(`${ORIGIN}/settings/partyinvite`)
		expect(res.status).toBe(401)
	})
})

describe('GET /thread/party', () => {
	it('answers an empty object', async () => {
		const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
			headers: await bearer(883001),
		})
		expect(res.status).toBe(200)
		expect(await res.json()).toEqual({})
	})

	it('401s without a token', async () => {
		const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`)
		expect(res.status).toBe(401)
	})
})

describe('GET /thread/chatPrivacySetting', () => {
	it('reports Friends for both settings by default, keyed to the caller', async () => {
		const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, {
			headers: await bearer(886001),
		})
		expect(res.status).toBe(200)
		// camelCase, and the enum by NUMBER (0 = Friends) — this build has no by-name enum
		// formatter, so a string would decode as nothing.
		expect(await res.json()).toEqual({
			playerId: 886001,
			directMessagePrivacySetting: 0,
			groupChatPrivacySetting: 0,
		})
	})

	it('reads the stored settings out of the player settings map', async () => {
		await env.RECFLARE_PLAYER_SETTINGS.put(
			'player:886003',
			JSON.stringify({
				directMessagePrivacySetting: 'Favorites',
				groupChatPrivacySetting: 'NoOne',
			})
		)
		const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, {
			headers: await bearer(886003),
		})
		expect(await res.json()).toEqual({
			playerId: 886003,
			directMessagePrivacySetting: 1,
			groupChatPrivacySetting: 2,
		})
	})

	it('falls back to Friends for a stored value it can’t parse', async () => {
		await env.RECFLARE_PLAYER_SETTINGS.put(
			'player:886004',
			JSON.stringify({ directMessagePrivacySetting: 'Nobody at all' })
		)
		const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, {
			headers: await bearer(886004),
		})
		expect(
			((await res.json()) as { directMessagePrivacySetting: number }).directMessagePrivacySetting
		).toBe(0)
	})

	it('reads playerId off the token, not a query param', async () => {
		const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting?playerId=999999`, {
			headers: await bearer(886002),
		})
		expect(((await res.json()) as { playerId: number }).playerId).toBe(886002)
	})

	it('401s without a token', async () => {
		const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`)
		expect(res.status).toBe(401)
	})
})

describe('PUT /thread/chatPrivacySetting', () => {
	const path = `${ORIGIN}/thread/chatPrivacySetting`

	/** The client's PUT: one form field, the enum by NAME. */
	async function put(playerId: number, body: Record<string, string>) {
		return SELF.fetch(path, {
			method: 'PUT',
			headers: await bearer(playerId),
			body: new URLSearchParams(body),
		})
	}

	const settings = async (playerId: number) =>
		env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(`player:${playerId}`, 'json')

	it('stores the DM setting and answers the resulting settings', async () => {
		const res = await put(887001, { directMessagePrivacySetting: 'Favorites' })
		expect(res.status).toBe(200)
		// The same body the GET serves — the enum by NUMBER, not the name that was posted.
		expect(await res.json()).toEqual({
			playerId: 887001,
			directMessagePrivacySetting: 1,
			groupChatPrivacySetting: 0,
		})
		// Stored by NAME in the player settings map the `playersettings` worker owns.
		expect((await settings(887001))?.directMessagePrivacySetting).toBe('Favorites')
	})

	it('stores the group chat setting on its own', async () => {
		const res = await put(887002, { groupChatPrivacySetting: 'NoOne' })
		expect(await res.json()).toEqual({
			playerId: 887002,
			directMessagePrivacySetting: 0,
			groupChatPrivacySetting: 2,
		})
	})

	it('leaves the other setting alone — the client sends one field per call', async () => {
		await put(887003, { directMessagePrivacySetting: 'NoOne' })
		const res = await put(887003, { groupChatPrivacySetting: 'Favorites' })
		expect(await res.json()).toEqual({
			playerId: 887003,
			directMessagePrivacySetting: 2,
			groupChatPrivacySetting: 1,
		})
	})

	it('merges, leaving the player’s other settings untouched', async () => {
		await env.RECFLARE_PLAYER_SETTINGS.put(
			'player:887004',
			JSON.stringify({ 'Recroom.OOBE': '77' })
		)
		await put(887004, { directMessagePrivacySetting: 'Favorites' })
		expect(await settings(887004)).toEqual({
			'Recroom.OOBE': '77',
			directMessagePrivacySetting: 'Favorites',
		})
	})

	it('accepts the enum by ordinal too, and is case-insensitive about the name', async () => {
		expect(await (await put(887005, { directMessagePrivacySetting: '2' })).json()).toMatchObject({
			directMessagePrivacySetting: 2,
		})
		expect(
			await (await put(887005, { directMessagePrivacySetting: 'favorites' })).json()
		).toMatchObject({ directMessagePrivacySetting: 1 })
	})

	it('accepts the fields in the query string as well as the body', async () => {
		const res = await SELF.fetch(`${path}?groupChatPrivacySetting=NoOne`, {
			method: 'PUT',
			headers: await bearer(887006),
		})
		expect(await res.json()).toMatchObject({ groupChatPrivacySetting: 2 })
	})

	it('ignores an unreadable value rather than 400ing or writing a default', async () => {
		await put(887007, { directMessagePrivacySetting: 'Favorites' })
		const res = await put(887007, { directMessagePrivacySetting: 'Whoever' })
		expect(res.status).toBe(200)
		// Unchanged — a value that won't parse is not a write of `Friends`.
		expect(await res.json()).toMatchObject({ directMessagePrivacySetting: 1 })
	})

	it('401s without a token', async () => {
		const res = await SELF.fetch(path, {
			method: 'PUT',
			body: new URLSearchParams({ directMessagePrivacySetting: 'NoOne' }),
		})
		expect(res.status).toBe(401)
	})
})

describe('GET /thread/checkCanSendDirectMessageWithPrivacySetting', () => {
	const path = `${ORIGIN}/thread/checkCanSendDirectMessageWithPrivacySetting`

	it('always allows the DM, as a bare ChatResult integer', async () => {
		const res = await SELF.fetch(`${path}?receivingPlayerId=205`, {
			headers: await bearer(884001),
		})
		expect(res.status).toBe(200)
		expect(res.headers.get('content-type')).toContain('application/json')
		// The whole body is the ChatResult — 0 is Success. NOT a boolean: the client
		// instantiates its response wrapper with the enum, so `true` would decode as nothing.
		expect(await res.text()).toBe('0')
	})

	it('answers the same for any player, and with no player named', async () => {
		// `receivingPlayerId` is ignored — nothing here stores a privacy setting to refuse on.
		for (const query of ['?receivingPlayerId=205', '?receivingPlayerId=999999', '']) {
			const res = await SELF.fetch(`${path}${query}`, { headers: await bearer(884002) })
			expect(await res.text()).toBe('0')
		}
	})

	it('401s without a token', async () => {
		const res = await SELF.fetch(`${path}?receivingPlayerId=205`)
		expect(res.status).toBe(401)
	})
})

describe('POST /thread/withmembers', () => {
	async function withMembers(caller: number, body: string) {
		return SELF.fetch(`${ORIGIN}/thread/withmembers`, {
			method: 'POST',
			headers: {
				...(await bearer(caller)),
				'Content-Type': 'application/x-www-form-urlencoded',
			},
			body,
		})
	}

	it('opens a thread with the named players plus the caller', async () => {
		const caller = 882001
		const res = await withMembers(caller, 'ids=2&ids=155&messageCount=50')
		expect(res.status).toBe(200)

		const thread = (await res.json()) as {
			chatThreadId: number
			playerIds: number[]
			messages: unknown[]
		}
		// A page of messages, not the list's single latestMessage. A brand-new thread
		// isn't empty: it opens with the system "started a chat" notice.
		expect(thread.messages).toEqual([
			expect.objectContaining({
				senderPlayerId: SYSTEM_SENDER_ID,
				contents: startedChatContents(caller),
			}),
		])
		expect(thread).toMatchObject({
			playerIds: [2, 155, caller],
			lastReadMessageId: 0,
			chatThreadName: '',
			snoozedUntil: null,
			isFavorited: false,
		})
		expect(thread).not.toHaveProperty('latestMessage')

		// The thread is real: it shows up in the caller's list, and its members can read it.
		expect((await getThreadsForPlayer(env.DB, caller)).map((t) => t.chatThreadId)).toContain(
			thread.chatThreadId
		)
		expect(await isThreadMember(env.DB, thread.chatThreadId, 155)).toBe(true)
	})

	// Two nulls GetChatBetweenPlayers can't survive: lastReadMessageId deserializes into
	// a non-nullable int ("expected 'Number Token', actual 'null'"), and chatThreadName
	// is dereferenced unchecked (NullReferenceException). Unread is 0, unnamed is ''.
	it('never serializes lastReadMessageId or chatThreadName as null', async () => {
		const res = await withMembers(882008, 'ids=153')
		const body = await res.text()
		expect(body).not.toContain('"lastReadMessageId":null')
		expect(body).not.toContain('"chatThreadName":null')

		const thread = JSON.parse(body) as { lastReadMessageId: number; chatThreadName: string }
		expect(thread.lastReadMessageId).toBe(0)
		expect(thread.chatThreadName).toBe('')
	})

	it('collapses duplicate ids and the caller naming themselves', async () => {
		const caller = 882002
		const res = await withMembers(caller, `ids=${caller}&ids=882003&ids=882003`)
		expect(((await res.json()) as { playerIds: number[] }).playerIds).toEqual([caller, 882003])
	})

	// Fetch-or-create: reopening a chat with the same people must land back in the
	// conversation that already has the history, not a fresh empty one.
	it('returns the existing thread rather than opening a second', async () => {
		const caller = 882004
		const first = (await (await withMembers(caller, 'ids=882005')).json()) as {
			chatThreadId: number
		}
		await postMessage(env.DB, {
			chatThreadId: first.chatThreadId,
			senderPlayerId: 882005,
			contents: '{"Type":0,"Version":1,"Data":"hi"}',
		})

		const second = (await (await withMembers(caller, 'ids=882005')).json()) as {
			chatThreadId: number
			messages: unknown[]
		}
		expect(second.chatThreadId).toBe(first.chatThreadId)
		// The opening notice plus the real message — reopening adds neither a thread nor
		// a second notice.
		expect(second.messages).toHaveLength(2)
	})

	// Membership is matched as a whole set, so a DM isn't mistaken for a group that
	// happens to contain the same two people.
	it('does not confuse a subset or superset for the same thread', async () => {
		const caller = 882009
		const pair = (await (await withMembers(caller, 'ids=882010')).json()) as {
			chatThreadId: number
		}
		const trio = (await (await withMembers(caller, 'ids=882010&ids=882011')).json()) as {
			chatThreadId: number
		}
		expect(trio.chatThreadId).not.toBe(pair.chatThreadId)
	})

	it('honours messageCount when paging the thread', async () => {
		const caller = 882012
		const opened = (await (await withMembers(caller, 'ids=882013')).json()) as {
			chatThreadId: number
		}
		for (const data of ['one', 'two', 'three']) {
			await postMessage(env.DB, {
				chatThreadId: opened.chatThreadId,
				senderPlayerId: caller,
				contents: JSON.stringify({ Type: 0, Version: 1, Data: data }),
			})
		}

		const paged = (await (await withMembers(caller, 'ids=882013&messageCount=2')).json()) as {
			messages: unknown[]
		}
		expect(paged.messages).toHaveLength(2)
	})

	it('rejects a thread with nobody else in it', async () => {
		const caller = 882006
		expect((await withMembers(caller, '')).status).toBe(400)
		expect((await withMembers(caller, `ids=${caller}`)).status).toBe(400)
		expect((await withMembers(caller, 'ids=notanumber')).status).toBe(400)
	})

	it('rejects an oversized roster', async () => {
		const ids = Array.from({ length: 60 }, (_, i) => `ids=${883000 + i}`).join('&')
		expect((await withMembers(882007, ids)).status).toBe(400)
	})

	it('401s without a token', async () => {
		const res = await SELF.fetch(`${ORIGIN}/thread/withmembers`, {
			method: 'POST',
			headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
			body: 'ids=2',
		})
		expect(res.status).toBe(401)
	})
})

describe('POST /thread', () => {
	async function createViaPost(caller: number, body: string) {
		return SELF.fetch(`${ORIGIN}/thread`, {
			method: 'POST',
			headers: {
				...(await bearer(caller)),
				'Content-Type': 'application/x-www-form-urlencoded',
			},
			body,
		})
	}

	// The call the client actually makes after /thread/withmembers: members, blank
	// contents. A blank field must not post an empty message, and reports
	// invalid-arguments rather than success.
	it('opens a thread with no message when messageContents is blank', async () => {
		const caller = 884001
		const res = await createViaPost(caller, 'ids=155&ids=2&messageContents=')
		expect(res.status).toBe(200)

		const body = (await res.json()) as {
			chatThread: { chatThreadId: number; latestMessage: { senderPlayerId: number } | null }
			chatResult: number
		}
		expect(body.chatResult).toBe(1)
		expect(body.chatThread).toMatchObject({ playerIds: [2, 155, caller] })

		// Nothing of the caller's was posted — but the thread still isn't empty: it opens
		// with the system notice, which is what the client needs to render it at all.
		const messages = await getThreadMessages(env.DB, body.chatThread.chatThreadId)
		expect(messages).toEqual([
			expect.objectContaining({
				senderPlayerId: SYSTEM_SENDER_ID,
				contents: startedChatContents(caller),
			}),
		])
		expect(body.chatThread.latestMessage?.senderPlayerId).toBe(SYSTEM_SENDER_ID)
	})

	it('posts the first message when messageContents is given', async () => {
		const caller = 884002
		const contents = '{"Type":0,"Version":1,"Data":"hi"}'
		const res = await createViaPost(
			caller,
			`ids=884003&messageContents=${encodeURIComponent(contents)}`
		)

		const body = (await res.json()) as {
			chatThread: {
				chatThreadId: number
				latestMessage: { contents: string; senderPlayerId: number } | null
			}
			chatResult: number
		}
		expect(body.chatResult).toBe(0)
		// Stored verbatim, attributed to the caller, and already the thread's latest.
		expect(body.chatThread.latestMessage).toMatchObject({ contents, senderPlayerId: caller })
		// The opening notice, then the caller's message.
		expect(await getThreadMessages(env.DB, body.chatThread.chatThreadId)).toHaveLength(2)
	})

	// The first message goes through the same profanity filter every later one does.
	it('masks profanity in the first message', async () => {
		const caller = 884010
		const contents = '{"Type":0,"Version":1,"Data":"fuck this"}'
		const res = await createViaPost(
			caller,
			`ids=884011&messageContents=${encodeURIComponent(contents)}`
		)

		const body = (await res.json()) as {
			chatThread: { chatThreadId: number; latestMessage: { contents: string } | null }
			chatResult: number
		}
		expect(body.chatResult).toBe(0)
		expect(body.chatThread.latestMessage?.contents).toBe(
			'{"Type":0,"Version":1,"Data":"**** this"}'
		)
	})

	// Sending to people you already have a thread with appends to it, rather than
	// stranding the message in a second conversation.
	it('appends to the existing thread with the same members', async () => {
		const caller = 884005
		const first = (await (
			await createViaPost(caller, 'ids=884006&messageContents=%7B%22Data%22%3A%22one%22%7D')
		).json()) as { chatThread: { chatThreadId: number } }
		const second = (await (
			await createViaPost(caller, 'ids=884006&messageContents=%7B%22Data%22%3A%22two%22%7D')
		).json()) as { chatThread: { chatThreadId: number } }

		expect(second.chatThread.chatThreadId).toBe(first.chatThread.chatThreadId)
		// The opening notice, then both messages.
		expect(await getThreadMessages(env.DB, first.chatThread.chatThreadId)).toHaveLength(3)
	})

	it('rejects a thread with nobody else in it', async () => {
		expect((await createViaPost(884004, 'messageContents=')).status).toBe(400)
	})

	it('401s without a token', async () => {
		const res = await SELF.fetch(`${ORIGIN}/thread`, {
			method: 'POST',
			headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
			body: 'ids=2&messageContents=',
		})
		expect(res.status).toBe(401)
	})
})

describe('GET /thread/:id', () => {
	async function openThread(caller: number, chatThreadId: number, query = '?messageCount=50') {
		return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}${query}`, {
			headers: await bearer(caller),
		})
	}

	it('opens a thread with its recent messages, newest first', async () => {
		const caller = 885001
		const chatThreadId = await createThread(env.DB, [caller, 885002])
		const older = await postMessage(env.DB, {
			chatThreadId,
			senderPlayerId: 885002,
			timeSent: '2022-02-19T22:13:56.7224503',
			contents: '{"Type":0,"Version":1,"Data":"on discord?"}',
		})
		const newer = await postMessage(env.DB, {
			chatThreadId,
			senderPlayerId: 885002,
			timeSent: '2022-02-21T18:08:56.0362822',
			contents: '{"Type":0,"Version":1,"Data":"hi"}',
		})

		const res = await openThread(caller, chatThreadId)
		expect(res.status).toBe(200)

		// An object, not a bare array: the client parses this one as a thread and rejects
		// an array outright ("expected '{', actual '['").
		const body = await res.text()
		expect(body.startsWith('{')).toBe(true)

		const thread = JSON.parse(body) as { messages: unknown[] }
		expect(thread).toMatchObject({
			chatThreadId,
			playerIds: [caller, 885002],
			lastReadMessageId: 0,
			chatThreadName: '',
			snoozedUntil: null,
			isFavorited: false,
		})
		expect(thread.messages).toEqual([newer, older])
		expect(thread).not.toHaveProperty('latestMessage')
	})

	// The sibling route serves the same messages as a bare array — the two shapes are
	// deliberately different, and the client depends on which is which.
	it('carries the same messages /thread/:id/message serves as an array', async () => {
		const caller = 885010
		const chatThreadId = await createThread(env.DB, [caller, 885011])
		await postMessage(env.DB, {
			chatThreadId,
			senderPlayerId: 885011,
			contents: '{"Type":0,"Version":1,"Data":"hi"}',
		})

		const thread = (await (await openThread(caller, chatThreadId)).json()) as {
			messages: unknown[]
		}
		const messages = await (
			await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/message?MessageCount=50`, {
				headers: await bearer(caller),
			})
		).json()
		expect(Array.isArray(messages)).toBe(true)
		expect(thread.messages).toEqual(messages)
	})

	it('honours messageCount', async () => {
		const caller = 885003
		const chatThreadId = await createThread(env.DB, [caller, 885004])
		for (const data of ['one', 'two', 'three']) {
			await postMessage(env.DB, {
				chatThreadId,
				senderPlayerId: caller,
				contents: JSON.stringify({ Type: 0, Version: 1, Data: data }),
			})
		}

		const res = await openThread(caller, chatThreadId, '?messageCount=2')
		expect(((await res.json()) as { messages: unknown[] }).messages).toHaveLength(2)
	})

	// A thread opened moments ago has nothing in it and still has to open — an empty
	// messages array, not a 404.
	it('opens an empty thread with an empty messages array', async () => {
		const caller = 885005
		const chatThreadId = await createThread(env.DB, [caller, 885006])

		const res = await openThread(caller, chatThreadId)
		expect(res.status).toBe(200)

		const { messages } = (await res.json()) as { messages: unknown[] }
		// Built directly by createThread with no starter, so genuinely empty.
		expect(messages).toEqual([])
	})

	it('hides threads the caller is not in', async () => {
		const chatThreadId = await createThread(env.DB, [885007, 885008])
		expect((await openThread(885009, chatThreadId)).status).toBe(404)
		expect((await openThread(885009, 999999)).status).toBe(404)
	})

	it('401s without a token', async () => {
		const res = await SELF.fetch(`${ORIGIN}/thread/1?messageCount=50`)
		expect(res.status).toBe(401)
	})
})

describe('ChatMessageReceived push', () => {
	/** The stubbed hub (see vitest.config.ts) records what it was sent. */
	interface SentNotification {
		playerId: number
		notificationType: NotificationType
		data: Record<string, unknown>
	}
	const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
		getByName(name: string): { takeSent(): Promise<SentNotification[]> }
	}

	async function send(caller: number, body: string) {
		return SELF.fetch(`${ORIGIN}/thread`, {
			method: 'POST',
			headers: {
				...(await bearer(caller)),
				'Content-Type': 'application/x-www-form-urlencoded',
			},
			body,
		})
	}

	beforeEach(async () => {
		await hub.getByName('global').takeSent()
	})

	// Every member is notified, the sender included: the client doesn't fold the HTTP
	// response into its thread cache, so without a self-push its own message doesn't
	// show until the next refetch.
	it('notifies every member of the thread, sender included', async () => {
		const caller = 886001
		const contents = '{"Type":0,"Version":1,"Data":"hi"}'
		const res = await send(
			caller,
			`ids=886002&ids=886003&messageContents=${encodeURIComponent(contents)}`
		)
		const { chatThread } = (await res.json()) as {
			chatThread: { chatThreadId: number; latestMessage: { chatMessageId: number } }
		}

		const sent = await hub.getByName('global').takeSent()
		expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
		expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
			true
		)
		expect(sent[0]!.data).toEqual({
			chatMessageId: chatThread.latestMessage.chatMessageId,
			chatThreadId: chatThread.chatThreadId,
			senderPlayerId: caller,
			timeSent: expect.any(String),
			contents,
			moderationState: 0,
		})
	})

	it('pushes nothing when there is no message to push', async () => {
		await send(886004, 'ids=886005&messageContents=')
		expect(await hub.getByName('global').takeSent()).toEqual([])
	})
})

// A membership row whose thread row is gone must never be resolved to: it would hand
// back an id nothing can render ("thread N vanished after creation"), and because the
// oldest match wins it would keep winning on every later call.
describe('orphaned membership rows', () => {
	it('ignores members of a thread whose message_thread row is gone', async () => {
		const caller = 887001
		const other = 887002
		const orphaned = await createThread(env.DB, [caller, other])
		await env.DB.prepare('DELETE FROM message_thread WHERE chat_thread_id = ?1')
			.bind(orphaned)
			.run()

		expect(await findThreadWithMembers(env.DB, [caller, other])).toBeNull()

		// Opening the chat recovers: a usable thread comes back, and it isn't the orphan.
		const res = await SELF.fetch(`${ORIGIN}/thread/withmembers`, {
			method: 'POST',
			headers: {
				...(await bearer(caller)),
				'Content-Type': 'application/x-www-form-urlencoded',
			},
			body: `ids=${other}&messageCount=50`,
		})
		expect(res.status).toBe(200)

		const thread = (await res.json()) as { chatThreadId: number; playerIds: number[] }
		expect(thread.chatThreadId).not.toBe(orphaned)
		expect(thread.playerIds).toEqual([caller, other])

		// And it stays stable — the orphan never wins a later lookup.
		expect(await findThreadWithMembers(env.DB, [caller, other])).toBe(thread.chatThreadId)
	})
})

describe('marking a thread read', () => {
	async function read(caller: number, path: string, method = 'POST') {
		return SELF.fetch(`${ORIGIN}${path}`, { method, headers: await bearer(caller) })
	}

	it('moves the pointer to a specific message, on both verbs', async () => {
		const caller = 888001
		const chatThreadId = await createThread(env.DB, [caller, 888002])
		const first = await postMessage(env.DB, {
			chatThreadId,
			senderPlayerId: 888002,
			contents: '{"Type":0,"Version":1,"Data":"one"}',
		})
		const second = await postMessage(env.DB, {
			chatThreadId,
			senderPlayerId: 888002,
			contents: '{"Type":0,"Version":1,"Data":"two"}',
		})

		const res = await read(caller, `/thread/${chatThreadId}/message/${first.chatMessageId}/read`)
		expect(res.status).toBe(200)
		// The bare ChatResult integer, not an envelope.
		expect(await res.json()).toBe(0)
		expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
			first.chatMessageId
		)

		await read(caller, `/thread/${chatThreadId}/message/${second.chatMessageId}/read`, 'PUT')
		expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
			second.chatMessageId
		)
	})

	it('marks the whole thread read without a message id', async () => {
		const caller = 888003
		const chatThreadId = await createThread(env.DB, [caller, 888004])
		const latest = await postMessage(env.DB, {
			chatThreadId,
			senderPlayerId: 888004,
			contents: '{"Type":0,"Version":1,"Data":"hi"}',
		})

		expect((await read(caller, `/thread/${chatThreadId}/read`)).status).toBe(200)
		expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
			latest.chatMessageId
		)
	})

	// The client acks whatever id it was shown — including the synthetic message's
	// 9007199254740976. Clamping keeps that from stranding the thread as read forever.
	it('clamps an id beyond the thread to the real latest message', async () => {
		const caller = 888005
		const chatThreadId = await createThread(env.DB, [caller, 888006])
		const real = await postMessage(env.DB, {
			chatThreadId,
			senderPlayerId: 888006,
			contents: '{"Type":0,"Version":1,"Data":"hi"}',
		})

		await read(caller, `/thread/${chatThreadId}/message/9007199254740976/read`)
		expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
			real.chatMessageId
		)

		// A later real message is still unread, rather than swallowed by the bogus ack.
		const next = await postMessage(env.DB, {
			chatThreadId,
			senderPlayerId: 888006,
			contents: '{"Type":0,"Version":1,"Data":"later"}',
		})
		expect(
			(await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId
		).toBeLessThan(next.chatMessageId)
	})

	it('never moves the pointer backwards', async () => {
		const caller = 888007
		const chatThreadId = await createThread(env.DB, [caller, 888008])
		const first = await postMessage(env.DB, {
			chatThreadId,
			senderPlayerId: 888008,
			contents: '{"Type":0,"Version":1,"Data":"one"}',
		})
		const second = await postMessage(env.DB, {
			chatThreadId,
			senderPlayerId: 888008,
			contents: '{"Type":0,"Version":1,"Data":"two"}',
		})

		await read(caller, `/thread/${chatThreadId}/message/${second.chatMessageId}/read`)
		await read(caller, `/thread/${chatThreadId}/message/${first.chatMessageId}/read`)
		expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe(
			second.chatMessageId
		)
	})

	it('is gated on membership and auth', async () => {
		const chatThreadId = await createThread(env.DB, [888009, 888010])
		expect((await read(888011, `/thread/${chatThreadId}/read`)).status).toBe(404)

		const anon = await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/read`, { method: 'POST' })
		expect(anon.status).toBe(401)
	})
})

describe('POST /thread/:id', () => {
	// The exact body the client sends: a Version 2 envelope whose Data carries a `<=>`
	// prefix. Nothing in the worker parses it, so it must survive byte-for-byte.
	const CONTENTS = '{"Type":0,"Version":2,"Data":"<=>hey"}'

	async function send(caller: number, path: string, contents = CONTENTS) {
		return SELF.fetch(`${ORIGIN}${path}`, {
			method: 'POST',
			headers: {
				...(await bearer(caller)),
				'Content-Type': 'application/x-www-form-urlencoded',
			},
			body: `messageContents=${encodeURIComponent(contents)}`,
		})
	}

	it('appends to an existing thread and answers the send wrapper', async () => {
		const caller = 889001
		const chatThreadId = await createThread(env.DB, [caller, 889002], null, caller)

		const res = await send(caller, `/thread/${chatThreadId}`)
		expect(res.status).toBe(200)

		const body = (await res.json()) as {
			chatResult: number
			chatThread: {
				chatThreadId: number
				playerIds: number[]
				lastReadMessageId: number
				chatThreadType: number
				messages: ChatMessage[]
			}
		}
		expect(body.chatResult).toBe(0)

		// The whole thread comes back, newest message first, with the opening notice
		// beneath it — the shape the client re-renders the conversation from.
		expect(body.chatThread).toMatchObject({ chatThreadId, playerIds: [caller, 889002] })
		expect(body.chatThread.messages).toHaveLength(2)
		expect(body.chatThread.messages[0]).toMatchObject({
			chatThreadId,
			senderPlayerId: caller,
			contents: CONTENTS,
			moderationState: 0,
		})
		expect(body.chatThread.messages[1]).toMatchObject({ senderPlayerId: SYSTEM_SENDER_ID })

		// Sending marks the thread read for the sender, so it doesn't come back unread.
		expect(body.chatThread.lastReadMessageId).toBe(body.chatThread.messages[0]!.chatMessageId)

		// And it's stored, not just echoed.
		expect(await getThreadMessages(env.DB, chatThreadId)).toEqual(body.chatThread.messages)
	})

	it('answers the PascalCase ChatMessage the client’s send handler dereferences', async () => {
		const caller = 889020
		const chatThreadId = await createThread(env.DB, [caller, 889021], null, caller)

		const res = await send(caller, `/thread/${chatThreadId}`)
		const body = (await res.json()) as {
			ChatMessage: Record<string, unknown> | null
			ChatResult: number
			chatResult: number
			chatThread: { messages: ChatMessage[] }
		}

		// The handler reads `ChatMessage` the moment `ChatResult` is 0 — a success answered
		// without one is a null-reference exception inside the client, not a failed parse.
		expect(body.ChatResult).toBe(0)
		const posted = body.chatThread.messages[0]!
		expect(body.ChatMessage).toEqual({
			ChatMessageId: posted.chatMessageId,
			ChatThreadId: chatThreadId,
			SenderPlayerId: caller,
			TimeSent: posted.timeSent,
			// The envelope verbatim. The client parses this into `MessageJson` in a
			// post-deserialize hook that only LOGS on failure and then dereferences the null,
			// so `Contents` has to stay a `{ Type, Version, Data }` string with a non-null Data.
			Contents: CONTENTS,
			ModerationState: 0,
		})

		// The two result keys are one value serialized twice, so a decoder reading either
		// spelling lands on the same answer.
		expect(body.chatResult).toBe(body.ChatResult)
	})

	it('accepts the /thread/:id/message spelling too', async () => {
		const caller = 889003
		const chatThreadId = await createThread(env.DB, [caller, 889004], null, caller)

		const res = await send(caller, `/thread/${chatThreadId}/message`)
		expect(((await res.json()) as { chatResult: number }).chatResult).toBe(0)
		expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(2)
	})

	it('pushes ChatMessageReceived to every member', async () => {
		const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
			getByName(name: string): {
				takeSent(): Promise<Array<{ playerId: number; notificationType: NotificationType }>>
			}
		}
		const caller = 889005
		const chatThreadId = await createThread(env.DB, [caller, 889006], null, caller)
		await hub.getByName('global').takeSent()

		await send(caller, `/thread/${chatThreadId}`)
		const sent = await hub.getByName('global').takeSent()
		expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006])
		expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
			true
		)
	})

	it('reports invalid arguments for blank contents without storing anything', async () => {
		const caller = 889007
		const chatThreadId = await createThread(env.DB, [caller, 889008], null, caller)

		const res = await send(caller, `/thread/${chatThreadId}`, '   ')
		expect(res.status).toBe(200)

		const body = (await res.json()) as {
			ChatMessage: unknown
			ChatResult: number
			chatResult: number
			chatThread: { messages: unknown[] }
		}
		expect(body.chatResult).toBe(1)
		expect(body.ChatResult).toBe(1)
		// Nothing was posted, so there is no message to carry — and a null is safe here
		// precisely because the client only dereferences `ChatMessage` on result 0.
		expect(body.ChatMessage).toBeNull()
		// The thread still comes back — only the opening notice is in it.
		expect(body.chatThread.messages).toHaveLength(1)
		expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(1)
	})

	// The same filter api's POST /api/sanitize/v1 runs — the client isn't obliged to have
	// called it, so a message posted straight here must not reach the thread unfiltered.
	it('masks profanity in the envelope’s Data and leaves the rest of it alone', async () => {
		const caller = 889012
		const chatThreadId = await createThread(env.DB, [caller, 889013], null, caller)

		const res = await send(
			caller,
			`/thread/${chatThreadId}`,
			'{"Type":0,"Version":2,"Data":"<=>what the fuck man","Blocks":[]}'
		)
		const body = (await res.json()) as {
			chatResult: number
			chatThread: { messages: ChatMessage[] }
		}
		expect(body.chatResult).toBe(0)

		// One `*` per character, so the word keeps its length; Type/Version/Blocks and the
		// Version 2 `<=>` marker come through untouched.
		expect(body.chatThread.messages[0]!.contents).toBe(
			'{"Type":0,"Version":2,"Data":"<=>what the **** man","Blocks":[]}'
		)
		// Masked in the row too, not just in the response.
		expect((await getThreadMessages(env.DB, chatThreadId))[0]!.contents).toBe(
			body.chatThread.messages[0]!.contents
		)
	})

	// Nothing to object to must come back as the very bytes that were sent — the envelope
	// is only rebuilt when something was actually masked.
	it('stores a clean envelope byte-for-byte', async () => {
		const caller = 889014
		const chatThreadId = await createThread(env.DB, [caller, 889015], null, caller)

		const contents = '{"Type":0,"Version":2,"Data":"Grape Escape","Blocks":[{"Id":"x"}]}'
		await send(caller, `/thread/${chatThreadId}`, contents)
		expect((await getThreadMessages(env.DB, chatThreadId))[0]!.contents).toBe(contents)
	})

	// Contents that aren't an envelope are plain text with nothing in them to preserve.
	it('censors contents that aren’t a JSON envelope whole', async () => {
		const caller = 889016
		const chatThreadId = await createThread(env.DB, [caller, 889017], null, caller)

		await send(caller, `/thread/${chatThreadId}`, 'fuck off')
		expect((await getThreadMessages(env.DB, chatThreadId))[0]!.contents).toBe('**** off')
	})

	it('is gated on membership and auth', async () => {
		const chatThreadId = await createThread(env.DB, [889009, 889010], null, 889009)
		expect((await send(889011, `/thread/${chatThreadId}`)).status).toBe(404)

		const anon = await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}`, {
			method: 'POST',
			headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
			body: 'messageContents=hi',
		})
		expect(anon.status).toBe(401)
	})
})

describe('POST /thread/:id/member/:playerId', () => {
	async function addMember(caller: number, chatThreadId: number, playerId: number) {
		return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/member/${playerId}`, {
			method: 'POST',
			headers: await bearer(caller),
		})
	}

	it('adds a player to a thread the caller is in', async () => {
		const caller = 890001
		const chatThreadId = await createThread(env.DB, [caller, 890002], null, caller)

		const res = await addMember(caller, chatThreadId, 890003)
		expect(res.status).toBe(200)
		expect(await res.json()).toBe(0)

		expect(await isThreadMember(env.DB, chatThreadId, 890003)).toBe(true)
		// The new member sees the thread, its history included.
		const thread = await getThreadForPlayer(env.DB, chatThreadId, 890003)
		expect(thread?.playerIds).toEqual([caller, 890002, 890003])
	})

	it('reports the player is already on the thread', async () => {
		const caller = 890004
		const chatThreadId = await createThread(env.DB, [caller, 890005], null, caller)
		expect(await (await addMember(caller, chatThreadId, 890005)).json()).toBe(4)
	})

	// A non-member gets the same answer as for a thread that doesn't exist, so the
	// endpoint can't be used to probe for threads.
	it('refuses a caller who is not on the thread', async () => {
		const chatThreadId = await createThread(env.DB, [890006, 890007], null, 890006)
		expect(await (await addMember(890008, chatThreadId, 890009)).json()).toBe(3)
		expect(await isThreadMember(env.DB, chatThreadId, 890009)).toBe(false)

		expect(await (await addMember(890008, 999999, 890009)).json()).toBe(3)
	})

	it('401s without a token', async () => {
		const res = await SELF.fetch(`${ORIGIN}/thread/1/member/2`, { method: 'POST' })
		expect(res.status).toBe(401)
	})
})

describe('renaming and leaving a thread', () => {
	async function post(caller: number, path: string, body?: string, method = 'POST') {
		return SELF.fetch(`${ORIGIN}${path}`, {
			method,
			headers: {
				...(await bearer(caller)),
				...(body === undefined ? {} : { 'Content-Type': 'application/x-www-form-urlencoded' }),
			},
			body,
		})
	}

	it('renames a thread for everyone on it', async () => {
		const caller = 891001
		const chatThreadId = await createThread(env.DB, [caller, 891002], null, caller)

		const res = await post(caller, `/thread/${chatThreadId}/rename`, 'name=my%20chat')
		expect(res.status).toBe(200)
		expect(await res.json()).toBe(0)

		// Both members see the new name.
		expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toBe('my chat')
		expect((await getThreadForPlayer(env.DB, chatThreadId, 891002))?.chatThreadName).toBe('my chat')
	})

	it('clears the name back to unnamed, never null', async () => {
		const caller = 891003
		const chatThreadId = await createThread(env.DB, [caller, 891004], 'Group Chat =]', caller)

		await post(caller, `/thread/${chatThreadId}/rename`, 'name=')
		expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toBe('')
	})

	it('truncates an overlong name rather than rejecting it', async () => {
		const caller = 891005
		const chatThreadId = await createThread(env.DB, [caller, 891006], null, caller)

		await post(caller, `/thread/${chatThreadId}/rename`, `name=${'x'.repeat(200)}`)
		expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toHaveLength(
			128
		)
	})

	it('refuses to rename a thread the caller is not on', async () => {
		const chatThreadId = await createThread(env.DB, [891007, 891008], null, 891007)
		expect(await (await post(891009, `/thread/${chatThreadId}/rename`, 'name=nope')).json()).toBe(3)
		expect((await getThreadForPlayer(env.DB, chatThreadId, 891007))?.chatThreadName).toBe('')
	})

	it('leaves a thread, posting the notice and keeping the history', async () => {
		const caller = 891010
		const stayer = 891011
		const chatThreadId = await createThread(env.DB, [caller, stayer], null, caller)
		await postMessage(env.DB, {
			chatThreadId,
			senderPlayerId: caller,
			contents: '{"Type":0,"Version":1,"Data":"bye"}',
		})

		const res = await post(caller, `/thread/${chatThreadId}/leave`)
		expect(res.status).toBe(200)
		expect(await res.json()).toBe(0)

		// Gone for the leaver, intact for everyone else.
		expect(await isThreadMember(env.DB, chatThreadId, caller)).toBe(false)
		expect(await getThreadForPlayer(env.DB, chatThreadId, caller)).toBeNull()

		const remaining = await getThreadForPlayer(env.DB, chatThreadId, stayer)
		expect(remaining?.playerIds).toEqual([stayer])
		expect(remaining?.latestMessage).toMatchObject({
			senderPlayerId: SYSTEM_SENDER_ID,
			contents: leftChatContents(caller),
		})
		// Opening notice, the message, and the leave notice.
		expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(3)
	})

	it('accepts DELETE for leave as well as POST', async () => {
		const caller = 891012
		const chatThreadId = await createThread(env.DB, [caller, 891013], null, caller)

		expect(
			await (await post(caller, `/thread/${chatThreadId}/leave`, undefined, 'DELETE')).json()
		).toBe(0)
		expect(await isThreadMember(env.DB, chatThreadId, caller)).toBe(false)
	})

	it('reports membership-not-found when leaving a thread you are not on', async () => {
		const chatThreadId = await createThread(env.DB, [891014, 891015], null, 891014)
		expect(await (await post(891016, `/thread/${chatThreadId}/leave`)).json()).toBe(3)
		// Nothing was posted to a thread the caller has no business touching.
		expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(1)
	})

	it('401s without a token', async () => {
		const rename = await SELF.fetch(`${ORIGIN}/thread/1/rename`, { method: 'POST' })
		expect(rename.status).toBe(401)
		const leave = await SELF.fetch(`${ORIGIN}/thread/1/leave`, { method: 'POST' })
		expect(leave.status).toBe(401)
	})
})

describe('POST /thread/:id/snooze', () => {
	async function snooze(caller: number, chatThreadId: number, body: string) {
		return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/snooze`, {
			method: 'POST',
			headers: {
				...(await bearer(caller)),
				'Content-Type': 'application/x-www-form-urlencoded',
			},
			body,
		})
	}

	it('snoozes and unsnoozes for the caller alone', async () => {
		const caller = 892001
		const other = 892002
		const chatThreadId = await createThread(env.DB, [caller, other], null, caller)

		const res = await snooze(caller, chatThreadId, 'snooze=True')
		expect(res.status).toBe(200)
		expect(await res.json()).toBe(0)

		expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.snoozedUntil).toBe(
			'9999-12-31T23:59:59Z'
		)
		// Snoozing is per-member: the other player is untouched.
		expect((await getThreadForPlayer(env.DB, chatThreadId, other))?.snoozedUntil).toBeNull()

		await snooze(caller, chatThreadId, 'snooze=False')
		expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.snoozedUntil).toBeNull()
	})

	it('refuses a thread the caller is not on', async () => {
		const chatThreadId = await createThread(env.DB, [892003, 892004], null, 892003)
		expect(await (await snooze(892005, chatThreadId, 'snooze=True')).json()).toBe(3)
	})

	it('401s without a token', async () => {
		const res = await SELF.fetch(`${ORIGIN}/thread/1/snooze`, {
			method: 'POST',
			headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
			body: 'snooze=True',
		})
		expect(res.status).toBe(401)
	})
})

describe('PUT /thread/:id/favorite', () => {
	async function favorite(caller: number, chatThreadId: number, body: string, method = 'PUT') {
		return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/favorite`, {
			method,
			headers: {
				...(await bearer(caller)),
				'Content-Type': 'application/x-www-form-urlencoded',
			},
			body,
		})
	}

	it('favorites and unfavorites for the caller alone', async () => {
		const caller = 893001
		const other = 893002
		const chatThreadId = await createThread(env.DB, [caller, other], null, caller)

		const res = await favorite(caller, chatThreadId, 'favorite=True')
		expect(res.status).toBe(200)
		expect(await res.json()).toBe(0)

		expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
		// Per-member, like snoozing: the other player's inbox is untouched.
		expect((await getThreadForPlayer(env.DB, chatThreadId, other))?.isFavorited).toBe(false)

		await favorite(caller, chatThreadId, 'favorite=False')
		expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(false)
	})

	it('accepts POST as well as PUT', async () => {
		const caller = 893003
		const chatThreadId = await createThread(env.DB, [caller, 893004], null, caller)

		expect(await (await favorite(caller, chatThreadId, 'favorite=True', 'POST')).json()).toBe(0)
		expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
	})

	it('refuses a thread the caller is not on', async () => {
		const chatThreadId = await createThread(env.DB, [893005, 893006], null, 893005)
		expect(await (await favorite(893007, chatThreadId, 'favorite=True')).json()).toBe(3)
	})

	it('401s without a token', async () => {
		const res = await SELF.fetch(`${ORIGIN}/thread/1/favorite`, {
			method: 'PUT',
			headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
			body: 'favorite=True',
		})
		expect(res.status).toBe(401)
	})
})

describe('openapi', () => {
	it('GET /openapi.json documents every route', async () => {
		const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
		expect(res.status).toBe(200)
		const spec = (await res.json()) as {
			openapi: string
			paths: Record<string, Record<string, { summary?: string }>>
		}
		expect(spec.openapi).toMatch(/^3\.1/)

		// The spec route hides itself.
		expect(spec.paths['/openapi.json']).toBeUndefined()

		// Every schema inlines — a `$ref` here means a schema picked up a `.meta({ id })`
		// and emitted a reference the framework didn't hoist into components.schemas.
		expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)

		// Every route the worker serves is described. This is the drift guard: adding a
		// route without a describeRoute() block fails here rather than silently shipping
		// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
		// `.on([...], …)` routes contribute every method they were registered for.
		const documented = new Set(
			Object.entries(spec.paths).flatMap(([path, ops]) =>
				Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
			)
		)
		expect([...documented].sort()).toEqual([
			'DELETE /thread/{id}/leave',
			'GET /',
			'GET /settings/partyinvite',
			'GET /thread',
			'GET /thread/chatPrivacySetting',
			'GET /thread/checkCanSendDirectMessageWithPrivacySetting',
			'GET /thread/party',
			'GET /thread/{id}',
			'GET /thread/{id}/message',
			'POST /thread',
			'POST /thread/withmembers',
			'POST /thread/{id}',
			'POST /thread/{id}/favorite',
			'POST /thread/{id}/leave',
			'POST /thread/{id}/member/{playerId}',
			'POST /thread/{id}/message',
			'POST /thread/{id}/message/{messageId}/read',
			'POST /thread/{id}/read',
			'POST /thread/{id}/rename',
			'POST /thread/{id}/snooze',
			'PUT /thread/chatPrivacySetting',
			'PUT /thread/{id}/favorite',
			'PUT /thread/{id}/message/{messageId}/read',
			'PUT /thread/{id}/read',
			'PUT /thread/{id}/rename',
			'PUT /thread/{id}/snooze',
		])

		// Every operation carries a summary — a path present but undescribed is not
		// documentation.
		for (const ops of Object.values(spec.paths)) {
			for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
		}
	})
})
