import { resolver } from 'hono-openapi'
import { z } from 'zod'

import type { OpenAPIV3_1 } from 'openapi-types'

/**
 * OpenAPI schemas for the api worker.
 *
 * IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
 * generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
 * rationale as the auth/accounts/match/econ workers: a reverse-engineered protocol,
 * lenient handlers, no runtime validation.
 *
 * Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
 * meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
 * into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
 * schema inline, which renders correctly in any tool.
 */

/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
	return { description, content: { 'application/json': { schema: resolver(schema) } } }
}

function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
	const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
	return jsonSchema as OpenAPIV3_1.SchemaObject
}

/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
	const s = toOpenApiSchema(schema)
	return {
		description,
		content: {
			'application/x-www-form-urlencoded': { schema: s },
			'multipart/form-data': { schema: s },
		},
	}
}

/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
	return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}

/** The empty-body 401 the auth-gated routes return. */
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }

/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]

/**
 * A bearer token is honoured but not required: anonymous is a valid alternative. For routes
 * that serve public data but show more to a known caller (a creator's own unpublished
 * custom avatar items) instead of 401ing.
 */
export const OPTIONAL_AUTHED: OpenAPIV3_1.SecurityRequirementObject[] = [{}, { bearerAuth: [] }]

/** An integer path parameter (ids are constrained to `[0-9]+` by the route pattern). */
export function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
	return { name, in: 'path', required: true, description, schema: { type: 'integer' } }
}

/** A string path parameter. */
export function stringParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
	return { name, in: 'path', required: true, description, schema: { type: 'string' } }
}

/** An optional string query parameter. */
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
	return { name, in: 'query', required: false, description, schema: { type: 'string' } }
}

/** An optional integer query parameter (`skip` / `take` / `sort` / `filter`). */
export function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
	return { name, in: 'query', required: false, description, schema: { type: 'integer' } }
}

/** The `skip`/`take` pair every paginated feed accepts. */
export function pageParams(defaultTake: number): OpenAPIV3_1.ParameterObject[] {
	return [
		intQuery('skip', 'How many entries to skip (default 0)'),
		intQuery('take', `How many entries to return (default ${defaultTake})`),
	]
}

// ---- Loose shapes ----------------------------------------------------------
// Several routes serve opaque static config blobs (the game configs, the charades word
// list) or empty-list stubs. Modelling every field adds noise without value, so these
// use deliberately loose schemas.

/** An opaque JSON object (a static config blob, a stub, …). */
export const JsonObject = z.record(z.string(), z.unknown())
/** An opaque JSON array (a static list served verbatim, or an empty-list stub). */
export const JsonArray = z.array(z.unknown())

/** A bare JSON boolean — several routes answer `true`/`false` with no envelope. */
export const BareBoolean = z.boolean()

/** A bare JSON integer (e.g. `/api/customAvatarItems/v1/minPriceForPublicItem`). */
export const BareInteger = z.number().int()

/** A bare JSON string (`POST /api/sanitize/v1` echoes one back). */
export const BareString = z.string()

/** The `{ error }` body the 400 / 403 branches return. */
export const ErrorResponse = z.object({ error: z.string() })

/**
 * The `{ success, error }` envelope the report / warning writes and the message send
 * answer with — `error` is an empty string on success, never null, and the rejected
 * branches use the same shape so there is only one thing to parse.
 */
export const SuccessErrorEnvelope = z.object({
	success: z.boolean(),
	error: z.string().describe('Empty string when the call succeeded'),
})

// ---- Config ----------------------------------------------------------------

/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
export const AmplitudeConfig = z.object({
	AmplitudeKey: z.string(),
	StatSigKey: z.string(),
	RudderStackKey: z.string(),
	UseRudderStack: z.boolean(),
})

/** `GET /api/config/v1/azurespeech` — speech-to-text config; `Enabled` is false here. */
export const AzureSpeechConfig = z.object({
	Key: z.string(),
	Region: z.string(),
	Enabled: z.boolean(),
})

/** `GET /api/config/v1/backtrace` — the client's crash-reporter budget and filters. */
export const BacktraceConfig = z.object({
	ReportBudget: z.int(),
	FilterType: z.int(),
	SampleRate: z.int(),
	LogLineCount: z.int(),
	CaptureNativeCrashes: z.int(),
	AMRThresholdMS: z.int(),
	MessageCount: z.int(),
	MessageRegex: z.string(),
	VersionRegex: z.string(),
})

/**
 * `GET /statsigUserProperties` — the reference server answers this with a single
 * `success` carrying its `StatsigEnabled` config value, a BOOL rather than an int.
 */
export const StatsigUserProperties = z.object({
	success: z.boolean().describe('The Statsig-enabled flag (true)'),
})

/**
 * `GET /api/config/v2` — the big client config blob (a static asset), with
 * `ShareBaseUrl` derived from the deploy-time base domain.
 */
export const ApiConfigV2 = JsonObject.describe(
	'The static client config, plus a ShareBaseUrl templated from the deploy domain'
)

/**
 * `GET /api/versioncheck/islandedversions` — builds islanded onto their own matchmaking
 * pool. Always empty here.
 */
export const IslandedVersions = z.array(z.string())

/** `GET /api/versioncheck/v4` — whether the client's `?v=` build is one we serve. */
export const VersionCheck = z.object({
	VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
	UpdateNotificationStage: z.int(),
	IsVersionIslanded: z.boolean(),
	IsCrossPlayDisabled: z.boolean(),
})

// ---- Social ----------------------------------------------------------------

/**
 * The per-player relationship projection (`RelationshipResponse`). `PlayerID` is the
 * OTHER player; the type and flags are taken from the caller's own side of the row, so
 * the two players in a pair see different projections of it.
 */
export const RelationshipDto = z.object({
	PlayerID: z.int().describe('The other player in the pair'),
	RelationshipType: z
		.int()
		.describe('0 = none, 1 = friend request sent, 2 = friend request received, 3 = friend'),
	Favorited: z.int().describe('0/1 — the caller‘s own flag'),
	Ignored: z.int().describe('0/1 — the caller‘s own flag'),
	Muted: z.int().describe('0/1 — the caller‘s own flag'),
})

/**
 * `POST /api/messages/v2/send` form body — a message sent to another player. Everything
 * is a string on the wire (it's form-encoded). The sender is NOT in the body — it's
 * taken from the bearer token.
 */
export const SendMessageRequest = z.object({
	ToPlayerId: z.string().describe('Account id of the recipient'),
	Type: z
		.string()
		.optional()
		.describe('The Message-model type, e.g. `10`. Passed through unmapped; defaults to 0'),
	Data: z.string().optional().describe('The message payload; often empty'),
})

/**
 * `POST /api/messages/v1/sendMultiple` JSON body — the same message fanned out to
 * several recipients. Unlike the form-encoded single send, this one is real JSON, so
 * `Type` arrives as a number and `ToPlayerIds` as an array of numbers. The sender is
 * still taken from the bearer token, not the body.
 */
export const SendMultipleMessagesRequest = z.object({
	ToPlayerIds: z.array(z.int()).describe('Account ids of the recipients'),
	Type: z
		.int()
		.optional()
		.describe('The Message-model type, e.g. `20`. Passed through unmapped; defaults to 0'),
	Data: z.string().optional().describe('The message payload; often empty'),
})

/**
 * `POST /api/messages/v3/delete` JSON body — the messages the client is dropping from
 * its inbox. Ids are the `Id` of a stored message, which this server has never issued:
 * with no message store the list is only ever echoed back as accepted.
 */
export const DeleteMessagesRequest = z.object({
	MessageIds: z.array(z.int()).describe('Ids of the messages to delete'),
})

/**
 * `POST /api/messages/v1/friendOnlineStatus` — how many of the caller's friends are
 * online, wrapped in the client's `{ success, value }` envelope.
 */
export const FriendOnlineCountResponse = z.object({
	success: z.boolean(),
	value: z.object({
		FriendsOnlineCount: z.int().describe('Friends with live presence right now'),
	}),
})

/** The `{ Success, Message }` ack the flag toggles answer with. */
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })

/**
 * One entry of `GET /api/relationships/mutualfriends` — a friend both players share.
 * A trimmed account card, not a relationship: no relationship type or flags.
 */
export const MutualFriendDto = z.object({
	AccountId: z.int(),
	Username: z.string(),
	DisplayName: z.string(),
	ProfileImage: z.string().describe('The image name; an empty string when the account has none'),
})

// ---- Progression -----------------------------------------------------------

/**
 * A player's reputation (cheer counters), read from the `reputation` table. A player
 * nobody has cheered yet has no row and reads back all-zero with full cheer credit.
 * `SelectedCheer` is an int (0 = none), not null, and `IsCheerful` is a bool the client
 * reads to decide whether the player may hand out cheers at all.
 *
 * `CheerCredit` is the odd one out: it is what the player has left to GIVE (out of 20 per
 * day), not something they have received, and it comes from `player_cheer` rather than
 * from the reputation row.
 */
export const ReputationDto = z.object({
	AccountId: z.int(),
	IsCheerful: z.boolean(),
	Noteriety: z.int(),
	SelectedCheer: z.int().describe('The cheer pinned to the profile; 0 = none selected'),
	CheerCredit: z.int(),
	CheerGeneral: z.int(),
	CheerHelpful: z.int(),
	CheerCreative: z.int(),
	CheerGreatHost: z.int(),
	CheerSportsman: z.int(),
	SubscriberCount: z.int(),
	SubscribedCount: z.int(),
})

/** A player's level/XP (`/api/players/v1/progression/:id`). */
export const ProgressionDto = z.object({
	PlayerId: z.int(),
	Level: z.int(),
	XP: z.int(),
})

/**
 * The form body of `POST /api/PlayerCheer/v1/create`. Nothing here is stored beyond the
 * counter the cheer increments: `Anonymous` is spent on the notification it triggers and
 * `RoomId` is dropped outright — see the route.
 */
export const CheerPlayerRequest = z.object({
	PlayerIdTo: z.string().describe('The account being cheered'),
	CheerCategory: z
		.string()
		.describe('0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative'),
	RoomId: z
		.string()
		.optional()
		.describe(
			'The room it happened in. Accepted but NOT used — the audience for the cheer’s ' +
				'effect comes from the caller’s live presence, so a client cannot aim it at a room ' +
				'it is not in'
		),
	Anonymous: z
		.string()
		.optional()
		.describe(
			'`True`/`False` (default `False`). Not stored — it picks the `PlayerCheerAnonymous` ' +
				'message type (sender 0) over `PlayerCheer` for the frame that plays the cheer'
		),
})

/** The form body of `POST /api/PlayerCheer/v1/SetSelectedCheer`. */
export const SetSelectedCheerRequest = z.object({
	CheerCategory: z
		.string()
		.describe(
			'The category to pin: 0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative; -1 unpins'
		),
})

/**
 * What a cheer answers — the reference's PascalCase `{ Success, Message }`, NOT the
 * lowercase `{ success, error }` envelope the reports use, and `Message` is NULL on success
 * where that one sends an empty string. On a refusal it names the reason (out of credit,
 * bad category, cheering yourself), which the client shows the player.
 */
export const CheerPlayerResponse = z.object({
	Success: z.boolean(),
	Message: z.string().nullable().describe('Null when the cheer landed'),
})

/** The `metadata` JSON field of a custom-avatar-item creation. */
export const CreateCustomAvatarItemMetadata = z.object({
	Name: z.string(),
	Description: z.string().optional(),
	Price: z.number().int().optional(),
	BaseAvatarItemId: z.number().int(),
	BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'),
	Accessibility: z.number().int().optional(),
})

/** The multipart body `POST /api/customAvatarItems/v1` takes. */
export const CreateCustomAvatarItemRequest = z.object({
	metadata: z.string().describe('JSON `CreateCustomAvatarItemMetadata`, posted as a text field'),
	thumbnailImage: z.string().describe('The thumbnail PNG (binary file part)'),
	design: z.string().describe('The design blob (binary file part)'),
})

/** The client's `CustomAvatarItem` record. */
export const CustomAvatarItemDto = z.object({
	CustomAvatarItemId: z.string(),
	CreatorAccountId: z.number().int(),
	Name: z.string(),
	Description: z.string(),
	Price: z.number().int(),
	Accessibility: z.number().int(),
	ForceCannotPublish: z.boolean(),
	IsFeatured: z.boolean(),
	IsRecRoomApproved: z.boolean(),
	BaseAvatarItemId: z.number().int(),
	BaseAvatarItemColor: z.string(),
	DesignFilename: z.string(),
	ThumbnailImageFilename: z.string(),
	CreatedAt: z.string(),
	ModifiedAt: z.string(),
	PreviewOrientation: z.number().int(),
	RankingContext: z.null(),
	OutfitType: z.number().int(),
	CurrentSaves: z.array(z.unknown()),
	PurchaseInfo: z.null(),
})

/** The JSON body `PUT /api/customAvatarItems/v1/:id` takes; null leaves a field unchanged. */
export const UpdateCustomAvatarItemRequest = z.object({
	Name: z.string().nullable().optional(),
	Description: z.string().nullable().optional(),
	Price: z.number().int().nullable().optional(),
	Accessibility: z.number().int().nullable().optional(),
})

/** A bare list of custom avatar items (the featured feed). */
export const CustomAvatarItemList = z.array(CustomAvatarItemDto)

/** The PascalCase `{ Value, Success, Error, error_id }` envelope custom-avatar-item routes answer with. */
export const CustomAvatarItemResponse = z.object({
	Value: CustomAvatarItemDto.nullable(),
	Success: z.boolean(),
	Error: z.string().nullable(),
	error_id: z.string().nullable(),
})

/** The `Ids` form body the bulk POST endpoints take. */
export const BulkIdsRequest = z.object({
	Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
})

// ---- Inventions ------------------------------------------------------------

/** One version of an invention — carries the blob name the client downloads. */
export const InventionVersionDto = z.object({
	InventionId: z.int(),
	ReplicationId: z.string(),
	VersionNumber: z.int(),
	BlobName: z.string().describe('The `.inv` key in the storage worker‘s bucket'),
	BlobHash: z
		.string()
		.nullable()
		.describe('Base64 SHA-256 of the blob; null when it was never uploaded'),
	InstantiationCost: z.int(),
	LightsCost: z.int(),
	ChipsCost: z.int(),
	CloudVariablesCost: z.int(),
	AICost: z.int(),
})

/** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */
export const InventionTagDto = z.object({
	Tag: z.string(),
	Type: z.int().describe('0 = custom, 2 = auto'),
})

/** A stored invention record (the reference's `RRInvention`). */
export const InventionDto = z.object({
	InventionId: z.int(),
	ReplicationId: z.string(),
	CreatorPlayerId: z.int(),
	Name: z.string(),
	Description: z.string(),
	ImageName: z.string(),
	CurrentVersionNumber: z.int(),
	CurrentVersion: InventionVersionDto,
	Accessibility: z.int(),
	IsPublished: z.boolean().describe('Unpublished inventions are visible only to their creator'),
	IsFeatured: z.boolean(),
	ModifiedAt: z.string(),
	CreatedAt: z.string(),
	FirstPublishedAt: z.string().nullable(),
	CreationRoomId: z.int(),
	NumPlayersHaveUsedInRoom: z.int(),
	NumDownloads: z.int(),
	CheerCount: z.int(),
	CreatorPermission: z.int(),
	GeneralPermission: z.int().describe('What other players may do with it once published'),
	IsAGInvention: z.boolean(),
	IsCertifiedInvention: z.boolean(),
	Price: z.int(),
	AllowTrial: z.boolean(),
	HideFromPlayer: z.boolean(),
	ReferencedInventions: z.array(z.int()),
	Tags: z
		.array(InventionTagDto)
		.optional()
		.describe('Unset on save — the real RRInvention carries no Tags field'),
})

/** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */
export const InventionSaveResult = z.object({
	Status: z.int().describe('0 = success'),
	Invention: InventionDto,
	InventionVersion: InventionVersionDto,
})

/** The tag filter chips on a browse screen, derived from the tags actually in use. */
export const TagFilters = z.object({
	PinnedFilters: z.array(z.string()),
	PopularFilters: z.array(z.string()),
	TrendingFilters: z
		.array(z.string())
		.nullable()
		.describe('Null — needs recent-activity data we don‘t keep'),
})

/** `GET /api/inventions/v1/details` — an invention's detail card is just its tags. */
export const InventionDetails = z.object({ Tags: z.array(InventionTagDto) })

/** `GET /api/inventions/v1/personaldetails/:id` — the caller's own relation to it. */
export const InventionPersonalDetails = z.object({
	IsCheering: z.boolean().describe('Always false — nothing can cheer an invention yet'),
})

/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
export const SetTagsRequest = z.object({
	InventionId: z.int(),
	AutoTags: z
		.array(z.string())
		.optional()
		.describe('Client-derived tags (Type 2); each at most 15 letters once lowercased'),
	CustomTags: z
		.array(z.string())
		.optional()
		.describe('Creator-submitted tags (Type 0); each at most 15 letters once lowercased'),
})

/** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */
export const SetTagsResponse = z.object({
	Result: z.int().describe('0 = success'),
	Tags: z.array(z.string()).describe('Auto tags first, then custom'),
})

/** `POST /api/inventions/v1/updateprice` JSON body. */
export const UpdatePriceRequest = z.object({
	InventionId: z.int(),
	Price: z.int().describe('Must be >= 0'),
})

/** `POST /api/inventions/v6/save` JSON body — camelCase, unlike the read shapes. */
export const SaveInventionRequest = z.object({
	inventionDataFilename: z
		.string()
		.describe('The blob uploaded through the storage worker; the one required field'),
	name: z
		.string()
		.optional()
		.describe('3–24 chars: letters, digits, spaces, dashes, colons. Omitted/blank ⇒ “Untitled”'),
	description: z
		.string()
		.optional()
		.describe('At most 512 chars. Omitted/blank ⇒ “No description yet”'),
	imageName: z.string().optional(),
	instantiationCost: z.int().optional(),
	lightsCost: z.int().optional(),
	chipsCost: z.int().optional(),
	cloudVariablesCost: z.int().optional(),
	aiCost: z.int().optional(),
	creationRoomId: z.int().optional(),
	referencedInventions: z.array(z.int()).optional(),
})

// ---- Avatar / custom avatar items ------------------------------------------

/** `POST /api/avatar/v2/gifts/generate` — a generated gift box (always a token gift). */
export const GeneratedGift = z.object({
	Id: z.int().describe('Always 0 — gifts generated here are not persisted'),
	FromPlayerId: z.int(),
	ConsumableItemDesc: z.string(),
	AvatarItemDesc: z.string(),
	FriendlyName: z.string(),
	AvatarItemType: z.int(),
	EquipmentPrefabName: z.string(),
	EquipmentModificationGuid: z.string(),
	CurrencyType: z.int(),
	Currency: z.int().describe('A random token amount'),
	Xp: z.int(),
	Level: z.int(),
	Platform: z.int(),
	PlatformsToSpawnOn: z.int(),
	BalanceType: z.int(),
	GiftContext: z.int(),
	GiftRarity: z.int(),
	Message: z.string(),
})

/** `POST /api/avatar/v2/gifts/generate` form body. */
export const GenerateGiftRequest = z.object({
	GiftContext: z.string().optional().describe('Where the gift was earned'),
	Message: z.string().optional(),
	Xp: z.string().optional(),
})

/**
 * `POST /api/customAvatarItems/v1/bulk` form body. A repeated form field, not a JSON
 * array: the reference binds `[FromForm] List<string>`, so the client posts
 * `customAvatarItemIds=a&customAvatarItemIds=b`.
 */
export const BulkCustomAvatarItemsRequest = z.object({
	customAvatarItemIds: z
		.array(z.string())
		.describe('The ids to resolve; repeat the field once per id'),
})

/** A paginated custom-avatar-item page (no storage yet, so always empty). */
export const CustomAvatarItemsPage = z.object({
	Results: CustomAvatarItemList,
	TotalResults: z.int(),
})

/**
 * One custom-item save — the rebuilt version of a legacy avatar item. This is the
 * official shape, recorded for documentation: nothing stores custom items yet, so we
 * never actually emit one of these.
 */
export const CustomAvatarItemSave = z.object({
	customAvatarItemSaveId: z.int().describe('The save’s id'),
	customAvatarItemId: z.string().describe('Guid of the custom item this save belongs to'),
	unityAssetId: z.string().describe('Guid of the built Unity asset'),
	createdAt: z.string().describe('ISO 8601 timestamp'),
	thumbnailFileName: z.string(),
	additionalConfiguration: z.string(),
	unityAsset: z.string(),
	unityAssetHash: z.string(),
})

/**
 * The custom-item saves that replace a set of legacy avatar items, keyed by the legacy
 * item's `AvatarItemDesc`. Nothing stores custom items yet, so the map is always empty —
 * the value shape is documented rather than served.
 */
export const LegacyAvatarItemSaves = z.object({
	customAvatarItemSavesByAvatarItemDesc: z.record(z.string(), CustomAvatarItemSave),
})

/** `GET /outfits/me` — the outfit stored in slot 0, served back exactly as it was saved. */
export const StoredOutfit = z.object({
	LegacyData: z.object({
		SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
		SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
		FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
		SkinColor: z.string().nullable(),
		HairColor: z.string().nullable(),
	}),
	Selections: JsonArray,
	DataVersion: z.int().describe('The client’s outfit format version, as saved'),
	CustomizationSettings: z
		.string()
		.nullable()
		.describe('JSON-in-a-string: the same outfit in the newer structured form'),
	ThumbnailFileName: z.string().nullable(),
	Name: z.string().nullable(),
	Accessibility: z.int(),
	Slot: z.int().describe('0 — the outfit being worn'),
})

/**
 * `GET /outfits/me` for a player who has never saved — the brand-new-account envelope.
 * Flatter than a stored outfit rather than a nulled-out copy of it: four empty strings and
 * nothing else, no `LegacyData`, no `Selections`, no `DataVersion`. `OutfitSelections` is
 * the flat field name here, not `SelectionsV1`/`SelectionsV2`.
 */
export const EmptyOutfit = z.object({
	FaceFeatures: z.string(),
	HairColor: z.string(),
	OutfitSelections: z.string(),
	SkinColor: z.string(),
})

/** `GET /outfits/me` — the stored outfit, or the empty envelope for a new player. */
export const OutfitsMeResponse = z.union([StoredOutfit, EmptyOutfit])

/**
 * `PUT /outfits/me` JSON body — the outfit the client is saving, in the newer envelope.
 * The heavy fields are JSON-in-a-string, exactly as the client serialises them:
 * `SelectionsV2` and `CustomizationSettings` are whole documents encoded as strings, and
 * `FaceFeatures` likewise. Note the two formats overlap: `LegacyData` carries the old
 * flat descriptors while `CustomizationSettings` carries the same outfit in the new
 * structured form, and the client sends both. `Selections` arrives empty — the actual
 * selections are inside those strings.
 */
export const OutfitsMeRequest = z.object({
	DataVersion: z.int().describe('The client’s outfit format version (2 in observed saves)'),
	LegacyData: z.object({
		SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
		SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
		FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
		SkinColor: z.string().nullable(),
		HairColor: z.string().nullable(),
	}),
	CustomizationSettings: z
		.string()
		.nullable()
		.describe('JSON-in-a-string: the same outfit in the newer structured form'),
	Selections: JsonArray.describe('Empty in observed saves'),
	Slot: z.int(),
	Name: z.string().nullable(),
	Accessibility: z.int(),
	ThumbnailFileName: z.string().nullable(),
})

/**
 * `POST /outfits/bulk` JSON body — whose outfits to fetch. The client sends the accounts it
 * needs to dress (a room's roster, typically), and the two `UnityAsset*` fields name the
 * baked-asset build it would like them for.
 */
export const OutfitsBulkRequest = z.object({
	AccountIds: z.array(z.int()).describe('The accounts whose worn outfit is wanted'),
	UnityAssetTarget: z
		.string()
		.nullable()
		.describe('Baked-asset platform. Accepted and ignored — nothing bakes assets here'),
	UnityAssetVersion: z
		.string()
		.nullable()
		.describe('Baked-asset version. Accepted and ignored, like its sibling'),
})

/**
 * `POST /outfits/bulk` — the worn outfit of each account asked for, keyed by account id.
 *
 * The key is the id as a STRING (a JSON object key always is) and the value is the same
 * stored outfit `GET /outfits/me` serves. An account with nothing saved is ABSENT from the
 * map rather than present with a null — a map expresses "no outfit" by not carrying the key.
 */
export const OutfitsBulkResponse = z.object({
	OutfitsByAccountId: z
		.record(z.string(), StoredOutfit)
		.describe('Keyed by account id as a string. Accounts with no saved outfit are omitted'),
})

/**
 * `PUT /outfits/me` — the base envelope, with NO `Value` key: three keys and that is the
 * whole body. The save answers only whether it worked; the client keeps the outfit it just
 * sent rather than re-rendering from a response, so nothing here echoes the outfit back.
 *
 * Note the mixed casing — `Success` and `Error` are PascalCase, `error_id` is snake_case.
 * That is what the reference sends, and the client's decoder matches on the exact names, so
 * do not "tidy" it into one convention.
 */
export const OutfitSaveResponse = z.object({
	Success: z.boolean(),
	Error: z.string().nullable().describe('Null on success'),
	error_id: z.string().nullable().describe('Null on success. snake_case, unlike its siblings'),
})

/** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */
export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() })

// ---- Gameplay --------------------------------------------------------------

/**
 * `POST /api/sanitize/v1` (and `/isPure`) JSON body. Only `Value` is acted on, plus
 * `ReplacementChar` and `PreRemoveBlockedCharacters` on the sanitize route; the rest are
 * what the client sends, kept here so the spec shows a real request.
 */
export const SanitizeRequest = z.object({
	Value: z.string().describe('The text to clean or check'),
	ReplacementChar: z
		.string()
		.optional()
		.describe('The mask a swear’s characters are replaced with. Defaults to `*`'),
	PreRemoveBlockedCharacters: z
		.boolean()
		.optional()
		.describe('Strip control and zero-width characters before filtering'),
	Context: z.string().optional().describe('The surface being checked, e.g. `RoomChat`. Ignored'),
	Intent: z.int().optional().describe('Reference filtering intent. Ignored'),
	ruleset: z
		.int()
		.optional()
		.describe('Reference ruleset — lowercase, as the client sends it. Ignored'),
})

/** `POST /api/sanitize/v1/isPure` — whether the text is free of profanity. */
export const IsPureResponse = z.object({ IsPure: z.boolean() })

/** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */
export const KeepsakeConfig = z.object({
	KeepsakeFeatureEnabled: z.boolean(),
	KeepsakeRoomLimit: z.int(),
	SocialXpBoostEnabled: z.boolean(),
})

/**
 * `GET /api/keepsakes/categories` — the keepsake catalog, as a counted result set
 * rather than the bare list the stubs around it serve. Empty until a catalog exists.
 */
export const KeepsakeCategories = z.object({
	Results: JsonArray.describe('The categories — empty, as no keepsake catalog is stored'),
	TotalResults: z.int().describe('How many results `Results` carries'),
})

/**
 * A scheduled player event (Rec Room's `PlayerEvent`) — the record every read endpoint
 * serves verbatim. The `State` / `Accessibility` / `*Permissions` ints are stored and
 * echoed as the client sends them; their enums aren't reversed yet.
 */
export const PlayerEventDto = z.object({
	PlayerEventId: z.int(),
	CreatorPlayerId: z.int(),
	ImageName: z.string().nullable().describe('Banner image; null until one is uploaded'),
	RoomId: z.int(),
	SubRoomId: z.int().nullable().describe('Null when the event doesn’t pin a subroom'),
	ClubId: z.int().nullable().describe('Null when the event isn’t a club’s'),
	Name: z.string(),
	Description: z.string(),
	StartTime: z.string().describe('ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`)'),
	EndTime: z
		.string()
		.describe('ISO 8601 UTC, seconds precision; at most 24 hours after `StartTime`'),
	AttendeeCount: z.int().describe('Starts at 1 — the creator attends their own event'),
	State: z.int().describe('0 = scheduled'),
	Accessibility: z.int(),
	IsMultiInstance: z.boolean(),
	SupportMultiInstanceRoomChat: z.boolean(),
	DefaultBroadcastPermissions: z.int(),
	CanRequestBroadcastPermissions: z.int(),
})

/**
 * `GET /api/playerevents/v1/:eventId?includeDetails=True` — the record plus the one
 * field the flag adds: the LOWERCASE `tags`, in an otherwise PascalCase record. Always
 * empty, since no event tags are stored; the key is absent altogether when the flag
 * isn't passed. The entry shape is the one the notification projection declares.
 */
export const PlayerEventDetailsDto = PlayerEventDto.extend({
	tags: z
		.array(z.object({ tag: z.string(), type: z.int() }))
		.optional()
		.describe('Present only with `includeDetails=True`; the stored `{ tag, type }` pairs'),
})

/**
 * The client's BASE event, 17 keys — what `GET /api/playerevents/v1` serves, and what the
 * v2 envelope carries once `Tags` is added. The stored record minus `State`, with
 * `ImageName` as a string (`""`, not null) and a `BroadcastingRoomInstanceId` (always null —
 * nothing broadcasts an event yet).
 *
 * The by-id, bulk and search reads serve the stored RECORD verbatim instead, so don't unify
 * the two.
 */
export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName: true }).extend({
	ImageName: z.string().describe('Empty string when the event has no image, never null'),
	BroadcastingRoomInstanceId: z
		.int()
		.nullable()
		.describe('Always null — no event broadcasts to a room instance yet'),
})

/**
 * The event as the v2 envelope carries it: the stored record MINUS `State`, PLUS `Tags`
 * and `BroadcastingRoomInstanceId`. `ImageName` is `""` rather than null when there is no
 * image.
 *
 * `Tags` has two shapes, picked from the caller's build: Rec Room reshaped it without
 * minting a new path, so a build newer than `20230414` gets the tag NAMES and every older
 * one (and any caller whose token names no build) gets the `{ Tag, Type }` pairs. Neither
 * is the lowercase `{ tag, type }` the v1 read serves.
 */
export const PlayerEventEnvelopeDto = PlayerEventBaseDto.extend({
	Tags: z
		.union([z.array(z.string()), z.array(z.object({ Tag: z.string(), Type: z.int() }))])
		.describe(
			'The event’s tags: names for a build newer than 20230414, `{ Tag, Type }` pairs for ' +
				'that build and older'
		),
})

/**
 * The `{ PlayerEvent, Result, TagModifyResult }` envelope the v2 routes answer with — the
 * writes and `GET /api/playerevents/v2/{eventId}`.
 *
 * `TagModifyResult` reports the tag edit that rides along with a write: `Result` 0 and the
 * tags the event now carries. It is an object — it was served as null back when no event
 * tags were stored.
 */
export const PlayerEventResultDto = z.object({
	PlayerEvent: PlayerEventEnvelopeDto,
	Result: z.int().describe('0 = success'),
	TagModifyResult: z.object({
		Result: z.int().describe('0 = success'),
		Tags: z.array(z.string()).describe('The tags the event now carries'),
	}),
})

/**
 * The envelope a delete answers with. Same three keys as {@link PlayerEventResultDto},
 * but both payload fields are null — the event is gone, so there is nothing to redraw
 * and the client reads only `Result`.
 */
export const PlayerEventDeletedDto = z.object({
	PlayerEvent: z.null(),
	Result: z.int().describe('0 = success'),
	TagModifyResult: z.null(),
})

/**
 * The JSON body of an event create / update. Every field is optional: create defaults
 * what's missing, update leaves anything absent at its stored value. The fields may be
 * posted at the top level or nested under `PlayerEvent` — the client posts back the
 * same envelope it read — and both forms are accepted. `PlayerEventId`,
 * `CreatorPlayerId` and `AttendeeCount` are ignored if present: the id is assigned
 * here, the creator comes from the bearer token, and RSVPs aren't set by hand.
 */
export const PlayerEventRequest = PlayerEventDto.partial().extend({
	PlayerEvent: z
		.unknown()
		.optional()
		.describe('The event’s fields, if nested rather than posted at the top level'),
})

/**
 * `PUT /api/playerevents/v2/{eventId}/time` form body — the event's window, moved. Both
 * bounds are optional; an absent one keeps its stored value, so the start can be nudged
 * without restating the end. The RESOLVED window must end after it starts and run no
 * longer than 24 hours.
 */
export const PlayerEventTimeRequest = z.object({
	startTime: z
		.string()
		.optional()
		.describe('New start, any parseable ISO 8601 — the client sends .NET tick precision'),
	endTime: z.string().optional().describe('New end, same form'),
})

/**
 * `PUT /api/playerevents/v2/{eventId}/accessibility` form body. The client sends the
 * `RoomAccessibility` NAME, as it does on the subroom route in `rooms`; the ordinal is
 * accepted too.
 */
export const PlayerEventAccessibilityRequest = z.object({
	accessibility: z
		.string()
		.describe(
			'`Private`, `Public`, `Unlisted`, `Dev_only` or `Dev_Unlisted` (case-insensitive) — ' +
				'or its ordinal 0–4'
		),
})

/** `PUT /api/playerevents/v2/{eventId}/name` form body. */
export const PlayerEventNameRequest = z.object({
	name: z.string().describe('The new title; blank is refused — an event always has a name'),
})

/** `PUT /api/playerevents/v2/{eventId}/description` form body. */
export const PlayerEventDescriptionRequest = z.object({
	description: z.string().optional().describe('The new blurb; absent clears it'),
})

/**
 * `PUT /api/playerevents/v2/{eventId}/tags` body — a BARE JSON ARRAY of tag names
 * (`["tag1","class"]`), not an object. The whole set the event should carry.
 */
export const PlayerEventTagsRequest = z.array(z.string()).describe('The event’s whole tag set')

/**
 * `GET /api/playerevents/v1/:eventId/responses` — one player's RSVP to one event, as
 * the guest list serves it.
 */
export const PlayerEventResponseDto = z.object({
	PlayerEventResponseId: z.int().describe('Stable id of the RSVP row'),
	PlayerEventId: z.int(),
	PlayerId: z.int(),
	CreatedAt: z
		.string()
		.describe(
			'When the answer that stands was given — a changed answer updates the row, so this ' +
				'moves with it rather than recording the player’s first response'
		),
	Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'),
})

/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
export const PlayerEventRespondRequest = z.object({
	PlayerEventId: z.int(),
	Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'),
})

/**
 * `POST /api/playerevents/v1/report` JSON body — a report against an event. JSON, note,
 * where the player report next to it is form-encoded. The reporter is NOT in the body:
 * it's the bearer token's player.
 */
export const PlayerEventReportRequest = z.object({
	PlayerEventId: z.int().describe('The event being reported'),
	ReportCategory: z
		.int()
		.optional()
		.describe('The reason picked in the report UI, e.g. `101`. Stored verbatim; unmapped'),
	Details: z.string().optional().describe('The free-text description the reporter typed'),
})

/** `POST /api/playerevents/v1/bulkInvite` JSON body — who to invite to which event. */
export const PlayerEventBulkInviteRequest = z.object({
	PlayerEventId: z.int(),
	InvitedPlayerIds: z
		.array(z.int())
		.describe('Ids to invite; duplicates and the caller are ignored'),
})

/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */
export const PlayerEventsAll = z.object({
	Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'),
	Responses: JsonArray.describe(
		'Events the caller RSVP’d to — always empty; RSVPs are stored, but this field’s ' +
			'entry shape has not been observed yet'
	),
})

/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
export const PlayerEventsPage = z.object({
	ContinuationToken: z.string().describe('Empty = no next page'),
	Events: JsonArray,
})

// ---- Moderation ------------------------------------------------------------

/**
 * One row of `GET /api/PlayerReporting/v1/voteToKickReasons` — the label the client puts
 * on a vote-to-kick button, and the report category the kick is filed under if it passes.
 */
export const VoteToKickReason = z.object({
	Reason: z.string().describe('The label shown on the button'),
	ReportCategory: z
		.int()
		.describe('The category the resulting report is filed under: 101, 102, 103 or 6'),
})

/**
 * `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
 * answer (no ban storage yet), mirroring the reference server's stub
 * `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1) rather than 0,
 * which is a real category, and `Message` is null — the client distinguishes "no
 * message" from a blank one, so we send null where the reference sends an empty string.
 * `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so
 * they carry their C# defaults (false / null).
 */
export const ModerationBlockDetails = z.object({
	ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
	Duration: z.int(),
	GameSessionId: z.int(),
	IsBan: z.boolean(),
	IsHostKick: z.boolean(),
	IsVoiceModAutoban: z.boolean(),
	Message: z.string().nullable(),
	PlayerIdReporter: z.int().nullable(),
	TimeoutStartedAt: z.string().nullable(),
})

/**
 * `POST /api/PlayerReporting/v3/create` form body — a player report. Everything is a
 * string on the wire (it's form-encoded); only `PlayerIdReported` is required. The
 * reporter is NOT in the body — it's taken from the bearer token.
 */
export const CreateReportRequest = z.object({
	PlayerIdReported: z.string().describe('Account id of the player being reported'),
	ReportCategory: z
		.string()
		.optional()
		.describe('The reason picked in the report UI, e.g. `100`. Stored verbatim; unmapped'),
	Details: z.string().optional().describe('The free-text description the reporter typed'),
	HeightReporter: z
		.string()
		.optional()
		.describe('Reporter’s player height in metres at report time, e.g. `1.64`'),
	HeightReported: z.string().optional().describe('Reported player’s height in metres'),
	RoomId: z.string().optional().describe('Room the report was raised in, if any'),
	RoomInstanceType: z
		.string()
		.optional()
		.describe('Instance type name, e.g. `Public`. Stored verbatim'),
})

/**
 * `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
 * is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
 * moderator is NOT in the body — it's taken from the bearer token.
 */
export const CreateWarningRequest = z.object({
	WarnedPlayerId: z.string().describe('Account id of the player being warned'),
	ReportCategory: z
		.string()
		.optional()
		.describe('The reason category, e.g. `101`. Stored verbatim; unmapped'),
	DisplayReason: z
		.string()
		.optional()
		.describe('What the warned player is shown, e.g. `Sexual gestures`'),
	ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
})

/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
export const DeviceIdRequest = z.object({
	oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
	newDeviceId: z.string().optional(),
	platform: z.string().optional(),
})

// ---- Rooms -----------------------------------------------------------------

/** `GET /api/quickPlay/v1/getandclear` — a pending quick-play action; all null = none. */
export const QuickPlayResponse = z.object({
	RoomName: z.string().nullable(),
	ActionCode: z.string().nullable(),
	TargetPlayerId: z.int().nullable(),
})

/** `POST /api/rooms/v1/verifyRole` form body. */
export const VerifyRoleRequest = z.object({
	roomId: z.string(),
	role: z.string().describe('The minimum role level required'),
	context: z.string().optional().describe('e.g. MakerPen — accepted and ignored'),
})

// ---- Images ----------------------------------------------------------------

/**
 * A stored image record. Note the room photo feed (`/api/images/v4/room/:roomId`)
 * serves this shape raw, while the player lists serve the `ImagesPlayer` projection
 * below — deliberately different, see the client-contract notes in CLAUDE.md.
 */
export const SavedImageDto = z.object({
	Id: z.int(),
	Type: z.int().describe('SavedImageType: 1 = share camera, 3 = room, 4 = profile, …'),
	Accessibility: z.int(),
	AccessibilityLocked: z.boolean(),
	ImageName: z.string().describe('The bucket key the img worker serves it back by'),
	Description: z.string().nullable(),
	PlayerId: z.int(),
	TaggedPlayerIds: z.array(z.int()),
	RoomId: z.int().nullable(),
	PlayerEventId: z.int().nullable(),
	CreatedAt: z.string(),
	CheerCount: z.int(),
	CommentCount: z.int(),
})

/**
 * `GET /api/images/v6` — an image's metadata by bucket key. A third projection of the same
 * row: renamed like `ImagesPlayer` (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`)
 * but carrying `ClubId`, and with no nullable fields — `RoomId`, `PlayerEventId` and
 * `ClubId` are 0 where the row holds null, `Description` is `""`. Don't unify it with the
 * other two.
 */
export const ImageMetadataDto = z.object({
	SavedImageId: z.int(),
	ImageName: z.string().describe('The bucket key the img worker serves it back by'),
	PlayerId: z.int(),
	RoomId: z.int().describe('0 when the photo was not taken in a room'),
	PlayerEventId: z.int().describe('0 when it belongs to no event'),
	ClubId: z.int().describe('Always 0 — nothing here associates an image with a club'),
	Description: z.string().describe('Empty string, never null'),
	Accessibility: z.int(),
	AccessibilityLocked: z.boolean(),
	SavedImageType: z.int().describe('1 = share camera, 3 = room, 4 = profile, …'),
	CreatedAt: z.string(),
	CheerCount: z.int(),
	CommentCount: z.int(),
})

/**
 * The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`,
 * `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed
 * MUST serve this: the raw SavedImage renders blank thumbnails.
 */
export const ImagesPlayerDto = z.object({
	SavedImageId: z.int(),
	SavedImageType: z.int(),
	Accessibility: z.int(),
	AccessibilityLocked: z.boolean(),
	CheerCount: z.int(),
	CommentCount: z.int(),
	CreatedAt: z.string(),
	Description: z.string().nullable(),
	ImageName: z.string(),
	PlayerEventId: z.int().nullable(),
	PlayerId: z.int(),
	RoomId: z.int().nullable(),
})

/** One entry in the anonymous slideshow feed, joined to its creator and room. */
export const SlideshowImageDto = z.object({
	SavedImageId: z.int(),
	ImageName: z.string(),
	Username: z.string(),
	RoomName: z.string().nullable(),
	RoomId: z.int().nullable(),
	SavedImageType: z.int(),
	PlayerEventId: z.int().nullable(),
	Accessibility: z.int(),
	PlayerIds: z.array(z.int()),
})

/** `GET /api/images/v1/slideshow` — the feed plus a short cache hint. */
export const SlideshowResponse = z.object({
	Images: z.array(SlideshowImageDto),
	ValidTill: z.string().describe('ISO timestamp ~2 minutes out; the client refreshes against it'),
})

/** `POST /api/images/v4/uploadsaved` multipart body. */
export const UploadImageRequest = z.object({
	image: z.string().describe('The image file (`file` is accepted too)'),
	imgMeta: z
		.string()
		.optional()
		.describe(
			'A JSON `SavedImageMetaDTO`: { playerIds, savedImageType, roomId, playerEventId, accessibility, description }'
		),
})

/** `POST /api/images/v4/uploadsaved` — the stored bucket key. */
export const UploadImageResponse = z.object({
	ImageName: z.string().describe('The bucket key; the img worker serves the object by it'),
})

/** `DELETE /api/images/v1/deletesaved` JSON body. */
export const DeleteImageRequest = z.object({ ImageName: z.string() })

/**
 * `POST /api/images/v5/cheered/bulk` form body — the saved-image ids to report cheer state
 * for, as a REPEATED `id` field (`id=651&id=570&…`), one value per id. The client sends a
 * whole photo-grid page this way, around a hundred ids at a time.
 */
export const CheeredBulkRequest = z.object({
	id: z.string().describe('Repeated once per image id; each value may also be comma-separated'),
})

/** `POST /api/images/v1/cheer` JSON body. */
export const CheerImageRequest = z.object({
	SavedImageId: z.int(),
	Cheer: z.boolean().describe('True to cheer, false to un-cheer'),
})

/**
 * `PUT /api/players/v1/playerPhotoTaggingSetting` JSON body — who may tag the caller in
 * photos. The value is an opaque enum ordinal: it is stored and served back untouched, so
 * whatever the client means by a given number survives a round trip without this server
 * needing to know the enum.
 */
export const PhotoTaggingSettingRequest = z.object({
	Setting: z.int().describe('The preference’s enum ordinal, stored verbatim'),
})

/**
 * `GET|PUT /api/players/v1/playerPhotoTaggingSetting` — a BARE JSON integer, not an
 * envelope and not a `{ value }` wrapper. Both routes answer the setting the player now
 * has: the reference's GET and its PUT both `Ok(...)` the stored value.
 */
export const PhotoTaggingSettingResponse = z
	.int()
	.describe('The caller’s photo-tagging preference; 0 until they set one')

/** The bare `{ success: true }` ack the image writes answer with. */
export const SuccessResponse = z.object({ success: z.boolean() })

/** One entry of `GET /api/images/v5/cheered/bulk`, one per requested id, in order. */
export const CheeredEntry = z.object({
	SavedImageId: z.int(),
	IsCheered: z.boolean(),
})
