import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
import { pool, query, queryOne } from './db.ts';
import {
  isExternalImageUrl,
  isUploadImagePath,
  linkEntityImageUpload,
  listEntityImageUploads,
  uploadImageUrl,
  uploadPublicBaseUrl
} from './uploads.ts';
import { Buffer } from 'node:buffer';
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { PoolClient, QueryResultRow } from 'pg';
import {
  requestAiModerationReview,
  type AiModerationStatus
} from './aiModeration.ts';
import { createLifePostReactionNotification, createUserFollowNotification } from './notifications.ts';
import {
  createThreadWebSocketTicket,
  publishThreadMessageCreated,
  publishThreadMessageModeration,
  publishThreadReactionUpdated,
  publishThreadReadUpdated
} from './threadsRealtime.ts';

type QueryValue = string | string[] | undefined;

type QueryParams = Record<string, QueryValue>;
type ListPage<T> = {
  items: T[];
  nextCursor: string | null;
  hasMore: boolean;
};
export type ThreadReactionType = string;
export type ThreadReactionCounts = Record<string, number>;
export type ThreadChannelTag = { id: number; name: string; sortOrder: number };
export type ThreadChannel = {
  id: number;
  name: string;
  allowUserThreads: boolean;
  sortOrder: number;
  tags: ThreadChannelTag[];
  languages: Array<{ code: string; name: string }>;
  unreadCount: number;
};
export type ThreadSummary = {
  id: number;
  channelId: number;
  title: string;
  languageCode: string;
  tags: ThreadChannelTag[];
  locked: boolean;
  messageCount: number;
  lastActiveAt: Date;
  createdAt: Date;
  author: { id: number; displayName: string } | null;
  reactionCounts: ThreadReactionCounts;
  myReactions: ThreadReactionType[];
  followed: boolean;
  unread: boolean;
};
export type ThreadMessage = {
  id: number;
  threadId: number;
  body: string;
  moderationStatus: AiModerationStatus;
  moderationLanguageCode: string | null;
  moderationReason: string | null;
  createdAt: Date;
  updatedAt: Date;
  author: { id: number; displayName: string } | null;
  reactionCounts: ThreadReactionCounts;
  myReactions: ThreadReactionType[];
};
export type ThreadMessagesPage = {
  items: ThreadMessage[];
  beforeCursor: string | null;
  hasMoreBefore: boolean;
};
export type ThreadsPage = ListPage<ThreadSummary>;

type DbClient = PoolClient;
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
type DataToolScopeSummary = {
  scope: DataToolScope;
  count: number;
};
type DataToolRows = Record<string, unknown>[];
type DataToolScopeData = Record<string, DataToolRows | undefined>;
type DataToolsBundle = {
  version: 1;
  exportedAt: string;
  scopes: DataToolScope[];
  data: Partial<Record<DataToolScope, DataToolScopeData>>;
};
type GlobalSearchGroupType =
  | 'pokemon'
  | 'habitats'
  | 'items'
  | 'ancient-artifacts'
  | 'recipes'
  | 'daily-checklist'
  | 'life'
  | 'users';
type GlobalSearchItem = {
  id: number;
  type: GlobalSearchGroupType;
  title: string;
  url: string;
  summary: string | null;
  meta: string | null;
  image: EntityImageValue | PokemonImage | null;
};
type GlobalSearchGroup = {
  type: GlobalSearchGroupType;
  items: GlobalSearchItem[];
};
type GlobalSearchResults = {
  query: string;
  groups: GlobalSearchGroup[];
};

type TranslationField = 'name' | 'title' | 'details' | 'genus' | 'effect' | 'mosslaxEffect';
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
type EntityType =
  | 'pokemon'
  | 'pokemon-types'
  | 'skills'
  | 'environments'
  | 'favorite-things'
  | 'acquisition-methods'
  | 'items'
  | 'ancient-artifacts'
  | 'maps'
  | 'habitats'
  | 'daily-checklist-items'
  | 'game-versions'
  | 'dish-categories'
  | 'dish-flavors'
  | 'dishes';

type ConfigType =
  | 'pokemon-types'
  | 'skills'
  | 'environments'
  | 'favorite-things'
  | 'acquisition-methods'
  | 'maps'
  | 'game-versions'
  | 'dish-flavors';

type ConfigDefinition = {
  table: string;
  entityType: EntityType;
  hasItemDrop?: boolean;
  hasTrading?: boolean;
  hasChangeLog?: boolean;
  hasDescription?: boolean;
  oppositeColumn?: string;
};
type SortableContentType = 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
type SortableContentDefinition = {
  table: string;
  entityType: SortableContentType;
};

type IdQuantity = {
  itemId: number;
  quantity: number;
};

type SkillItemDrop = {
  skillId: number;
  itemId: number;
};

type PokemonStats = {
  hp: number;
  attack: number;
  defense: number;
  specialAttack: number;
  specialDefense: number;
  speed: number;
};

type PokemonImage = {
  path: string;
  url: string;
  style: string;
  version: string;
  variant: string;
  description: string;
  source?: 'sprite' | 'upload' | 'external';
};

type EntityImageValue = {
  path: string;
  url: string;
};

type PokemonImageCandidate = Omit<PokemonImage, 'url'>;

type PokemonImageOptionsResult = {
  id: number;
  identifier: string;
  images: PokemonImage[];
};

type TradingPreference = 'like' | 'neutral';

type PokemonTradingItemPayload = {
  itemId: number;
  preference: TradingPreference;
};

type PokemonPayload = {
  dataId: number | null;
  dataIdentifier: string;
  displayId: number;
  isEventItem: boolean;
  name: string;
  genus: string;
  details: string;
  heightInches: number;
  weightPounds: number;
  translations: TranslationInput;
  typeIds: number[];
  stats: PokemonStats;
  environmentId: number;
  skillIds: number[];
  favoriteThingIds: number[];
  skillItemDrops: SkillItemDrop[];
  tradingItems: PokemonTradingItemPayload[];
  image: PokemonImage | null;
};

type PokemonFetchResult = {
  id: number;
  identifier: string;
  name: string;
  genus: string;
  heightInches: number;
  weightPounds: number;
  translations: TranslationInput;
  typeIds: number[];
  stats: PokemonStats;
};

type PokemonFetchOption = {
  id: number;
  identifier: string;
  name: string;
};

type CsvRow = Record<string, string>;
type PokemonCsvData = {
  pokemonRows: CsvRow[];
  pokemonByLookup: Map<string, CsvRow>;
  namesByPokemonId: Map<number, CsvRow>;
  genusByPokemonId: Map<number, CsvRow>;
  typesById: Map<number, CsvRow>;
  canonicalTypeRows: CsvRow[];
};

type ItemPayload = {
  name: string;
  details: string;
  basePrice: number | null;
  ancientArtifactCategoryId: number | null;
  ancientArtifactCategoryKey: string | null;
  translations: TranslationInput;
  categoryId: number;
  categoryKey: string;
  usageId: number | null;
  usageKey: string | null;
  dyeability: number;
  patternEditable: boolean;
  noRecipe: boolean;
  isEventItem: boolean;
  acquisitionMethodIds: number[];
  tagIds: number[];
  imagePath: string;
  insertBeforeItemId: number | null;
  insertAfterItemId: number | null;
};

type AncientArtifactPayload = {
  name: string;
  details: string;
  translations: TranslationInput;
  categoryId: number;
  categoryKey: string;
  tagIds: number[];
  imagePath: string;
};

type RecipePayload = {
  itemId: number;
  acquisitionMethodIds: number[];
  materials: IdQuantity[];
};

type DishCategoryPayload = {
  name: string;
  effect: string;
  translations: TranslationInput;
  cookwareItemId: number;
  mainMaterialItemId: number;
  totalMaterialQuantity: number;
};

type DishPayload = {
  categoryId: number;
  itemId: number;
  flavorId: number;
  secondaryMaterialItemIds: number[];
  pokemonSkillId: number | null;
  mosslaxEffect: string;
  translations: TranslationInput;
};

type DailyChecklistPayload = {
  title: string;
  translations: TranslationInput;
};

type LifePostPayload = {
  body: string;
  gameVersionId: number | null;
  languageCode: string | null;
};

type LifeCommentPayload = {
  body: string;
  languageCode: string | null;
};

type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
type DiscussionEntityDefinition = {
  table: string;
};
type EntityDiscussionCommentPayload = {
  body: string;
  languageCode: string | null;
};
type EntityDiscussionCommentRow = {
  id: number;
  entityType: DiscussionEntityType;
  entityId: number;
  parentCommentId: number | null;
  body: string;
  deleted: boolean;
  moderationStatus: AiModerationStatus;
  moderationLanguageCode: string | null;
  moderationReason: string | null;
  createdAt: Date;
  createdAtCursor?: string;
  updatedAt: Date;
  author: { id: number; displayName: string } | null;
  likeCount: number;
  replyCount: number;
  myLiked: boolean;
};
type EntityDiscussionComment = Omit<EntityDiscussionCommentRow, 'createdAtCursor'> & {
  replies: EntityDiscussionComment[];
};
type EntityDiscussionCommentsPage = {
  items: EntityDiscussionComment[];
  nextCursor: string | null;
  hasMore: boolean;
  total: number;
};

type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
type LifeReactionCounts = Record<LifeReactionType, number>;
type LifeReactionUser = {
  user: { id: number; displayName: string };
  reactionType: LifeReactionType;
  reactedAt: Date;
};
type LifeReactionUsersPage = {
  items: LifeReactionUser[];
  nextCursor: string | null;
  hasMore: boolean;
  total: number;
};
type LifeReactionUserCursor = {
  reactedAt: string;
  userId: number;
};

type LifeCommentRow = {
  id: number;
  postId: number;
  parentCommentId: number | null;
  body: string;
  deleted: boolean;
  moderationStatus: AiModerationStatus;
  moderationLanguageCode: string | null;
  moderationReason: string | null;
  createdAt: Date;
  createdAtCursor?: string;
  updatedAt: Date;
  author: { id: number; displayName: string } | null;
  likeCount: number;
  replyCount: number;
  myLiked: boolean;
};

type LifeComment = Omit<LifeCommentRow, 'createdAtCursor'> & {
  replies: LifeComment[];
};

type LifePostRow = {
  id: number;
  body: string;
  moderationStatus: AiModerationStatus;
  moderationLanguageCode: string | null;
  moderationReason: string | null;
  createdAt: Date;
  createdAtCursor: string;
  updatedAt: Date;
  author: { id: number; displayName: string } | null;
  updatedBy: { id: number; displayName: string } | null;
  gameVersion: { id: number; name: string; changeLog: string } | null;
};

type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
  commentPreview: LifeComment[];
  commentCount: number;
  reactionCounts: LifeReactionCounts;
  myReaction: LifeReactionType | null;
};

type LifePostCursor = {
  createdAt: string;
  id: number;
};

type LifePostSort = 'latest' | 'oldest';
type CommentSort = 'oldest' | 'latest' | 'most-liked' | 'most-replied';
type CommentCursor = {
  createdAt: string;
  id: number;
  count?: number;
};

type LifePostFilters = {
  authorId?: number;
  followedByUserId?: number;
};

type LifePostsPage = {
  items: LifePost[];
  nextCursor: string | null;
  hasMore: boolean;
};

type LifeCommentsPage = {
  items: LifeComment[];
  nextCursor: string | null;
  hasMore: boolean;
  total: number;
};

type PublicProfileUser = {
  id: number;
  displayName: string;
  joinedAt: Date;
};

type PublicProfileStats = {
  wikiEdits: number;
  wikiCreates: number;
  wikiUpdates: number;
  wikiDeletes: number;
  imageUploads: number;
  lifePosts: number;
  lifeComments: number;
  lifeReactions: number;
  discussionComments: number;
};

type PublicProfileContribution = {
  contentType: string;
  total: number;
  creates: number;
  updates: number;
  deletes: number;
  lastContributedAt: Date | null;
};

type PublicProfileViewerRelation = 'none' | 'following' | 'followed-by' | 'friends';

type PublicProfileSocial = {
  followerCount: number;
  followingCount: number;
  friendCount: number;
  viewerRelation: PublicProfileViewerRelation;
};

type PublicUserProfile = {
  user: PublicProfileUser;
  stats: PublicProfileStats;
  social: PublicProfileSocial;
  contributions: PublicProfileContribution[];
};

type UserReactionActivity = {
  postId: number;
  reactionType: LifeReactionType;
  reactedAt: Date;
  post: LifePost;
};

type UserReactionActivityPage = {
  items: UserReactionActivity[];
  nextCursor: string | null;
  hasMore: boolean;
};

type UserCommentActivitySource = 'life' | 'discussion';

type UserCommentActivity = {
  id: number;
  source: UserCommentActivitySource;
  body: string;
  createdAt: Date;
  target: {
    type: 'life-post' | DiscussionEntityType;
    id: number;
    title: string;
    excerpt: string;
  };
};

type UserCommentActivityPage = {
  items: UserCommentActivity[];
  nextCursor: string | null;
  hasMore: boolean;
};

type UserCommentActivityCursor = LifePostCursor & {
  source: UserCommentActivitySource;
};

type HabitatPayload = {
  name: string;
  translations: TranslationInput;
  isEventItem: boolean;
  imagePath: string;
  recipeItems: IdQuantity[];
  pokemonAppearances: Array<{
    pokemonId: number;
    mapId: number;
    timeOfDay: string;
    weather: string;
    rarity: number;
  }>;
};

type LanguagePayload = {
  code: string;
  name: string;
  enabled: boolean;
  isDefault: boolean;
  sortOrder: number;
};

export type ModuleSettings = {
  tradingEnabled: boolean;
};

type ValidationError = Error & { statusCode: number };
type EditAction = 'create' | 'update' | 'delete';
type EditChange = {
  label: string;
  before: string;
  after: string;
};
type EditHistoryEntry = {
  action: EditAction;
  changes: EditChange[];
  createdAt: Date;
  user: { id: number; displayName: string } | null;
};
type TranslationChangeSource = {
  translations?: TranslationInput | null;
};
type PokemonChangeSource = {
  displayId: number;
  isEventItem: boolean;
  name: string;
  genus: string;
  details: string;
  heightInches: number;
  weightPounds: number;
  image: PokemonImage | null;
  types: Array<{ name: string }>;
  stats: PokemonStats;
  environment: { name: string };
  skills: Array<{ name: string; itemDrop?: { name: string } | null }>;
  favorite_things: Array<{ name: string }>;
  tradingItems: Array<{ name: string; preference: TradingPreference }>;
} & TranslationChangeSource;
type ItemChangeSource = {
  name: string;
  details: string;
  basePrice: number | null;
  ancientArtifactCategory: { name: string } | null;
  isEventItem: boolean;
  image: EntityImageValue | null;
  category: { name: string };
  usage: { name: string } | null;
  customization: { dyeability: number; patternEditable: boolean };
  noRecipe: boolean;
  acquisitionMethods: Array<{ name: string }>;
  tags: Array<{ name: string }>;
} & TranslationChangeSource;
type AncientArtifactChangeSource = {
  name: string;
  details: string;
  image: EntityImageValue | null;
  category: { name: string };
  tags: Array<{ name: string }>;
} & TranslationChangeSource;
type HabitatChangeSource = {
  name: string;
  isEventItem: boolean;
  image: EntityImageValue | null;
  recipe: Array<{ name: string; quantity: number }>;
  pokemon: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }>;
} & TranslationChangeSource;
type RecipeChangeSource = {
  item: { name: string };
  acquisition_methods: Array<{ name: string }>;
  materials: Array<{ name: string; quantity: number }>;
};

type DishCategoryChangeSource = {
  name: string;
  effect: string;
  translations?: TranslationInput;
  cookware: { name: string };
  mainMaterial: { name: string };
  totalMaterialQuantity: number;
};

type DishChangeSource = {
  category: { name: string };
  item: { name: string };
  flavor: { name: string };
  secondaryMaterials: Array<{ name: string }>;
  pokemonSkill: { name: string } | null;
  mosslaxEffect: string;
  translations?: TranslationInput;
};
type DailyChecklistChangeSource = {
  title: string;
} & TranslationChangeSource;
type ConfigChangeSource = {
  name: string;
  description?: string;
  opposite?: { name: string } | null;
  hasItemDrop?: boolean;
  hasTrading?: boolean;
  changeLog?: string;
} & TranslationChangeSource;

const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const defaultLocale = 'en';
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
const defaultLifePostLimit = 20;
const maxLifePostLimit = 50;
const defaultCommentLimit = 20;
const maxCommentLimit = 50;
const lifeCommentPreviewLimit = 2;
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
const pokemonTypeIconIds = new Set(Array.from({ length: 19 }, (_value, index) => index + 1));
const pokemonSpriteBaseUrl = 'https://pokesprite.tootaio.com';
const itemStaticImagePathPrefix = '/pokopia/items/';
const habitatStaticImagePathPrefix = '/pokopia/habitats/';
const pokemonSpriteRequestTimeoutMs = 2500;
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
  { key: 'hp', label: 'HP' },
  { key: 'attack', label: 'Attack' },
  { key: 'defense', label: 'Defense' },
  { key: 'specialAttack', label: 'Special Attack' },
  { key: 'specialDefense', label: 'Special Defense' },
  { key: 'speed', label: 'Speed' }
];

type SystemListOption = {
  id: number;
  key: string;
  labels: Record<typeof defaultLocale | 'zh-CN', string>;
};

const itemCategoryOptions = [
  { id: 1, key: 'furniture', labels: { en: 'Furniture', 'zh-CN': '家具' } },
  { id: 2, key: 'misc', labels: { en: 'Misc', 'zh-CN': '杂项' } },
  { id: 3, key: 'outdoor', labels: { en: 'Outdoor', 'zh-CN': '户外' } },
  { id: 4, key: 'utilities', labels: { en: 'Utilities', 'zh-CN': '实用工具' } },
  { id: 5, key: 'buildings', labels: { en: 'Buildings', 'zh-CN': '建筑' } },
  { id: 6, key: 'blocks', labels: { en: 'Blocks', 'zh-CN': '方块' } },
  { id: 7, key: 'kits', labels: { en: 'Kits', 'zh-CN': '套件' } },
  { id: 8, key: 'nature', labels: { en: 'Nature', 'zh-CN': '自然' } },
  { id: 9, key: 'food', labels: { en: 'Food', 'zh-CN': '食物' } },
  { id: 10, key: 'materials', labels: { en: 'Materials', 'zh-CN': '材料' } },
  { id: 11, key: 'key-items', labels: { en: 'Key Items', 'zh-CN': '关键物品' } },
  { id: 12, key: 'other', labels: { en: 'Other', 'zh-CN': '其他' } }
] as const satisfies readonly SystemListOption[];

const itemUsageOptions = [
  { id: 1, key: 'decoration', labels: { en: 'Decoration', 'zh-CN': '装饰' } },
  { id: 2, key: 'relaxation', labels: { en: 'Relaxation', 'zh-CN': '休闲' } },
  { id: 3, key: 'toy', labels: { en: 'Toy', 'zh-CN': '玩具' } },
  { id: 4, key: 'road', labels: { en: 'Road', 'zh-CN': '道路' } },
  { id: 5, key: 'food', labels: { en: 'Food', 'zh-CN': '食物' } }
] as const satisfies readonly SystemListOption[];

const ancientArtifactCategoryOptions = [
  { id: 1, key: 'lost-relics-l', labels: { en: 'Lost Relics (L)', 'zh-CN': 'Lost Relics (L)' } },
  { id: 2, key: 'lost-relics-s', labels: { en: 'Lost Relics (S)', 'zh-CN': 'Lost Relics (S)' } },
  { id: 3, key: 'fossils', labels: { en: 'Fossils', 'zh-CN': '化石' } }
] as const satisfies readonly SystemListOption[];

const configDefinitions: Record<ConfigType, ConfigDefinition> = {
  'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' },
  skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true },
  environments: { table: 'environments', entityType: 'environments', hasDescription: true, oppositeColumn: 'opposite_environment_id' },
  'favorite-things': { table: 'favorite_things', entityType: 'favorite-things', oppositeColumn: 'opposite_favorite_thing_id' },
  'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
  maps: { table: 'maps', entityType: 'maps' },
  'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true },
  'dish-flavors': { table: 'dish_flavors', entityType: 'dish-flavors' }
};

const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
  items: { table: 'items', entityType: 'items' },
  'ancient-artifacts': { table: 'items', entityType: 'ancient-artifacts' },
  recipes: { table: 'recipes', entityType: 'recipes' },
  habitats: { table: 'habitats', entityType: 'habitats' }
};

const discussionEntityDefinitions: Record<DiscussionEntityType, DiscussionEntityDefinition> = {
  pokemon: { table: 'pokemon' },
  items: { table: 'items' },
  recipes: { table: 'recipes' },
  habitats: { table: 'habitats' },
  'ancient-artifacts': { table: 'items' }
};

let pokemonCsvDataCache: Promise<PokemonCsvData> | null = null;

function asString(value: QueryValue): string | undefined {
  return Array.isArray(value) ? value[0] : value;
}

const defaultPublicListLimit = 24;
const maxPublicListLimit = 72;

function isPagedListRequest(paramsQuery: QueryParams): boolean {
  return asString(paramsQuery.limit) !== undefined || asString(paramsQuery.cursor) !== undefined;
}

function cleanPublicListLimit(value: QueryValue): number {
  const limit = Number(asString(value));
  return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxPublicListLimit) : defaultPublicListLimit;
}

function encodeOffsetCursor(offset: number): string {
  return Buffer.from(JSON.stringify({ offset }), 'utf8').toString('base64url');
}

function decodeOffsetCursor(value: QueryValue): number {
  const rawValue = asString(value);
  if (!rawValue) {
    return 0;
  }

  try {
    const payload = JSON.parse(Buffer.from(rawValue, 'base64url').toString('utf8')) as { offset?: unknown };
    const offset = Number(payload.offset);
    return Number.isInteger(offset) && offset >= 0 ? offset : 0;
  } catch {
    return 0;
  }
}

async function queryMaybePaged<T extends QueryResultRow>(
  sql: string,
  params: unknown[],
  paramsQuery: QueryParams
): Promise<T[] | ListPage<T>> {
  if (!isPagedListRequest(paramsQuery)) {
    return query<T>(sql, params);
  }

  const limit = cleanPublicListLimit(paramsQuery.limit);
  const offset = decodeOffsetCursor(paramsQuery.cursor);
  const pagedParams = [...params, limit + 1, offset];
  const rows = await query<T>(
    `
      ${sql}
      LIMIT $${pagedParams.length - 1}
      OFFSET $${pagedParams.length}
    `,
    pagedParams
  );
  const items = rows.slice(0, limit);
  const nextOffset = offset + items.length;

  return {
    items,
    nextCursor: rows.length > limit ? encodeOffsetCursor(nextOffset) : null,
    hasMore: rows.length > limit
  };
}

export function cleanLocale(value: unknown): string {
  const locale = typeof value === 'string' ? value.trim() : '';
  return localePattern.test(locale) ? locale : defaultLocale;
}

function cleanModerationLanguageCode(value: unknown): string | null {
  const languageCode = typeof value === 'string' ? value.trim() : '';
  if (!languageCode || languageCode === 'all') {
    return null;
  }

  if (!localePattern.test(languageCode)) {
    throw validationError('server.validation.invalidField');
  }

  return languageCode;
}

function sqlLiteral(value: string): string {
  return `'${value.replaceAll("'", "''")}'`;
}

function uploadedImageJson(pathExpression: string): string {
  return `
    CASE
      WHEN lower(${pathExpression}) LIKE 'https://%' THEN json_build_object(
        'path', ${pathExpression},
        'url', ${pathExpression}
      )
      WHEN ${pathExpression} LIKE ${sqlLiteral(`${itemStaticImagePathPrefix}%`)}
        OR ${pathExpression} LIKE ${sqlLiteral(`${habitatStaticImagePathPrefix}%`)} THEN json_build_object(
        'path', ${pathExpression},
        'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${pathExpression}
      )
      WHEN ${pathExpression} <> '' THEN json_build_object(
        'path', ${pathExpression},
        'url', ${sqlLiteral(uploadPublicBaseUrl)} || ${pathExpression}
      )
      ELSE NULL
    END
  `;
}

function pokemonImageJson(alias: string): string {
  return `
    CASE
      WHEN lower(${alias}.image_path) LIKE 'https://%' THEN json_build_object(
        'path', ${alias}.image_path,
        'url', ${alias}.image_path,
        'style', '',
        'version', '',
        'variant', ${alias}.name,
        'description', '',
        'source', 'external'
      )
      WHEN ${alias}.image_path LIKE '/sprites/%' THEN json_build_object(
        'path', ${alias}.image_path,
        'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${alias}.image_path,
        'style', ${alias}.image_style,
        'version', ${alias}.image_version,
        'variant', ${alias}.image_variant,
        'description', ${alias}.image_description,
        'source', 'sprite'
      )
      WHEN ${alias}.image_path <> '' THEN json_build_object(
        'path', ${alias}.image_path,
        'url', ${sqlLiteral(uploadPublicBaseUrl)} || ${alias}.image_path,
        'style', 'Upload',
        'version', 'Community upload',
        'variant', ${alias}.name,
        'description', '',
        'source', 'upload'
      )
      ELSE NULL
    END
  `;
}

function imagePathLabel(path: string | null | undefined): string {
  const cleanPath = path?.trim() ?? '';
  if (cleanPath === '') {
    return '';
  }
  if (isExternalImageUrl(cleanPath)) {
    return cleanPath;
  }

  const parts = cleanPath.split('/');
  return parts.length >= 3 ? `${parts[1]} / ${parts[2]}` : cleanPath;
}

function localizedField(
  entityType: EntityType,
  entityIdExpression: string,
  baseExpression: string,
  fieldName: TranslationField,
  locale: string
): string {
  const entity = sqlLiteral(entityType);
  const field = sqlLiteral(fieldName);
  const requestedLocale = sqlLiteral(cleanLocale(locale));
  const defaultLocaleSql = sqlLiteral(defaultLocale);

  return `
    COALESCE(
      (
        SELECT et.value
        FROM entity_translations et
        WHERE et.entity_type = ${entity}
          AND et.entity_id = ${entityIdExpression}
          AND et.locale = ${requestedLocale}
          AND et.field_name = ${field}
      ),
      (
        SELECT et.value
        FROM entity_translations et
        WHERE et.entity_type = ${entity}
          AND et.entity_id = ${entityIdExpression}
          AND et.locale = ${defaultLocaleSql}
          AND et.field_name = ${field}
      ),
      ${baseExpression}
    )
  `;
}

function localizedName(entityType: EntityType, entityAlias: string, locale: string): string {
  return localizedField(entityType, `${entityAlias}.id`, `${entityAlias}.name`, 'name', locale);
}

function translationsSelect(entityType: EntityType, entityIdExpression: string): string {
  return `
    COALESCE((
      SELECT jsonb_object_agg(locale, fields)
      FROM (
        SELECT locale, jsonb_object_agg(field_name, value) AS fields
        FROM entity_translations
        WHERE entity_type = ${sqlLiteral(entityType)}
          AND entity_id = ${entityIdExpression}
        GROUP BY locale
      ) translation_rows
    ), '{}'::jsonb)
  `;
}

function cleanTranslations(value: unknown, allowedFields: TranslationField[]): TranslationInput {
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
    return {};
  }

  const translations: TranslationInput = {};
  const allowedFieldSet = new Set(allowedFields);

  for (const [locale, fields] of Object.entries(value as Record<string, unknown>)) {
    if (!localePattern.test(locale) || locale === defaultLocale || !fields || typeof fields !== 'object' || Array.isArray(fields)) {
      continue;
    }

    const cleanFields: Partial<Record<TranslationField, string>> = {};
    for (const [fieldName, fieldValue] of Object.entries(fields as Record<string, unknown>)) {
      if (!allowedFieldSet.has(fieldName as TranslationField) || typeof fieldValue !== 'string') {
        continue;
      }

      const cleanValue = fieldValue.trim();
      if (cleanValue !== '') {
        cleanFields[fieldName as TranslationField] = cleanValue;
      }
    }

    if (Object.keys(cleanFields).length > 0) {
      translations[locale] = cleanFields;
    }
  }

  return translations;
}

async function replaceEntityTranslations(
  client: DbClient,
  entityType: EntityType,
  entityId: number,
  translations: TranslationInput,
  fields: TranslationField[]
): Promise<void> {
  await client.query(
    `
      DELETE FROM entity_translations
      WHERE entity_type = $1
        AND entity_id = $2
        AND field_name = ANY($3::text[])
    `,
    [entityType, entityId, fields]
  );

  for (const [locale, translatedFields] of Object.entries(translations)) {
    for (const fieldName of fields) {
      const value = translatedFields[fieldName];
      if (typeof value !== 'string' || value.trim() === '') {
        continue;
      }

      await client.query(
        `
          INSERT INTO entity_translations (entity_type, entity_id, locale, field_name, value)
          VALUES ($1, $2, $3, $4, $5)
        `,
        [entityType, entityId, locale, fieldName, value.trim()]
      );
    }
  }
}

async function deleteEntityTranslations(client: DbClient, entityType: EntityType, entityId: number): Promise<void> {
  await client.query('DELETE FROM entity_translations WHERE entity_type = $1 AND entity_id = $2', [entityType, entityId]);
}

function optionSelect(
  tableName: string,
  entityType: EntityType,
  locale: string
): Promise<Array<{ id: number; name: string }>> {
  const name = localizedName(entityType, 'o', locale);
  return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
}

function systemListLabel(option: SystemListOption, locale: string): string {
  const clean = cleanLocale(locale) as keyof SystemListOption['labels'];
  return option.labels[clean] ?? option.labels[defaultLocale];
}

function systemListOptions(options: readonly SystemListOption[], locale: string): Array<{ id: number; key: string; name: string }> {
  return options.map((option) => ({ id: option.id, key: option.key, name: systemListLabel(option, locale) }));
}

function systemListOptionById(
  options: readonly SystemListOption[],
  id: number,
  message: string
): SystemListOption {
  const option = options.find((item) => item.id === id);
  if (!option) {
    throw validationError(message);
  }
  return option;
}

function systemListOptionByKey(options: readonly SystemListOption[], key: string | null | undefined): SystemListOption | null {
  return options.find((item) => item.key === key) ?? null;
}

function systemListNameByKey(options: readonly SystemListOption[], key: string | null | undefined, locale = defaultLocale): string | null {
  const option = systemListOptionByKey(options, key);
  return option ? systemListLabel(option, locale) : null;
}

function systemListIdSql(expression: string, options: readonly SystemListOption[]): string {
  const cases = options.map((option) => `WHEN ${sqlLiteral(option.key)} THEN ${option.id}`).join(' ');
  return `CASE ${expression} ${cases} ELSE NULL END`;
}

function systemListNameSql(expression: string, options: readonly SystemListOption[], locale: string): string {
  const cases = options
    .map((option) => `WHEN ${sqlLiteral(option.key)} THEN ${sqlLiteral(systemListLabel(option, locale))}`)
    .join(' ');
  return `CASE ${expression} ${cases} ELSE '' END`;
}

function systemListJsonSql(expression: string, options: readonly SystemListOption[], locale: string): string {
  return `json_build_object('id', ${systemListIdSql(expression, options)}, 'key', ${expression}, 'name', ${systemListNameSql(expression, options, locale)})`;
}

function publicModuleSettings(row: { tradingEnabled: boolean } | null): ModuleSettings {
  return {
    tradingEnabled: row?.tradingEnabled ?? true
  };
}

async function moduleSettingsForClient(client: DbClient): Promise<ModuleSettings> {
  const result = await client.query<{ tradingEnabled: boolean }>(
    'SELECT trading_enabled AS "tradingEnabled" FROM module_settings WHERE id = true'
  );
  return publicModuleSettings(result.rows[0] ?? null);
}

export async function getModuleSettings(): Promise<ModuleSettings> {
  const row = await queryOne<{ tradingEnabled: boolean }>(
    'SELECT trading_enabled AS "tradingEnabled" FROM module_settings WHERE id = true'
  );
  return publicModuleSettings(row);
}

export async function updateModuleSettings(payload: Record<string, unknown>, userId: number): Promise<ModuleSettings> {
  const current = await getModuleSettings();
  const tradingEnabled = typeof payload.tradingEnabled === 'boolean' ? payload.tradingEnabled : current.tradingEnabled;
  const row = await queryOne<{ tradingEnabled: boolean }>(
    `
      INSERT INTO module_settings (id, trading_enabled, updated_by_user_id, updated_at)
      VALUES (true, $1, $2, now())
      ON CONFLICT (id) DO UPDATE SET
        trading_enabled = EXCLUDED.trading_enabled,
        updated_by_user_id = EXCLUDED.updated_by_user_id,
        updated_at = now()
      RETURNING trading_enabled AS "tradingEnabled"
    `,
    [tradingEnabled, userId]
  );
  return publicModuleSettings(row);
}

function gameVersionOptions(locale: string): Promise<Array<{ id: number; name: string; changeLog: string }>> {
  const name = localizedName('game-versions', 'gv', locale);
  return query(`SELECT gv.id, ${name} AS name, gv.change_log AS "changeLog" FROM game_versions gv ORDER BY ${orderByEntity('gv')}`);
}

function skillOptions(locale: string): Promise<Array<{ id: number; name: string; hasItemDrop: boolean; hasTrading: boolean }>> {
  const name = localizedName('skills', 's', locale);
  return query(
    `SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop", s.has_trading AS "hasTrading" FROM skills s ORDER BY ${orderByEntity('s')}`
  );
}

function auditSelect(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
  return `
    ${entityAlias}.created_at AS "createdAt",
    ${entityAlias}.updated_at AS "updatedAt",
    CASE
      WHEN ${createdAlias}.id IS NULL THEN NULL
      ELSE json_build_object('id', ${createdAlias}.id, 'displayName', ${createdAlias}.display_name)
    END AS "createdBy",
    CASE
      WHEN ${updatedAlias}.id IS NULL THEN NULL
      ELSE json_build_object('id', ${updatedAlias}.id, 'displayName', ${updatedAlias}.display_name)
    END AS "updatedBy"
  `;
}

function auditJoins(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
  return `
    LEFT JOIN users ${createdAlias} ON ${createdAlias}.id = ${entityAlias}.created_by_user_id
    LEFT JOIN users ${updatedAlias} ON ${updatedAlias}.id = ${entityAlias}.updated_by_user_id
  `;
}

function configOrder(): string {
  return orderByEntity('c');
}

function configSelect(definition: ConfigDefinition, locale: string): string {
  const name = localizedName(definition.entityType, 'c', locale);
  const oppositeName = localizedName(definition.entityType, 'opposite_config', locale);
  const translations = translationsSelect(definition.entityType, 'c.id');
  const columns = [`c.id`, `${name} AS name`, `c.name AS "baseName"`, `${translations} AS translations`];
  if (definition.hasDescription) {
    columns.push(`c.description`);
  }
  if (definition.oppositeColumn) {
    columns.push(
      `
        CASE
          WHEN opposite_config.id IS NULL THEN NULL
          ELSE json_build_object('id', opposite_config.id, 'name', ${oppositeName})
        END AS opposite
      `
    );
  }
  if (definition.hasItemDrop) {
    columns.push(`c.has_item_drop AS "hasItemDrop"`);
  }
  if (definition.hasTrading) {
    columns.push(`c.has_trading AS "hasTrading"`);
  }
  if (definition.hasChangeLog) {
    columns.push(`c.change_log AS "changeLog"`);
  }
  return columns.join(', ');
}

function configRelationJoins(definition: ConfigDefinition): string {
  if (!definition.oppositeColumn) {
    return '';
  }

  return `LEFT JOIN ${definition.table} opposite_config ON opposite_config.id = c.${definition.oppositeColumn}`;
}

function validationError(message: string): ValidationError {
  const error = new Error(message) as ValidationError;
  error.statusCode = 400;
  return error;
}

function forbiddenError(): ValidationError {
  const error = new Error('server.errors.permissionDenied') as ValidationError;
  error.statusCode = 403;
  return error;
}

function requirePositiveInteger(value: unknown, message: string): number {
  const numberValue = Number(value);
  if (!Number.isInteger(numberValue) || numberValue <= 0) {
    throw validationError(message);
  }
  return numberValue;
}

function optionalPositiveInteger(value: unknown, message: string): number | null {
  if (value === undefined || value === null || value === '') {
    return null;
  }

  return requirePositiveInteger(value, message);
}

function cleanName(value: unknown, message = 'server.validation.nameRequired'): string {
  if (typeof value !== 'string' || value.trim() === '') {
    throw validationError(message);
  }
  return value.trim();
}

function cleanOptionalText(value: unknown): string {
  return typeof value === 'string' ? value.trim() : '';
}

function isStaticImageFileName(fileName: string): boolean {
  return Boolean(fileName) && !fileName.includes('/') && !fileName.includes('\\') && !fileName.includes('..') && /^[A-Za-z0-9._()-]+$/.test(fileName);
}

function isItemStaticImagePath(value: string): boolean {
  return isStaticImageFileName(value.startsWith(itemStaticImagePathPrefix) ? value.slice(itemStaticImagePathPrefix.length) : '');
}

function isHabitatStaticImagePath(value: string): boolean {
  return isStaticImageFileName(value.startsWith(habitatStaticImagePathPrefix) ? value.slice(habitatStaticImagePathPrefix.length) : '');
}

function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' | 'ancient-artifacts'): string {
  const imagePath = cleanOptionalText(value);
  if (imagePath === '') {
    return '';
  }
  if (isExternalImageUrl(imagePath)) {
    return imagePath;
  }
  if (entityType === 'habitats' && isHabitatStaticImagePath(imagePath)) {
    return imagePath;
  }
  if (!isUploadImagePath(imagePath) || !imagePath.startsWith(`${entityType}/`)) {
    throw validationError('server.validation.imagePathInvalid');
  }
  return imagePath;
}

function cleanItemOrArtifactImagePath(value: unknown): string {
  const imagePath = cleanOptionalText(value);
  if (imagePath === '') {
    return '';
  }
  if (isExternalImageUrl(imagePath)) {
    return imagePath;
  }
  if (isItemStaticImagePath(imagePath)) {
    return imagePath;
  }
  if (!isUploadImagePath(imagePath) || (!imagePath.startsWith('items/') && !imagePath.startsWith('ancient-artifacts/'))) {
    throw validationError('server.validation.imagePathInvalid');
  }
  return imagePath;
}

function cleanIds(value: unknown): number[] {
  if (!Array.isArray(value)) {
    return [];
  }
  return [...new Set(value.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item > 0))];
}

function cleanIdValues(value: unknown): number[] {
  return cleanIds(Array.isArray(value) ? value : [value]);
}

function cleanPokemonStats(value: unknown): PokemonStats {
  const row = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};

  return pokemonStatLabels.reduce((stats, stat) => {
    const numberValue = Number(row[stat.key] ?? 0);
    if (!Number.isInteger(numberValue) || numberValue < 0) {
      throw validationError('server.validation.statNonNegative');
    }

    return { ...stats, [stat.key]: numberValue };
  }, {} as PokemonStats);
}

function cleanNonNegativeNumber(value: unknown, message: string): number {
  const numberValue = Number(value ?? 0);
  if (!Number.isFinite(numberValue) || numberValue < 0) {
    throw validationError(message);
  }
  return numberValue;
}

function cleanOptionalNonNegativeInteger(value: unknown, message: string): number | null {
  const rawValue = typeof value === 'string' ? value.trim() : value;
  if (rawValue === undefined || rawValue === null || rawValue === '') {
    return null;
  }

  const numberValue = Number(rawValue);
  if (!Number.isInteger(numberValue) || numberValue < 0) {
    throw validationError(message);
  }

  return numberValue;
}

function cleanOptionalSystemListOption(
  value: unknown,
  options: readonly SystemListOption[],
  message: string
): SystemListOption | null {
  const optionId = cleanOptionalNonNegativeInteger(value, message);
  if (optionId === null) {
    return null;
  }

  return systemListOptionById(options, optionId, message);
}

function cleanQuantities(value: unknown): IdQuantity[] {
  if (!Array.isArray(value)) {
    return [];
  }

  return value
    .map((item) => {
      const row = item as Partial<IdQuantity>;
      return {
        itemId: Number(row.itemId),
        quantity: Number(row.quantity)
      };
    })
    .filter((item) => Number.isInteger(item.itemId) && item.itemId > 0 && Number.isInteger(item.quantity) && item.quantity > 0);
}

function cleanOptions(value: unknown, allowedValues: string[]): string[] {
  const values = Array.isArray(value) ? value : [value];
  return [...new Set(values.map((item) => String(item ?? '')).filter((item) => allowedValues.includes(item)))];
}

function orderByEntity(entityAlias: string): string {
  return `${entityAlias}.sort_order, ${entityAlias}.id`;
}

async function withTransaction<T>(callback: (client: DbClient) => Promise<T>): Promise<T> {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');
    const result = await callback(client);
    await client.query('COMMIT');
    return result;
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

async function nextSortOrder(client: DbClient, tableName: string): Promise<number> {
  const result = await client.query<{ sortOrder: number }>(
    `SELECT COALESCE(MAX(sort_order), 0) + 10 AS "sortOrder" FROM ${tableName}`
  );
  return result.rows[0]?.sortOrder ?? 10;
}

async function nextPokemonInternalId(
  client: DbClient,
  dataId: number | null
): Promise<number> {
  if (dataId !== null) {
    return dataId;
  }

  const result = await client.query<{ id: number }>(
    'SELECT COALESCE(MAX(id), 999999) + 1 AS id FROM pokemon WHERE id >= 1000000'
  );
  return result.rows[0]?.id ?? 1000000;
}

async function reorderTableRows(
  client: DbClient,
  tableName: string,
  ids: number[],
  userId: number
): Promise<void> {
  const existing = await client.query<{ id: number; sortOrder: number }>(
    `SELECT id, sort_order AS "sortOrder" FROM ${tableName} WHERE id = ANY($1::integer[])`,
    [ids]
  );

  if (existing.rowCount !== ids.length) {
    throw validationError('server.validation.recordMissing');
  }

  const sortOrders = new Map(existing.rows.map((row) => [row.id, row.sortOrder]));
  for (const [index, id] of ids.entries()) {
    const nextSortOrder = (index + 1) * 10;
    const previousSortOrder = sortOrders.get(id);
    if (previousSortOrder === nextSortOrder) {
      continue;
    }

    await client.query(
      `
        UPDATE ${tableName}
        SET sort_order = $1, updated_by_user_id = $2, updated_at = now()
        WHERE id = $3
      `,
      [nextSortOrder, userId, id]
    );
  }
}

async function recordEditLog(
  client: DbClient,
  entityType: string,
  entityId: number,
  action: EditAction,
  userId: number,
  changes: EditChange[] = []
): Promise<void> {
  await client.query(
    `
      INSERT INTO wiki_edit_logs (entity_type, entity_id, action, user_id, changes)
      VALUES ($1, $2, $3, $4, $5::jsonb)
    `,
    [entityType, entityId, action, userId, JSON.stringify(changes)]
  );
}

function cleanLanguagePayload(payload: Record<string, unknown>, requireCode: boolean): LanguagePayload {
  const code = typeof payload.code === 'string' ? payload.code.trim() : '';
  if (requireCode && !localePattern.test(code)) {
    throw validationError('server.validation.languageCodeInvalid');
  }

  const sortOrder = Number(payload.sortOrder ?? 0);

  return {
    code,
    name: cleanName(payload.name, 'server.validation.languageNameRequired'),
    enabled: payload.enabled !== false,
    isDefault: Boolean(payload.isDefault),
    sortOrder: Number.isInteger(sortOrder) && sortOrder >= 0 ? sortOrder : 0
  };
}

function requireLanguageCode(value: unknown): string {
  const code = typeof value === 'string' ? value.trim() : '';
  if (!localePattern.test(code)) {
    throw validationError('server.validation.languageCodeInvalid');
  }
  return code;
}

export async function listLanguages(includeDisabled = false) {
  return query<LanguagePayload>(
    `
      SELECT code, name, enabled, is_default AS "isDefault", sort_order AS "sortOrder"
      FROM languages
      ${includeDisabled ? '' : 'WHERE enabled = true'}
      ORDER BY sort_order, name
    `
  );
}

export async function createLanguage(payload: Record<string, unknown>) {
  const cleanPayload = cleanLanguagePayload(payload, true);
  if (cleanPayload.isDefault && cleanPayload.code !== defaultLocale) {
    throw validationError('server.validation.defaultLanguageMustBeEnglish');
  }
  if (!cleanPayload.enabled && cleanPayload.isDefault) {
    throw validationError('server.validation.defaultLanguageMustBeEnabled');
  }

  await withTransaction(async (client) => {
    if (cleanPayload.isDefault) {
      await client.query('UPDATE languages SET is_default = false');
    }

    await client.query(
      `
        INSERT INTO languages (code, name, enabled, is_default, sort_order)
        VALUES ($1, $2, $3, $4, $5)
      `,
      [cleanPayload.code, cleanPayload.name, cleanPayload.enabled, cleanPayload.isDefault, cleanPayload.sortOrder]
    );
  });

  return listLanguages(true);
}

export async function updateLanguage(code: string, payload: Record<string, unknown>) {
  const locale = requireLanguageCode(code);
  const cleanPayload = cleanLanguagePayload({ ...payload, code: locale }, false);
  if (cleanPayload.isDefault && locale !== defaultLocale) {
    throw validationError('server.validation.defaultLanguageMustBeEnglish');
  }
  if (!cleanPayload.enabled && cleanPayload.isDefault) {
    throw validationError('server.validation.defaultLanguageMustBeEnabled');
  }

  await withTransaction(async (client) => {
    const current = await client.query<{ isDefault: boolean }>(
      'SELECT is_default AS "isDefault" FROM languages WHERE code = $1',
      [locale]
    );

    if (current.rowCount === 0) {
      throw validationError('server.validation.languageNotFound');
    }

    if (!cleanPayload.enabled && current.rows[0].isDefault) {
      throw validationError('server.validation.defaultLanguageMustBeEnabled');
    }

    if (current.rows[0].isDefault && !cleanPayload.isDefault) {
      throw validationError('server.validation.defaultLanguageRequired');
    }

    if (cleanPayload.isDefault) {
      await client.query('UPDATE languages SET is_default = false WHERE code <> $1', [locale]);
    }

    await client.query(
      `
        UPDATE languages
        SET name = $1,
            enabled = $2,
            is_default = $3,
            sort_order = $4
        WHERE code = $5
      `,
      [cleanPayload.name, cleanPayload.enabled, cleanPayload.isDefault, cleanPayload.sortOrder, locale]
    );
  });

  return listLanguages(true);
}

export async function deleteLanguage(code: string) {
  const locale = requireLanguageCode(code);
  if (locale === defaultLocale) {
    throw validationError('server.validation.defaultLanguageCannotBeDeleted');
  }

  return withTransaction(async (client) => {
    const result = await client.query<{ isDefault: boolean }>(
      'DELETE FROM languages WHERE code = $1 AND is_default = false RETURNING is_default AS "isDefault"',
      [locale]
    );
    return (result.rowCount ?? 0) > 0;
  });
}

export async function reorderLanguages(payload: Record<string, unknown>) {
  const codes = Array.isArray(payload.codes) ? payload.codes.map(requireLanguageCode) : [];
  if (codes.length === 0) {
    throw validationError('server.validation.selectLanguage');
  }

  await withTransaction(async (client) => {
    const existing = await client.query<{ code: string }>(
      'SELECT code FROM languages WHERE code = ANY($1::text[])',
      [codes]
    );

    if (existing.rowCount !== codes.length) {
      throw validationError('server.validation.languageDoesNotExist');
    }

    for (const [index, code] of codes.entries()) {
      await client.query(
        `
          UPDATE languages
          SET sort_order = $1
          WHERE code = $2
        `,
        [(index + 1) * 10, code]
      );
    }
  });

  return listLanguages(true);
}

function parseCsv(content: string, fileName: string): CsvRow[] {
  const rows: string[][] = [];
  let row: string[] = [];
  let cell = '';
  let inQuotes = false;

  for (let index = 0; index < content.length; index += 1) {
    const char = content[index];

    if (inQuotes) {
      if (char === '"' && content[index + 1] === '"') {
        cell += '"';
        index += 1;
      } else if (char === '"') {
        inQuotes = false;
      } else {
        cell += char;
      }
      continue;
    }

    if (char === '"') {
      inQuotes = true;
    } else if (char === ',') {
      row.push(cell);
      cell = '';
    } else if (char === '\n') {
      row.push(cell);
      if (row.some((value) => value !== '')) {
        rows.push(row);
      }
      row = [];
      cell = '';
    } else if (char !== '\r') {
      cell += char;
    }
  }

  if (cell !== '' || row.length > 0) {
    row.push(cell);
    if (row.some((value) => value !== '')) {
      rows.push(row);
    }
  }

  const headers = rows[0]?.map((header) => header.replace(/^\uFEFF/, ''));
  if (!headers?.length) {
    throw validationError('server.validation.pokemonDataFileEmpty');
  }

  return rows.slice(1).map((values) =>
    headers.reduce<CsvRow>((record, header, index) => {
      record[header] = values[index] ?? '';
      return record;
    }, {})
  );
}

async function readPokemonDataFile(fileName: string): Promise<string> {
  const sourceDir = dirname(fileURLToPath(import.meta.url));
  const directories = [
    process.env.POKOPIA_DATA_DIR ? resolve(process.env.POKOPIA_DATA_DIR) : '',
    resolve(process.cwd(), 'data'),
    resolve(process.cwd(), '..', 'data'),
    resolve(sourceDir, '..', 'data'),
    resolve(sourceDir, '..', '..', 'data')
  ].filter(Boolean);
  const uniqueDirectories = [...new Set(directories)];

  for (const directory of uniqueDirectories) {
    try {
      return await readFile(resolve(directory, fileName), 'utf8');
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
        throw error;
      }
    }
  }

  throw validationError('server.validation.pokemonDataFileUnavailable');
}

function csvInteger(row: CsvRow, fieldName: string): number {
  const value = Number(row[fieldName]);
  return Number.isInteger(value) ? value : 0;
}

function csvNumber(row: CsvRow, fieldName: string): number {
  const value = Number(row[fieldName]);
  return Number.isFinite(value) ? value : 0;
}

function csvText(row: CsvRow, fieldName: string): string {
  return row[fieldName]?.trim() ?? '';
}

function indexRowsByNumber(rows: CsvRow[], fieldName: string): Map<number, CsvRow> {
  return rows.reduce((index, row) => {
    const id = csvInteger(row, fieldName);
    if (id > 0) {
      index.set(id, row);
    }
    return index;
  }, new Map<number, CsvRow>());
}

async function loadPokemonCsvData(): Promise<PokemonCsvData> {
  if (!pokemonCsvDataCache) {
    pokemonCsvDataCache = (async () => {
      const [pokemonContent, namesContent, genusContent, typesContent] = await Promise.all([
        readPokemonDataFile('pokemon_data.csv'),
        readPokemonDataFile('localized_pokemon_name.csv'),
        readPokemonDataFile('localized_pokemon_genus.csv'),
        readPokemonDataFile('localized_type_name.csv')
      ]);
      const pokemonRows = parseCsv(pokemonContent, 'pokemon_data.csv');
      const typeRows = parseCsv(typesContent, 'localized_type_name.csv');
      const pokemonByLookup = new Map<string, CsvRow>();

      for (const row of pokemonRows) {
        const id = csvInteger(row, 'id');
        const identifier = csvText(row, 'identifier').toLowerCase();
        if (id > 0) {
          pokemonByLookup.set(String(id), row);
        }
        if (identifier) {
          pokemonByLookup.set(identifier, row);
        }
      }

      return {
        pokemonRows,
        pokemonByLookup,
        namesByPokemonId: indexRowsByNumber(parseCsv(namesContent, 'localized_pokemon_name.csv'), 'pokemon_species_id'),
        genusByPokemonId: indexRowsByNumber(parseCsv(genusContent, 'localized_pokemon_genus.csv'), 'pokemon_species_id'),
        typesById: indexRowsByNumber(typeRows, 'type_id'),
        canonicalTypeRows: typeRows.filter((row) => pokemonTypeIconIds.has(csvInteger(row, 'type_id')))
      };
    })();
  }

  return pokemonCsvDataCache;
}

function pokemonDataLookupKey(value: unknown): string {
  const rawValue = typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : '';
  if (rawValue === '') {
    throw validationError('server.validation.pokemonIdentifierRequired');
  }

  const numericValue = Number(rawValue);
  if (Number.isInteger(numericValue) && numericValue > 0) {
    return String(numericValue);
  }

  return rawValue.toLowerCase();
}

function languageCsvColumn(code: string): string | null {
  const [language, region = ''] = code.split('-');
  const languageKey = language.toLowerCase();
  const regionKey = region.toUpperCase();
  const directColumns: Record<string, string> = {
    de: 'de',
    en: 'en',
    es: 'es',
    fr: 'fr',
    it: 'it',
    ja: 'ja',
    ko: 'ko'
  };

  if (languageKey === 'zh') {
    return ['HK', 'MO', 'TW'].includes(regionKey) ? 'zh_hant' : 'zh_hans';
  }

  return directColumns[languageKey] ?? null;
}

function localizedCsvText(row: CsvRow, code: string): string {
  const column = languageCsvColumn(code);
  return column ? csvText(row, column) : '';
}

function defaultLanguage(languages: LanguagePayload[]): LanguagePayload | undefined {
  return languages.find((language) => language.isDefault) ?? languages.find((language) => language.code === defaultLocale) ?? languages[0];
}

function defaultCsvText(row: CsvRow, languages: LanguagePayload[], fallback: string): string {
  const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
  return localizedCsvText(row, defaultCode) || localizedCsvText(row, defaultLocale) || fallback;
}

function pokemonSpriteUrl(path: string): string {
  return `${pokemonSpriteBaseUrl}${path}`;
}

function pokemonImageWithUrl(candidate: PokemonImageCandidate): PokemonImage {
  return { ...candidate, url: pokemonSpriteUrl(candidate.path), source: 'sprite' };
}

function pokemonImageCandidates(id: number): PokemonImageCandidate[] {
  return [
    {
      path: `/sprites/pokemon/other/official-artwork/${id}.png`,
      style: 'Official artwork',
      version: 'Official artwork',
      variant: 'Default',
      description: 'Large official artwork'
    },
    {
      path: `/sprites/pokemon/other/official-artwork/shiny/${id}.png`,
      style: 'Official artwork',
      version: 'Official artwork',
      variant: 'Shiny',
      description: 'Large shiny official artwork'
    },
    {
      path: `/sprites/pokemon/other/home/${id}.png`,
      style: 'Pokemon HOME',
      version: 'HOME',
      variant: 'Default',
      description: 'Modern HOME render'
    },
    {
      path: `/sprites/pokemon/other/home/shiny/${id}.png`,
      style: 'Pokemon HOME',
      version: 'HOME',
      variant: 'Shiny',
      description: 'Modern shiny HOME render'
    },
    {
      path: `/sprites/pokemon/other/home/female/${id}.png`,
      style: 'Pokemon HOME',
      version: 'HOME',
      variant: 'Female',
      description: 'Modern female HOME render'
    },
    {
      path: `/sprites/pokemon/other/home/shiny/female/${id}.png`,
      style: 'Pokemon HOME',
      version: 'HOME',
      variant: 'Shiny female',
      description: 'Modern shiny female HOME render'
    },
    {
      path: `/sprites/pokemon/other/dream-world/${id}.svg`,
      style: 'Dream World',
      version: 'Dream World',
      variant: 'Default',
      description: 'Dream World SVG artwork'
    },
    {
      path: `/sprites/pokemon/other/dream-world/female/${id}.svg`,
      style: 'Dream World',
      version: 'Dream World',
      variant: 'Female',
      description: 'Dream World female SVG artwork'
    },
    {
      path: `/sprites/pokemon/other/showdown/${id}.gif`,
      style: 'Pokemon Showdown',
      version: 'Showdown',
      variant: 'Front animated',
      description: 'Animated front battle sprite'
    },
    {
      path: `/sprites/pokemon/other/showdown/shiny/${id}.gif`,
      style: 'Pokemon Showdown',
      version: 'Showdown',
      variant: 'Shiny front animated',
      description: 'Animated shiny front battle sprite'
    },
    {
      path: `/sprites/pokemon/other/showdown/female/${id}.gif`,
      style: 'Pokemon Showdown',
      version: 'Showdown',
      variant: 'Female front animated',
      description: 'Animated female front battle sprite'
    },
    {
      path: `/sprites/pokemon/other/showdown/back/${id}.gif`,
      style: 'Pokemon Showdown',
      version: 'Showdown',
      variant: 'Back animated',
      description: 'Animated back battle sprite'
    },
    {
      path: `/sprites/pokemon/${id}.png`,
      style: 'Default sprite',
      version: 'PokeAPI',
      variant: 'Front',
      description: 'Compact front sprite'
    },
    {
      path: `/sprites/pokemon/shiny/${id}.png`,
      style: 'Default sprite',
      version: 'PokeAPI',
      variant: 'Shiny front',
      description: 'Compact shiny front sprite'
    },
    {
      path: `/sprites/pokemon/female/${id}.png`,
      style: 'Default sprite',
      version: 'PokeAPI',
      variant: 'Female front',
      description: 'Compact female front sprite'
    },
    {
      path: `/sprites/pokemon/back/${id}.png`,
      style: 'Default sprite',
      version: 'PokeAPI',
      variant: 'Back',
      description: 'Compact back sprite'
    },
    {
      path: `/sprites/pokemon/back/shiny/${id}.png`,
      style: 'Default sprite',
      version: 'PokeAPI',
      variant: 'Shiny back',
      description: 'Compact shiny back sprite'
    },
    {
      path: `/sprites/pokemon/versions/generation-v/black-white/animated/${id}.gif`,
      style: 'Game version',
      version: 'Black / White',
      variant: 'Animated front',
      description: 'Generation V animated sprite'
    },
    {
      path: `/sprites/pokemon/versions/generation-v/black-white/animated/shiny/${id}.gif`,
      style: 'Game version',
      version: 'Black / White',
      variant: 'Animated shiny',
      description: 'Generation V animated shiny sprite'
    },
    {
      path: `/sprites/pokemon/versions/generation-v/black-white/${id}.png`,
      style: 'Game version',
      version: 'Black / White',
      variant: 'Front',
      description: 'Generation V front sprite'
    },
    {
      path: `/sprites/pokemon/versions/generation-vi/x-y/${id}.png`,
      style: 'Game version',
      version: 'X / Y',
      variant: 'Front',
      description: 'Generation VI front sprite'
    },
    {
      path: `/sprites/pokemon/versions/generation-vii/ultra-sun-ultra-moon/${id}.png`,
      style: 'Game version',
      version: 'Ultra Sun / Ultra Moon',
      variant: 'Front',
      description: 'Generation VII front sprite'
    },
    {
      path: `/sprites/pokemon/versions/generation-ix/scarlet-violet/${id}.png`,
      style: 'Game version',
      version: 'Scarlet / Violet',
      variant: 'Front',
      description: 'Generation IX front sprite'
    },
    {
      path: `/sprites/pokemon/versions/generation-iii/emerald/${id}.png`,
      style: 'Game version',
      version: 'Emerald',
      variant: 'Front',
      description: 'Generation III front sprite'
    },
    {
      path: `/sprites/pokemon/versions/generation-i/red-blue/${id}.png`,
      style: 'Game version',
      version: 'Red / Blue',
      variant: 'Front',
      description: 'Generation I front sprite'
    }
  ];
}

function pokemonImageLabel(image: PokemonImage | null | undefined): string {
  if (!image) {
    return '';
  }
  return image.source === 'upload'
    || image.source === 'external'
    || isUploadImagePath(image.path)
    || isExternalImageUrl(image.path)
    ? imagePathLabel(image.path)
    : `${image.style} - ${image.version} - ${image.variant}`;
}

function pokemonImageDataIdFromPath(path: string): number | null {
  const match = path.match(/^\/sprites\/pokemon\/(?:.+\/)?([1-9]\d*)\.(?:png|gif|svg)$/);
  if (!match) {
    return null;
  }

  const id = Number(match[1]);
  return Number.isSafeInteger(id) ? id : null;
}

function pokemonImageCandidateForPath(path: string): PokemonImage | null {
  const cleanPath = path.trim();
  const id = pokemonImageDataIdFromPath(cleanPath);
  if (!id) {
    return null;
  }

  const candidate = pokemonImageCandidates(id).find((item) => item.path === cleanPath);
  return candidate ? pokemonImageWithUrl(candidate) : null;
}

function cleanPokemonImage(value: unknown, displayId: number): PokemonImage | null {
  const path = typeof value === 'string' ? value.trim() : '';
  if (path === '') {
    return null;
  }

  if (isExternalImageUrl(path)) {
    return {
      path,
      url: path,
      style: '',
      version: '',
      variant: `#${displayId}`,
      description: '',
      source: 'external'
    };
  }

  if (isUploadImagePath(path)) {
    if (!path.startsWith('pokemon/')) {
      throw validationError('server.validation.imagePathInvalid');
    }
    return {
      path,
      url: uploadImageUrl(path),
      style: 'Upload',
      version: 'Community upload',
      variant: `#${displayId}`,
      description: '',
      source: 'upload'
    };
  }

  const image = pokemonImageCandidateForPath(path);
  if (!image) {
    throw validationError('server.validation.pokemonImagePathInvalid');
  }
  return image;
}

async function pokemonImageExists(candidate: PokemonImageCandidate): Promise<boolean> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), pokemonSpriteRequestTimeoutMs);

  try {
    const response = await fetch(pokemonSpriteUrl(candidate.path), { method: 'HEAD', signal: controller.signal });
    return response.ok;
  } catch {
    return false;
  } finally {
    clearTimeout(timeout);
  }
}

function assignTranslation(translations: TranslationInput, locale: string, fieldName: TranslationField, value: string): void {
  if (!value) {
    return;
  }

  translations[locale] = {
    ...(translations[locale] ?? {}),
    [fieldName]: value
  };
}

function localizedCsvTranslations(
  rows: Array<{ row: CsvRow; fieldName: TranslationField }>,
  languages: LanguagePayload[]
): TranslationInput {
  const translations: TranslationInput = {};
  const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;

  for (const language of languages) {
    if (language.code === defaultCode) {
      continue;
    }

    for (const { row, fieldName } of rows) {
      assignTranslation(translations, language.code, fieldName, localizedCsvText(row, language.code));
    }
  }

  return cleanTranslations(translations, rows.map((row) => row.fieldName));
}

function fetchedPokemonStats(row: CsvRow): PokemonStats {
  return {
    hp: csvInteger(row, 'hp'),
    attack: csvInteger(row, 'atk'),
    defense: csvInteger(row, 'def'),
    specialAttack: csvInteger(row, 'sp_atk'),
    specialDefense: csvInteger(row, 'sp_def'),
    speed: csvInteger(row, 'spd')
  };
}

function fetchedPokemonTypeIds(row: CsvRow, data: PokemonCsvData): number[] {
  const typeIds = [csvInteger(row, 'type_1_id'), csvInteger(row, 'type_2_id')].filter((typeId) => typeId > 0);

  if (typeIds.length === 0 || typeIds.some((typeId) => !data.typesById.has(typeId) || !pokemonTypeIconIds.has(typeId))) {
    throw validationError('server.validation.pokemonTypeDataUnavailable');
  }

  return typeIds;
}

async function ensurePokemonTypeCatalog(
  client: DbClient,
  data: PokemonCsvData,
  languages: LanguagePayload[],
  userId: number
): Promise<void> {
  for (const row of data.canonicalTypeRows) {
    const typeId = csvInteger(row, 'type_id');
    const name = defaultCsvText(row, languages, csvText(row, 'identifier'));
    const translations = localizedCsvTranslations([{ row, fieldName: 'name' }], languages);
    const existing = await client.query<ConfigChangeSource>(
      `
        SELECT pt.name, ${translationsSelect('pokemon-types', 'pt.id')} AS translations
        FROM pokemon_types pt
        WHERE pt.id = $1
      `,
      [typeId]
    );

    if (existing.rowCount === 0) {
      await client.query(
        `
          INSERT INTO pokemon_types (
            id,
            name,
            sort_order,
            created_by_user_id,
            updated_by_user_id
          )
          VALUES ($1, $2, $3, $4, $4)
        `,
        [typeId, name, typeId * 10, userId]
      );
      await recordEditLog(client, 'pokemon-types', typeId, 'create', userId);
    } else {
      const changes = configEditChanges(
        { table: 'pokemon_types', entityType: 'pokemon-types' },
        existing.rows[0],
        { name, description: '', translations, oppositeName: '', hasItemDrop: false, hasTrading: false, changeLog: '' }
      );
      if (changes.length) {
        await client.query(
          `
            UPDATE pokemon_types
            SET name = $1,
                updated_by_user_id = $2,
                updated_at = now()
            WHERE id = $3
          `,
          [name, userId, typeId]
        );
        await recordEditLog(client, 'pokemon-types', typeId, 'update', userId, changes);
      }
    }

    await replaceEntityTranslations(client, 'pokemon-types', typeId, translations, ['name']);
  }

  await client.query(
    `
      SELECT setval(
        pg_get_serial_sequence('pokemon_types', 'id'),
        GREATEST((SELECT COALESCE(MAX(id), 1) FROM pokemon_types), 1),
        true
      )
    `
  );
}

export async function fetchPokemonData(payload: Record<string, unknown>, userId: number): Promise<PokemonFetchResult> {
  const lookupKey = pokemonDataLookupKey(payload.identifier);
  const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
  const pokemonRow = data.pokemonByLookup.get(lookupKey);

  if (!pokemonRow) {
    throw validationError('server.validation.pokemonDataNotFound');
  }

  const id = csvInteger(pokemonRow, 'id');
  const nameRow = data.namesByPokemonId.get(id) ?? pokemonRow;
  const genusRow = data.genusByPokemonId.get(id) ?? pokemonRow;
  const identifier = csvText(pokemonRow, 'identifier');
  const typeIds = fetchedPokemonTypeIds(pokemonRow, data);

  await withTransaction((client) => ensurePokemonTypeCatalog(client, data, languages, userId));

  return {
    id,
    identifier,
    name: defaultCsvText(nameRow, languages, identifier),
    genus: defaultCsvText(genusRow, languages, ''),
    heightInches: Math.round(csvNumber(pokemonRow, 'height_m') * 39.37007874015748 * 100) / 100,
    weightPounds: Math.round(csvNumber(pokemonRow, 'weight_kg') * 2.2046226218 * 10) / 10,
    translations: localizedCsvTranslations(
      [
        { row: nameRow, fieldName: 'name' },
        { row: genusRow, fieldName: 'genus' }
      ],
      languages
    ),
    typeIds,
    stats: fetchedPokemonStats(pokemonRow)
  };
}

export async function fetchPokemonImageOptions(payload: Record<string, unknown>): Promise<PokemonImageOptionsResult> {
  const lookupKey = pokemonDataLookupKey(payload.identifier);
  const data = await loadPokemonCsvData();
  const pokemonRow = data.pokemonByLookup.get(lookupKey);

  if (!pokemonRow) {
    throw validationError('server.validation.pokemonDataNotFound');
  }

  const id = csvInteger(pokemonRow, 'id');
  const images = (
    await Promise.all(
      pokemonImageCandidates(id).map(async (candidate) => (await pokemonImageExists(candidate) ? pokemonImageWithUrl(candidate) : null))
    )
  ).filter((image): image is PokemonImage => image !== null);

  return {
    id,
    identifier: csvText(pokemonRow, 'identifier'),
    images
  };
}

function pokemonFetchOption(row: CsvRow, data: PokemonCsvData, languages: LanguagePayload[], locale: string): PokemonFetchOption {
  const id = csvInteger(row, 'id');
  const identifier = csvText(row, 'identifier');
  const nameRow = data.namesByPokemonId.get(id) ?? row;

  return {
    id,
    identifier,
    name: localizedCsvText(nameRow, cleanLocale(locale)) || defaultCsvText(nameRow, languages, identifier)
  };
}

function pokemonFetchOptionMatches(
  row: CsvRow,
  data: PokemonCsvData,
  languages: LanguagePayload[],
  locale: string,
  search: string
): boolean {
  if (!search) {
    return true;
  }

  const id = csvInteger(row, 'id');
  const identifier = csvText(row, 'identifier');
  const nameRow = data.namesByPokemonId.get(id) ?? row;
  const defaultCode = defaultLanguage(languages)?.code ?? defaultLocale;
  const searchFields = [
    String(id),
    identifier,
    localizedCsvText(nameRow, cleanLocale(locale)),
    localizedCsvText(nameRow, defaultCode),
    localizedCsvText(nameRow, defaultLocale)
  ];
  const keyword = search.toLowerCase();

  return searchFields.some((field) => field.toLowerCase().includes(keyword));
}

export async function listPokemonFetchOptions(paramsQuery: QueryParams, locale = defaultLocale): Promise<PokemonFetchOption[]> {
  const search = asString(paramsQuery.search)?.trim() ?? '';
  const includeAll = asString(paramsQuery.all) === 'true';
  const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
  const rows = data.pokemonRows.filter(
    (row) => csvInteger(row, 'id') > 0 && pokemonFetchOptionMatches(row, data, languages, locale, search)
  );

  return (includeAll ? rows : rows.slice(0, 20)).map((row) => pokemonFetchOption(row, data, languages, locale));
}

function displayValue(value: string | null | undefined): string {
  const cleanValue = value?.trim() ?? '';
  return cleanValue === '' ? 'None' : cleanValue;
}

function pushChange(changes: EditChange[], label: string, before: string | null | undefined, after: string | null | undefined): void {
  const beforeValue = displayValue(before);
  const afterValue = displayValue(after);

  if (beforeValue !== afterValue) {
    changes.push({ label, before: beforeValue, after: afterValue });
  }
}

const translationChangeLabels: Record<TranslationField, string> = {
  name: 'Name',
  title: 'Title',
  details: 'Details',
  genus: 'Genus',
  effect: 'Effect',
  mosslaxEffect: 'Mosslax effect'
};

function translationFieldValue(
  translations: TranslationInput | null | undefined,
  locale: string,
  field: TranslationField
): string | null {
  const value = translations?.[locale]?.[field];
  return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
}

function pushTranslationChanges(
  changes: EditChange[],
  before: TranslationInput | null | undefined,
  after: TranslationInput,
  fields: TranslationField[]
): void {
  const locales = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after)])]
    .filter((locale) => locale !== defaultLocale)
    .sort((a, b) => a.localeCompare(b));

  for (const locale of locales) {
    for (const field of fields) {
      pushChange(
        changes,
        `${translationChangeLabels[field]} (${locale})`,
        translationFieldValue(before, locale, field),
        translationFieldValue(after, locale, field)
      );
    }
  }
}

function boolValue(value: boolean): string {
  return value ? 'Yes' : 'No';
}

function namedListValue(items: Array<{ name: string }> | null | undefined): string {
  if (!items?.length) {
    return 'None';
  }

  return [...new Set(items.map((item) => item.name))]
    .sort((a, b) => a.localeCompare(b))
    .join(' / ');
}

function quantityListValue(items: Array<{ name: string; quantity: number }> | null | undefined): string {
  if (!items?.length) {
    return 'None';
  }

  return items
    .map((item) => ({ name: item.name, value: `${item.name} x${item.quantity}` }))
    .sort((a, b) => a.name.localeCompare(b.name))
    .map((item) => item.value)
    .join(' / ');
}

function skillDropListValue(skills: Array<{ name: string; itemDrop?: { name: string } | null }> | null | undefined): string {
  const rows = skills
    ?.filter((skill) => skill.itemDrop)
    .map((skill) => `${skill.name}: ${skill.itemDrop?.name}`)
    .sort((a, b) => a.localeCompare(b)) ?? [];

  return rows.length ? rows.join(' / ') : 'None';
}

function pokemonStatsValue(stats: PokemonStats | null | undefined): string {
  return pokemonStatLabels.map((stat) => `${stat.label}: ${stats?.[stat.key] ?? 0}`).join(' / ');
}

function roundMeasure(value: number, precision: number): number {
  const scale = 10 ** precision;
  return Math.round(value * scale) / scale;
}

function formatFixedMeasure(value: number, precision: number): string {
  return value.toFixed(precision);
}

function feetInchesValue(inches: number): string {
  const totalInches = Math.round(inches);
  const feet = Math.floor(totalInches / 12);
  const remainingInches = totalInches - feet * 12;
  return `${feet}'${remainingInches}"`;
}

function pokemonHeightValue(inches: number | null | undefined): string {
  const value = inches ?? 0;
  return `${feetInchesValue(value)} / ${formatFixedMeasure(roundMeasure(value * 0.0254, 2), 2)} m`;
}

function pokemonWeightValue(pounds: number | null | undefined): string {
  const value = pounds ?? 0;
  return `${formatFixedMeasure(roundMeasure(value, 1), 1)} lb / ${formatFixedMeasure(roundMeasure(value * 0.45359237, 2), 2)} kg`;
}

function appearanceListValue(
  rows: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }> | null | undefined
): string {
  if (!rows?.length) {
    return 'None';
  }

  return rows
    .map((row) => `${row.name}: ${row.time_of_day} / ${row.weather} / ${row.rarity} stars / ${row.map.name}`)
    .sort((a, b) => a.localeCompare(b))
    .join(' / ');
}

async function entityNameMap(client: DbClient, tableName: string, ids: number[]): Promise<Map<number, string>> {
  const uniqueIds = [...new Set(ids)].filter((id) => Number.isInteger(id) && id > 0);
  if (!uniqueIds.length) {
    return new Map();
  }

  const result = await client.query<{ id: number; name: string }>(
    `SELECT id, name FROM ${tableName} WHERE id = ANY($1::integer[])`,
    [uniqueIds]
  );

  return new Map(result.rows.map((row) => [row.id, row.name]));
}

function namesFromIds(ids: number[], namesById: Map<number, string>): string {
  const names = [...new Set(ids)]
    .map((id) => namesById.get(id))
    .filter((name): name is string => Boolean(name))
    .sort((a, b) => a.localeCompare(b));

  return names.length ? names.join(' / ') : 'None';
}

function namedTradingListValue(
  rows: Array<{ name: string; preference: TradingPreference }> | null | undefined
): string {
  if (!rows?.length) {
    return 'None';
  }

  return rows
    .map((row) => `${row.preference === 'like' ? 'Likes' : 'Neutral'}: ${row.name}`)
    .sort((a, b) => a.localeCompare(b))
    .join(' / ');
}

async function quantityPayloadValue(client: DbClient, rows: IdQuantity[]): Promise<string> {
  const namesById = await entityNameMap(client, 'items', rows.map((row) => row.itemId));
  return quantityListValue(
    rows
      .map((row) => {
        const name = namesById.get(row.itemId);
        return name ? { name, quantity: row.quantity } : null;
      })
      .filter((row): row is { name: string; quantity: number } => row !== null)
  );
}

async function pokemonEditChanges(
  client: DbClient,
  before: PokemonChangeSource,
  after: PokemonPayload
): Promise<EditChange[]> {
  const changes: EditChange[] = [];
  const environmentNames = await entityNameMap(client, 'environments', [after.environmentId]);
  const typeNames = await entityNameMap(client, 'pokemon_types', after.typeIds);
  const skillNames = await entityNameMap(client, 'skills', after.skillIds);
  const favoriteThingNames = await entityNameMap(client, 'favorite_things', after.favoriteThingIds);
  const dropSkillNames = await entityNameMap(client, 'skills', after.skillItemDrops.map((drop) => drop.skillId));
  const dropItemNames = await entityNameMap(client, 'items', after.skillItemDrops.map((drop) => drop.itemId));
  const tradingItemNames = await entityNameMap(client, 'items', after.tradingItems.map((item) => item.itemId));
  const afterTradingItems = after.tradingItems
    .map((item) => {
      const itemName = tradingItemNames.get(item.itemId);
      return itemName ? `${item.preference === 'like' ? 'Likes' : 'Neutral'}: ${itemName}` : null;
    })
    .filter((value): value is string => value !== null)
    .sort((a, b) => a.localeCompare(b));
  const afterTradingItemsValue = afterTradingItems.length ? afterTradingItems.join(' / ') : 'None';
  const afterDrops = after.skillItemDrops
    .map((drop) => {
      const skillName = dropSkillNames.get(drop.skillId);
      const itemName = dropItemNames.get(drop.itemId);
      return skillName && itemName ? `${skillName}: ${itemName}` : null;
    })
    .filter((drop): drop is string => drop !== null)
    .sort((a, b) => a.localeCompare(b))
    .join(' / ');

  pushChange(changes, 'Name', before.name, after.name);
  pushChange(changes, 'Pokopia ID', String(before.displayId), String(after.displayId));
  pushChange(changes, 'Event Pokemon', boolValue(before.isEventItem), boolValue(after.isEventItem));
  pushChange(changes, 'Genus', before.genus, after.genus);
  pushChange(changes, 'Details', before.details, after.details);
  pushTranslationChanges(changes, before.translations, after.translations, ['name', 'genus', 'details']);
  pushChange(changes, 'Height', pokemonHeightValue(before.heightInches), pokemonHeightValue(after.heightInches));
  pushChange(changes, 'Weight', pokemonWeightValue(before.weightPounds), pokemonWeightValue(after.weightPounds));
  pushChange(changes, 'Image', pokemonImageLabel(before.image), pokemonImageLabel(after.image));
  pushChange(changes, 'Types', namedListValue(before.types), namesFromIds(after.typeIds, typeNames));
  pushChange(changes, 'Stats', pokemonStatsValue(before.stats), pokemonStatsValue(after.stats));
  pushChange(changes, 'Ideal Habitat', before.environment.name, environmentNames.get(after.environmentId));
  pushChange(changes, 'Specialities', namedListValue(before.skills), namesFromIds(after.skillIds, skillNames));
  pushChange(changes, 'Favourites', namedListValue(before.favorite_things), namesFromIds(after.favoriteThingIds, favoriteThingNames));
  pushChange(changes, 'Trading items', namedTradingListValue(before.tradingItems), afterTradingItemsValue);
  pushChange(changes, 'Speciality drops', skillDropListValue(before.skills), afterDrops);

  return changes;
}

async function itemEditChanges(
  client: DbClient,
  before: ItemChangeSource,
  after: ItemPayload
): Promise<EditChange[]> {
  const changes: EditChange[] = [];
  const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
  const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);

  pushChange(changes, 'Name', before.name, after.name);
  pushChange(changes, 'Description', before.details, after.details);
  pushChange(
    changes,
    'Base Price',
    before.basePrice === null ? null : String(before.basePrice),
    after.basePrice === null ? null : String(after.basePrice)
  );
  pushChange(
    changes,
    'Ancient Artifact',
    before.ancientArtifactCategory?.name ?? 'None',
    systemListNameByKey(ancientArtifactCategoryOptions, after.ancientArtifactCategoryKey) ?? 'None'
  );
  pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
  pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
  pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
  pushChange(changes, 'Category', before.category.name, systemListNameByKey(itemCategoryOptions, after.categoryKey));
  pushChange(changes, 'Usage', before.usage?.name, systemListNameByKey(itemUsageOptions, after.usageKey));
  pushChange(changes, 'Dyeability', dyeabilityValue(before.customization.dyeability), dyeabilityValue(after.dyeability));
  pushChange(changes, 'Pattern editable', boolValue(before.customization.patternEditable), boolValue(after.patternEditable));
  pushChange(changes, 'No recipe', boolValue(before.noRecipe), boolValue(after.noRecipe));
  pushChange(changes, 'Acquisition methods', namedListValue(before.acquisitionMethods), namesFromIds(after.acquisitionMethodIds, methodNames));
  pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));

  return changes;
}

type ItemPossibleTagEntity = {
  id: number;
  name: string;
};

type ItemPossibleTagPokemon = {
  id: number;
  displayId: number;
  name: string;
  isEventItem: boolean;
  image: EntityImageValue | null;
};

type ItemPossibleTagObservation = {
  pokemon: ItemPossibleTagPokemon;
  preference: TradingPreference;
  tags: ItemPossibleTagEntity[];
};

type ItemPossibleTags = {
  highlyLikely: ItemPossibleTagEntity[];
  possible: ItemPossibleTagEntity[];
  excluded: ItemPossibleTagEntity[];
  evidence: {
    likes: ItemPossibleTagObservation[];
    neutral: ItemPossibleTagObservation[];
  };
};

function inferItemPossibleTags(
  allTags: ItemPossibleTagEntity[],
  observations: ItemPossibleTagObservation[]
): ItemPossibleTags {
  const allTagIds = new Set(allTags.map((tag) => tag.id));
  const neutralExcludedTagIds = new Set<number>();
  const filteredLikeSets: number[][] = [];
  const likes: ItemPossibleTagObservation[] = [];
  const neutral: ItemPossibleTagObservation[] = [];

  for (const observation of observations) {
    const filteredTagIds = [...new Set(observation.tags.map((tag) => tag.id).filter((id) => allTagIds.has(id)))];
    const filteredObservation = {
      ...observation,
      tags: observation.tags.filter((tag) => allTagIds.has(tag.id))
    };

    if (observation.preference === 'neutral') {
      neutral.push(filteredObservation);
      filteredTagIds.forEach((id) => neutralExcludedTagIds.add(id));
      continue;
    }

    likes.push(filteredObservation);
    if (filteredTagIds.length > 0) {
      filteredLikeSets.push(filteredTagIds);
    }
  }

  const allowedTagIds = allTags.map((tag) => tag.id).filter((id) => !neutralExcludedTagIds.has(id));
  const conflicts = likes.some((observation) => observation.tags.length > 0 && observation.tags.every((tag) => neutralExcludedTagIds.has(tag.id)));
  const unionLikeIds = new Set(filteredLikeSets.flat());
  const intersectionLikeIds =
    filteredLikeSets.length >= 2
      ? filteredLikeSets.reduce((result, current) => result.filter((id) => current.includes(id)))
      : [];
  const candidateTagIds = conflicts
    ? []
    : filteredLikeSets.length > 0
      ? allowedTagIds.filter((id) => unionLikeIds.has(id))
      : allowedTagIds;
  const highlyLikelyTagIds = filteredLikeSets.length >= 2
    ? intersectionLikeIds.filter((id) => candidateTagIds.includes(id))
    : [];
  const possibleTagIds = candidateTagIds.filter((id) => !highlyLikelyTagIds.includes(id));
  const excludedTagIds = allTags.map((tag) => tag.id).filter((id) => !candidateTagIds.includes(id));

  const tagsById = new Map(allTags.map((tag) => [tag.id, tag]));
  return {
    highlyLikely: highlyLikelyTagIds.map((id) => tagsById.get(id)).filter((tag): tag is ItemPossibleTagEntity => Boolean(tag)),
    possible: possibleTagIds.map((id) => tagsById.get(id)).filter((tag): tag is ItemPossibleTagEntity => Boolean(tag)),
    excluded: excludedTagIds.map((id) => tagsById.get(id)).filter((tag): tag is ItemPossibleTagEntity => Boolean(tag)),
    evidence: { likes, neutral }
  };
}

async function ancientArtifactEditChanges(
  client: DbClient,
  before: AncientArtifactChangeSource,
  after: AncientArtifactPayload
): Promise<EditChange[]> {
  const changes: EditChange[] = [];
  const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);

  pushChange(changes, 'Name', before.name, after.name);
  pushChange(changes, 'Description', before.details, after.details);
  pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
  pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
  pushChange(changes, 'Category', before.category.name, systemListNameByKey(ancientArtifactCategoryOptions, after.categoryKey));
  pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));
  return changes;
}

async function habitatEditChanges(
  client: DbClient,
  before: HabitatChangeSource,
  after: HabitatPayload
): Promise<EditChange[]> {
  const changes: EditChange[] = [];
  const pokemonNames = await entityNameMap(client, 'pokemon', after.pokemonAppearances.map((row) => row.pokemonId));
  const mapNames = await entityNameMap(client, 'maps', after.pokemonAppearances.map((row) => row.mapId));
  const afterAppearances = after.pokemonAppearances
    .map((row) => {
      const pokemonName = pokemonNames.get(row.pokemonId);
      const mapName = mapNames.get(row.mapId);
      return pokemonName && mapName ? `${pokemonName}: ${row.timeOfDay} / ${row.weather} / ${row.rarity} stars / ${mapName}` : null;
    })
    .filter((row): row is string => row !== null)
    .sort((a, b) => a.localeCompare(b))
    .join(' / ');

  pushChange(changes, 'Name', before.name, after.name);
  pushTranslationChanges(changes, before.translations, after.translations, ['name']);
  pushChange(changes, 'Event Habitat', boolValue(before.isEventItem), boolValue(after.isEventItem));
  pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
  pushChange(changes, 'Recipe', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems));
  pushChange(changes, 'Possible Pokemon', appearanceListValue(before.pokemon), afterAppearances);

  return changes;
}

async function recipeEditChanges(
  client: DbClient,
  before: RecipeChangeSource,
  after: RecipePayload
): Promise<EditChange[]> {
  const changes: EditChange[] = [];
  const itemNames = await entityNameMap(client, 'items', [after.itemId]);
  const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);

  pushChange(changes, 'Item', before.item.name, itemNames.get(after.itemId));
  pushChange(changes, 'Acquisition methods', namedListValue(before.acquisition_methods), namesFromIds(after.acquisitionMethodIds, methodNames));
  pushChange(changes, 'Materials', quantityListValue(before.materials), await quantityPayloadValue(client, after.materials));

  return changes;
}

function dailyChecklistEditChanges(before: DailyChecklistChangeSource, after: DailyChecklistPayload): EditChange[] {
  const changes: EditChange[] = [];
  pushChange(changes, 'Title', before.title, after.title);
  pushTranslationChanges(changes, before.translations, after.translations, ['title']);
  return changes;
}

function configEditChanges(
  definition: ConfigDefinition,
  before: ConfigChangeSource,
  after: {
    name: string;
    description: string;
    translations: TranslationInput;
    oppositeName: string;
    hasItemDrop: boolean;
    hasTrading: boolean;
    changeLog: string;
  }
): EditChange[] {
  const changes: EditChange[] = [];
  pushChange(changes, 'Name', before.name, after.name);
  pushTranslationChanges(changes, before.translations, after.translations, ['name']);
  if (definition.hasDescription) {
    pushChange(changes, 'Description', before.description, after.description);
  }
  if (definition.oppositeColumn) {
    pushChange(changes, 'Opposite', before.opposite?.name ?? '', after.oppositeName);
  }
  if (definition.hasItemDrop) {
    pushChange(changes, 'Has item drop', boolValue(Boolean(before.hasItemDrop)), boolValue(after.hasItemDrop));
  }
  if (definition.hasTrading) {
    pushChange(changes, 'Has trading', boolValue(Boolean(before.hasTrading)), boolValue(after.hasTrading));
  }
  if (definition.hasChangeLog) {
    pushChange(changes, 'ChangeLog', before.changeLog, after.changeLog);
  }
  return changes;
}

function getEditHistory(entityType: string, entityId: number): Promise<EditHistoryEntry[]> {
  return query(
    `
      SELECT
        l.action,
        COALESCE(l.changes, '[]'::jsonb) AS changes,
        l.created_at AS "createdAt",
        CASE
          WHEN u.id IS NULL THEN NULL
          ELSE json_build_object('id', u.id, 'displayName', u.display_name)
        END AS user
      FROM wiki_edit_logs l
      LEFT JOIN users u ON u.id = l.user_id
      WHERE l.entity_type = $1
        AND l.entity_id = $2
      ORDER BY l.created_at DESC, l.id DESC
    `,
    [entityType, entityId]
  );
}

function pokemonProjection(locale: string): string {
  const pokemonName = localizedName('pokemon', 'p', locale);
  const pokemonGenus = localizedField('pokemon', 'p.id', 'p.genus', 'genus', locale);
  const pokemonDetails = localizedField('pokemon', 'p.id', 'p.details', 'details', locale);
  const typeName = localizedName('pokemon-types', 'pt', locale);
  const environmentName = localizedName('environments', 'e', locale);
  const skillName = localizedName('skills', 's', locale);
  const favoriteThingName = localizedName('favorite-things', 'ft', locale);

  return `
    SELECT
      p.id,
      p.data_id AS "dataId",
      p.data_identifier AS "dataIdentifier",
      p.display_id AS "displayId",
      ${pokemonName} AS name,
      p.name AS "baseName",
      p.is_event_item AS "isEventItem",
      ${pokemonGenus} AS genus,
      p.genus AS "baseGenus",
      ${pokemonDetails} AS details,
      p.details AS "baseDetails",
      p.height_inches AS "heightInches",
      round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters",
      p.weight_pounds AS "weightPounds",
      round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg",
      ${pokemonImageJson('p')} AS image,
      json_build_object(
        'hp', p.hp,
        'attack', p.attack,
        'defense', p.defense,
        'specialAttack', p.special_attack,
        'specialDefense', p.special_defense,
        'speed', p.speed
      ) AS stats,
      ${translationsSelect('pokemon', 'p.id')} AS translations,
      ${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')},
      json_build_object(
        'id', e.id,
        'name', ${environmentName},
        'description', e.description
      ) AS environment,
      COALESCE((
        SELECT json_agg(json_build_object('id', pt.id, 'name', ${typeName}) ORDER BY ppt.slot_order)
        FROM pokemon_pokemon_types ppt
        JOIN pokemon_types pt ON pt.id = ppt.type_id
        WHERE ppt.pokemon_id = p.id
      ), '[]'::json) AS types,
      COALESCE((
        SELECT json_agg(json_build_object('id', s.id, 'name', ${skillName}, 'hasItemDrop', s.has_item_drop, 'hasTrading', s.has_trading) ORDER BY ${orderByEntity('s')})
        FROM pokemon_skills ps
        JOIN skills s ON s.id = ps.skill_id
        WHERE ps.pokemon_id = p.id
      ), '[]'::json) AS skills,
      COALESCE((
        SELECT json_agg(
          json_build_object(
            'id', ft.id,
            'name', ${favoriteThingName}
          )
          ORDER BY ${orderByEntity('ft')}
        )
        FROM pokemon_favorite_things pft
        JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
        WHERE pft.pokemon_id = p.id
      ), '[]'::json) AS favorite_things
    FROM pokemon p
    JOIN environments e ON e.id = p.environment_id
    ${auditJoins('p', 'pokemon_created_user', 'pokemon_updated_user')}
  `;
}

export async function getOptions(locale = defaultLocale) {
  const [
    pokemonTypes,
    skills,
    environments,
    favoriteThings,
    acquisitionMethods,
    maps,
    gameVersions,
    dishFlavors,
    moduleSettings
  ] = await Promise.all([
    optionSelect('pokemon_types', 'pokemon-types', locale),
    skillOptions(locale),
    optionSelect('environments', 'environments', locale),
    optionSelect('favorite_things', 'favorite-things', locale),
    optionSelect('acquisition_methods', 'acquisition-methods', locale),
    optionSelect('maps', 'maps', locale),
    gameVersionOptions(locale),
    optionSelect('dish_flavors', 'dish-flavors', locale),
    getModuleSettings()
  ]);

  return {
    pokemonTypes,
    skills,
    environments,
    favoriteThings,
    itemCategories: systemListOptions(itemCategoryOptions, locale),
    itemUsages: systemListOptions(itemUsageOptions, locale),
    ancientArtifactCategories: systemListOptions(ancientArtifactCategoryOptions, locale),
    acquisitionMethods,
    itemTags: favoriteThings,
    maps,
    gameVersions,
    dishFlavors,
    moduleSettings
  };
}

function cleanDailyChecklistPayload(payload: Record<string, unknown>): DailyChecklistPayload {
  return {
    title: cleanName(payload.title, 'server.validation.taskRequired'),
    translations: cleanTranslations(payload.translations, ['title'])
  };
}

export async function listDailyChecklistItems(paramsQuery: QueryParams = {}, locale = defaultLocale) {
  const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
  return queryMaybePaged(
    `
      SELECT c.id, ${title} AS title, c.title AS "baseTitle", ${translationsSelect('daily-checklist-items', 'c.id')} AS translations
      FROM daily_checklist_items c
      ORDER BY c.sort_order, c.id
    `,
    [],
    paramsQuery
  );
}

export async function globalSearch(paramsQuery: QueryParams = {}, locale = defaultLocale): Promise<GlobalSearchResults> {
  const search = asString(paramsQuery.query)?.trim() ?? asString(paramsQuery.search)?.trim() ?? '';
  if (!search) {
    return { query: '', groups: [] };
  }

  const pattern = `%${search}%`;
  const limit = 5;
  const pokemonName = localizedName('pokemon', 'p', locale);
  const habitatName = localizedName('habitats', 'h', locale);
  const itemName = localizedName('items', 'i', locale);
  const itemCategoryName = systemListJsonSql('i.category_key', itemCategoryOptions, locale);
  const artifactName = localizedName('items', 'artifact_item', locale);
  const artifactCategoryName = systemListJsonSql('artifact_item.ancient_artifact_category_key', ancientArtifactCategoryOptions, locale);
  const recipeItemName = localizedName('items', 'result_item', locale);
  const recipeMaterialName = localizedName('items', 'material_item', locale);
  const checklistTitle = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
  const [pokemon, habitats, items, artifacts, recipes, checklist, life, users] = await Promise.all([
    query<GlobalSearchItem>(
      `
        SELECT
          p.id,
          'pokemon' AS type,
          ${pokemonName} AS title,
          '/pokemon/' || p.id AS url,
          NULLIF(p.genus, '') AS summary,
          '#' || p.display_id::text AS meta,
          ${pokemonImageJson('p')} AS image
        FROM pokemon p
        WHERE ${pokemonName} ILIKE $1
        ORDER BY p.display_id, p.sort_order, p.id
        LIMIT $2
      `,
      [pattern, limit]
    ),
    query<GlobalSearchItem>(
      `
        SELECT
          h.id,
          'habitats' AS type,
          ${habitatName} AS title,
          '/habitats/' || h.id AS url,
          NULL AS summary,
          NULL AS meta,
          ${uploadedImageJson('h.image_path')} AS image
        FROM habitats h
        WHERE ${habitatName} ILIKE $1
        ORDER BY ${orderByEntity('h')}
        LIMIT $2
      `,
      [pattern, limit]
    ),
    query<GlobalSearchItem>(
      `
        SELECT
          i.id,
          'items' AS type,
          ${itemName} AS title,
          '/items/' || i.id AS url,
          NULLIF(i.details, '') AS summary,
          (${itemCategoryName}->>'name') AS meta,
          ${uploadedImageJson('i.image_path')} AS image
        FROM items i
        WHERE ${itemName} ILIKE $1
        ORDER BY ${orderByEntity('i')}
        LIMIT $2
      `,
      [pattern, limit]
    ),
    query<GlobalSearchItem>(
      `
        SELECT
          artifact_item.id,
          'ancient-artifacts' AS type,
          ${artifactName} AS title,
          '/ancient-artifacts/' || artifact_item.id AS url,
          NULLIF(artifact_item.details, '') AS summary,
          (${artifactCategoryName}->>'name') AS meta,
          ${uploadedImageJson('artifact_item.image_path')} AS image
        FROM items artifact_item
        WHERE ${artifactName} ILIKE $1
          AND artifact_item.ancient_artifact_category_key IS NOT NULL
        ORDER BY ${orderByEntity('artifact_item')}
        LIMIT $2
      `,
      [pattern, limit]
    ),
    query<GlobalSearchItem>(
      `
        SELECT
          r.id,
          'recipes' AS type,
          ${recipeItemName} AS title,
          '/recipes/' || r.id AS url,
          (
            SELECT string_agg(material_rows.name, ' / ' ORDER BY material_rows.name)
            FROM (
              SELECT DISTINCT ${recipeMaterialName} AS name
              FROM recipe_materials rm
              JOIN items material_item ON material_item.id = rm.item_id
              WHERE rm.recipe_id = r.id
            ) material_rows
          ) AS summary,
          NULL AS meta,
          ${uploadedImageJson('result_item.image_path')} AS image
        FROM recipes r
        JOIN items result_item ON result_item.id = r.item_id
        WHERE ${recipeItemName} ILIKE $1
          OR EXISTS (
            SELECT 1
            FROM recipe_materials rm
            JOIN items material_item ON material_item.id = rm.item_id
            WHERE rm.recipe_id = r.id
              AND ${recipeMaterialName} ILIKE $1
          )
        ORDER BY ${orderByEntity('r')}
        LIMIT $2
      `,
      [pattern, limit]
    ),
    query<GlobalSearchItem>(
      `
        SELECT
          c.id,
          'daily-checklist' AS type,
          ${checklistTitle} AS title,
          '/checklist' AS url,
          NULL AS summary,
          NULL AS meta,
          NULL AS image
        FROM daily_checklist_items c
        WHERE ${checklistTitle} ILIKE $1
        ORDER BY ${orderByEntity('c')}
        LIMIT $2
      `,
      [pattern, limit]
    ),
    query<GlobalSearchItem>(
      `
        SELECT
          lp.id,
          'life' AS type,
          LEFT(lp.body, 120) AS title,
          '/life/' || lp.id AS url,
          NULL AS summary,
          NULL AS meta,
          NULL AS image
        FROM life_posts lp
        WHERE lp.deleted_at IS NULL
          AND lp.ai_moderation_status = 'approved'
          AND lp.body ILIKE $1
        ORDER BY lp.created_at DESC, lp.id DESC
        LIMIT $2
      `,
      [pattern, limit]
    ),
    query<GlobalSearchItem>(
      `
        SELECT
          u.id,
          'users' AS type,
          u.display_name AS title,
          '/profile/' || u.id AS url,
          NULL AS summary,
          NULL AS meta,
          NULL AS image
        FROM users u
        WHERE u.display_name ILIKE $1
        ORDER BY lower(u.display_name), u.id
        LIMIT $2
      `,
      [pattern, limit]
    )
  ]);

  const groups: GlobalSearchGroup[] = [
    { type: 'pokemon', items: pokemon },
    { type: 'habitats', items: habitats },
    { type: 'items', items: items },
    { type: 'ancient-artifacts', items: artifacts },
    { type: 'recipes', items: recipes },
    { type: 'daily-checklist', items: checklist },
    { type: 'life', items: life },
    { type: 'users', items: users }
  ];

  return { query: search, groups: groups.filter((group) => group.items.length > 0) };
}

async function getDailyChecklistItemById(id: number, locale = defaultLocale) {
  const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
  return queryOne(
    `
      SELECT c.id, ${title} AS title, c.title AS "baseTitle", ${translationsSelect('daily-checklist-items', 'c.id')} AS translations
      FROM daily_checklist_items c
      WHERE c.id = $1
    `,
    [id]
  );
}

export async function createDailyChecklistItem(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanDailyChecklistPayload(payload);

  const id = await withTransaction(async (client) => {
    const orderResult = await client.query<{ sortOrder: number }>(
      'SELECT COALESCE(MAX(sort_order), 0) + 10 AS "sortOrder" FROM daily_checklist_items'
    );
    const sortOrder = orderResult.rows[0]?.sortOrder ?? 10;

    const result = await client.query<{ id: number }>(
      `
        INSERT INTO daily_checklist_items (title, sort_order, created_by_user_id, updated_by_user_id)
        VALUES ($1, $2, $3, $3)
        RETURNING id
      `,
      [cleanPayload.title, sortOrder, userId]
    );

    const createdId = result.rows[0].id;
    await replaceEntityTranslations(client, 'daily-checklist-items', createdId, cleanPayload.translations, ['title']);
    await recordEditLog(client, 'daily-checklist-items', createdId, 'create', userId);
    return createdId;
  });

  return getDailyChecklistItemById(id, locale);
}

export async function updateDailyChecklistItem(
  id: number,
  payload: Record<string, unknown>,
  userId: number,
  locale = defaultLocale
) {
  const cleanPayload = cleanDailyChecklistPayload(payload);
  const before = await getDailyChecklistItemById(id, defaultLocale);

  const updated = await withTransaction(async (client) => {
    const result = await client.query(
      `
        UPDATE daily_checklist_items
        SET title = $1, updated_by_user_id = $2, updated_at = now()
        WHERE id = $3
      `,
      [cleanPayload.title, userId, id]
    );

    if (result.rowCount === 0) {
      return false;
    }

    await replaceEntityTranslations(client, 'daily-checklist-items', id, cleanPayload.translations, ['title']);
    const changes = before ? dailyChecklistEditChanges(before as DailyChecklistChangeSource, cleanPayload) : [];
    await recordEditLog(client, 'daily-checklist-items', id, 'update', userId, changes);
    return true;
  });

  return updated ? getDailyChecklistItemById(id, locale) : null;
}

export async function reorderDailyChecklistItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const ids = cleanIds(payload.ids);
  if (ids.length === 0) {
    throw validationError('server.validation.selectTask');
  }

  await withTransaction(async (client) => {
    const existing = await client.query<{ id: number; sortOrder: number }>(
      'SELECT id, sort_order AS "sortOrder" FROM daily_checklist_items WHERE id = ANY($1::integer[])',
      [ids]
    );

    if (existing.rowCount !== ids.length) {
      throw validationError('server.validation.taskDoesNotExist');
    }

    const sortOrders = new Map(existing.rows.map((row) => [row.id, row.sortOrder]));
    for (const [index, id] of ids.entries()) {
      const nextSortOrder = (index + 1) * 10;
      const previousSortOrder = sortOrders.get(id);
      if (previousSortOrder === nextSortOrder) {
        continue;
      }

      await client.query(
        `
          UPDATE daily_checklist_items
          SET sort_order = $1, updated_by_user_id = $2, updated_at = now()
          WHERE id = $3
        `,
        [nextSortOrder, userId, id]
      );
    }
  });

  return listDailyChecklistItems({}, locale);
}

export async function deleteDailyChecklistItem(id: number, userId: number) {
  return withTransaction(async (client) => {
    const result = await client.query<{ id: number }>('DELETE FROM daily_checklist_items WHERE id = $1 RETURNING id', [id]);
    if (result.rowCount === 0) {
      return false;
    }

    await deleteEntityTranslations(client, 'daily-checklist-items', id);
    await recordEditLog(client, 'daily-checklist-items', id, 'delete', userId);
    return true;
  });
}

function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload {
  const body = cleanName(payload.body, 'server.validation.postRequired');
  if (body.length > 2000) {
    throw validationError('server.validation.postTooLong');
  }
  const gameVersionId = optionalPositiveInteger(payload.gameVersionId, 'server.validation.gameVersionInvalid');

  return {
    body,
    gameVersionId,
    languageCode: cleanModerationLanguageCode(payload.languageCode)
  };
}

function cleanLifeCommentPayload(payload: Record<string, unknown>): LifeCommentPayload {
  const body = cleanName(payload.body, 'server.validation.commentRequired');
  if (body.length > 1000) {
    throw validationError('server.validation.commentTooLong');
  }

  return { body, languageCode: cleanModerationLanguageCode(payload.languageCode) };
}

function emptyLifeReactionCounts(): LifeReactionCounts {
  return {
    like: 0,
    helpful: 0,
    fun: 0,
    thanks: 0
  };
}

function isLifeReactionType(value: unknown): value is LifeReactionType {
  return typeof value === 'string' && lifeReactionTypes.includes(value as LifeReactionType);
}

function cleanLifeReactionType(value: unknown): LifeReactionType {
  if (!isLifeReactionType(value)) {
    throw validationError('server.validation.reactionInvalid');
  }

  return value;
}

function cleanLifeReactionFilter(value: QueryValue): LifeReactionType | null {
  const reactionType = asString(value);
  if (!reactionType) {
    return null;
  }

  return cleanLifeReactionType(reactionType);
}

function cleanUserCommentActivitySourceFilter(value: QueryValue): UserCommentActivitySource | null {
  const source = asString(value);
  if (!source) {
    return null;
  }

  if (source !== 'life' && source !== 'discussion') {
    throw validationError('server.validation.invalidField');
  }

  return source;
}

function cleanModerationLanguageFilter(value: QueryValue): string | null {
  return cleanModerationLanguageCode(asString(value));
}

function addModerationVisibilityCondition(
  conditions: string[],
  params: unknown[],
  alias: string,
  ownerColumn: string,
  userId: number | null,
  canViewAll: boolean
): void {
  if (canViewAll) {
    return;
  }

  if (userId !== null) {
    params.push(userId);
    conditions.push(`(${alias}.ai_moderation_status = 'approved' OR ${ownerColumn} = $${params.length})`);
    return;
  }

  conditions.push(`${alias}.ai_moderation_status = 'approved'`);
}

function moderationVisibilitySql(alias: string, ownerColumn: string, userId: number | null, canViewAll: boolean): string {
  if (canViewAll) {
    return 'true';
  }
  if (userId !== null) {
    return `(${alias}.ai_moderation_status = 'approved' OR ${ownerColumn} = ${userId})`;
  }
  return `${alias}.ai_moderation_status = 'approved'`;
}

function visibleLifeCommentSql(alias: string, ownerColumn: string, userId: number | null): string {
  return userId !== null ? `(${alias}.deleted_at IS NULL OR ${ownerColumn} = ${userId})` : `${alias}.deleted_at IS NULL`;
}

function addModerationLanguageCondition(
  conditions: string[],
  params: unknown[],
  alias: string,
  languageCode: string | null
): void {
  if (!languageCode) {
    return;
  }

  params.push(languageCode);
  conditions.push(`${alias}.ai_moderation_language_code = $${params.length}`);
}

function lifePostProjection(locale = defaultLocale): string {
  const gameVersionName = localizedName('game-versions', 'gv', locale);

  return `
    SELECT
      lp.id,
      lp.body,
      lp.ai_moderation_status AS "moderationStatus",
      lp.ai_moderation_language_code AS "moderationLanguageCode",
      lp.ai_moderation_reason AS "moderationReason",
      lp.created_at AS "createdAt",
      lp.created_at::text AS "createdAtCursor",
      lp.updated_at AS "updatedAt",
      CASE
        WHEN created_user.id IS NULL THEN NULL
        ELSE json_build_object('id', created_user.id, 'displayName', created_user.display_name)
      END AS author,
      CASE
        WHEN updated_user.id IS NULL THEN NULL
        ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
      END AS "updatedBy",
      CASE
        WHEN gv.id IS NULL THEN NULL
        ELSE json_build_object('id', gv.id, 'name', ${gameVersionName}, 'changeLog', gv.change_log)
      END AS "gameVersion"
    FROM life_posts lp
    LEFT JOIN game_versions gv ON gv.id = lp.game_version_id
    LEFT JOIN users created_user ON created_user.id = lp.created_by_user_id
    LEFT JOIN users updated_user ON updated_user.id = lp.updated_by_user_id
  `;
}

function cleanLifePostLimit(value: QueryValue): number {
  const rawLimit = asString(value);
  if (rawLimit === undefined || rawLimit === '') {
    return defaultLifePostLimit;
  }

  const limit = Number(rawLimit);
  return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLifePostLimit) : defaultLifePostLimit;
}

function cleanLifePostSort(value: QueryValue): LifePostSort {
  const sort = asString(value);
  return sort === 'oldest' ? sort : 'latest';
}

function cleanCommentLimit(value: QueryValue): number {
  const rawLimit = asString(value);
  if (rawLimit === undefined || rawLimit === '') {
    return defaultCommentLimit;
  }

  const limit = Number(rawLimit);
  return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxCommentLimit) : defaultCommentLimit;
}

function cleanCommentSort(value: QueryValue): CommentSort {
  const sort = asString(value);
  return sort === 'latest' || sort === 'most-liked' || sort === 'most-replied' ? sort : 'oldest';
}

function decodeCommentCursor(value: QueryValue): CommentCursor | null {
  const rawCursor = asString(value);
  if (!rawCursor) {
    return null;
  }

  try {
    const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<CommentCursor>;
    const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
    const id = Number(cursor.id);
    const count = cursor.count === undefined ? undefined : Number(cursor.count);

    if (
      !createdAt ||
      Number.isNaN(new Date(createdAt).getTime()) ||
      !Number.isInteger(id) ||
      id <= 0 ||
      (count !== undefined && (!Number.isInteger(count) || count < 0))
    ) {
      throw validationError('server.validation.cursorInvalid');
    }

    return { createdAt, id, count };
  } catch (error) {
    if (error instanceof Error && 'statusCode' in error) {
      throw error;
    }
    throw validationError('server.validation.cursorInvalid');
  }
}

function encodeCommentCursor(comment: LifeCommentRow | EntityDiscussionCommentRow, sort: CommentSort): string {
  const count = sort === 'most-liked' ? comment.likeCount : sort === 'most-replied' ? comment.replyCount : undefined;
  return Buffer.from(JSON.stringify({ createdAt: comment.createdAtCursor, id: comment.id, count }), 'utf8').toString('base64url');
}

function commentSortOrder(alias: string, sort: CommentSort): string {
  if (sort === 'latest') {
    return `${alias}.created_at DESC, ${alias}.id DESC`;
  }
  if (sort === 'most-liked') {
    return `"likeCount" DESC, ${alias}.created_at DESC, ${alias}.id DESC`;
  }
  if (sort === 'most-replied') {
    return `"replyCount" DESC, ${alias}.created_at DESC, ${alias}.id DESC`;
  }
  return `${alias}.created_at, ${alias}.id`;
}

function addCommentCursorCondition(
  conditions: string[],
  params: unknown[],
  alias: string,
  cursor: CommentCursor,
  sort: CommentSort
): void {
  if (sort === 'latest') {
    params.push(cursor.createdAt, cursor.id);
    conditions.push(`(${alias}.created_at, ${alias}.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
    return;
  }

  if (sort === 'most-liked' || sort === 'most-replied') {
    params.push(cursor.count ?? 0, cursor.createdAt, cursor.id);
    const countExpression = sort === 'most-liked' ? 'like_stats.like_count' : 'reply_stats.reply_count';
    conditions.push(`
      (
        ${countExpression} < $${params.length - 2}::integer
        OR (
          ${countExpression} = $${params.length - 2}::integer
          AND (${alias}.created_at, ${alias}.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)
        )
      )
    `);
    return;
  }

  params.push(cursor.createdAt, cursor.id);
  conditions.push(`(${alias}.created_at, ${alias}.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
}

function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
  const rawCursor = asString(value);
  if (!rawCursor) {
    return null;
  }

  try {
    const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<LifePostCursor>;
    const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
    const id = Number(cursor.id);

    if (
      !createdAt ||
      Number.isNaN(new Date(createdAt).getTime()) ||
      !Number.isInteger(id) ||
      id <= 0
    ) {
      throw validationError('server.validation.cursorInvalid');
    }

    return { createdAt, id };
  } catch (error) {
    if (error instanceof Error && 'statusCode' in error) {
      throw error;
    }
    throw validationError('server.validation.cursorInvalid');
  }
}

function encodeLifePostCursor(post: LifePostRow): string {
  return Buffer.from(JSON.stringify({ createdAt: post.createdAtCursor, id: post.id }), 'utf8').toString('base64url');
}

function encodeProfileCursor(cursor: LifePostCursor): string {
  return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
}

function decodeLifeReactionUserCursor(value: QueryValue): LifeReactionUserCursor | null {
  const rawCursor = asString(value);
  if (!rawCursor) {
    return null;
  }

  try {
    const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<LifeReactionUserCursor>;
    const reactedAt = typeof cursor.reactedAt === 'string' ? cursor.reactedAt : '';
    const userId = Number(cursor.userId);

    if (!reactedAt || Number.isNaN(new Date(reactedAt).getTime()) || !Number.isInteger(userId) || userId <= 0) {
      throw validationError('server.validation.cursorInvalid');
    }

    return { reactedAt, userId };
  } catch (error) {
    if (error instanceof Error && 'statusCode' in error) {
      throw error;
    }
    throw validationError('server.validation.cursorInvalid');
  }
}

function encodeLifeReactionUserCursor(cursor: LifeReactionUserCursor): string {
  return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
}

function decodeUserCommentActivityCursor(value: QueryValue): UserCommentActivityCursor | null {
  const rawCursor = asString(value);
  if (!rawCursor) {
    return null;
  }

  try {
    const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<UserCommentActivityCursor>;
    const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
    const id = Number(cursor.id);
    const source = cursor.source;

    if (
      !createdAt ||
      Number.isNaN(new Date(createdAt).getTime()) ||
      !Number.isInteger(id) ||
      id <= 0 ||
      (source !== 'life' && source !== 'discussion')
    ) {
      throw validationError('server.validation.cursorInvalid');
    }

    return { createdAt, id, source };
  } catch (error) {
    if (error instanceof Error && 'statusCode' in error) {
      throw error;
    }
    throw validationError('server.validation.cursorInvalid');
  }
}

function encodeUserCommentActivityCursor(cursor: UserCommentActivityCursor): string {
  return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
}

function hydrateLifePost(
  post: LifePostRow,
  commentPreviewByPost: Map<number, LifeComment[]>,
  commentCountsByPost: Map<number, number>,
  countsByPost: Map<number, LifeReactionCounts>,
  myReactionsByPost: Map<number, LifeReactionType>
): LifePost {
  return {
    id: post.id,
    body: post.body,
    moderationStatus: post.moderationStatus,
    moderationLanguageCode: post.moderationLanguageCode,
    moderationReason: post.moderationReason,
    createdAt: post.createdAt,
    updatedAt: post.updatedAt,
    author: post.author,
    updatedBy: post.updatedBy,
    gameVersion: post.gameVersion,
    commentPreview: commentPreviewByPost.get(post.id) ?? [],
    commentCount: commentCountsByPost.get(post.id) ?? 0,
    reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
    myReaction: myReactionsByPost.get(post.id) ?? null
  };
}

function lifeCommentProjection(whereClause: string, userId: number | null = null, canViewAll = false): string {
  const myLikedExpression =
    userId === null
      ? 'false'
      : `EXISTS (SELECT 1 FROM life_comment_likes my_like WHERE my_like.comment_id = lc.id AND my_like.user_id = ${userId})`;
  const replyVisibility = [
    'reply.parent_comment_id = lc.id',
    visibleLifeCommentSql('reply', 'reply.created_by_user_id', userId),
    moderationVisibilitySql('reply', 'reply.created_by_user_id', userId, canViewAll)
  ].join(' AND ');

  return `
    SELECT
      lc.id,
      lc.post_id AS "postId",
      lc.parent_comment_id AS "parentCommentId",
      lc.body,
      lc.deleted_at IS NOT NULL AS deleted,
      lc.ai_moderation_status AS "moderationStatus",
      lc.ai_moderation_language_code AS "moderationLanguageCode",
      lc.ai_moderation_reason AS "moderationReason",
      lc.created_at AS "createdAt",
      lc.created_at::text AS "createdAtCursor",
      lc.updated_at AS "updatedAt",
      CASE WHEN comment_user.id IS NULL THEN NULL ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name) END AS author,
      like_stats.like_count AS "likeCount",
      reply_stats.reply_count AS "replyCount",
      ${myLikedExpression} AS "myLiked"
    FROM life_post_comments lc
    LEFT JOIN LATERAL (
      SELECT COUNT(*)::integer AS like_count
      FROM life_comment_likes lcl
      WHERE lcl.comment_id = lc.id
    ) like_stats ON true
    LEFT JOIN LATERAL (
      SELECT COUNT(*)::integer AS reply_count
      FROM life_post_comments reply
      WHERE ${replyVisibility}
    ) reply_stats ON true
    LEFT JOIN users comment_user ON comment_user.id = lc.created_by_user_id
    ${whereClause}
  `;
}

function visibleLifeCommentExpression(alias: string, ownerColumn: string, userParamIndex: number | null): string {
  return userParamIndex !== null ? `(${alias}.deleted_at IS NULL OR ${ownerColumn} = $${userParamIndex})` : `${alias}.deleted_at IS NULL`;
}

function addVisibleLifeCommentCondition(conditions: string[], params: unknown[], userId: number | null): void {
  const userParamIndex = params.length + 1;
  if (userId !== null) {
    params.push(userId);
  }
  conditions.push(visibleLifeCommentExpression('lc', 'lc.created_by_user_id', userId === null ? null : userParamIndex));
  conditions.push(`
    (
      lc.parent_comment_id IS NULL
      OR EXISTS (
        SELECT 1
        FROM life_post_comments parent_comment
        WHERE parent_comment.id = lc.parent_comment_id
          AND ${visibleLifeCommentExpression('parent_comment', 'parent_comment.created_by_user_id', userId === null ? null : userParamIndex)}
      )
    )
  `);
}

function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] {
  const comments = new Map<number, LifeComment>();
  const topLevelComments: LifeComment[] = [];

  for (const row of rows) {
    const { createdAtCursor: _createdAtCursor, ...comment } = row;
    comments.set(row.id, { ...comment, replies: [] });
  }

  for (const comment of comments.values()) {
    if (comment.parentCommentId === null) {
      topLevelComments.push(comment);
      continue;
    }

    const parent = comments.get(comment.parentCommentId);
    if (parent?.parentCommentId === null) {
      parent.replies.push(comment);
    } else {
      topLevelComments.push(comment);
    }
  }

  return topLevelComments;
}

async function lifeCommentCountsForPosts(
  postIds: number[],
  userId: number | null,
  canViewAll: boolean
): Promise<Map<number, number>> {
  const countsByPost = new Map<number, number>();
  for (const postId of postIds) {
    countsByPost.set(postId, 0);
  }

  if (postIds.length === 0) {
    return countsByPost;
  }

  const params: unknown[] = [postIds];
  const conditions = ['lc.post_id = ANY($1::integer[])'];
  addVisibleLifeCommentCondition(conditions, params, userId);
  addModerationVisibilityCondition(conditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);

  const rows = await query<{ postId: number; total: number }>(
    `
      SELECT post_id AS "postId", COUNT(*)::integer AS total
      FROM life_post_comments lc
      WHERE ${conditions.join(' AND ')}
      GROUP BY post_id
    `,
    params
  );

  for (const row of rows) {
    countsByPost.set(row.postId, row.total);
  }

  return countsByPost;
}

async function lifeCommentPreviewForPosts(
  postIds: number[],
  userId: number | null,
  canViewAll: boolean
): Promise<Map<number, LifeComment[]>> {
  const commentsByPost = new Map<number, LifeComment[]>();
  if (postIds.length === 0) {
    return commentsByPost;
  }

  const params: unknown[] = [postIds];
  const previewConditions = ['lc.post_id = ANY($1::integer[])', 'lc.parent_comment_id IS NULL'];
  addVisibleLifeCommentCondition(previewConditions, params, userId);
  addModerationVisibilityCondition(previewConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
  params.push(lifeCommentPreviewLimit);

  const rows = await query<LifeCommentRow>(
    `
      WITH preview_top AS (
        SELECT id
        FROM (
          SELECT
            lc.id,
            ROW_NUMBER() OVER (PARTITION BY lc.post_id ORDER BY lc.created_at DESC, lc.id DESC) AS preview_rank
          FROM life_post_comments lc
          WHERE ${previewConditions.join(' AND ')}
        ) ranked
        WHERE preview_rank <= $${params.length}
      )
      ${lifeCommentProjection('WHERE lc.id IN (SELECT id FROM preview_top)', userId, canViewAll)}
      ORDER BY lc.post_id, lc.created_at, lc.id
    `,
    params
  );

  for (const postId of postIds) {
    commentsByPost.set(postId, buildLifeCommentTree(rows.filter((comment) => comment.postId === postId)));
  }

  return commentsByPost;
}

export async function listLifeComments(
  postIdValue: number,
  paramsQuery: QueryParams = {},
  userId: number | null = null,
  canViewAll = false
): Promise<LifeCommentsPage | null> {
  const postId = requirePositiveInteger(postIdValue, 'server.validation.recordInvalid');
  const cursor = decodeCommentCursor(paramsQuery.cursor);
  const limit = cleanCommentLimit(paramsQuery.limit);
  const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
  const sort = cleanCommentSort(paramsQuery.sort);
  const postParams: unknown[] = [postId];
  const postConditions = ['lp.id = $1', 'lp.deleted_at IS NULL'];
  addModerationVisibilityCondition(postConditions, postParams, 'lp', 'lp.created_by_user_id', userId, canViewAll);
  const exists = await queryOne<{ exists: boolean }>(
    `
      SELECT EXISTS (
        SELECT 1
        FROM life_posts lp
        WHERE ${postConditions.join(' AND ')}
      ) AS exists
    `,
    postParams
  );

  if (exists?.exists !== true) {
    return null;
  }

  const params: unknown[] = [postId];
  const topLevelConditions = ['lc.post_id = $1', 'lc.parent_comment_id IS NULL'];
  addVisibleLifeCommentCondition(topLevelConditions, params, userId);
  addModerationVisibilityCondition(topLevelConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
  addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode);

  if (cursor) {
    addCommentCursorCondition(topLevelConditions, params, 'lc', cursor, sort);
  }

  params.push(limit + 1);
  const topLevelRows = await query<LifeCommentRow>(
    `
      ${lifeCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`, userId, canViewAll)}
      ORDER BY ${commentSortOrder('lc', sort)}
      LIMIT $${params.length}
    `,
    params
  );
  const hasMore = topLevelRows.length > limit;
  const topLevelComments = hasMore ? topLevelRows.slice(0, limit) : topLevelRows;
  const topLevelIds = topLevelComments.map((comment) => comment.id);
  const replyRows = topLevelIds.length
    ? await (async () => {
        const replyParams: unknown[] = [topLevelIds];
        const replyConditions = ['lc.parent_comment_id = ANY($1::integer[])'];
        addVisibleLifeCommentCondition(replyConditions, replyParams, userId);
        addModerationVisibilityCondition(replyConditions, replyParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
        addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode);
        return query<LifeCommentRow>(
          `
            ${lifeCommentProjection(`WHERE ${replyConditions.join(' AND ')}`, userId, canViewAll)}
            ORDER BY lc.created_at, lc.id
          `,
          replyParams
        );
      })()
    : [];
  const totalParams: unknown[] = [postId];
  const totalConditions = ['lc.post_id = $1'];
  addVisibleLifeCommentCondition(totalConditions, totalParams, userId);
  addModerationVisibilityCondition(totalConditions, totalParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
  addModerationLanguageCondition(totalConditions, totalParams, 'lc', languageCode);
  const total = await queryOne<{ total: number }>(
    `
      SELECT COUNT(*)::integer AS total
      FROM life_post_comments lc
      WHERE ${totalConditions.join(' AND ')}
    `,
    totalParams
  );

  return {
    items: buildLifeCommentTree([...topLevelComments, ...replyRows]),
    nextCursor:
      hasMore && topLevelComments.length > 0
        ? encodeCommentCursor(topLevelComments[topLevelComments.length - 1], sort)
        : null,
    hasMore,
    total: total?.total ?? 0
  };
}

async function lifeReactionsForPosts(
  postIds: number[],
  userId: number | null
): Promise<{
  countsByPost: Map<number, LifeReactionCounts>;
  myReactionsByPost: Map<number, LifeReactionType>;
}> {
  const countsByPost = new Map<number, LifeReactionCounts>();
  const myReactionsByPost = new Map<number, LifeReactionType>();

  for (const postId of postIds) {
    countsByPost.set(postId, emptyLifeReactionCounts());
  }

  if (postIds.length === 0) {
    return { countsByPost, myReactionsByPost };
  }

  const countRows = await query<{ postId: number; reactionType: LifeReactionType; count: number }>(
    `
      SELECT
        post_id AS "postId",
        reaction_type AS "reactionType",
        COUNT(*)::integer AS count
      FROM life_post_reactions
      WHERE post_id = ANY($1::integer[])
      GROUP BY post_id, reaction_type
    `,
    [postIds]
  );

  for (const row of countRows) {
    const counts = countsByPost.get(row.postId);
    if (counts && isLifeReactionType(row.reactionType)) {
      counts[row.reactionType] = row.count;
    }
  }

  if (userId !== null) {
    const myRows = await query<{ postId: number; reactionType: LifeReactionType }>(
      `
        SELECT post_id AS "postId", reaction_type AS "reactionType"
        FROM life_post_reactions
        WHERE post_id = ANY($1::integer[])
          AND user_id = $2
      `,
      [postIds, userId]
    );

    for (const row of myRows) {
      if (isLifeReactionType(row.reactionType)) {
        myReactionsByPost.set(row.postId, row.reactionType);
      }
    }
  }

  return { countsByPost, myReactionsByPost };
}

export async function listLifePostReactionUsers(
  postIdValue: number,
  paramsQuery: QueryParams = {},
  userId: number | null = null,
  canViewAll = false
): Promise<LifeReactionUsersPage | null> {
  const postId = requirePositiveInteger(postIdValue, 'server.validation.recordInvalid');
  const cursor = decodeLifeReactionUserCursor(paramsQuery.cursor);
  const limit = cleanLifePostLimit(paramsQuery.limit);
  const reactionType = cleanLifeReactionFilter(paramsQuery.reactionType);
  const postParams: unknown[] = [postId];
  const postConditions = ['lp.id = $1', 'lp.deleted_at IS NULL'];
  addModerationVisibilityCondition(postConditions, postParams, 'lp', 'lp.created_by_user_id', userId, canViewAll);
  const exists = await queryOne<{ exists: boolean }>(
    `
      SELECT EXISTS (
        SELECT 1
        FROM life_posts lp
        WHERE ${postConditions.join(' AND ')}
      ) AS exists
    `,
    postParams
  );

  if (exists?.exists !== true) {
    return null;
  }

  const params: unknown[] = [postId];
  const conditions = ['lpr.post_id = $1'];
  if (reactionType) {
    params.push(reactionType);
    conditions.push(`lpr.reaction_type = $${params.length}`);
  }
  if (cursor) {
    params.push(cursor.reactedAt, cursor.userId);
    conditions.push(`(lpr.updated_at, lpr.user_id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
  }

  params.push(limit + 1);
  const rows = await query<{
    userId: number;
    displayName: string;
    reactionType: LifeReactionType;
    reactedAt: Date;
    reactedAtCursor: string;
  }>(
    `
      SELECT
        u.id AS "userId",
        u.display_name AS "displayName",
        lpr.reaction_type AS "reactionType",
        lpr.updated_at AS "reactedAt",
        lpr.updated_at::text AS "reactedAtCursor"
      FROM life_post_reactions lpr
      JOIN users u ON u.id = lpr.user_id
      WHERE ${conditions.join(' AND ')}
      ORDER BY lpr.updated_at DESC, lpr.user_id DESC
      LIMIT $${params.length}
    `,
    params
  );
  const hasMore = rows.length > limit;
  const items = hasMore ? rows.slice(0, limit) : rows;
  const totalParams: unknown[] = [postId];
  const totalConditions = ['post_id = $1'];
  if (reactionType) {
    totalParams.push(reactionType);
    totalConditions.push(`reaction_type = $${totalParams.length}`);
  }
  const total = await queryOne<{ total: number }>(
    `
      SELECT COUNT(*)::integer AS total
      FROM life_post_reactions
      WHERE ${totalConditions.join(' AND ')}
    `,
    totalParams
  );

  return {
    items: items.map((item) => ({
      user: { id: item.userId, displayName: item.displayName },
      reactionType: item.reactionType,
      reactedAt: item.reactedAt
    })),
    nextCursor:
      hasMore && items.length > 0
        ? encodeLifeReactionUserCursor({
            reactedAt: items[items.length - 1].reactedAtCursor,
            userId: items[items.length - 1].userId
          })
        : null,
    hasMore,
    total: total?.total ?? 0
  };
}

async function getLifeCommentById(id: number, userId: number | null = null, canViewAll = false): Promise<LifeComment | null> {
  const row = await queryOne<LifeCommentRow>(
    `
      ${lifeCommentProjection('WHERE lc.id = $1', userId, canViewAll)}
    `,
    [id]
  );

  if (!row) {
    return null;
  }

  const { createdAtCursor: _createdAtCursor, ...comment } = row;
  return { ...comment, replies: [] };
}

async function listLifePostsWithFilters(
  paramsQuery: QueryParams = {},
  userId: number | null = null,
  locale = defaultLocale,
  filters: LifePostFilters = {},
  canViewAll = false
): Promise<LifePostsPage> {
  const cursor = decodeLifePostCursor(paramsQuery.cursor);
  const limit = cleanLifePostLimit(paramsQuery.limit);
  const sort = cleanLifePostSort(paramsQuery.sort);
  const search = asString(paramsQuery.search)?.trim();
  const gameVersionIdValue = asString(paramsQuery.gameVersionId)?.trim();
  const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
  const params: unknown[] = [];
  const conditions: string[] = ['lp.deleted_at IS NULL'];

  if (filters.authorId !== undefined) {
    params.push(filters.authorId);
    conditions.push(`lp.created_by_user_id = $${params.length}`);
  }

  if (filters.followedByUserId !== undefined) {
    params.push(filters.followedByUserId);
    conditions.push(`
      EXISTS (
        SELECT 1
        FROM user_follows uf
        WHERE uf.follower_user_id = $${params.length}
          AND uf.followed_user_id = lp.created_by_user_id
      )
    `);
  }

  addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', userId, canViewAll);
  addModerationLanguageCondition(conditions, params, 'lp', languageCode);

  if (search) {
    params.push(`%${search}%`);
    conditions.push(`lp.body ILIKE $${params.length}`);
  }

  if (gameVersionIdValue && gameVersionIdValue !== 'all') {
    const gameVersionId = requirePositiveInteger(gameVersionIdValue, 'server.validation.gameVersionInvalid');
    params.push(gameVersionId);
    conditions.push(`lp.game_version_id = $${params.length}`);
  }

  if (cursor) {
    params.push(cursor.createdAt, cursor.id);
    conditions.push(
      `(lp.created_at, lp.id) ${sort === 'oldest' ? '>' : '<'} ($${params.length - 1}::timestamptz, $${params.length}::integer)`
    );
  }

  const orderClause = `ORDER BY lp.created_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, lp.id ${sort === 'oldest' ? 'ASC' : 'DESC'}`;
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
  params.push(limit + 1);
  const rows = await query<LifePostRow>(
    `
      ${lifePostProjection(locale)}
      ${whereClause}
      ${orderClause}
      LIMIT $${params.length}
    `,
    params
  );
  const hasMore = rows.length > limit;
  const posts = hasMore ? rows.slice(0, limit) : rows;

  const postIds = posts.map((post) => post.id);
  const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, userId, canViewAll);
  const commentCountsByPost = await lifeCommentCountsForPosts(postIds, userId, canViewAll);
  const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);

  return {
    items: posts.map((post) =>
      hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost)
    ),
    nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null,
    hasMore
  };
}

export async function listLifePosts(
  paramsQuery: QueryParams = {},
  userId: number | null = null,
  locale = defaultLocale,
  canViewAll = false
): Promise<LifePostsPage> {
  return listLifePostsWithFilters(paramsQuery, userId, locale, {}, canViewAll);
}

export async function listFollowingLifePosts(
  userId: number,
  paramsQuery: QueryParams = {},
  locale = defaultLocale,
  canViewAll = false
): Promise<LifePostsPage> {
  return listLifePostsWithFilters(paramsQuery, userId, locale, { followedByUserId: userId }, canViewAll);
}

async function getPublicProfileUser(userIdValue: number): Promise<PublicProfileUser | null> {
  const userId = requirePositiveInteger(userIdValue, 'server.validation.recordInvalid');
  return queryOne<PublicProfileUser>(
    `
      SELECT
        id,
        display_name AS "displayName",
        created_at AS "joinedAt"
      FROM users
      WHERE id = $1
    `,
    [userId]
  );
}

function publicContributionType(entityType: string): string {
  return entityType === 'daily-checklist-items' ? 'daily-checklist' : entityType;
}

async function getPublicProfileSocial(userId: number, viewerUserId: number | null): Promise<PublicProfileSocial> {
  const social = await queryOne<
    Omit<PublicProfileSocial, 'viewerRelation'> & {
      viewerFollows: boolean;
      targetFollowsViewer: boolean;
    }
  >(
    `
      SELECT
        COALESCE((SELECT COUNT(*)::integer FROM user_follows WHERE followed_user_id = $1), 0) AS "followerCount",
        COALESCE((SELECT COUNT(*)::integer FROM user_follows WHERE follower_user_id = $1), 0) AS "followingCount",
        COALESCE((
          SELECT COUNT(*)::integer
          FROM user_follows outgoing
          WHERE outgoing.follower_user_id = $1
            AND EXISTS (
              SELECT 1
              FROM user_follows incoming
              WHERE incoming.follower_user_id = outgoing.followed_user_id
                AND incoming.followed_user_id = $1
            )
        ), 0) AS "friendCount",
        CASE
          WHEN $2::integer IS NULL OR $2::integer = $1 THEN false
          ELSE EXISTS (
            SELECT 1
            FROM user_follows
            WHERE follower_user_id = $2::integer
              AND followed_user_id = $1
          )
        END AS "viewerFollows",
        CASE
          WHEN $2::integer IS NULL OR $2::integer = $1 THEN false
          ELSE EXISTS (
            SELECT 1
            FROM user_follows
            WHERE follower_user_id = $1
              AND followed_user_id = $2::integer
          )
        END AS "targetFollowsViewer"
    `,
    [userId, viewerUserId]
  );

  const viewerRelation =
    social?.viewerFollows && social.targetFollowsViewer
      ? 'friends'
      : social?.viewerFollows
        ? 'following'
        : social?.targetFollowsViewer
          ? 'followed-by'
          : 'none';

  return {
    followerCount: social?.followerCount ?? 0,
    followingCount: social?.followingCount ?? 0,
    friendCount: social?.friendCount ?? 0,
    viewerRelation
  };
}

export async function getPublicUserProfile(userIdValue: number, viewerUserId: number | null = null): Promise<PublicUserProfile | null> {
  const user = await getPublicProfileUser(userIdValue);
  if (!user) {
    return null;
  }

  const stats = await queryOne<PublicProfileStats>(
    `
      SELECT
        COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1), 0) AS "wikiEdits",
        COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'create'), 0) AS "wikiCreates",
        COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'update'), 0) AS "wikiUpdates",
        COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'delete'), 0) AS "wikiDeletes",
        COALESCE((SELECT COUNT(*)::integer FROM entity_image_uploads WHERE created_by_user_id = $1), 0) AS "imageUploads",
        COALESCE((
          SELECT COUNT(*)::integer
          FROM life_posts
          WHERE created_by_user_id = $1
            AND deleted_at IS NULL
            AND ai_moderation_status = 'approved'
        ), 0) AS "lifePosts",
        COALESCE((
          SELECT COUNT(*)::integer
          FROM life_post_comments lc
          JOIN life_posts lp ON lp.id = lc.post_id
          WHERE lc.created_by_user_id = $1
            AND lc.deleted_at IS NULL
            AND lp.deleted_at IS NULL
            AND lc.ai_moderation_status = 'approved'
            AND lp.ai_moderation_status = 'approved'
        ), 0) AS "lifeComments",
        COALESCE((
          SELECT COUNT(*)::integer
          FROM life_post_reactions lpr
          JOIN life_posts lp ON lp.id = lpr.post_id
          WHERE lpr.user_id = $1
            AND lp.deleted_at IS NULL
            AND lp.ai_moderation_status = 'approved'
        ), 0) AS "lifeReactions",
        COALESCE((
          SELECT COUNT(*)::integer
          FROM entity_discussion_comments
          WHERE created_by_user_id = $1
            AND deleted_at IS NULL
            AND ai_moderation_status = 'approved'
        ), 0) AS "discussionComments"
    `,
    [user.id]
  );

  const contributions = await query<PublicProfileContribution>(
    `
      SELECT
        entity_type AS "contentType",
        COUNT(*)::integer AS total,
        COUNT(*) FILTER (WHERE action = 'create')::integer AS creates,
        COUNT(*) FILTER (WHERE action = 'update')::integer AS updates,
        COUNT(*) FILTER (WHERE action = 'delete')::integer AS deletes,
        MAX(created_at) AS "lastContributedAt"
      FROM wiki_edit_logs
      WHERE user_id = $1
      GROUP BY entity_type
      ORDER BY total DESC, "lastContributedAt" DESC, entity_type
    `,
    [user.id]
  );

  const social = await getPublicProfileSocial(user.id, viewerUserId);

  return {
    user,
    stats: stats ?? {
      wikiEdits: 0,
      wikiCreates: 0,
      wikiUpdates: 0,
      wikiDeletes: 0,
      imageUploads: 0,
      lifePosts: 0,
      lifeComments: 0,
      lifeReactions: 0,
      discussionComments: 0
    },
    social,
    contributions: contributions.map((item) => ({
      ...item,
      contentType: publicContributionType(item.contentType)
    }))
  };
}

export async function followUser(followerUserId: number, followedUserIdValue: number): Promise<PublicUserProfile | null> {
  const followedUserId = requirePositiveInteger(followedUserIdValue, 'server.validation.recordInvalid');
  if (followerUserId === followedUserId) {
    throw validationError('server.validation.cannotFollowSelf');
  }

  const followedUser = await getPublicProfileUser(followedUserId);
  if (!followedUser) {
    return null;
  }

  const inserted = await queryOne<{ inserted: boolean }>(
    `
      INSERT INTO user_follows (follower_user_id, followed_user_id)
      VALUES ($1, $2)
      ON CONFLICT (follower_user_id, followed_user_id) DO NOTHING
      RETURNING true AS inserted
    `,
    [followerUserId, followedUser.id]
  );

  if (inserted?.inserted === true) {
    await createUserFollowNotification(followerUserId, followedUser.id);
  }

  return getPublicUserProfile(followedUser.id, followerUserId);
}

export async function unfollowUser(followerUserId: number, followedUserIdValue: number): Promise<PublicUserProfile | null> {
  const followedUserId = requirePositiveInteger(followedUserIdValue, 'server.validation.recordInvalid');
  if (followerUserId === followedUserId) {
    throw validationError('server.validation.cannotFollowSelf');
  }

  const followedUser = await getPublicProfileUser(followedUserId);
  if (!followedUser) {
    return null;
  }

  await query(
    `
      DELETE FROM user_follows
      WHERE follower_user_id = $1
        AND followed_user_id = $2
    `,
    [followerUserId, followedUser.id]
  );

  return getPublicUserProfile(followedUser.id, followerUserId);
}

export async function listUserLifePosts(
  userIdValue: number,
  paramsQuery: QueryParams = {},
  viewerUserId: number | null = null,
  locale = defaultLocale,
  canViewAll = false
): Promise<LifePostsPage | null> {
  const user = await getPublicProfileUser(userIdValue);
  if (!user) {
    return null;
  }

  return listLifePostsWithFilters(paramsQuery, viewerUserId, locale, { authorId: user.id }, canViewAll);
}

async function hydrateLifePostsById(
  postIds: number[],
  viewerUserId: number | null,
  locale: string,
  canViewAll = false
): Promise<Map<number, LifePost>> {
  const postById = new Map<number, LifePost>();
  if (postIds.length === 0) {
    return postById;
  }

  const params: unknown[] = [postIds];
  const conditions = ['lp.id = ANY($1::integer[])', 'lp.deleted_at IS NULL'];
  addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', viewerUserId, canViewAll);
  const posts = await query<LifePostRow>(
    `
      ${lifePostProjection(locale)}
      WHERE ${conditions.join(' AND ')}
    `,
    params
  );
  const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, viewerUserId, canViewAll);
  const commentCountsByPost = await lifeCommentCountsForPosts(postIds, viewerUserId, canViewAll);
  const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, viewerUserId);

  for (const post of posts) {
    postById.set(post.id, hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost));
  }

  return postById;
}

export async function listUserReactionActivities(
  userIdValue: number,
  paramsQuery: QueryParams = {},
  viewerUserId: number | null = null,
  locale = defaultLocale
): Promise<UserReactionActivityPage | null> {
  const user = await getPublicProfileUser(userIdValue);
  if (!user) {
    return null;
  }

  const cursor = decodeLifePostCursor(paramsQuery.cursor);
  const limit = cleanLifePostLimit(paramsQuery.limit);
  const reactionType = cleanLifeReactionFilter(paramsQuery.reactionType);
  const params: unknown[] = [user.id];
  const conditions = ['lpr.user_id = $1', 'lp.deleted_at IS NULL', "lp.ai_moderation_status = 'approved'"];

  if (reactionType) {
    params.push(reactionType);
    conditions.push(`lpr.reaction_type = $${params.length}`);
  }

  if (cursor) {
    params.push(cursor.createdAt, cursor.id);
    conditions.push(`(lpr.updated_at, lpr.post_id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
  }

  params.push(limit + 1);
  const rows = await query<{
    postId: number;
    reactionType: LifeReactionType;
    reactedAt: Date;
    reactedAtCursor: string;
  }>(
    `
      SELECT
        lpr.post_id AS "postId",
        lpr.reaction_type AS "reactionType",
        lpr.updated_at AS "reactedAt",
        lpr.updated_at::text AS "reactedAtCursor"
      FROM life_post_reactions lpr
      JOIN life_posts lp ON lp.id = lpr.post_id
      WHERE ${conditions.join(' AND ')}
      ORDER BY lpr.updated_at DESC, lpr.post_id DESC
      LIMIT $${params.length}
    `,
    params
  );
  const hasMore = rows.length > limit;
  const activities = hasMore ? rows.slice(0, limit) : rows;
  const postById = await hydrateLifePostsById(
    activities.map((activity) => activity.postId),
    viewerUserId,
    locale
  );

  return {
    items: activities.flatMap((activity) => {
      const post = postById.get(activity.postId);
      return post
        ? [
            {
              postId: activity.postId,
              reactionType: activity.reactionType,
              reactedAt: activity.reactedAt,
              post
            }
          ]
        : [];
    }),
    nextCursor:
      hasMore && activities.length > 0
        ? encodeProfileCursor({
            createdAt: activities[activities.length - 1].reactedAtCursor,
            id: activities[activities.length - 1].postId
          })
        : null,
    hasMore
  };
}

export async function listUserCommentActivities(
  userIdValue: number,
  paramsQuery: QueryParams = {},
  locale = defaultLocale
): Promise<UserCommentActivityPage | null> {
  const user = await getPublicProfileUser(userIdValue);
  if (!user) {
    return null;
  }

  const cursor = decodeUserCommentActivityCursor(paramsQuery.cursor);
  const limit = cleanLifePostLimit(paramsQuery.limit);
  const sourceFilter = cleanUserCommentActivitySourceFilter(paramsQuery.source);
  const pokemonName = localizedName('pokemon', 'p', locale);
  const itemName = localizedName('items', 'i', locale);
  const recipeItemName = localizedName('items', 'recipe_item', locale);
  const habitatName = localizedName('habitats', 'h', locale);
  const artifactName = localizedName('items', 'artifact_item', locale);
  const params: unknown[] = [user.id];
  const outerConditions: string[] = [];

  if (sourceFilter) {
    params.push(sourceFilter);
    outerConditions.push(`source = $${params.length}`);
  }

  if (cursor) {
    params.push(cursor.createdAt, cursor.source, cursor.id);
    outerConditions.push(
      `(created_at, source, id) < ($${params.length - 2}::timestamptz, $${params.length - 1}::text, $${params.length}::integer)`
    );
  }

  params.push(limit + 1);
  const outerWhere = outerConditions.length ? `WHERE ${outerConditions.join(' AND ')}` : '';
  const rows = await query<{
    id: number;
    source: UserCommentActivitySource;
    body: string;
    createdAt: Date;
    createdAtCursor: string;
    targetType: 'life-post' | DiscussionEntityType;
    targetId: number;
    targetTitle: string;
    targetExcerpt: string;
  }>(
    `
      WITH activity AS (
        SELECT
          'life'::text AS source,
          lc.id,
          lc.body,
          lc.created_at,
          lc.created_at::text AS cursor_at,
          'life-post'::text AS target_type,
          lp.id AS target_id,
          COALESCE(post_user.display_name, '') AS target_title,
          lp.body AS target_excerpt
        FROM life_post_comments lc
        JOIN life_posts lp ON lp.id = lc.post_id
        LEFT JOIN users post_user ON post_user.id = lp.created_by_user_id
        WHERE lc.created_by_user_id = $1
          AND lc.deleted_at IS NULL
          AND lp.deleted_at IS NULL
          AND lc.ai_moderation_status = 'approved'
          AND lp.ai_moderation_status = 'approved'

        UNION ALL

        SELECT
          'discussion'::text AS source,
          edc.id,
          edc.body,
          edc.created_at,
          edc.created_at::text AS cursor_at,
          edc.entity_type AS target_type,
          edc.entity_id AS target_id,
          COALESCE(
            CASE edc.entity_type
              WHEN 'pokemon' THEN ${pokemonName}
              WHEN 'items' THEN ${itemName}
              WHEN 'recipes' THEN ${recipeItemName}
              WHEN 'habitats' THEN ${habitatName}
              WHEN 'ancient-artifacts' THEN ${artifactName}
              ELSE ''
            END,
            ''
          ) AS target_title,
          ''::text AS target_excerpt
        FROM entity_discussion_comments edc
        LEFT JOIN pokemon p ON edc.entity_type = 'pokemon' AND p.id = edc.entity_id
        LEFT JOIN items i ON edc.entity_type = 'items' AND i.id = edc.entity_id
        LEFT JOIN recipes r ON edc.entity_type = 'recipes' AND r.id = edc.entity_id
        LEFT JOIN items recipe_item ON recipe_item.id = r.item_id
        LEFT JOIN habitats h ON edc.entity_type = 'habitats' AND h.id = edc.entity_id
        LEFT JOIN items artifact_item ON edc.entity_type = 'ancient-artifacts' AND artifact_item.id = edc.entity_id
        WHERE edc.created_by_user_id = $1
          AND edc.deleted_at IS NULL
          AND edc.ai_moderation_status = 'approved'
      )
      SELECT
        source,
        id,
        body,
        created_at AS "createdAt",
        cursor_at AS "createdAtCursor",
        target_type AS "targetType",
        target_id AS "targetId",
        target_title AS "targetTitle",
        target_excerpt AS "targetExcerpt"
      FROM activity
      ${outerWhere}
      ORDER BY created_at DESC, source DESC, id DESC
      LIMIT $${params.length}
    `,
    params
  );
  const hasMore = rows.length > limit;
  const activities = hasMore ? rows.slice(0, limit) : rows;

  return {
    items: activities.map((activity) => ({
      id: activity.id,
      source: activity.source,
      body: activity.body,
      createdAt: activity.createdAt,
      target: {
        type: activity.targetType,
        id: activity.targetId,
        title: activity.targetTitle,
        excerpt: activity.targetExcerpt
      }
    })),
    nextCursor:
      hasMore && activities.length > 0
        ? encodeUserCommentActivityCursor({
            createdAt: activities[activities.length - 1].createdAtCursor,
            id: activities[activities.length - 1].id,
            source: activities[activities.length - 1].source
          })
        : null,
    hasMore
  };
}

async function getLifePostById(
  id: number,
  userId: number | null = null,
  locale = defaultLocale,
  options: { enforceVisibility?: boolean; canViewAll?: boolean } = {}
): Promise<LifePost | null> {
  const params: unknown[] = [id];
  const conditions = ['lp.id = $1', 'lp.deleted_at IS NULL'];

  if (options.enforceVisibility) {
    addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', userId, options.canViewAll === true);
  }

  const post = await queryOne<LifePostRow>(
    `
      ${lifePostProjection(locale)}
      WHERE ${conditions.join(' AND ')}
    `,
    params
  );

  if (!post) {
    return null;
  }

  const canViewAll = options.canViewAll === true;
  const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id], userId, canViewAll);
  const commentCountsByPost = await lifeCommentCountsForPosts([post.id], userId, canViewAll);
  const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
  return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost);
}

export async function getLifePost(
  idValue: number,
  userId: number | null = null,
  locale = defaultLocale,
  canViewAll = false
): Promise<LifePost | null> {
  const id = requirePositiveInteger(idValue, 'server.validation.recordInvalid');
  return getLifePostById(id, userId, locale, { enforceVisibility: true, canViewAll });
}

async function ensureGameVersion(client: DbClient, gameVersionId: number | null): Promise<void> {
  if (gameVersionId === null) {
    return;
  }

  const result = await client.query<{ id: number }>('SELECT id FROM game_versions WHERE id = $1', [gameVersionId]);
  if (result.rowCount === 0) {
    throw validationError('server.validation.gameVersionInvalid');
  }
}

export async function createLifePost(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanLifePostPayload(payload);

  const id = await withTransaction(async (client) => {
    await ensureGameVersion(client, cleanPayload.gameVersionId);
    const result = await client.query<{ id: number }>(
      `
        INSERT INTO life_posts (
          body,
          game_version_id,
          ai_moderation_status,
          ai_moderation_language_code,
          created_by_user_id,
          updated_by_user_id
        )
        VALUES ($1, $2, 'reviewing', NULL, $3, $3)
        RETURNING id
      `,
      [cleanPayload.body, cleanPayload.gameVersionId, userId]
    );

    return result.rows[0].id;
  });

  await requestAiModerationReview({ type: 'life-post', id }, { languageCode: cleanPayload.languageCode, resetRetries: true });
  return getLifePostById(id, userId, locale);
}

export async function updateLifePost(
  id: number,
  payload: Record<string, unknown>,
  userId: number,
  locale = defaultLocale,
  allowAny = false
) {
  const cleanPayload = cleanLifePostPayload(payload);

  const updatedId = await withTransaction(async (client) => {
    await ensureGameVersion(client, cleanPayload.gameVersionId);
    const result = await client.query<{ id: number }>(
      `
        UPDATE life_posts
        SET body = $1,
            game_version_id = $2,
            ai_moderation_status = 'reviewing',
            ai_moderation_language_code = NULL,
            ai_moderation_content_hash = NULL,
            ai_moderation_checked_at = NULL,
            ai_moderation_retry_count = 0,
            ai_moderation_updated_at = now(),
            updated_by_user_id = $3,
            updated_at = now()
        WHERE id = $4
          AND ($5 = true OR created_by_user_id = $3)
          AND deleted_at IS NULL
        RETURNING id
      `,
      [cleanPayload.body, cleanPayload.gameVersionId, userId, id, allowAny]
    );

    return result.rows[0]?.id ?? null;
  });

  if (updatedId) {
    await requestAiModerationReview(
      { type: 'life-post', id: updatedId },
      { languageCode: cleanPayload.languageCode, resetRetries: true }
    );
  }

  return updatedId ? getLifePostById(updatedId, userId, locale) : null;
}

export async function deleteLifePost(id: number, userId: number, allowAny = false) {
  const result = await queryOne<{ id: number }>(
    `
      UPDATE life_posts
      SET deleted_at = now(),
          deleted_by_user_id = $2,
          updated_by_user_id = $2,
          updated_at = now()
      WHERE id = $1
        AND ($3 = true OR created_by_user_id = $2)
        AND deleted_at IS NULL
      RETURNING id
    `,
    [id, userId, allowAny]
  );

  return Boolean(result);
}

export async function retryLifePostModeration(id: number, userId: number, locale = defaultLocale, allowAny = false) {
  const postId = requirePositiveInteger(id, 'server.validation.recordInvalid');
  const row = await queryOne<{ id: number }>(
    `
      SELECT id
      FROM life_posts
      WHERE id = $1
        AND ($3 = true OR created_by_user_id = $2)
        AND deleted_at IS NULL
        AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
    `,
    [postId, userId, allowAny]
  );

  if (!row) {
    return null;
  }

  await requestAiModerationReview({ type: 'life-post', id: postId }, { incrementRetries: true });
  return getLifePostById(postId, userId, locale);
}

export async function setLifePostReaction(
  postId: number,
  payload: Record<string, unknown>,
  userId: number,
  locale = defaultLocale
) {
  const reactionType = cleanLifeReactionType(payload.reactionType);

  const result = await queryOne<{ postId: number }>(
    `
      INSERT INTO life_post_reactions (post_id, user_id, reaction_type)
      SELECT $1, $2, $3
      WHERE EXISTS (
        SELECT 1
        FROM life_posts
        WHERE id = $1
          AND deleted_at IS NULL
          AND ai_moderation_status = 'approved'
      )
      ON CONFLICT (post_id, user_id)
      DO UPDATE SET reaction_type = EXCLUDED.reaction_type, updated_at = now()
      RETURNING post_id AS "postId"
    `,
    [postId, userId, reactionType]
  );

  if (result) {
    await createLifePostReactionNotification(result.postId, userId);
  }

  return result ? getLifePostById(result.postId, userId, locale) : null;
}

export async function deleteLifePostReaction(postId: number, userId: number, locale = defaultLocale) {
  await queryOne<{ postId: number }>(
    `
      DELETE FROM life_post_reactions
      WHERE post_id = $1
        AND user_id = $2
        AND EXISTS (
          SELECT 1
          FROM life_posts
          WHERE id = $1
            AND deleted_at IS NULL
            AND ai_moderation_status = 'approved'
        )
      RETURNING post_id AS "postId"
    `,
    [postId, userId]
  );

  return getLifePostById(postId, userId, locale);
}

export async function createLifeComment(postId: number, payload: Record<string, unknown>, userId: number) {
  const cleanPayload = cleanLifeCommentPayload(payload);

  const result = await queryOne<{ id: number }>(
    `
      INSERT INTO life_post_comments (post_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
      SELECT $1, $2, 'reviewing', NULL, $3
      WHERE EXISTS (
        SELECT 1
        FROM life_posts
        WHERE id = $1
          AND deleted_at IS NULL
          AND ai_moderation_status = 'approved'
      )
      RETURNING id
    `,
    [postId, cleanPayload.body, userId]
  );

  if (result) {
    await requestAiModerationReview(
      { type: 'life-comment', id: result.id },
      { languageCode: cleanPayload.languageCode, resetRetries: true }
    );
  }

  return result ? getLifeCommentById(result.id, userId, false) : null;
}

export async function createLifeCommentReply(
  postId: number,
  commentId: number,
  payload: Record<string, unknown>,
  userId: number
) {
  const cleanPayload = cleanLifeCommentPayload(payload);

  const result = await queryOne<{ id: number }>(
    `
      INSERT INTO life_post_comments (
        post_id,
        parent_comment_id,
        body,
        ai_moderation_status,
        ai_moderation_language_code,
        created_by_user_id
      )
      SELECT lc.post_id, lc.id, $3, 'reviewing', NULL, $4
      FROM life_post_comments lc
      JOIN life_posts lp ON lp.id = lc.post_id
      WHERE lc.post_id = $1
        AND lc.id = $2
        AND lc.parent_comment_id IS NULL
        AND lc.deleted_at IS NULL
        AND lc.ai_moderation_status = 'approved'
        AND lp.deleted_at IS NULL
        AND lp.ai_moderation_status = 'approved'
      RETURNING id
    `,
    [postId, commentId, cleanPayload.body, userId]
  );

  if (result) {
    await requestAiModerationReview(
      { type: 'life-comment', id: result.id },
      { languageCode: cleanPayload.languageCode, resetRetries: true }
    );
  }

  return result ? getLifeCommentById(result.id, userId, false) : null;
}

export async function deleteLifeComment(id: number, userId: number, allowAny = false) {
  const result = await queryOne<{ id: number }>(
    `
      UPDATE life_post_comments
      SET deleted_at = now(), deleted_by_user_id = $2, updated_at = now()
      WHERE id = $1
        AND ($3 = true OR created_by_user_id = $2)
        AND deleted_at IS NULL
      RETURNING id
    `,
    [id, userId, allowAny]
  );

  return Boolean(result);
}

export async function restoreLifeComment(id: number, userId: number) {
  const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
  const result = await queryOne<{ id: number }>(
    `
      UPDATE life_post_comments
      SET deleted_at = NULL, deleted_by_user_id = NULL, updated_at = now()
      WHERE id = $1
        AND created_by_user_id = $2
        AND deleted_at IS NOT NULL
        AND EXISTS (
          SELECT 1
          FROM life_posts lp
          WHERE lp.id = life_post_comments.post_id
            AND lp.deleted_at IS NULL
        )
      RETURNING id
    `,
    [commentId, userId]
  );

  return result ? getLifeCommentById(result.id, userId, false) : null;
}

export async function retryLifeCommentModeration(id: number, userId: number, allowAny = false) {
  const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
  const row = await queryOne<{ id: number }>(
    `
      SELECT id
      FROM life_post_comments
      WHERE id = $1
        AND ($3 = true OR created_by_user_id = $2)
        AND deleted_at IS NULL
        AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
    `,
    [commentId, userId, allowAny]
  );

  if (!row) {
    return null;
  }

  await requestAiModerationReview({ type: 'life-comment', id: commentId }, { incrementRetries: true });
  return getLifeCommentById(commentId, userId, allowAny);
}

async function approvedLifeCommentExists(commentId: number): Promise<boolean> {
  const row = await queryOne<{ id: number }>(
    `
      SELECT lc.id
      FROM life_post_comments lc
      JOIN life_posts lp ON lp.id = lc.post_id
      WHERE lc.id = $1
        AND lc.deleted_at IS NULL
        AND lc.ai_moderation_status = 'approved'
        AND lp.deleted_at IS NULL
        AND lp.ai_moderation_status = 'approved'
    `,
    [commentId]
  );

  return Boolean(row);
}

export async function setLifeCommentLike(id: number, userId: number): Promise<LifeComment | null> {
  const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
  if (!(await approvedLifeCommentExists(commentId))) {
    return null;
  }

  await queryOne<{ commentId: number }>(
    `
      INSERT INTO life_comment_likes (comment_id, user_id)
      VALUES ($1, $2)
      ON CONFLICT (comment_id, user_id) DO NOTHING
      RETURNING comment_id AS "commentId"
    `,
    [commentId, userId]
  );

  return getLifeCommentById(commentId, userId);
}

export async function deleteLifeCommentLike(id: number, userId: number): Promise<LifeComment | null> {
  const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
  if (!(await approvedLifeCommentExists(commentId))) {
    return null;
  }

  await queryOne<{ commentId: number }>(
    `
      DELETE FROM life_comment_likes
      WHERE comment_id = $1
        AND user_id = $2
      RETURNING comment_id AS "commentId"
    `,
    [commentId, userId]
  );

  return getLifeCommentById(commentId, userId);
}

function cleanDiscussionEntityType(value: unknown): DiscussionEntityType {
  if (typeof value !== 'string' || !Object.hasOwn(discussionEntityDefinitions, value)) {
    throw validationError('server.validation.entityTypeInvalid');
  }

  return value as DiscussionEntityType;
}

function cleanEntityDiscussionCommentPayload(payload: Record<string, unknown>): EntityDiscussionCommentPayload {
  const body = cleanName(payload.body, 'server.validation.commentRequired');
  if (body.length > 1000) {
    throw validationError('server.validation.commentTooLong');
  }

  return { body, languageCode: cleanModerationLanguageCode(payload.languageCode) };
}

async function entityDiscussionExists(
  client: Pick<DbClient, 'query'>,
  entityType: DiscussionEntityType,
  entityId: number
): Promise<boolean> {
  const definition = discussionEntityDefinitions[entityType];
  const result = await client.query<{ exists: boolean }>(
    `SELECT EXISTS (SELECT 1 FROM ${definition.table} WHERE id = $1) AS exists`,
    [entityId]
  );

  return result.rows[0]?.exists === true;
}

function entityDiscussionCommentProjection(whereClause: string, userId: number | null = null, canViewAll = false): string {
  const myLikedExpression =
    userId === null
      ? 'false'
      : `EXISTS (
          SELECT 1
          FROM entity_discussion_comment_likes my_like
          WHERE my_like.comment_id = edc.id AND my_like.user_id = ${userId}
        )`;
  const replyVisibility = [
    'reply.parent_comment_id = edc.id',
    'reply.deleted_at IS NULL',
    moderationVisibilitySql('reply', 'reply.created_by_user_id', userId, canViewAll)
  ].join(' AND ');

  return `
    SELECT
      edc.id,
      edc.entity_type AS "entityType",
      edc.entity_id AS "entityId",
      edc.parent_comment_id AS "parentCommentId",
      CASE WHEN edc.deleted_at IS NULL THEN edc.body ELSE '' END AS body,
      edc.deleted_at IS NOT NULL AS deleted,
      edc.ai_moderation_status AS "moderationStatus",
      edc.ai_moderation_language_code AS "moderationLanguageCode",
      edc.ai_moderation_reason AS "moderationReason",
      edc.created_at AS "createdAt",
      edc.created_at::text AS "createdAtCursor",
      edc.updated_at AS "updatedAt",
      CASE
        WHEN edc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
        ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name)
      END AS author,
      like_stats.like_count AS "likeCount",
      reply_stats.reply_count AS "replyCount",
      ${myLikedExpression} AS "myLiked"
    FROM entity_discussion_comments edc
    LEFT JOIN LATERAL (
      SELECT COUNT(*)::integer AS like_count
      FROM entity_discussion_comment_likes edcl
      WHERE edcl.comment_id = edc.id
    ) like_stats ON true
    LEFT JOIN LATERAL (
      SELECT COUNT(*)::integer AS reply_count
      FROM entity_discussion_comments reply
      WHERE ${replyVisibility}
    ) reply_stats ON true
    LEFT JOIN users comment_user ON comment_user.id = edc.created_by_user_id
    ${whereClause}
  `;
}

function buildEntityDiscussionCommentTree(rows: EntityDiscussionCommentRow[]): EntityDiscussionComment[] {
  const comments = new Map<number, EntityDiscussionComment>();
  const topLevelComments: EntityDiscussionComment[] = [];

  for (const row of rows) {
    const { createdAtCursor: _createdAtCursor, ...comment } = row;
    comments.set(row.id, { ...comment, replies: [] });
  }

  for (const comment of comments.values()) {
    if (comment.parentCommentId === null) {
      topLevelComments.push(comment);
      continue;
    }

    const parent = comments.get(comment.parentCommentId);
    if (parent?.parentCommentId === null) {
      parent.replies.push(comment);
    } else {
      topLevelComments.push(comment);
    }
  }

  return topLevelComments;
}

async function getEntityDiscussionCommentById(
  id: number,
  userId: number | null = null,
  canViewAll = false
): Promise<EntityDiscussionComment | null> {
  const row = await queryOne<EntityDiscussionCommentRow>(
    `
      ${entityDiscussionCommentProjection('WHERE edc.id = $1', userId, canViewAll)}
    `,
    [id]
  );

  if (!row) {
    return null;
  }

  const { createdAtCursor: _createdAtCursor, ...comment } = row;
  return { ...comment, replies: [] };
}

export async function listEntityDiscussionComments(
  entityTypeValue: string,
  entityIdValue: number,
  paramsQuery: QueryParams = {},
  userId: number | null = null,
  canViewAll = false
): Promise<EntityDiscussionCommentsPage | null> {
  const entityType = cleanDiscussionEntityType(entityTypeValue);
  const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
  const cursor = decodeCommentCursor(paramsQuery.cursor);
  const limit = cleanCommentLimit(paramsQuery.limit);
  const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
  const sort = cleanCommentSort(paramsQuery.sort);

  if (!(await entityDiscussionExists(pool, entityType, entityId))) {
    return null;
  }

  const params: unknown[] = [entityType, entityId];
  const topLevelConditions = ['edc.entity_type = $1', 'edc.entity_id = $2', 'edc.parent_comment_id IS NULL'];
  addModerationVisibilityCondition(topLevelConditions, params, 'edc', 'edc.created_by_user_id', userId, canViewAll);
  addModerationLanguageCondition(topLevelConditions, params, 'edc', languageCode);

  if (cursor) {
    addCommentCursorCondition(topLevelConditions, params, 'edc', cursor, sort);
  }

  params.push(limit + 1);
  const topLevelRows = await query<EntityDiscussionCommentRow>(
    `
      ${entityDiscussionCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`, userId, canViewAll)}
      ORDER BY ${commentSortOrder('edc', sort)}
      LIMIT $${params.length}
    `,
    params
  );
  const hasMore = topLevelRows.length > limit;
  const topLevelComments = hasMore ? topLevelRows.slice(0, limit) : topLevelRows;
  const topLevelIds = topLevelComments.map((comment) => comment.id);
  const replyRows = topLevelIds.length
    ? await (async () => {
        const replyParams: unknown[] = [topLevelIds];
        const replyConditions = ['edc.parent_comment_id = ANY($1::integer[])'];
        addModerationVisibilityCondition(replyConditions, replyParams, 'edc', 'edc.created_by_user_id', userId, canViewAll);
        addModerationLanguageCondition(replyConditions, replyParams, 'edc', languageCode);
        return query<EntityDiscussionCommentRow>(
          `
            ${entityDiscussionCommentProjection(`WHERE ${replyConditions.join(' AND ')}`, userId, canViewAll)}
            ORDER BY edc.created_at, edc.id
          `,
          replyParams
        );
      })()
    : [];
  const totalParams: unknown[] = [entityType, entityId];
  const totalConditions = ['edc.entity_type = $1', 'edc.entity_id = $2'];
  addModerationVisibilityCondition(totalConditions, totalParams, 'edc', 'edc.created_by_user_id', userId, canViewAll);
  addModerationLanguageCondition(totalConditions, totalParams, 'edc', languageCode);
  const total = await queryOne<{ total: number }>(
    `
      SELECT COUNT(*)::integer AS total
      FROM entity_discussion_comments edc
      WHERE ${totalConditions.join(' AND ')}
    `,
    totalParams
  );

  return {
    items: buildEntityDiscussionCommentTree([...topLevelComments, ...replyRows]),
    nextCursor:
      hasMore && topLevelComments.length > 0
        ? encodeCommentCursor(topLevelComments[topLevelComments.length - 1], sort)
        : null,
    hasMore,
    total: total?.total ?? 0
  };
}

export async function createEntityDiscussionComment(
  entityTypeValue: string,
  entityIdValue: number,
  payload: Record<string, unknown>,
  userId: number
): Promise<EntityDiscussionComment | null> {
  const entityType = cleanDiscussionEntityType(entityTypeValue);
  const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
  const cleanPayload = cleanEntityDiscussionCommentPayload(payload);

  const id = await withTransaction(async (client) => {
    if (!(await entityDiscussionExists(client, entityType, entityId))) {
      return null;
    }

    const result = await client.query<{ id: number }>(
      `
        INSERT INTO entity_discussion_comments (
          entity_type,
          entity_id,
          body,
          ai_moderation_status,
          ai_moderation_language_code,
          created_by_user_id
        )
        VALUES ($1, $2, $3, 'reviewing', NULL, $4)
        RETURNING id
      `,
      [entityType, entityId, cleanPayload.body, userId]
    );

    return result.rows[0].id;
  });

  if (id) {
    await requestAiModerationReview(
      { type: 'discussion-comment', id },
      { languageCode: cleanPayload.languageCode, resetRetries: true }
    );
  }

  return id ? getEntityDiscussionCommentById(id, userId, false) : null;
}

export async function createEntityDiscussionReply(
  entityTypeValue: string,
  entityIdValue: number,
  commentIdValue: number,
  payload: Record<string, unknown>,
  userId: number
): Promise<EntityDiscussionComment | null> {
  const entityType = cleanDiscussionEntityType(entityTypeValue);
  const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
  const commentId = requirePositiveInteger(commentIdValue, 'server.validation.commentInvalid');
  const cleanPayload = cleanEntityDiscussionCommentPayload(payload);

  const id = await withTransaction(async (client) => {
    if (!(await entityDiscussionExists(client, entityType, entityId))) {
      return null;
    }

    const result = await client.query<{ id: number }>(
      `
        INSERT INTO entity_discussion_comments (
          entity_type,
          entity_id,
          parent_comment_id,
          body,
          ai_moderation_status,
          ai_moderation_language_code,
          created_by_user_id
        )
        SELECT edc.entity_type, edc.entity_id, edc.id, $4, 'reviewing', NULL, $5
        FROM entity_discussion_comments edc
        WHERE edc.entity_type = $1
          AND edc.entity_id = $2
          AND edc.id = $3
          AND edc.parent_comment_id IS NULL
          AND edc.deleted_at IS NULL
          AND edc.ai_moderation_status = 'approved'
        RETURNING id
      `,
      [entityType, entityId, commentId, cleanPayload.body, userId]
    );

    return result.rows[0]?.id ?? null;
  });

  if (id) {
    await requestAiModerationReview(
      { type: 'discussion-comment', id },
      { languageCode: cleanPayload.languageCode, resetRetries: true }
    );
  }

  return id ? getEntityDiscussionCommentById(id, userId, false) : null;
}

export async function deleteEntityDiscussionComment(id: number, userId: number, allowAny = false): Promise<boolean> {
  const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
  const result = await queryOne<{ id: number }>(
    `
      UPDATE entity_discussion_comments
      SET deleted_at = now(),
          deleted_by_user_id = $2,
          updated_at = now()
      WHERE id = $1
        AND ($3 = true OR created_by_user_id = $2)
        AND deleted_at IS NULL
      RETURNING id
    `,
    [commentId, userId, allowAny]
  );

  return Boolean(result);
}

export async function retryEntityDiscussionCommentModeration(
  id: number,
  userId: number,
  allowAny = false
): Promise<EntityDiscussionComment | null> {
  const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
  const row = await queryOne<{ id: number }>(
    `
      SELECT id
      FROM entity_discussion_comments
      WHERE id = $1
        AND ($3 = true OR created_by_user_id = $2)
        AND deleted_at IS NULL
        AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
    `,
    [commentId, userId, allowAny]
  );

  if (!row) {
    return null;
  }

  await requestAiModerationReview({ type: 'discussion-comment', id: commentId }, { incrementRetries: true });
  return getEntityDiscussionCommentById(commentId, userId, allowAny);
}

async function approvedEntityDiscussionCommentExists(commentId: number): Promise<boolean> {
  const row = await queryOne<{ id: number }>(
    `
      SELECT id
      FROM entity_discussion_comments
      WHERE id = $1
        AND deleted_at IS NULL
        AND ai_moderation_status = 'approved'
    `,
    [commentId]
  );

  return Boolean(row);
}

export async function setEntityDiscussionCommentLike(id: number, userId: number): Promise<EntityDiscussionComment | null> {
  const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
  if (!(await approvedEntityDiscussionCommentExists(commentId))) {
    return null;
  }

  await queryOne<{ commentId: number }>(
    `
      INSERT INTO entity_discussion_comment_likes (comment_id, user_id)
      VALUES ($1, $2)
      ON CONFLICT (comment_id, user_id) DO NOTHING
      RETURNING comment_id AS "commentId"
    `,
    [commentId, userId]
  );

  return getEntityDiscussionCommentById(commentId, userId);
}

export async function deleteEntityDiscussionCommentLike(id: number, userId: number): Promise<EntityDiscussionComment | null> {
  const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
  if (!(await approvedEntityDiscussionCommentExists(commentId))) {
    return null;
  }

  await queryOne<{ commentId: number }>(
    `
      DELETE FROM entity_discussion_comment_likes
      WHERE comment_id = $1
        AND user_id = $2
      RETURNING comment_id AS "commentId"
    `,
    [commentId, userId]
  );

  return getEntityDiscussionCommentById(commentId, userId);
}

async function deleteEntityDiscussionCommentsForEntity(
  client: DbClient,
  entityType: DiscussionEntityType,
  entityId: number
): Promise<void> {
  await client.query(
    `
      DELETE FROM entity_discussion_comments
      WHERE entity_type = $1
        AND entity_id = $2
    `,
    [entityType, entityId]
  );
}

export function isConfigType(type: string): type is ConfigType {
  return Object.hasOwn(configDefinitions, type);
}

async function syncConfigOpposite(
  client: DbClient,
  definition: ConfigDefinition,
  id: number,
  oppositeId: number | null,
  userId: number
): Promise<void> {
  const oppositeColumn = definition.oppositeColumn;
  if (!oppositeColumn) {
    return;
  }

  if (oppositeId === id) {
    throw validationError('server.validation.invalidField');
  }

  if (oppositeId !== null) {
    const opposite = await client.query<{ id: number }>(
      `SELECT id FROM ${definition.table} WHERE id = $1 FOR UPDATE`,
      [oppositeId]
    );
    if (opposite.rowCount === 0) {
      throw validationError('server.validation.selectRecord');
    }
  }

  await client.query(
    `
      UPDATE ${definition.table}
      SET ${oppositeColumn} = NULL,
          updated_by_user_id = $2,
          updated_at = now()
      WHERE id <> $1
        AND (${oppositeColumn} = $1 OR (${oppositeId === null ? 'FALSE' : `${oppositeColumn} = $3`}))
    `,
    oppositeId === null ? [id, userId] : [id, userId, oppositeId]
  );

  await client.query(
    `
      UPDATE ${definition.table}
      SET ${oppositeColumn} = $1,
          updated_by_user_id = $2,
          updated_at = now()
      WHERE id = $3
    `,
    [oppositeId, userId, id]
  );

  if (oppositeId !== null) {
    await client.query(
      `
        UPDATE ${definition.table}
        SET ${oppositeColumn} = $1,
            updated_by_user_id = $2,
            updated_at = now()
        WHERE id = $3
      `,
      [id, userId, oppositeId]
    );
  }
}

export async function listConfig(type: ConfigType, locale = defaultLocale) {
  const definition = configDefinitions[type];
  return query(
    `
      SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
      FROM ${definition.table} c
      ${configRelationJoins(definition)}
      ${auditJoins('c')}
      ORDER BY ${configOrder()}
    `
  );
}

async function getConfigById(type: ConfigType, id: number, locale = defaultLocale) {
  const definition = configDefinitions[type];
  return queryOne(
    `
      SELECT ${configSelect(definition, locale)}, ${auditSelect('c')}
      FROM ${definition.table} c
      ${configRelationJoins(definition)}
      ${auditJoins('c')}
      WHERE c.id = $1
    `,
    [id]
  );
}

export async function createConfig(type: ConfigType, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const definition = configDefinitions[type];
  const name = cleanName(payload.name);
  const translations = cleanTranslations(payload.translations, ['name']);
  const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
  const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
  const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
  const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false;
  const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';

  const id = await withTransaction(async (client) => {
    const sortOrder = await nextSortOrder(client, definition.table);
    const columns = ['name'];
    const values: unknown[] = [name];
    if (definition.hasDescription) {
      columns.push('description');
      values.push(description);
    }
    if (definition.hasItemDrop) {
      columns.push('has_item_drop');
      values.push(hasItemDrop);
    }
    if (definition.hasTrading) {
      columns.push('has_trading');
      values.push(hasTrading);
    }
    if (definition.hasChangeLog) {
      columns.push('change_log');
      values.push(changeLog);
    }
    columns.push('sort_order', 'created_by_user_id', 'updated_by_user_id');
    values.push(sortOrder, userId, userId);
    const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
    const result = await client.query<{ id: number }>(
      `
        INSERT INTO ${definition.table} (${columns.join(', ')})
        VALUES (${placeholders})
        RETURNING id
      `,
      values
    );

    const createdId = result.rows[0].id;
    await syncConfigOpposite(client, definition, createdId, oppositeId, userId);
    await replaceEntityTranslations(client, definition.entityType, createdId, translations, ['name']);
    await recordEditLog(client, type, createdId, 'create', userId);
    return createdId;
  });

  return getConfigById(type, id, locale);
}

export async function reorderConfig(type: ConfigType, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const definition = configDefinitions[type];
  const ids = cleanIds(payload.ids);
  if (ids.length === 0) {
    throw validationError('server.validation.selectRecord');
  }

  await withTransaction(async (client) => {
    await reorderTableRows(client, definition.table, ids, userId);
  });

  return listConfig(type, locale);
}

export async function updateConfig(
  type: ConfigType,
  id: number,
  payload: Record<string, unknown>,
  userId: number,
  locale = defaultLocale
) {
  const definition = configDefinitions[type];
  const name = cleanName(payload.name);
  const translations = cleanTranslations(payload.translations, ['name']);
  const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
  const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
  const before = await getConfigById(type, id, defaultLocale);
  const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
  const hasTrading = definition.hasTrading
    ? Object.hasOwn(payload, 'hasTrading')
      ? Boolean(payload.hasTrading)
      : Boolean((before as { hasTrading?: boolean } | null)?.hasTrading)
    : false;
  const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';

  if (oppositeId === id) {
    throw validationError('server.validation.invalidField');
  }

  const updated = await withTransaction(async (client) => {
    const assignments = ['name = $1'];
    const values: unknown[] = [name];
    if (definition.hasDescription) {
      values.push(description);
      assignments.push(`description = $${values.length}`);
    }
    if (definition.hasItemDrop) {
      values.push(hasItemDrop);
      assignments.push(`has_item_drop = $${values.length}`);
    }
    if (definition.hasTrading) {
      values.push(hasTrading);
      assignments.push(`has_trading = $${values.length}`);
    }
    if (definition.hasChangeLog) {
      values.push(changeLog);
      assignments.push(`change_log = $${values.length}`);
    }
    values.push(userId);
    assignments.push(`updated_by_user_id = $${values.length}`, 'updated_at = now()');
    values.push(id);
    const result = await client.query(
      `
        UPDATE ${definition.table}
        SET ${assignments.join(', ')}
        WHERE id = $${values.length}
      `,
      values
    );

    if (result.rowCount === 0) {
      return false;
    }

    await syncConfigOpposite(client, definition, id, oppositeId, userId);

    if (definition.hasItemDrop && !hasItemDrop) {
      await client.query('DELETE FROM pokemon_skill_item_drops WHERE skill_id = $1', [id]);
    }
    if (definition.hasTrading && !hasTrading) {
      await client.query(
        `
          DELETE FROM pokemon_trading_items pti
          WHERE EXISTS (
            SELECT 1
            FROM pokemon_skills ps
            WHERE ps.pokemon_id = pti.pokemon_id
              AND ps.skill_id = $1
          )
            AND NOT EXISTS (
              SELECT 1
              FROM pokemon_skills ps
              JOIN skills s ON s.id = ps.skill_id
              WHERE ps.pokemon_id = pti.pokemon_id
                AND s.has_trading = true
            )
        `,
        [id]
      );
    }

    await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']);
    const oppositeNames = definition.oppositeColumn && oppositeId ? await entityNameMap(client, definition.table, [oppositeId]) : new Map<number, string>();
    const changes = before
      ? configEditChanges(definition, before as ConfigChangeSource, {
          name,
          description,
          translations,
          oppositeName: oppositeId ? oppositeNames.get(oppositeId) ?? '' : '',
          hasItemDrop,
          hasTrading,
          changeLog
        })
      : [];
    await recordEditLog(client, type, id, 'update', userId, changes);
    return true;
  });

  return updated ? getConfigById(type, id, locale) : null;
}

export async function deleteConfig(type: ConfigType, id: number, userId: number) {
  const definition = configDefinitions[type];
  return withTransaction(async (client) => {
    const result = await client.query<{ id: number }>(`DELETE FROM ${definition.table} WHERE id = $1 RETURNING id`, [id]);
    if (result.rowCount === 0) {
      return false;
    }

    await deleteEntityTranslations(client, definition.entityType, id);
    await recordEditLog(client, type, id, 'delete', userId);
    return true;
  });
}

async function reorderContent(type: SortableContentType, payload: Record<string, unknown>, userId: number): Promise<void> {
  const definition = sortableContentDefinitions[type];
  const ids = cleanIds(payload.ids);
  if (ids.length === 0) {
    throw validationError('server.validation.selectRecord');
  }

  await withTransaction(async (client) => {
    await reorderTableRows(client, definition.table, ids, userId);
  });
}

export async function reorderItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  await reorderContent('items', payload, userId);
  return listItems({}, locale);
}

export async function reorderAncientArtifacts(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  await reorderContent('ancient-artifacts', payload, userId);
  return listAncientArtifacts({}, locale);
}

export async function reorderRecipes(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  await reorderContent('recipes', payload, userId);
  return listRecipes({}, locale);
}

export async function reorderHabitats(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  await reorderContent('habitats', payload, userId);
  return listHabitats({}, locale);
}

export async function listPokemon(paramsQuery: QueryParams, locale = defaultLocale) {
  const params: unknown[] = [];
  const conditions: string[] = [];
  const search = asString(paramsQuery.search)?.trim();
  const isEventItem = asString(paramsQuery.isEventItem);
  const environmentId = Number(asString(paramsQuery.environmentId));
  const skillIds = parseIdList(asString(paramsQuery.skillIds));
  const favoriteThingIds = parseIdList(asString(paramsQuery.favoriteThingIds));

  if (isEventItem === 'true' || isEventItem === 'false') {
    params.push(isEventItem === 'true');
    conditions.push(`p.is_event_item = $${params.length}`);
  }

  if (search) {
    params.push(`%${search}%`);
    conditions.push(`${localizedName('pokemon', 'p', locale)} ILIKE $${params.length}`);
  }

  if (Number.isInteger(environmentId) && environmentId > 0) {
    params.push(environmentId);
    conditions.push(`p.environment_id = $${params.length}`);
  }

  const skillFilter = sqlForRelationFilter(
    skillIds,
    parseMatchMode(asString(paramsQuery.skillMode)),
    'pokemon_skills',
    'pokemon_id',
    'skill_id',
    'p.id',
    params
  );
  if (skillFilter) {
    conditions.push(skillFilter);
  }

  const favoriteThingFilter = sqlForRelationFilter(
    favoriteThingIds,
    parseMatchMode(asString(paramsQuery.favoriteThingMode)),
    'pokemon_favorite_things',
    'pokemon_id',
    'favorite_thing_id',
    'p.id',
    params
  );
  if (favoriteThingFilter) {
    conditions.push(favoriteThingFilter);
  }

  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
  return queryMaybePaged(`${pokemonProjection(locale)} ${whereClause} ORDER BY p.display_id, p.sort_order, p.id`, params, paramsQuery);
}

export async function getPokemon(id: number, locale = defaultLocale) {
  const pokemon = await queryOne(`${pokemonProjection(locale)} WHERE p.id = $1`, [id]);
  if (!pokemon) {
    return null;
  }

  const habitatName = localizedName('habitats', 'h', locale);
  const mapName = localizedName('maps', 'm', locale);
  const itemName = localizedName('items', 'i', locale);
  const tagName = localizedName('favorite-things', 'ft', locale);
  const relatedPokemonName = localizedName('pokemon', 'related_pokemon', locale);
  const relatedEnvironmentName = localizedName('environments', 'related_environment', locale);
  const relatedSkillName = localizedName('skills', 'related_skill', locale);
  const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale);
  const tradingItemName = localizedName('items', 'trading_item', locale);
  const moduleSettings = await getModuleSettings();

  const [habitats, itemDrops, favoriteThingItems, tradingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([
    query(
      `
        SELECT
          h.id,
          ${habitatName} AS name,
          ${uploadedImageJson('h.image_path')} AS image,
          hp.time_of_day,
          hp.weather,
          hp.rarity,
          json_build_object('id', m.id, 'name', ${mapName}) AS map
        FROM habitat_pokemon hp
        JOIN habitats h ON h.id = hp.habitat_id
        JOIN maps m ON m.id = hp.map_id
        WHERE hp.pokemon_id = $1
        ORDER BY ${orderByEntity('h')}, hp.rarity, ${orderByEntity('m')}
      `,
      [id]
    ),
    query<{ skillId: number; id: number; name: string; image: EntityImageValue | null }>(
      `
        SELECT psid.skill_id AS "skillId", i.id, ${itemName} AS name, ${uploadedImageJson('i.image_path')} AS image
        FROM pokemon_skill_item_drops psid
        JOIN skills s ON s.id = psid.skill_id
        JOIN items i ON i.id = psid.item_id
        WHERE psid.pokemon_id = $1
          AND s.has_item_drop = true
        ORDER BY ${orderByEntity('s')}, ${orderByEntity('i')}
      `,
      [id]
    ),
    query(
      `
        SELECT
          i.id,
          ${itemName} AS name,
          ${uploadedImageJson('i.image_path')} AS image,
          ${systemListJsonSql('i.category_key', itemCategoryOptions, locale)} AS category,
          json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${orderByEntity('ft')}) AS tags
        FROM pokemon_favorite_things pft
        JOIN item_favorite_things ift ON ift.favorite_thing_id = pft.favorite_thing_id
        JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
        JOIN items i ON i.id = ift.item_id
        WHERE pft.pokemon_id = $1
        GROUP BY i.id, i.name, i.image_path, i.category_key, i.sort_order
        ORDER BY i.category_key, ${orderByEntity('i')}
      `,
      [id]
    ),
    moduleSettings.tradingEnabled
      ? query(
          `
            SELECT
              ti.item_id AS "itemId",
              ti.preference,
              trading_item.id,
              ${tradingItemName} AS name,
              ${uploadedImageJson('trading_item.image_path')} AS image
            FROM pokemon_trading_items ti
            JOIN items trading_item ON trading_item.id = ti.item_id
            WHERE ti.pokemon_id = $1
              AND EXISTS (
                SELECT 1
                FROM pokemon_skills ps
                JOIN skills trading_skill ON trading_skill.id = ps.skill_id
                WHERE ps.pokemon_id = ti.pokemon_id
                  AND trading_skill.has_trading = true
              )
            ORDER BY ti.preference DESC, ${orderByEntity('trading_item')}
          `,
          [id]
        )
      : Promise.resolve([]),
    query(
      `
        WITH current_pokemon AS (
          SELECT p.id, p.environment_id
          FROM pokemon p
          WHERE p.id = $1
        ),
        current_favourites AS (
          SELECT
            pft.favorite_thing_id,
            ft.opposite_favorite_thing_id
          FROM pokemon_favorite_things pft
          JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
          WHERE pft.pokemon_id = $1
        ),
        scored_pokemon AS (
          SELECT
            related_pokemon.id,
            (related_pokemon.environment_id = current_pokemon.environment_id) AS "environmentMatches",
            (
              related_pokemon.environment_id = current_environment.opposite_environment_id
              OR related_environment.opposite_environment_id = current_pokemon.environment_id
            ) AS "environmentIsOpposite",
            COUNT(current_favourites.favorite_thing_id) FILTER (
              WHERE current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
            )::integer AS "favoriteThingMatchCount",
            COUNT(current_favourites.favorite_thing_id) FILTER (
              WHERE current_favourites.opposite_favorite_thing_id = related_pokemon_favourite.favorite_thing_id
                 OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
            )::integer AS "favoriteThingOppositeCount"
          FROM current_pokemon
          JOIN environments current_environment ON current_environment.id = current_pokemon.environment_id
          JOIN pokemon related_pokemon ON related_pokemon.id <> current_pokemon.id
          JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id
          LEFT JOIN pokemon_favorite_things related_pokemon_favourite
            ON related_pokemon_favourite.pokemon_id = related_pokemon.id
          LEFT JOIN favorite_things related_favorite_thing
            ON related_favorite_thing.id = related_pokemon_favourite.favorite_thing_id
          LEFT JOIN current_favourites
            ON current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
            OR current_favourites.opposite_favorite_thing_id = related_pokemon_favourite.favorite_thing_id
            OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
          GROUP BY
            related_pokemon.id,
            related_pokemon.environment_id,
            related_environment.opposite_environment_id,
            current_pokemon.environment_id,
            current_environment.opposite_environment_id
          HAVING related_pokemon.environment_id = current_pokemon.environment_id
            OR related_pokemon.environment_id = current_environment.opposite_environment_id
            OR related_environment.opposite_environment_id = current_pokemon.environment_id
            OR COUNT(current_favourites.favorite_thing_id) FILTER (
              WHERE current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
            ) > 0
            OR COUNT(current_favourites.favorite_thing_id) FILTER (
              WHERE current_favourites.opposite_favorite_thing_id = related_pokemon_favourite.favorite_thing_id
                 OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
            ) > 0
        )
        SELECT
          related_pokemon.id,
          related_pokemon.display_id AS "displayId",
          ${relatedPokemonName} AS name,
          related_pokemon.is_event_item AS "isEventItem",
          ${pokemonImageJson('related_pokemon')} AS image,
          json_build_object(
            'id', related_environment.id,
            'name', ${relatedEnvironmentName},
            'matches', scored_pokemon."environmentMatches",
            'isOpposite', scored_pokemon."environmentIsOpposite"
          ) AS environment,
          COALESCE((
            SELECT json_agg(
              json_build_object(
                'id', related_skill.id,
                'name', ${relatedSkillName},
                'hasItemDrop', related_skill.has_item_drop,
                'hasTrading', related_skill.has_trading
              )
              ORDER BY ${orderByEntity('related_skill')}
            )
            FROM pokemon_skills related_pokemon_skill
            JOIN skills related_skill ON related_skill.id = related_pokemon_skill.skill_id
            WHERE related_pokemon_skill.pokemon_id = related_pokemon.id
          ), '[]'::json) AS skills,
          COALESCE((
            SELECT json_agg(
              json_build_object(
                'id', related_favorite_thing.id,
                'name', ${relatedFavoriteThingName},
                'matches', EXISTS (
                  SELECT 1
                  FROM current_favourites
                  WHERE current_favourites.favorite_thing_id = related_favorite_thing.id
                ),
                'isOpposite', EXISTS (
                  SELECT 1
                  FROM current_favourites
                  WHERE current_favourites.opposite_favorite_thing_id = related_favorite_thing.id
                     OR related_favorite_thing.opposite_favorite_thing_id = current_favourites.favorite_thing_id
                )
              )
              ORDER BY ${orderByEntity('related_favorite_thing')}
            )
            FROM pokemon_favorite_things related_pokemon_favourite
            JOIN favorite_things related_favorite_thing ON related_favorite_thing.id = related_pokemon_favourite.favorite_thing_id
            WHERE related_pokemon_favourite.pokemon_id = related_pokemon.id
          ), '[]'::json) AS favorite_things
        FROM scored_pokemon
        JOIN pokemon related_pokemon ON related_pokemon.id = scored_pokemon.id
        JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id
        ORDER BY
          scored_pokemon."environmentMatches" DESC,
          scored_pokemon."favoriteThingMatchCount" DESC,
          scored_pokemon."environmentIsOpposite" DESC,
          scored_pokemon."favoriteThingOppositeCount" DESC,
          related_pokemon.display_id,
          related_pokemon.id
      `,
      [id]
    ),
    getEditHistory('pokemon', id),
    listEntityImageUploads('pokemon', id)
  ]);

  const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => {
    itemsBySkill.set(item.skillId, { id: item.id, name: item.name, image: item.image });
    return itemsBySkill;
  }, new Map<number, { id: number; name: string; image: EntityImageValue | null }>());

  const tradingItemsByPreference = tradingItems.map((item) => ({
    itemId: item.itemId,
    preference: item.preference,
    id: item.id,
    name: item.name,
    image: item.image
  }));

  const skills = Array.isArray(pokemon.skills)
    ? pokemon.skills.map((skill: { id: number; name: string }) => ({
        ...skill,
        itemDrop: dropsBySkill.get(skill.id) ?? null
      }))
    : [];

  return { ...pokemon, skills, habitats, favoriteThingItems, tradingItems: tradingItemsByPreference, relatedPokemon, editHistory, imageHistory };
}

function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
  const cleanTypeIds = cleanIds(payload.typeIds);
  const typeIds = cleanTypeIds.slice(0, 2);
  const skillIds = cleanIds(payload.skillIds);
  const favoriteThingIds = cleanIds(payload.favoriteThingIds);
  const selectedSkillIds = new Set(skillIds);
  const skillItemDrops = new Map<string, SkillItemDrop>();
  const tradingItems = new Map<string, PokemonTradingItemPayload>();

  if (typeIds.length === 0) {
    throw validationError('server.validation.typeMin');
  }
  if (cleanTypeIds.length > 2) {
    throw validationError('server.validation.typeMax');
  }
  if (skillIds.length > 2) {
    throw validationError('server.validation.skillMax');
  }
  if (favoriteThingIds.length > 6) {
    throw validationError('server.validation.favoriteMax');
  }

  if (Array.isArray(payload.tradingItems)) {
    for (const item of payload.tradingItems) {
      const row = item as Record<string, unknown>;
      const itemId = Number(row.itemId);
      const preference = row.preference;

      if (!Number.isInteger(itemId) || itemId <= 0 || (preference !== 'like' && preference !== 'neutral')) {
        throw validationError('server.validation.invalidField');
      }

      tradingItems.set(String(itemId), { itemId, preference });
    }
  }

  if (Array.isArray(payload.skillItemDrops)) {
    for (const item of payload.skillItemDrops) {
      const row = item as Record<string, unknown>;
      const skillId = Number(row.skillId);
      const itemId = Number(row.itemId);

      if (!Number.isInteger(itemId) || itemId <= 0) {
        continue;
      }

      if (!Number.isInteger(skillId) || skillId <= 0 || !selectedSkillIds.has(skillId)) {
        throw validationError('server.validation.dropItemSelectedSkill');
      }

      skillItemDrops.set(String(skillId), { skillId, itemId });
    }
  }

  const displayId = requirePositiveInteger(payload.displayId, 'server.validation.pokemonIdRequired');

  return {
    dataId: optionalPositiveInteger(payload.dataId, 'server.validation.pokemonIdRequired'),
    dataIdentifier: cleanOptionalText(payload.dataIdentifier),
    displayId,
    isEventItem: Boolean(payload.isEventItem),
    name: cleanName(payload.name, 'server.validation.pokemonNameRequired'),
    genus: cleanOptionalText(payload.genus),
    details: cleanOptionalText(payload.details),
    heightInches: cleanNonNegativeNumber(payload.heightInches, 'server.validation.heightNonNegative'),
    weightPounds: cleanNonNegativeNumber(payload.weightPounds, 'server.validation.weightNonNegative'),
    translations: cleanTranslations(payload.translations, ['name', 'details', 'genus']),
    typeIds,
    stats: cleanPokemonStats(payload.stats),
    environmentId: requirePositiveInteger(payload.environmentId, 'server.validation.environmentRequired'),
    skillIds,
    favoriteThingIds,
    skillItemDrops: [...skillItemDrops.values()],
    tradingItems: [...tradingItems.values()],
    image: cleanPokemonImage(payload.imagePath, displayId)
  };
}

async function normalizePokemonDataIdentity(payload: PokemonPayload): Promise<void> {
  if (payload.dataId === null) {
    payload.dataIdentifier = '';
    return;
  }

  const data = await loadPokemonCsvData();
  const pokemonRow = data.pokemonByLookup.get(String(payload.dataId));
  if (!pokemonRow) {
    throw validationError('server.validation.pokemonDataNotFound');
  }

  payload.dataIdentifier = csvText(pokemonRow, 'identifier');
}

async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload, replaceTrading: boolean): Promise<void> {
  await client.query('DELETE FROM pokemon_skill_item_drops WHERE pokemon_id = $1', [pokemonId]);
  await client.query('DELETE FROM pokemon_pokemon_types WHERE pokemon_id = $1', [pokemonId]);
  await client.query('DELETE FROM pokemon_skills WHERE pokemon_id = $1', [pokemonId]);
  await client.query('DELETE FROM pokemon_favorite_things WHERE pokemon_id = $1', [pokemonId]);
  if (replaceTrading) {
    await client.query('DELETE FROM pokemon_trading_items WHERE pokemon_id = $1', [pokemonId]);
  }

  for (const [index, typeId] of payload.typeIds.entries()) {
    await client.query('INSERT INTO pokemon_pokemon_types (pokemon_id, type_id, slot_order) VALUES ($1, $2, $3)', [
      pokemonId,
      typeId,
      index + 1
    ]);
  }

  for (const skillId of payload.skillIds) {
    await client.query('INSERT INTO pokemon_skills (pokemon_id, skill_id) VALUES ($1, $2)', [pokemonId, skillId]);
  }

  for (const favoriteThingId of payload.favoriteThingIds) {
    await client.query('INSERT INTO pokemon_favorite_things (pokemon_id, favorite_thing_id) VALUES ($1, $2)', [
      pokemonId,
      favoriteThingId
    ]);
  }

  const tradingSkillResult = replaceTrading && payload.skillIds.length
    ? await client.query<{ id: number }>('SELECT id FROM skills WHERE id = ANY($1::integer[]) AND has_trading = true', [payload.skillIds])
    : { rows: [] };
  const hasTradingSkill = replaceTrading && tradingSkillResult.rows.length > 0;

  if (hasTradingSkill) {
    for (const tradingItem of payload.tradingItems) {
      await client.query('INSERT INTO pokemon_trading_items (pokemon_id, item_id, preference) VALUES ($1, $2, $3)', [
        pokemonId,
        tradingItem.itemId,
        tradingItem.preference
      ]);
    }
  }

  if (payload.skillItemDrops.length > 0) {
    const allowedDrops = await client.query<{ id: number }>(
      'SELECT id FROM skills WHERE id = ANY($1::integer[]) AND has_item_drop = true',
      [payload.skillItemDrops.map((drop) => drop.skillId)]
    );
    const allowedDropSkillIds = new Set(allowedDrops.rows.map((row) => row.id));

    if (payload.skillItemDrops.some((drop) => !allowedDropSkillIds.has(drop.skillId))) {
      throw validationError('server.validation.skillNoDrop');
    }
  }

  for (const drop of payload.skillItemDrops) {
    await client.query(
      'INSERT INTO pokemon_skill_item_drops (pokemon_id, skill_id, item_id) VALUES ($1, $2, $3)',
      [pokemonId, drop.skillId, drop.itemId]
    );
  }
}

export async function createPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanPokemonPayload(payload);
  await normalizePokemonDataIdentity(cleanPayload);

  const id = await withTransaction(async (client) => {
    const pokemonId = await nextPokemonInternalId(client, cleanPayload.dataId);
    const sortOrder = await nextSortOrder(client, 'pokemon');
    await client.query(
      `
        INSERT INTO pokemon (
          id,
          data_id,
          data_identifier,
          display_id,
          name,
          is_event_item,
          genus,
          details,
          height_inches,
          weight_pounds,
          environment_id,
          hp,
          attack,
          defense,
          special_attack,
          special_defense,
          speed,
          image_path,
          image_style,
          image_version,
          image_variant,
          image_description,
          sort_order,
          created_by_user_id,
          updated_by_user_id
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $24)
      `,
      [
        pokemonId,
        cleanPayload.dataId,
        cleanPayload.dataIdentifier,
        cleanPayload.displayId,
        cleanPayload.name,
        cleanPayload.isEventItem,
        cleanPayload.genus,
        cleanPayload.details,
        cleanPayload.heightInches,
        cleanPayload.weightPounds,
        cleanPayload.environmentId,
        cleanPayload.stats.hp,
        cleanPayload.stats.attack,
        cleanPayload.stats.defense,
        cleanPayload.stats.specialAttack,
        cleanPayload.stats.specialDefense,
        cleanPayload.stats.speed,
        cleanPayload.image?.path ?? '',
        cleanPayload.image?.style ?? '',
        cleanPayload.image?.version ?? '',
        cleanPayload.image?.variant ?? '',
        cleanPayload.image?.description ?? '',
        sortOrder,
        userId
      ]
    );
    await linkEntityImageUpload(client, 'pokemon', pokemonId, cleanPayload.image?.path, cleanPayload.name);
    const moduleSettings = await moduleSettingsForClient(client);
    await replacePokemonRelations(client, pokemonId, cleanPayload, moduleSettings.tradingEnabled);
    await replaceEntityTranslations(client, 'pokemon', pokemonId, cleanPayload.translations, ['name', 'details', 'genus']);
    await recordEditLog(client, 'pokemon', pokemonId, 'create', userId);
    return pokemonId;
  });
  return getPokemon(id, locale);
}

export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanPokemonPayload(payload);
  await normalizePokemonDataIdentity(cleanPayload);
  if (cleanPayload.dataId !== null && cleanPayload.dataId !== id) {
    throw validationError('server.validation.pokemonDataIdMismatch');
  }
  const before = await getPokemon(id, defaultLocale);

  const updated = await withTransaction(async (client) => {
    const result = await client.query(
      `
        UPDATE pokemon
        SET
          data_id = $1,
          data_identifier = $2,
          display_id = $3,
          name = $4,
          is_event_item = $5,
          genus = $6,
          details = $7,
          height_inches = $8,
          weight_pounds = $9,
          environment_id = $10,
          hp = $11,
          attack = $12,
          defense = $13,
          special_attack = $14,
          special_defense = $15,
          speed = $16,
          image_path = $17,
          image_style = $18,
          image_version = $19,
          image_variant = $20,
          image_description = $21,
          updated_by_user_id = $22,
          updated_at = now()
        WHERE id = $23
      `,
      [
        cleanPayload.dataId,
        cleanPayload.dataIdentifier,
        cleanPayload.displayId,
        cleanPayload.name,
        cleanPayload.isEventItem,
        cleanPayload.genus,
        cleanPayload.details,
        cleanPayload.heightInches,
        cleanPayload.weightPounds,
        cleanPayload.environmentId,
        cleanPayload.stats.hp,
        cleanPayload.stats.attack,
        cleanPayload.stats.defense,
        cleanPayload.stats.specialAttack,
        cleanPayload.stats.specialDefense,
        cleanPayload.stats.speed,
        cleanPayload.image?.path ?? '',
        cleanPayload.image?.style ?? '',
        cleanPayload.image?.version ?? '',
        cleanPayload.image?.variant ?? '',
        cleanPayload.image?.description ?? '',
        userId,
        id
      ]
    );
    if (result.rowCount === 0) {
      return false;
    }
    await linkEntityImageUpload(client, 'pokemon', id, cleanPayload.image?.path, cleanPayload.name);
    const moduleSettings = await moduleSettingsForClient(client);
    await replacePokemonRelations(client, id, cleanPayload, moduleSettings.tradingEnabled);
    await replaceEntityTranslations(client, 'pokemon', id, cleanPayload.translations, ['name', 'details', 'genus']);
    const changes = before ? await pokemonEditChanges(client, before as unknown as PokemonChangeSource, cleanPayload) : [];
    await recordEditLog(client, 'pokemon', id, 'update', userId, changes);
    return true;
  });
  return updated ? getPokemon(id, locale) : null;
}

export async function deletePokemon(id: number, userId: number) {
  return withTransaction(async (client) => {
    const result = await client.query<{ id: number }>('DELETE FROM pokemon WHERE id = $1 RETURNING id', [id]);
    if (result.rowCount === 0) {
      return false;
    }

    await deleteEntityDiscussionCommentsForEntity(client, 'pokemon', id);
    await deleteEntityTranslations(client, 'pokemon', id);
    await recordEditLog(client, 'pokemon', id, 'delete', userId);
    return true;
  });
}

export async function listHabitats(paramsQuery: QueryParams = {}, locale = defaultLocale) {
  const habitatName = localizedName('habitats', 'h', locale);
  const itemName = localizedName('items', 'i', locale);
  const pokemonName = localizedName('pokemon', 'p', locale);
  const params: unknown[] = [];
  const conditions: string[] = [];
  const isEventItem = asString(paramsQuery.isEventItem);

  if (isEventItem === 'true' || isEventItem === 'false') {
    params.push(isEventItem === 'true');
    conditions.push(`h.is_event_item = $${params.length}`);
  }

  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';

  return queryMaybePaged(`
    SELECT
      h.id,
      ${habitatName} AS name,
      h.name AS "baseName",
      h.is_event_item AS "isEventItem",
      ${translationsSelect('habitats', 'h.id')} AS translations,
      ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
      ${uploadedImageJson('h.image_path')} AS image,
      COALESCE((
        SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')})
        FROM habitat_recipe_items hri
        JOIN items i ON i.id = hri.item_id
        WHERE hri.habitat_id = h.id
      ), '[]'::json) AS recipe,
      COALESCE((
        SELECT json_agg(
          json_build_object(
            'id', pokemon_rows.id,
            'displayId', pokemon_rows.display_id,
            'name', pokemon_rows.name,
            'isEventItem', pokemon_rows.is_event_item
          )
          ORDER BY pokemon_rows.display_id, pokemon_rows.sort_order, pokemon_rows.id
        )
        FROM (
          SELECT DISTINCT p.id, p.display_id, p.sort_order, ${pokemonName} AS name, p.is_event_item
          FROM habitat_pokemon hp
          JOIN pokemon p ON p.id = hp.pokemon_id
          WHERE hp.habitat_id = h.id
        ) pokemon_rows
      ), '[]'::json) AS pokemon
    FROM habitats h
    ${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
    ${whereClause}
    ORDER BY ${orderByEntity('h')}
  `, params, paramsQuery);
}

export async function getHabitat(id: number, locale = defaultLocale) {
  const habitatName = localizedName('habitats', 'h', locale);
  const itemName = localizedName('items', 'i', locale);
  const pokemonName = localizedName('pokemon', 'p', locale);
  const mapName = localizedName('maps', 'm', locale);

  const habitat = await queryOne(
    `
      SELECT
        h.id,
        ${habitatName} AS name,
        h.name AS "baseName",
        h.is_event_item AS "isEventItem",
        ${translationsSelect('habitats', 'h.id')} AS translations,
        ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
        ${uploadedImageJson('h.image_path')} AS image,
        COALESCE((
          SELECT json_agg(
            json_build_object(
              'id', i.id,
              'name', ${itemName},
              'image', ${uploadedImageJson('i.image_path')},
              'quantity', hri.quantity
            )
            ORDER BY ${orderByEntity('i')}
          )
          FROM habitat_recipe_items hri
          JOIN items i ON i.id = hri.item_id
          WHERE hri.habitat_id = h.id
        ), '[]'::json) AS recipe
      FROM habitats h
      ${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')}
      WHERE h.id = $1
    `,
    [id]
  );

  if (!habitat) {
    return null;
  }

  const [pokemon, editHistory, imageHistory] = await Promise.all([
    query(
      `
        SELECT
          p.id,
          p.display_id AS "displayId",
          ${pokemonName} AS name,
          p.is_event_item AS "isEventItem",
          ${pokemonImageJson('p')} AS image,
          hp.time_of_day,
          hp.weather,
          hp.rarity,
          json_build_object('id', m.id, 'name', ${mapName}) AS map
        FROM habitat_pokemon hp
        JOIN pokemon p ON p.id = hp.pokemon_id
        JOIN maps m ON m.id = hp.map_id
        WHERE hp.habitat_id = $1
        ORDER BY hp.rarity, p.display_id, p.sort_order, p.id, ${orderByEntity('m')}
      `,
      [id]
    ),
    getEditHistory('habitats', id),
    listEntityImageUploads('habitats', id)
  ]);

  return { ...habitat, pokemon, editHistory, imageHistory };
}

function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
  const appearances = Array.isArray(payload.pokemonAppearances) ? payload.pokemonAppearances : [];
  const pokemonAppearances = new Map<string, HabitatPayload['pokemonAppearances'][number]>();

  for (const item of appearances) {
    const row = item as Record<string, unknown>;
    const pokemonId = Number(row.pokemonId);
    const mapIds = cleanIdValues(row.mapIds);
    const selectedTimeOfDays = cleanOptions(row.timeOfDays, timeOfDays);
    const selectedWeathers = cleanOptions(row.weathers, weathers);
    const rarity = Number(row.rarity);

    if (!Number.isInteger(pokemonId) || pokemonId <= 0 || !Number.isInteger(rarity) || rarity < 1 || rarity > 3) {
      continue;
    }

    for (const mapId of mapIds) {
      for (const timeOfDay of selectedTimeOfDays) {
        for (const weather of selectedWeathers) {
          pokemonAppearances.set(`${pokemonId}:${mapId}:${timeOfDay}:${weather}`, {
            pokemonId,
            mapId,
            timeOfDay,
            weather,
            rarity
          });
        }
      }
    }
  }

  return {
    name: cleanName(payload.name, 'server.validation.habitatNameRequired'),
    translations: cleanTranslations(payload.translations, ['name']),
    isEventItem: Boolean(payload.isEventItem),
    imagePath: cleanUploadImagePath(payload.imagePath, 'habitats'),
    recipeItems: cleanQuantities(payload.recipeItems),
    pokemonAppearances: [...pokemonAppearances.values()]
  };
}

async function replaceHabitatRelations(client: DbClient, habitatId: number, payload: HabitatPayload): Promise<void> {
  await client.query('DELETE FROM habitat_recipe_items WHERE habitat_id = $1', [habitatId]);
  await client.query('DELETE FROM habitat_pokemon WHERE habitat_id = $1', [habitatId]);

  for (const item of payload.recipeItems) {
    await client.query('INSERT INTO habitat_recipe_items (habitat_id, item_id, quantity) VALUES ($1, $2, $3)', [
      habitatId,
      item.itemId,
      item.quantity
    ]);
  }

  for (const item of payload.pokemonAppearances) {
    await client.query(
      `
        INSERT INTO habitat_pokemon (habitat_id, pokemon_id, map_id, time_of_day, weather, rarity)
        VALUES ($1, $2, $3, $4, $5, $6)
      `,
      [habitatId, item.pokemonId, item.mapId, item.timeOfDay, item.weather, item.rarity]
    );
  }
}

export async function createHabitat(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanHabitatPayload(payload);

  const id = await withTransaction(async (client) => {
    const sortOrder = await nextSortOrder(client, 'habitats');
    const result = await client.query<{ id: number }>(
      `
        INSERT INTO habitats (name, is_event_item, image_path, sort_order, created_by_user_id, updated_by_user_id)
        VALUES ($1, $2, $3, $4, $5, $5)
        RETURNING id
      `,
      [cleanPayload.name, cleanPayload.isEventItem, cleanPayload.imagePath, sortOrder, userId]
    );
    const habitatId = result.rows[0].id;
    await linkEntityImageUpload(client, 'habitats', habitatId, cleanPayload.imagePath, cleanPayload.name);
    await replaceHabitatRelations(client, habitatId, cleanPayload);
    await replaceEntityTranslations(client, 'habitats', habitatId, cleanPayload.translations, ['name']);
    await recordEditLog(client, 'habitats', habitatId, 'create', userId);
    return habitatId;
  });
  return getHabitat(id, locale);
}

export async function updateHabitat(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanHabitatPayload(payload);
  const before = await getHabitat(id, defaultLocale);

  const updated = await withTransaction(async (client) => {
    const result = await client.query(
      'UPDATE habitats SET name = $1, is_event_item = $2, image_path = $3, updated_by_user_id = $4, updated_at = now() WHERE id = $5',
      [cleanPayload.name, cleanPayload.isEventItem, cleanPayload.imagePath, userId, id]
    );
    if (result.rowCount === 0) {
      return false;
    }
    await linkEntityImageUpload(client, 'habitats', id, cleanPayload.imagePath, cleanPayload.name);
    await replaceHabitatRelations(client, id, cleanPayload);
    await replaceEntityTranslations(client, 'habitats', id, cleanPayload.translations, ['name']);
    const changes = before ? await habitatEditChanges(client, before as unknown as HabitatChangeSource, cleanPayload) : [];
    await recordEditLog(client, 'habitats', id, 'update', userId, changes);
    return true;
  });
  return updated ? getHabitat(id, locale) : null;
}

export async function deleteHabitat(id: number, userId: number) {
  return withTransaction(async (client) => {
    const result = await client.query<{ id: number }>('DELETE FROM habitats WHERE id = $1 RETURNING id', [id]);
    if (result.rowCount === 0) {
      return false;
    }

    await deleteEntityDiscussionCommentsForEntity(client, 'habitats', id);
    await deleteEntityTranslations(client, 'habitats', id);
    await recordEditLog(client, 'habitats', id, 'delete', userId);
    return true;
  });
}

function itemProjection(locale: string): string {
  const itemName = localizedName('items', 'i', locale);
  const itemDetails = localizedField('items', 'i.id', 'i.details', 'details', locale);
  const tagName = localizedName('favorite-things', 't', locale);

  return `
    SELECT
      i.id,
      ${itemName} AS name,
      i.name AS "baseName",
      ${itemDetails} AS details,
      i.details AS "baseDetails",
      i.base_price AS "basePrice",
      CASE
        WHEN i.ancient_artifact_category_key IS NULL THEN NULL
        ELSE ${systemListJsonSql('i.ancient_artifact_category_key', ancientArtifactCategoryOptions, locale)}
      END AS "ancientArtifactCategory",
      i.is_event_item AS "isEventItem",
      ${translationsSelect('items', 'i.id')} AS translations,
      ${auditSelect('i', 'item_created_user', 'item_updated_user')},
      ${uploadedImageJson('i.image_path')} AS image,
      ${systemListJsonSql('i.category_key', itemCategoryOptions, locale)} AS category,
      CASE
        WHEN i.usage_key IS NULL THEN NULL
        ELSE ${systemListJsonSql('i.usage_key', itemUsageOptions, locale)}
      END AS usage,
      json_build_object(
        'dyeability', i.dyeability,
        'patternEditable', i.pattern_editable
      ) AS customization,
      i.no_recipe AS "noRecipe",
      COALESCE((
        SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${orderByEntity('t')})
        FROM item_favorite_things ift
        JOIN favorite_things t ON t.id = ift.favorite_thing_id
        WHERE ift.item_id = i.id
      ), '[]'::json) AS tags,
      CASE
        WHEN item_recipe.id IS NULL THEN NULL
        ELSE json_build_object(
          'id', item_recipe.id,
          'createdAt', item_recipe.created_at,
          'updatedAt', item_recipe.updated_at,
          'createdBy', CASE
            WHEN recipe_created_user.id IS NULL THEN NULL
            ELSE json_build_object('id', recipe_created_user.id, 'displayName', recipe_created_user.display_name)
          END,
          'updatedBy', CASE
            WHEN recipe_updated_user.id IS NULL THEN NULL
            ELSE json_build_object('id', recipe_updated_user.id, 'displayName', recipe_updated_user.display_name)
          END
        )
      END AS recipe
    FROM items i
    LEFT JOIN recipes item_recipe ON item_recipe.item_id = i.id
    LEFT JOIN users recipe_created_user ON recipe_created_user.id = item_recipe.created_by_user_id
    LEFT JOIN users recipe_updated_user ON recipe_updated_user.id = item_recipe.updated_by_user_id
    ${auditJoins('i', 'item_created_user', 'item_updated_user')}
  `;
}

export async function listItems(paramsQuery: QueryParams, locale = defaultLocale) {
  const params: unknown[] = [];
  const conditions: string[] = [];
  const categoryId = Number(asString(paramsQuery.categoryId));
  const usageId = Number(asString(paramsQuery.usageId));
  const ancientArtifactCategoryId = Number(asString(paramsQuery.ancientArtifactCategoryId));
  const isEventItem = asString(paramsQuery.isEventItem);
  const tagIds = parseIdList(asString(paramsQuery.tagIds));
  const search = asString(paramsQuery.search)?.trim();
  const recipeOrder = asString(paramsQuery.recipeOrder) === '1';
  const categoryOption = Number.isInteger(categoryId) && categoryId > 0
    ? systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired')
    : null;
  const usageOption = Number.isInteger(usageId) && usageId > 0
    ? systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired')
    : null;
  const ancientArtifactCategoryOption = Number.isInteger(ancientArtifactCategoryId) && ancientArtifactCategoryId > 0
    ? systemListOptionById(ancientArtifactCategoryOptions, ancientArtifactCategoryId, 'server.validation.invalidField')
    : null;

  if (search) {
    params.push(`%${search}%`);
    conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`);
  }

  if (isEventItem === 'true' || isEventItem === 'false') {
    params.push(isEventItem === 'true');
    conditions.push(`i.is_event_item = $${params.length}`);
  }

  if (categoryOption) {
    params.push(categoryOption.key);
    conditions.push(`i.category_key = $${params.length}`);
  }

  if (usageOption) {
    params.push(usageOption.key);
    conditions.push(`i.usage_key = $${params.length}`);
  }

  if (ancientArtifactCategoryOption) {
    params.push(ancientArtifactCategoryOption.key);
    conditions.push(`i.ancient_artifact_category_key = $${params.length}`);
  }

  const tagFilter = sqlForRelationFilter(
    tagIds,
    'any',
    'item_favorite_things',
    'item_id',
    'favorite_thing_id',
    'i.id',
    params
  );
  if (tagFilter) {
    conditions.push(tagFilter);
  }

  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
  const orderClause = recipeOrder
    ? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, ${orderByEntity('i')}`
    : `ORDER BY ${orderByEntity('i')}`;
  return queryMaybePaged(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params, paramsQuery);
}

export async function getItem(id: number, locale = defaultLocale) {
  const item = await queryOne(`${itemProjection(locale)} WHERE i.id = $1`, [id]);
  if (!item) {
    return null;
  }

  const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
  const resultItemName = localizedName('items', 'result_item', locale);
  const materialItemName = localizedName('items', 'mi', locale);
  const habitatName = localizedName('habitats', 'h', locale);
  const recipeItemName = localizedName('items', 'recipe_item', locale);
  const pokemonName = localizedName('pokemon', 'p', locale);
  const skillName = localizedName('skills', 's', locale);
  const possibleTagName = localizedName('favorite-things', 'possible_tag', locale);
  const evidenceTagName = localizedName('favorite-things', 'evidence_tag', locale);
  const moduleSettings = await getModuleSettings();

  const [
    acquisitionMethods,
    recipe,
    relatedRecipes,
    relatedHabitats,
    droppedByPokemon,
    allPossibleTags,
    possibleTagObservations,
    editHistory,
    imageHistory
  ] = await Promise.all([
    query(
      `
        SELECT am.id, ${acquisitionMethodName} AS name
        FROM item_acquisition_methods iam
        JOIN acquisition_methods am ON am.id = iam.acquisition_method_id
        WHERE iam.item_id = $1
        ORDER BY ${orderByEntity('am')}
      `,
      [id]
    ),
    queryOne(
      `
        SELECT
          r.id,
          ${resultItemName} AS name,
          ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
          COALESCE((
            SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
            FROM recipe_acquisition_methods ram
            JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
            WHERE ram.recipe_id = r.id
          ), '[]'::json) AS acquisition_methods,
          COALESCE((
            SELECT json_agg(
              json_build_object(
                'id', mi.id,
                'name', ${materialItemName},
                'image', ${uploadedImageJson('mi.image_path')},
                'quantity', rm.quantity
              )
              ORDER BY ${orderByEntity('mi')}
            )
            FROM recipe_materials rm
            JOIN items mi ON mi.id = rm.item_id
            WHERE rm.recipe_id = r.id
          ), '[]'::json) AS materials,
          json_build_object(
            'id', result_item.id,
            'name', ${resultItemName},
            'image', ${uploadedImageJson('result_item.image_path')},
            'category', ${systemListJsonSql('result_item.category_key', itemCategoryOptions, locale)},
            'usage', CASE
              WHEN result_item.usage_key IS NULL THEN NULL
              ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
            END
          ) AS item
        FROM recipes r
        JOIN items result_item ON result_item.id = r.item_id
        ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
        WHERE r.item_id = $1
      `,
      [id]
    ),
    query(
      `
        SELECT
          r.id,
          ${resultItemName} AS name,
          ${uploadedImageJson('result_item.image_path')} AS image,
          COALESCE((
            SELECT json_agg(
              json_build_object(
                'id', mi.id,
                'name', ${materialItemName},
                'image', ${uploadedImageJson('mi.image_path')},
                'quantity', recipe_material.quantity
              )
              ORDER BY ${orderByEntity('mi')}
            )
            FROM recipe_materials recipe_material
            JOIN items mi ON mi.id = recipe_material.item_id
            WHERE recipe_material.recipe_id = r.id
          ), '[]'::json) AS materials
        FROM recipe_materials used_material
        JOIN recipes r ON r.id = used_material.recipe_id
        JOIN items result_item ON result_item.id = r.item_id
        WHERE used_material.item_id = $1
        ORDER BY ${orderByEntity('r')}
      `,
      [id]
    ),
    query(
      `
        SELECT
          h.id,
          ${habitatName} AS name,
          ${uploadedImageJson('h.image_path')} AS image,
          COALESCE((
            SELECT json_agg(
              json_build_object(
                'id', recipe_item.id,
                'name', ${recipeItemName},
                'image', ${uploadedImageJson('recipe_item.image_path')},
                'quantity', recipe_item_row.quantity
              )
              ORDER BY ${orderByEntity('recipe_item')}
            )
            FROM habitat_recipe_items recipe_item_row
            JOIN items recipe_item ON recipe_item.id = recipe_item_row.item_id
            WHERE recipe_item_row.habitat_id = h.id
          ), '[]'::json) AS recipe
        FROM habitat_recipe_items used_item
        JOIN habitats h ON h.id = used_item.habitat_id
        WHERE used_item.item_id = $1
        ORDER BY ${orderByEntity('h')}
      `,
      [id]
    ),
    query(
      `
        SELECT
          json_build_object(
            'id', p.id,
            'displayId', p.display_id,
            'name', ${pokemonName},
            'isEventItem', p.is_event_item,
            'image', ${pokemonImageJson('p')}
          ) AS pokemon,
          json_build_object('id', s.id, 'name', ${skillName}) AS skill
        FROM pokemon_skill_item_drops psid
        JOIN pokemon p ON p.id = psid.pokemon_id
        JOIN skills s ON s.id = psid.skill_id
        WHERE psid.item_id = $1
          AND s.has_item_drop = true
        ORDER BY p.display_id, p.sort_order, p.id, ${orderByEntity('s')}
      `,
      [id]
    ),
    moduleSettings.tradingEnabled
      ? query<ItemPossibleTagEntity>(
          `
            SELECT possible_tag.id, ${possibleTagName} AS name
            FROM favorite_things possible_tag
            ORDER BY ${orderByEntity('possible_tag')}
          `
        )
      : Promise.resolve([]),
    moduleSettings.tradingEnabled
      ? query<ItemPossibleTagObservation>(
          `
            SELECT
              json_build_object(
                'id', p.id,
                'displayId', p.display_id,
                'name', ${pokemonName},
                'isEventItem', p.is_event_item,
                'image', ${pokemonImageJson('p')}
              ) AS pokemon,
              pti.preference,
              COALESCE((
                SELECT json_agg(json_build_object('id', evidence_tag.id, 'name', ${evidenceTagName}) ORDER BY ${orderByEntity('evidence_tag')})
                FROM pokemon_favorite_things pft
                JOIN favorite_things evidence_tag ON evidence_tag.id = pft.favorite_thing_id
                WHERE pft.pokemon_id = p.id
              ), '[]'::json) AS tags
            FROM pokemon_trading_items pti
            JOIN pokemon p ON p.id = pti.pokemon_id
            WHERE pti.item_id = $1
              AND EXISTS (
                SELECT 1
                FROM pokemon_skills ps
                JOIN skills trading_skill ON trading_skill.id = ps.skill_id
                WHERE ps.pokemon_id = p.id
                  AND trading_skill.has_trading = true
              )
            ORDER BY pti.preference DESC, p.display_id, p.sort_order, p.id
          `,
          [id]
        )
      : Promise.resolve([]),
    getEditHistory('items', id),
    listEntityImageUploads('items', id)
  ]);

  const possibleTags = inferItemPossibleTags(allPossibleTags, possibleTagObservations);
  return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, possibleTags, editHistory, imageHistory };
}

function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
  const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired');
  const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
    ? null
    : requirePositiveInteger(payload.usageId, 'server.validation.usageRequired');
  const ancientArtifactCategory = cleanOptionalSystemListOption(
    payload.ancientArtifactCategoryId,
    ancientArtifactCategoryOptions,
    'server.validation.invalidField'
  );
  const insertBeforeItemId = cleanOptionalPositiveInteger(payload.insertBeforeItemId);
  const insertAfterItemId = cleanOptionalPositiveInteger(payload.insertAfterItemId);
  const category = systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired');
  const usage = usageId === null ? null : systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired');

  if (insertBeforeItemId !== null && insertAfterItemId !== null) {
    throw validationError('server.validation.invalidField');
  }

  return {
    name: cleanName(payload.name, 'server.validation.itemNameRequired'),
    details: cleanOptionalText(payload.details),
    basePrice: cleanOptionalNonNegativeInteger(payload.basePrice, 'server.validation.invalidField'),
    ancientArtifactCategoryId: ancientArtifactCategory?.id ?? null,
    ancientArtifactCategoryKey: ancientArtifactCategory?.key ?? null,
    translations: cleanTranslations(payload.translations, ['name', 'details']),
    categoryId,
    categoryKey: category.key,
    usageId,
    usageKey: usage?.key ?? null,
    dyeability: cleanDyeability(payload),
    patternEditable: Boolean(payload.patternEditable),
    noRecipe: Boolean(payload.noRecipe),
    isEventItem: Boolean(payload.isEventItem),
    acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
    tagIds: cleanIds(payload.tagIds),
    imagePath: cleanItemOrArtifactImagePath(payload.imagePath),
    insertBeforeItemId,
    insertAfterItemId
  };
}

function cleanOptionalPositiveInteger(value: unknown): number | null {
  if (value === null || value === '' || value === undefined) {
    return null;
  }

  return requirePositiveInteger(value, 'server.validation.invalidField');
}

function cleanDyeability(payload: Record<string, unknown>): number {
  if (payload.dyeability === undefined || payload.dyeability === null || payload.dyeability === '') {
    if (payload.dualDyeable === true) {
      return 2;
    }
    if (payload.dyeable === true) {
      return 1;
    }
    return 0;
  }

  const dyeability = Number(payload.dyeability);
  if (!Number.isInteger(dyeability) || dyeability < 0 || dyeability > 3) {
    throw validationError('server.validation.invalidField');
  }

  return dyeability;
}

function dyeabilityValue(value: number): string {
  if (value === 3) {
    return 'Triple dyeable';
  }
  if (value === 2) {
    return 'Dual dyeable';
  }
  if (value === 1) {
    return 'Dyeable';
  }
  return 'Not dyeable';
}

async function orderedItemIds(client: DbClient, isEventItem: boolean): Promise<number[]> {
  const rows = await client.query<{ id: number }>(
    'SELECT id FROM items WHERE is_event_item = $1 ORDER BY sort_order, id',
    [isEventItem]
  );
  return rows.rows.map((row) => row.id);
}

async function ensureItemCanDisableRecipe(client: DbClient, itemId: number, noRecipe: boolean): Promise<void> {
  if (!noRecipe) {
    return;
  }

  const result = await client.query('SELECT 1 FROM recipes WHERE item_id = $1', [itemId]);
  if (result.rowCount && result.rowCount > 0) {
    throw validationError('server.validation.recipeFreeWithRecipe');
  }
}

async function replaceItemRelations(client: DbClient, itemId: number, payload: ItemPayload): Promise<void> {
  await client.query('DELETE FROM item_acquisition_methods WHERE item_id = $1', [itemId]);
  await client.query('DELETE FROM item_favorite_things WHERE item_id = $1', [itemId]);

  for (const methodId of payload.acquisitionMethodIds) {
    await client.query('INSERT INTO item_acquisition_methods (item_id, acquisition_method_id) VALUES ($1, $2)', [
      itemId,
      methodId
    ]);
  }

  for (const tagId of payload.tagIds) {
    await client.query('INSERT INTO item_favorite_things (item_id, favorite_thing_id) VALUES ($1, $2)', [
      itemId,
      tagId
    ]);
  }
}

export async function createItem(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanItemPayload(payload);

  const id = await withTransaction(async (client) => {
    const sortOrder = await nextSortOrder(client, 'items');
    const result = await client.query<{ id: number }>(
      `
        INSERT INTO items (
          name,
          details,
          ancient_artifact_category_key,
          base_price,
          category_key,
          usage_key,
          dyeability,
          dyeable,
          dual_dyeable,
          pattern_editable,
          no_recipe,
          is_event_item,
          image_path,
          sort_order,
          created_by_user_id,
          updated_by_user_id
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15)
        RETURNING id
      `,
      [
        cleanPayload.name,
        cleanPayload.details,
        cleanPayload.ancientArtifactCategoryKey,
        cleanPayload.basePrice,
        cleanPayload.categoryKey,
        cleanPayload.usageKey,
        cleanPayload.dyeability,
        cleanPayload.dyeability >= 1,
        cleanPayload.dyeability >= 2,
        cleanPayload.patternEditable,
        cleanPayload.noRecipe,
        cleanPayload.isEventItem,
        cleanPayload.imagePath,
        sortOrder,
        userId
      ]
    );
    const itemId = result.rows[0].id;
    await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
    await replaceItemRelations(client, itemId, cleanPayload);
    await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name', 'details']);

    if (cleanPayload.insertBeforeItemId !== null || cleanPayload.insertAfterItemId !== null) {
      const targetId = cleanPayload.insertBeforeItemId ?? cleanPayload.insertAfterItemId;
      if (targetId === null) {
        throw validationError('server.validation.invalidField');
      }

      const orderedIds = await orderedItemIds(client, cleanPayload.isEventItem);
      const targetIndex = orderedIds.indexOf(targetId);
      if (targetIndex < 0) {
        throw validationError('server.validation.recordMissing');
      }

      const insertedIndex = orderedIds.indexOf(itemId);
      if (insertedIndex >= 0) {
        orderedIds.splice(insertedIndex, 1);
      }

      orderedIds.splice(targetIndex + (cleanPayload.insertAfterItemId !== null ? 1 : 0), 0, itemId);
      await reorderTableRows(client, 'items', orderedIds, userId);
    }

    await recordEditLog(client, 'items', itemId, 'create', userId);
    return itemId;
  });
  return getItem(id, locale);
}

export async function updateItem(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanItemPayload(payload);
  const before = await getItem(id, defaultLocale);

  const updated = await withTransaction(async (client) => {
    await ensureItemCanDisableRecipe(client, id, cleanPayload.noRecipe);
    const result = await client.query(
      `
        UPDATE items
        SET name = $1,
            details = $2,
            ancient_artifact_category_key = $3,
            base_price = $4,
            category_key = $5,
            usage_key = $6,
            dyeability = $7,
            dyeable = $8,
            dual_dyeable = $9,
            pattern_editable = $10,
            no_recipe = $11,
            is_event_item = $12,
            image_path = $13,
            updated_by_user_id = $14,
            updated_at = now()
        WHERE id = $15
      `,
      [
        cleanPayload.name,
        cleanPayload.details,
        cleanPayload.ancientArtifactCategoryKey,
        cleanPayload.basePrice,
        cleanPayload.categoryKey,
        cleanPayload.usageKey,
        cleanPayload.dyeability,
        cleanPayload.dyeability >= 1,
        cleanPayload.dyeability >= 2,
        cleanPayload.patternEditable,
        cleanPayload.noRecipe,
        cleanPayload.isEventItem,
        cleanPayload.imagePath,
        userId,
        id
      ]
    );
    if (result.rowCount === 0) {
      return false;
    }
    await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
    await replaceItemRelations(client, id, cleanPayload);
    await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name', 'details']);
    const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
    await recordEditLog(client, 'items', id, 'update', userId, changes);
    return true;
  });
  return updated ? getItem(id, locale) : null;
}

export async function deleteItem(id: number, userId: number) {
  return withTransaction(async (client) => {
    const result = await client.query<{ id: number }>('DELETE FROM items WHERE id = $1 RETURNING id', [id]);
    if (result.rowCount === 0) {
      return false;
    }

    await deleteEntityDiscussionCommentsForEntity(client, 'items', id);
    await deleteEntityDiscussionCommentsForEntity(client, 'ancient-artifacts', id);
    await deleteEntityTranslations(client, 'items', id);
    await recordEditLog(client, 'items', id, 'delete', userId);
    return true;
  });
}

function ancientArtifactProjection(locale: string): string {
  const artifactName = localizedName('items', 'i', locale);
  const artifactDetails = localizedField('items', 'i.id', 'i.details', 'details', locale);
  const tagName = localizedName('favorite-things', 't', locale);

  return `
    SELECT
      i.id,
      ${artifactName} AS name,
      i.name AS "baseName",
      ${artifactDetails} AS details,
      i.details AS "baseDetails",
      ${translationsSelect('items', 'i.id')} AS translations,
      ${systemListJsonSql('i.ancient_artifact_category_key', ancientArtifactCategoryOptions, locale)} AS category,
      ${uploadedImageJson('i.image_path')} AS image,
      COALESCE((
        SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${orderByEntity('t')})
        FROM item_favorite_things ift
        JOIN favorite_things t ON t.id = ift.favorite_thing_id
        WHERE ift.item_id = i.id
      ), '[]'::json) AS tags,
      ${auditSelect('i', 'item_created_user', 'item_updated_user')}
    FROM items i
    ${auditJoins('i', 'item_created_user', 'item_updated_user')}
  `;
}

export async function listAncientArtifacts(paramsQuery: QueryParams = {}, locale = defaultLocale) {
  const params: unknown[] = [];
  const conditions: string[] = [];
  const search = asString(paramsQuery.search)?.trim();
  const categoryId = Number(asString(paramsQuery.categoryId));
  const tagIds = parseIdList(asString(paramsQuery.tagIds));
  const categoryOption = Number.isInteger(categoryId) && categoryId > 0
    ? systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired')
    : null;

  conditions.push('i.ancient_artifact_category_key IS NOT NULL');

  if (search) {
    params.push(`%${search}%`);
    conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`);
  }

  if (categoryOption) {
    params.push(categoryOption.key);
    conditions.push(`i.ancient_artifact_category_key = $${params.length}`);
  }

  const tagFilter = sqlForRelationFilter(
    tagIds,
    'any',
    'item_favorite_things',
    'item_id',
    'favorite_thing_id',
    'i.id',
    params
  );
  if (tagFilter) {
    conditions.push(tagFilter);
  }

  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
  return queryMaybePaged(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('i')}`, params, paramsQuery);
}

export async function getAncientArtifact(id: number, locale = defaultLocale) {
  const artifact = await queryOne(`${ancientArtifactProjection(locale)} WHERE i.id = $1 AND i.ancient_artifact_category_key IS NOT NULL`, [id]);
  if (!artifact) {
    return null;
  }

  const editHistory = await getEditHistory('items', id);
  const imageHistory = await listEntityImageUploads('items', id);
  return { ...artifact, editHistory, imageHistory };
}

function cleanAncientArtifactPayload(payload: Record<string, unknown>): AncientArtifactPayload {
  const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired');
  const category = systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired');

  return {
    name: cleanName(payload.name, 'server.validation.artifactNameRequired'),
    details: cleanOptionalText(payload.details),
    translations: cleanTranslations(payload.translations, ['name', 'details']),
    categoryId,
    categoryKey: category.key,
    tagIds: cleanIds(payload.tagIds),
    imagePath: cleanItemOrArtifactImagePath(payload.imagePath)
  };
}

async function replaceAncientArtifactRelations(client: DbClient, artifactId: number, payload: AncientArtifactPayload): Promise<void> {
  await client.query('DELETE FROM item_favorite_things WHERE item_id = $1', [artifactId]);

  for (const tagId of payload.tagIds) {
    await client.query(
      'INSERT INTO item_favorite_things (item_id, favorite_thing_id) VALUES ($1, $2)',
      [artifactId, tagId]
    );
  }
}

export async function createAncientArtifact(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanAncientArtifactPayload(payload);

  const id = await withTransaction(async (client) => {
    const sortOrder = await nextSortOrder(client, 'items');
    const result = await client.query<{ id: number }>(
      `
        INSERT INTO items (
          name,
          details,
          ancient_artifact_category_key,
          image_path,
          sort_order,
          created_by_user_id,
          updated_by_user_id
        )
        VALUES ($1, $2, $3, $4, $5, $6, $6)
        RETURNING id
      `,
      [
        cleanPayload.name,
        cleanPayload.details,
        cleanPayload.categoryKey,
        cleanPayload.imagePath,
        sortOrder,
        userId
      ]
    );
    const artifactId = result.rows[0].id;
    await linkEntityImageUpload(client, 'items', artifactId, cleanPayload.imagePath, cleanPayload.name);
    await replaceAncientArtifactRelations(client, artifactId, cleanPayload);
    await replaceEntityTranslations(client, 'items', artifactId, cleanPayload.translations, ['name', 'details']);
    await recordEditLog(client, 'items', artifactId, 'create', userId);
    return artifactId;
  });

  return getAncientArtifact(id, locale);
}

export async function updateAncientArtifact(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanAncientArtifactPayload(payload);
  const before = await getAncientArtifact(id, defaultLocale);

  const updated = await withTransaction(async (client) => {
    const result = await client.query(
      `
        UPDATE items
        SET name = $1,
            details = $2,
            ancient_artifact_category_key = $3,
            image_path = $4,
            updated_by_user_id = $5,
            updated_at = now()
        WHERE id = $6
          AND ancient_artifact_category_key IS NOT NULL
      `,
      [cleanPayload.name, cleanPayload.details, cleanPayload.categoryKey, cleanPayload.imagePath, userId, id]
    );
    if (result.rowCount === 0) {
      return false;
    }

    await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
    await replaceAncientArtifactRelations(client, id, cleanPayload);
    await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name', 'details']);
    const changes = before ? await ancientArtifactEditChanges(client, before as unknown as AncientArtifactChangeSource, cleanPayload) : [];
    await recordEditLog(client, 'items', id, 'update', userId, changes);
    return true;
  });

  return updated ? getAncientArtifact(id, locale) : null;
}

export async function deleteAncientArtifact(id: number, userId: number) {
  return withTransaction(async (client) => {
    const result = await client.query<{ id: number }>('DELETE FROM items WHERE id = $1 AND ancient_artifact_category_key IS NOT NULL RETURNING id', [id]);
    if (result.rowCount === 0) {
      return false;
    }

    await deleteEntityDiscussionCommentsForEntity(client, 'ancient-artifacts', id);
    await deleteEntityTranslations(client, 'items', id);
    await recordEditLog(client, 'items', id, 'delete', userId);
    return true;
  });
}

export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaultLocale) {
  const params: unknown[] = [];
  const conditions: string[] = [];
  const categoryId = Number(asString(paramsQuery.categoryId));
  const categoryOption = Number.isInteger(categoryId) && categoryId > 0
    ? systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired')
    : null;
  const resultItemName = localizedName('items', 'result_item', locale);
  const materialItemName = localizedName('items', 'i', locale);

  if (categoryOption) {
    params.push(categoryOption.key);
    conditions.push(`result_item.category_key = $${params.length}`);
  }

  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
  return queryMaybePaged(`
    SELECT
      r.id,
      ${resultItemName} AS name,
      ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
      COALESCE((
        SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('i')})
        FROM recipe_materials rm
        JOIN items i ON i.id = rm.item_id
        WHERE rm.recipe_id = r.id
      ), '[]'::json) AS materials
    FROM recipes r
    JOIN items result_item ON result_item.id = r.item_id
    ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
    ${whereClause}
    ORDER BY ${orderByEntity('r')}
  `, params, paramsQuery);
}

export async function getRecipe(id: number, locale = defaultLocale) {
  const resultItemName = localizedName('items', 'result_item', locale);
  const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
  const materialItemName = localizedName('items', 'i', locale);

  const recipe = await queryOne(
    `
      SELECT
        r.id,
        ${resultItemName} AS name,
        ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
        COALESCE((
          SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
          FROM recipe_acquisition_methods ram
          JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
          WHERE ram.recipe_id = r.id
        ), '[]'::json) AS acquisition_methods,
        COALESCE((
          SELECT json_agg(
            json_build_object(
              'id', i.id,
              'name', ${materialItemName},
              'image', ${uploadedImageJson('i.image_path')},
              'quantity', rm.quantity
            )
            ORDER BY ${orderByEntity('i')}
          )
          FROM recipe_materials rm
          JOIN items i ON i.id = rm.item_id
          WHERE rm.recipe_id = r.id
        ), '[]'::json) AS materials,
        json_build_object(
          'id', result_item.id,
          'name', ${resultItemName},
          'image', ${uploadedImageJson('result_item.image_path')},
          'category', ${systemListJsonSql('result_item.category_key', itemCategoryOptions, locale)},
          'usage', CASE
            WHEN result_item.usage_key IS NULL THEN NULL
            ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
          END
        ) AS item
      FROM recipes r
      JOIN items result_item ON result_item.id = r.item_id
      ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
      WHERE r.id = $1
    `,
    [id]
  );

  if (!recipe) {
    return null;
  }

  const editHistory = await getEditHistory('recipes', id);
  return { ...recipe, editHistory };
}

function cleanRecipePayload(payload: Record<string, unknown>): RecipePayload {
  return {
    itemId: requirePositiveInteger(payload.itemId, 'server.validation.itemRequired'),
    acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
    materials: cleanQuantities(payload.materials)
  };
}

async function replaceRecipeRelations(client: DbClient, recipeId: number, payload: RecipePayload): Promise<void> {
  await client.query('DELETE FROM recipe_acquisition_methods WHERE recipe_id = $1', [recipeId]);
  await client.query('DELETE FROM recipe_materials WHERE recipe_id = $1', [recipeId]);

  for (const methodId of payload.acquisitionMethodIds) {
    await client.query('INSERT INTO recipe_acquisition_methods (recipe_id, acquisition_method_id) VALUES ($1, $2)', [
      recipeId,
      methodId
    ]);
  }

  for (const material of payload.materials) {
    await client.query('INSERT INTO recipe_materials (recipe_id, item_id, quantity) VALUES ($1, $2, $3)', [
      recipeId,
      material.itemId,
      material.quantity
    ]);
  }
}

async function ensureItemCanHaveRecipe(client: DbClient, itemId: number): Promise<void> {
  const result = await client.query<{ no_recipe: boolean }>('SELECT no_recipe FROM items WHERE id = $1', [itemId]);
  if (result.rowCount === 0) {
    throw validationError('server.validation.itemRequired');
  }

  if (result.rows[0].no_recipe) {
    throw validationError('server.validation.recipeFreeItem');
  }
}

export async function createRecipe(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanRecipePayload(payload);

  const id = await withTransaction(async (client) => {
    await ensureItemCanHaveRecipe(client, cleanPayload.itemId);
    const sortOrder = await nextSortOrder(client, 'recipes');
    const result = await client.query<{ id: number }>(
      `
        INSERT INTO recipes (item_id, sort_order, created_by_user_id, updated_by_user_id)
        VALUES ($1, $2, $3, $3)
        RETURNING id
      `,
      [cleanPayload.itemId, sortOrder, userId]
    );
    const recipeId = result.rows[0].id;
    await replaceRecipeRelations(client, recipeId, cleanPayload);
    await recordEditLog(client, 'recipes', recipeId, 'create', userId);
    return recipeId;
  });
  return getRecipe(id, locale);
}

export async function updateRecipe(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanRecipePayload(payload);
  const before = await getRecipe(id, defaultLocale);

  const updated = await withTransaction(async (client) => {
    await ensureItemCanHaveRecipe(client, cleanPayload.itemId);
    const result = await client.query(
      'UPDATE recipes SET item_id = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3',
      [cleanPayload.itemId, userId, id]
    );
    if (result.rowCount === 0) {
      return false;
    }
    await replaceRecipeRelations(client, id, cleanPayload);
    const changes = before ? await recipeEditChanges(client, before as unknown as RecipeChangeSource, cleanPayload) : [];
    await recordEditLog(client, 'recipes', id, 'update', userId, changes);
    return true;
  });
  return updated ? getRecipe(id, locale) : null;
}

export async function deleteRecipe(id: number, userId: number) {
  return withTransaction(async (client) => {
    const result = await client.query<{ id: number }>('DELETE FROM recipes WHERE id = $1 RETURNING id', [id]);
    if (result.rowCount === 0) {
      return false;
    }

    await deleteEntityDiscussionCommentsForEntity(client, 'recipes', id);
    await recordEditLog(client, 'recipes', id, 'delete', userId);
    return true;
  });
}

function dishCategoryProjection(locale: string): string {
  const categoryName = localizedName('dish-categories', 'dc', locale);
  const categoryEffect = localizedField('dish-categories', 'dc.id', 'dc.effect', 'effect', locale);
  const cookwareName = localizedName('items', 'cookware_item', locale);
  const mainMaterialName = localizedName('items', 'main_material_item', locale);
  const dishItemName = localizedName('items', 'dish_item', locale);
  const flavorName = localizedName('dish-flavors', 'dish_flavor', locale);
  const secondaryMaterialName = localizedName('items', 'secondary_material_item', locale);
  const skillName = localizedName('skills', 'dish_skill', locale);
  const mosslaxEffect = localizedField('dishes', 'd.id', 'd.mosslax_effect', 'mosslaxEffect', locale);

  return `
    SELECT
      dc.id,
      ${categoryName} AS name,
      dc.name AS "baseName",
      ${categoryEffect} AS effect,
      dc.effect AS "baseEffect",
      dc.total_material_quantity AS "totalMaterialQuantity",
      ${translationsSelect('dish-categories', 'dc.id')} AS translations,
      ${auditSelect('dc', 'category_created_user', 'category_updated_user')},
      json_build_object(
        'id', cookware_item.id,
        'name', ${cookwareName},
        'image', ${uploadedImageJson('cookware_item.image_path')},
        'category', ${systemListJsonSql('cookware_item.category_key', itemCategoryOptions, locale)}
      ) AS cookware,
      json_build_object(
        'id', main_material_item.id,
        'name', ${mainMaterialName},
        'image', ${uploadedImageJson('main_material_item.image_path')},
        'category', ${systemListJsonSql('main_material_item.category_key', itemCategoryOptions, locale)}
      ) AS "mainMaterial",
      COALESCE((
        SELECT json_agg(
          json_build_object(
            'id', d.id,
            'flavor', json_build_object('id', dish_flavor.id, 'name', ${flavorName}),
            'mosslaxEffect', ${mosslaxEffect},
            'baseMosslaxEffect', d.mosslax_effect,
            'translations', ${translationsSelect('dishes', 'd.id')},
            'createdAt', d.created_at,
            'updatedAt', d.updated_at,
            'createdBy', CASE
              WHEN dish_created_user.id IS NULL THEN NULL
              ELSE json_build_object('id', dish_created_user.id, 'displayName', dish_created_user.display_name)
            END,
            'updatedBy', CASE
              WHEN dish_updated_user.id IS NULL THEN NULL
              ELSE json_build_object('id', dish_updated_user.id, 'displayName', dish_updated_user.display_name)
            END,
            'category', json_build_object('id', dc.id, 'name', ${categoryName}),
            'item', json_build_object(
              'id', dish_item.id,
              'name', ${dishItemName},
              'image', ${uploadedImageJson('dish_item.image_path')},
              'category', ${systemListJsonSql('dish_item.category_key', itemCategoryOptions, locale)}
            ),
            'secondaryMaterials', COALESCE((
              SELECT json_agg(
                json_build_object(
                  'id', secondary_material_item.id,
                  'name', ${secondaryMaterialName},
                  'image', ${uploadedImageJson('secondary_material_item.image_path')},
                  'category', ${systemListJsonSql('secondary_material_item.category_key', itemCategoryOptions, locale)}
                )
                ORDER BY secondary_slots.slot
              )
              FROM (VALUES (d.secondary_material_1_item_id, 1), (d.secondary_material_2_item_id, 2)) AS secondary_slots(item_id, slot)
              JOIN items secondary_material_item ON secondary_material_item.id = secondary_slots.item_id
            ), '[]'::json),
            'pokemonSkill', CASE
              WHEN dish_skill.id IS NULL THEN NULL
              ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop, 'hasTrading', dish_skill.has_trading)
            END
          )
          ORDER BY d.sort_order, d.id
        )
        FROM dishes d
        JOIN items dish_item ON dish_item.id = d.item_id
        JOIN dish_flavors dish_flavor ON dish_flavor.id = d.flavor_id
        LEFT JOIN skills dish_skill ON dish_skill.id = d.pokemon_skill_id
        LEFT JOIN users dish_created_user ON dish_created_user.id = d.created_by_user_id
        LEFT JOIN users dish_updated_user ON dish_updated_user.id = d.updated_by_user_id
        WHERE d.category_id = dc.id
      ), '[]'::json) AS dishes
    FROM dish_categories dc
    JOIN items cookware_item ON cookware_item.id = dc.cookware_item_id
    JOIN items main_material_item ON main_material_item.id = dc.main_material_item_id
    ${auditJoins('dc', 'category_created_user', 'category_updated_user')}
  `;
}

function dishProjection(locale: string): string {
  const categoryName = localizedName('dish-categories', 'dc', locale);
  const dishItemName = localizedName('items', 'dish_item', locale);
  const flavorName = localizedName('dish-flavors', 'dish_flavor', locale);
  const secondaryMaterialName = localizedName('items', 'secondary_material_item', locale);
  const skillName = localizedName('skills', 'dish_skill', locale);
  const mosslaxEffect = localizedField('dishes', 'd.id', 'd.mosslax_effect', 'mosslaxEffect', locale);

  return `
    SELECT
      d.id,
      json_build_object('id', dish_flavor.id, 'name', ${flavorName}) AS flavor,
      ${mosslaxEffect} AS "mosslaxEffect",
      d.mosslax_effect AS "baseMosslaxEffect",
      ${translationsSelect('dishes', 'd.id')} AS translations,
      ${auditSelect('d', 'dish_created_user', 'dish_updated_user')},
      json_build_object('id', dc.id, 'name', ${categoryName}) AS category,
      json_build_object(
        'id', dish_item.id,
        'name', ${dishItemName},
        'image', ${uploadedImageJson('dish_item.image_path')},
        'category', ${systemListJsonSql('dish_item.category_key', itemCategoryOptions, locale)}
      ) AS item,
      COALESCE((
        SELECT json_agg(
          json_build_object(
            'id', secondary_material_item.id,
            'name', ${secondaryMaterialName},
            'image', ${uploadedImageJson('secondary_material_item.image_path')},
            'category', ${systemListJsonSql('secondary_material_item.category_key', itemCategoryOptions, locale)}
          )
          ORDER BY secondary_slots.slot
        )
        FROM (VALUES (d.secondary_material_1_item_id, 1), (d.secondary_material_2_item_id, 2)) AS secondary_slots(item_id, slot)
        JOIN items secondary_material_item ON secondary_material_item.id = secondary_slots.item_id
      ), '[]'::json) AS "secondaryMaterials",
      CASE
        WHEN dish_skill.id IS NULL THEN NULL
        ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop, 'hasTrading', dish_skill.has_trading)
      END AS "pokemonSkill"
    FROM dishes d
    JOIN dish_categories dc ON dc.id = d.category_id
    JOIN items dish_item ON dish_item.id = d.item_id
    JOIN dish_flavors dish_flavor ON dish_flavor.id = d.flavor_id
    LEFT JOIN skills dish_skill ON dish_skill.id = d.pokemon_skill_id
    ${auditJoins('d', 'dish_created_user', 'dish_updated_user')}
  `;
}

export async function listDish(locale = defaultLocale) {
  return query(`${dishCategoryProjection(locale)} ORDER BY ${orderByEntity('dc')}`);
}

async function getDishCategory(id: number, locale = defaultLocale) {
  return queryOne(`${dishCategoryProjection(locale)} WHERE dc.id = $1`, [id]);
}

async function getDish(id: number, locale = defaultLocale) {
  return queryOne(`${dishProjection(locale)} WHERE d.id = $1`, [id]);
}

function cleanDishCategoryPayload(payload: Record<string, unknown>): DishCategoryPayload {
  const totalMaterialQuantity = requirePositiveInteger(payload.totalMaterialQuantity, 'server.validation.invalidField');
  if (totalMaterialQuantity < 2) {
    throw validationError('server.validation.invalidField');
  }

  return {
    name: cleanName(payload.name),
    effect: cleanName(payload.effect, 'server.validation.invalidField'),
    translations: cleanTranslations(payload.translations, ['name', 'effect']),
    cookwareItemId: requirePositiveInteger(payload.cookwareItemId, 'server.validation.itemRequired'),
    mainMaterialItemId: requirePositiveInteger(payload.mainMaterialItemId, 'server.validation.itemRequired'),
    totalMaterialQuantity
  };
}

function cleanDishPayload(payload: Record<string, unknown>): DishPayload {
  const secondaryMaterialItemIds = cleanIds(payload.secondaryMaterialItemIds).slice(0, 2);

  return {
    categoryId: requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired'),
    itemId: requirePositiveInteger(payload.itemId, 'server.validation.itemRequired'),
    flavorId: requirePositiveInteger(payload.flavorId, 'server.validation.invalidField'),
    secondaryMaterialItemIds,
    pokemonSkillId: optionalPositiveInteger(payload.pokemonSkillId, 'server.validation.invalidField'),
    mosslaxEffect: cleanName(payload.mosslaxEffect, 'server.validation.invalidField'),
    translations: cleanTranslations(payload.translations, ['mosslaxEffect'])
  };
}

async function ensureDishMaterialSlots(client: DbClient, payload: DishPayload): Promise<void> {
  const result = await client.query<{ totalMaterialQuantity: number; mainMaterialItemId: number }>(
    `
      SELECT
        total_material_quantity AS "totalMaterialQuantity",
        main_material_item_id AS "mainMaterialItemId"
      FROM dish_categories
      WHERE id = $1
    `,
    [payload.categoryId]
  );
  if (result.rowCount === 0) {
    throw validationError('server.validation.categoryRequired');
  }

  if (payload.secondaryMaterialItemIds.length > 1 && result.rows[0].totalMaterialQuantity <= 2) {
    throw validationError('server.validation.invalidField');
  }

  if (payload.secondaryMaterialItemIds.includes(result.rows[0].mainMaterialItemId)) {
    throw validationError('server.validation.invalidField');
  }
}

async function ensureDishCategoryMaterialSlots(client: DbClient, id: number, payload: DishCategoryPayload): Promise<void> {
  const result = await client.query<{ id: number }>(
    `
      SELECT id
      FROM dishes
      WHERE category_id = $1
        AND (
          ($2::integer <= 2 AND secondary_material_2_item_id IS NOT NULL)
          OR secondary_material_1_item_id = $3
          OR secondary_material_2_item_id = $3
        )
      LIMIT 1
    `,
    [id, payload.totalMaterialQuantity, payload.mainMaterialItemId]
  );
  if ((result.rowCount ?? 0) > 0) {
    throw validationError('server.validation.invalidField');
  }
}

async function dishCategoryEditChanges(
  client: DbClient,
  before: DishCategoryChangeSource,
  after: DishCategoryPayload
): Promise<EditChange[]> {
  const changes: EditChange[] = [];
  const itemNames = await entityNameMap(client, 'items', [after.cookwareItemId, after.mainMaterialItemId]);
  pushChange(changes, 'Name', before.name, after.name);
  pushTranslationChanges(changes, before.translations, after.translations, ['name', 'effect']);
  pushChange(changes, 'Cookware', before.cookware.name, itemNames.get(after.cookwareItemId));
  pushChange(changes, 'Main material', before.mainMaterial.name, itemNames.get(after.mainMaterialItemId));
  pushChange(changes, 'Total material quantity', String(before.totalMaterialQuantity), String(after.totalMaterialQuantity));
  pushChange(changes, 'Effect', before.effect, after.effect);
  return changes;
}

async function dishEditChanges(client: DbClient, before: DishChangeSource, after: DishPayload): Promise<EditChange[]> {
  const changes: EditChange[] = [];
  const categoryNames = await entityNameMap(client, 'dish_categories', [after.categoryId]);
  const itemNames = await entityNameMap(client, 'items', [after.itemId, ...after.secondaryMaterialItemIds]);
  const flavorNames = await entityNameMap(client, 'dish_flavors', [after.flavorId]);
  const skillNames = await entityNameMap(client, 'skills', after.pokemonSkillId ? [after.pokemonSkillId] : []);

  pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId));
  pushChange(changes, 'Dish item', before.item.name, itemNames.get(after.itemId));
  pushChange(changes, 'Flavor', before.flavor.name, flavorNames.get(after.flavorId));
  pushTranslationChanges(changes, before.translations, after.translations, ['mosslaxEffect']);
  pushChange(changes, 'Secondary materials', namedListValue(before.secondaryMaterials), namesFromIds(after.secondaryMaterialItemIds, itemNames));
  pushChange(changes, 'Pokemon speciality', before.pokemonSkill?.name, after.pokemonSkillId ? skillNames.get(after.pokemonSkillId) : null);
  pushChange(changes, 'Mosslax effect', before.mosslaxEffect, after.mosslaxEffect);
  return changes;
}

export async function createDishCategory(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanDishCategoryPayload(payload);
  const id = await withTransaction(async (client) => {
    const sortOrder = await nextSortOrder(client, 'dish_categories');
    const result = await client.query<{ id: number }>(
      `
        INSERT INTO dish_categories (
          name,
          cookware_item_id,
          main_material_item_id,
          total_material_quantity,
          effect,
          sort_order,
          created_by_user_id,
          updated_by_user_id
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
        RETURNING id
      `,
      [
        cleanPayload.name,
        cleanPayload.cookwareItemId,
        cleanPayload.mainMaterialItemId,
        cleanPayload.totalMaterialQuantity,
        cleanPayload.effect,
        sortOrder,
        userId
      ]
    );
    const categoryId = result.rows[0].id;
    await replaceEntityTranslations(client, 'dish-categories', categoryId, cleanPayload.translations, ['name', 'effect']);
    await recordEditLog(client, 'dish-categories', categoryId, 'create', userId);
    return categoryId;
  });
  return getDishCategory(id, locale);
}

export async function updateDishCategory(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanDishCategoryPayload(payload);
  const before = await getDishCategory(id, defaultLocale);
  const updated = await withTransaction(async (client) => {
    await ensureDishCategoryMaterialSlots(client, id, cleanPayload);
    const result = await client.query(
      `
        UPDATE dish_categories
        SET name = $1,
            cookware_item_id = $2,
            main_material_item_id = $3,
            total_material_quantity = $4,
            effect = $5,
            updated_by_user_id = $6,
            updated_at = now()
        WHERE id = $7
      `,
      [
        cleanPayload.name,
        cleanPayload.cookwareItemId,
        cleanPayload.mainMaterialItemId,
        cleanPayload.totalMaterialQuantity,
        cleanPayload.effect,
        userId,
        id
      ]
    );
    if (result.rowCount === 0) {
      return false;
    }
    await replaceEntityTranslations(client, 'dish-categories', id, cleanPayload.translations, ['name', 'effect']);
    const changes = before ? await dishCategoryEditChanges(client, before as unknown as DishCategoryChangeSource, cleanPayload) : [];
    await recordEditLog(client, 'dish-categories', id, 'update', userId, changes);
    return true;
  });
  return updated ? getDishCategory(id, locale) : null;
}

export async function deleteDishCategory(id: number, userId: number) {
  return withTransaction(async (client) => {
    const childRows = await client.query<{ id: number }>('SELECT id FROM dishes WHERE category_id = $1', [id]);
    const result = await client.query<{ id: number }>('DELETE FROM dish_categories WHERE id = $1 RETURNING id', [id]);
    if (result.rowCount === 0) {
      return false;
    }

    await client.query('DELETE FROM entity_translations WHERE entity_type = $1 AND entity_id = $2', ['dish-categories', id]);
    const childIds = childRows.rows.map((row) => row.id);
    if (childIds.length) {
      await client.query('DELETE FROM entity_translations WHERE entity_type = $1 AND entity_id = ANY($2::integer[])', [
        'dishes',
        childIds
      ]);
    }
    await recordEditLog(client, 'dish-categories', id, 'delete', userId);
    return true;
  });
}

export async function createDish(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanDishPayload(payload);
  const id = await withTransaction(async (client) => {
    await ensureDishMaterialSlots(client, cleanPayload);
    const sortOrder = await nextSortOrder(client, 'dishes');
    const result = await client.query<{ id: number }>(
      `
        INSERT INTO dishes (
          category_id,
          item_id,
          flavor_id,
          secondary_material_1_item_id,
          secondary_material_2_item_id,
          pokemon_skill_id,
          mosslax_effect,
          sort_order,
          created_by_user_id,
          updated_by_user_id
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
        RETURNING id
      `,
      [
        cleanPayload.categoryId,
        cleanPayload.itemId,
        cleanPayload.flavorId,
        cleanPayload.secondaryMaterialItemIds[0] ?? null,
        cleanPayload.secondaryMaterialItemIds[1] ?? null,
        cleanPayload.pokemonSkillId,
        cleanPayload.mosslaxEffect,
        sortOrder,
        userId
      ]
    );
    const dishId = result.rows[0].id;
    await replaceEntityTranslations(client, 'dishes', dishId, cleanPayload.translations, ['mosslaxEffect']);
    await recordEditLog(client, 'dishes', dishId, 'create', userId);
    return dishId;
  });
  return getDish(id, locale);
}

export async function updateDish(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const cleanPayload = cleanDishPayload(payload);
  const before = await getDish(id, defaultLocale);
  const updated = await withTransaction(async (client) => {
    await ensureDishMaterialSlots(client, cleanPayload);
    const result = await client.query(
      `
        UPDATE dishes
        SET category_id = $1,
            item_id = $2,
            flavor_id = $3,
            secondary_material_1_item_id = $4,
            secondary_material_2_item_id = $5,
            pokemon_skill_id = $6,
            mosslax_effect = $7,
            updated_by_user_id = $8,
            updated_at = now()
        WHERE id = $9
      `,
      [
        cleanPayload.categoryId,
        cleanPayload.itemId,
        cleanPayload.flavorId,
        cleanPayload.secondaryMaterialItemIds[0] ?? null,
        cleanPayload.secondaryMaterialItemIds[1] ?? null,
        cleanPayload.pokemonSkillId,
        cleanPayload.mosslaxEffect,
        userId,
        id
      ]
    );
    if (result.rowCount === 0) {
      return false;
    }
    await replaceEntityTranslations(client, 'dishes', id, cleanPayload.translations, ['mosslaxEffect']);
    const changes = before ? await dishEditChanges(client, before as unknown as DishChangeSource, cleanPayload) : [];
    await recordEditLog(client, 'dishes', id, 'update', userId, changes);
    return true;
  });
  return updated ? getDish(id, locale) : null;
}

export async function deleteDish(id: number, userId: number) {
  return withTransaction(async (client) => {
    const result = await client.query<{ id: number }>('DELETE FROM dishes WHERE id = $1 RETURNING id', [id]);
    if (result.rowCount === 0) {
      return false;
    }

    await deleteEntityTranslations(client, 'dishes', id);
    await recordEditLog(client, 'dishes', id, 'delete', userId);
    return true;
  });
}

export async function reorderDishCategories(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const ids = cleanIds(payload.ids);
  if (ids.length === 0) {
    throw validationError('server.validation.selectRecord');
  }
  await withTransaction(async (client) => {
    await reorderTableRows(client, 'dish_categories', ids, userId);
  });
  return listDish(locale);
}

export async function reorderDishes(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
  const ids = cleanIds(payload.ids);
  if (ids.length === 0) {
    throw validationError('server.validation.selectRecord');
  }
  await withTransaction(async (client) => {
    await reorderTableRows(client, 'dishes', ids, userId);
  });
  return listDish(locale);
}

const dataToolScopes = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[];
const dataToolMainTables: Record<Exclude<DataToolScope, 'artifacts'>, string> = {
  pokemon: 'pokemon',
  habitats: 'habitats',
  items: 'items',
  recipes: 'recipes',
  checklist: 'daily_checklist_items'
};

const dataToolColumns = {
  pokemon: [
    'id',
    'data_id',
    'data_identifier',
    'display_id',
    'name',
    'is_event_item',
    'genus',
    'details',
    'height_inches',
    'weight_pounds',
    'environment_id',
    'hp',
    'attack',
    'defense',
    'special_attack',
    'special_defense',
    'speed',
    'image_path',
    'image_style',
    'image_version',
    'image_variant',
    'image_description',
    'sort_order',
    'created_by_user_id',
    'updated_by_user_id',
    'created_at',
    'updated_at'
  ],
  pokemonTypeLinks: ['pokemon_id', 'type_id', 'slot_order'],
  pokemonSkills: ['pokemon_id', 'skill_id'],
  pokemonFavoriteThings: ['pokemon_id', 'favorite_thing_id'],
  pokemonSkillItemDrops: ['pokemon_id', 'skill_id', 'item_id'],
  pokemonTradingItems: ['pokemon_id', 'item_id', 'preference'],
  habitats: ['id', 'name', 'is_event_item', 'image_path', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
  habitatRecipeItems: ['habitat_id', 'item_id', 'quantity'],
  habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'],
  items: [
    'id',
    'name',
    'details',
    'base_price',
    'ancient_artifact_category_key',
    'category_key',
    'usage_key',
    'dyeability',
    'dyeable',
    'dual_dyeable',
    'pattern_editable',
    'no_recipe',
    'is_event_item',
    'image_path',
    'sort_order',
    'created_by_user_id',
    'updated_by_user_id',
    'created_at',
    'updated_at'
  ],
  itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
  itemFavoriteThings: ['item_id', 'favorite_thing_id'],
  recipes: ['id', 'item_id', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
  recipeAcquisitionMethods: ['recipe_id', 'acquisition_method_id'],
  recipeMaterials: ['recipe_id', 'item_id', 'quantity'],
  checklist: ['id', 'title', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
  translations: ['entity_type', 'entity_id', 'locale', 'field_name', 'value'],
  editLogs: ['id', 'entity_type', 'entity_id', 'action', 'user_id', 'changes', 'created_at'],
  imageUploads: [
    'id',
    'entity_type',
    'entity_id',
    'entity_name',
    'path',
    'original_filename',
    'mime_type',
    'byte_size',
    'created_by_user_id',
    'created_at'
  ],
  discussionComments: [
    'id',
    'entity_type',
    'entity_id',
    'parent_comment_id',
    'body',
    'ai_moderation_status',
    'ai_moderation_language_code',
    'ai_moderation_content_hash',
    'ai_moderation_checked_at',
    'ai_moderation_retry_count',
    'ai_moderation_updated_at',
    'created_by_user_id',
    'deleted_by_user_id',
    'deleted_at',
    'created_at',
    'updated_at'
  ],
  discussionCommentLikes: ['comment_id', 'user_id', 'created_at']
} as const;
const itemsCsvColumns = [
  'name',
  'category',
  'description',
  'image_file_name',
  'not_registered_in_collection',
  'cannot_grow_again_today'
] as const;
const habitatsCsvColumns = ['id', 'name', 'image_file_name'] as const;
const itemsCsvCategoryAliases = new Map<string, string>(
  itemCategoryOptions.flatMap((option) => [
    [option.key, option.key],
    [option.labels.en.toLowerCase(), option.key],
    [option.labels.en.toLowerCase().replaceAll(' ', '-'), option.key],
    [option.labels.en.toLowerCase().replace(/\.$/, ''), option.key]
  ])
);

itemsCsvCategoryAliases.set('misc.', 'misc');

function normalizeItemsCsvCategory(value: string): string {
  return value.trim().toLowerCase().replace(/\s+/g, ' ');
}

function itemsCsvCategoryKey(value: string): string {
  const categoryKey = itemsCsvCategoryAliases.get(normalizeItemsCsvCategory(value));
  if (!categoryKey) {
    throw validationError('server.validation.dataToolItemsCsvInvalid');
  }
  return categoryKey;
}

function itemsCsvBoolean(row: CsvRow, fieldName: string): boolean {
  const value = csvText(row, fieldName).toLowerCase();
  if (value === '' || value === 'false' || value === '0' || value === 'no') {
    return false;
  }
  if (value === 'true' || value === '1' || value === 'yes') {
    return true;
  }
  throw validationError('server.validation.dataToolItemsCsvInvalid');
}

function appendItemsCsvNote(details: string, note: string): string {
  return details ? `${details}\n${note}` : note;
}

function itemsCsvDetails(row: CsvRow): string {
  let details = csvText(row, 'description');
  if (itemsCsvBoolean(row, 'not_registered_in_collection')) {
    details = appendItemsCsvNote(details, 'Note: Not registered in collection');
  }
  if (itemsCsvBoolean(row, 'cannot_grow_again_today')) {
    details = appendItemsCsvNote(details, 'Note: Cannot have Grow used on it again today');
  }
  return details;
}

function itemsCsvImagePath(value: string): string {
  const fileName = value.trim();
  const imagePath = `${itemStaticImagePathPrefix}${fileName}`;
  if (!isItemStaticImagePath(imagePath)) {
    throw validationError('server.validation.dataToolItemsCsvInvalid');
  }
  return imagePath;
}

function habitatsCsvId(value: string): { normalizedId: string; isEventItem: boolean } {
  const id = value.trim();
  const eventMatch = id.match(/^E-?(\d+)$/i);
  if (eventMatch) {
    return { normalizedId: `E${eventMatch[1]}`, isEventItem: true };
  }
  if (!/^\d+$/.test(id)) {
    throw validationError('server.validation.dataToolHabitatsCsvInvalid');
  }
  return { normalizedId: id, isEventItem: false };
}

function habitatsCsvImagePath(value: string): string {
  const fileName = value.trim();
  const imagePath = `${habitatStaticImagePathPrefix}${fileName}`;
  if (!isHabitatStaticImagePath(imagePath)) {
    throw validationError('server.validation.dataToolHabitatsCsvInvalid');
  }
  return imagePath;
}

function cleanItemsCsvRows(value: unknown): CsvRow[] {
  if (typeof value !== 'string' || value.trim() === '') {
    throw validationError('server.validation.dataToolItemsCsvInvalid');
  }

  const rows = parseCsv(value, 'items.csv');
  if (!rows.length || rows.some((row) => itemsCsvColumns.some((column) => !(column in row)))) {
    throw validationError('server.validation.dataToolItemsCsvInvalid');
  }

  const names = new Set<string>();
  for (const row of rows) {
    const name = csvText(row, 'name');
    if (!name || names.has(name)) {
      throw validationError('server.validation.dataToolItemsCsvInvalid');
    }
    names.add(name);
  }

  return rows;
}

function cleanHabitatsCsvRows(value: unknown): CsvRow[] {
  if (typeof value !== 'string' || value.trim() === '') {
    throw validationError('server.validation.dataToolHabitatsCsvInvalid');
  }

  const rows = parseCsv(value, 'habitats.csv');
  if (!rows.length || rows.some((row) => habitatsCsvColumns.some((column) => !(column in row)))) {
    throw validationError('server.validation.dataToolHabitatsCsvInvalid');
  }

  const ids = new Set<string>();
  const names = new Set<string>();
  for (const row of rows) {
    const id = habitatsCsvId(csvText(row, 'id')).normalizedId;
    const name = csvText(row, 'name');
    habitatsCsvImagePath(csvText(row, 'image_file_name'));
    if (ids.has(id) || !name || names.has(name)) {
      throw validationError('server.validation.dataToolHabitatsCsvInvalid');
    }
    ids.add(id);
    names.add(name);
  }

  return rows;
}

function isDataToolScope(value: unknown): value is DataToolScope {
  return typeof value === 'string' && dataToolScopes.includes(value as DataToolScope);
}

function normalizeDataToolScopes(scopes: DataToolScope[]): DataToolScope[] {
  const scopeSet = new Set(scopes);
  if (scopeSet.has('items')) {
    scopeSet.add('recipes');
    scopeSet.delete('artifacts');
  }
  return dataToolScopes.filter((scope) => scopeSet.has(scope));
}

function cleanDataToolScopes(value: unknown): DataToolScope[] {
  if (!Array.isArray(value)) {
    throw validationError('server.validation.dataToolScopeRequired');
  }

  const scopes: DataToolScope[] = [];
  for (const scope of value) {
    if (!isDataToolScope(scope)) {
      throw validationError('server.validation.dataToolScopeInvalid');
    }
    if (!scopes.includes(scope)) {
      scopes.push(scope);
    }
  }

  if (scopes.length === 0) {
    throw validationError('server.validation.dataToolScopeRequired');
  }
  return normalizeDataToolScopes(scopes);
}

function cleanDataToolsBundle(value: unknown): DataToolsBundle {
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
    throw validationError('server.validation.dataToolBundleInvalid');
  }

  const bundle = value as Record<string, unknown>;
  if (bundle.version !== 1 || !bundle.data || typeof bundle.data !== 'object' || Array.isArray(bundle.data)) {
    throw validationError('server.validation.dataToolBundleInvalid');
  }

  return {
    version: 1,
    exportedAt: typeof bundle.exportedAt === 'string' ? bundle.exportedAt : new Date().toISOString(),
    scopes: cleanDataToolScopes(bundle.scopes),
    data: bundle.data as DataToolsBundle['data']
  };
}

function dataToolTableRows(data: DataToolScopeData | undefined, key: string): DataToolRows {
  const rows = data?.[key];
  if (rows === undefined) {
    return [];
  }
  if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) {
    throw validationError('server.validation.dataToolBundleInvalid');
  }
  return rows as DataToolRows;
}

function dataToolDataWithRows(key: string, ...sources: Array<DataToolScopeData | undefined>): DataToolScopeData | undefined {
  return sources.find((source) => source?.[key] !== undefined);
}

async function tableRows(client: DbClient, sql: string, params: unknown[] = []): Promise<DataToolRows> {
  const result = await client.query<Record<string, unknown>>(sql, params);
  return result.rows;
}

function normalizeImportValue(value: unknown): unknown {
  return value === undefined ? null : value;
}

function normalizeImportColumnValue(row: Record<string, unknown>, column: string): unknown {
  if (column === 'dyeability' && row[column] === undefined) {
    if (row.dual_dyeable === true) {
      return 2;
    }
    if (row.dyeable === true) {
      return 1;
    }
    return 0;
  }

  return normalizeImportValue(row[column]);
}

async function insertRows(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
  for (const row of rows) {
    const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
    const values = columns.map((column) => normalizeImportColumnValue(row, column));
    await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`, values);
  }
}

async function upsertRowsById(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
  const updateColumns = columns.filter((column) => column !== 'id');
  for (const row of rows) {
    const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
    const assignments = updateColumns.map((column) => `${column} = EXCLUDED.${column}`).join(', ');
    const values = columns.map((column) => normalizeImportColumnValue(row, column));
    await client.query(
      `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT (id) DO UPDATE SET ${assignments}`,
      values
    );
  }
}

async function insertRowsIgnoreConflicts(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
  for (const row of rows) {
    const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
    const values = columns.map((column) => normalizeImportColumnValue(row, column));
    await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT DO NOTHING`, values);
  }
}

async function resetIdentity(client: DbClient, tableName: string): Promise<void> {
  const result = await client.query<{ maxId: number | null }>(`SELECT MAX(id)::integer AS "maxId" FROM ${tableName}`);
  const maxId = result.rows[0]?.maxId ?? null;
  if (maxId === null) {
    await client.query(`ALTER TABLE ${tableName} ALTER COLUMN id RESTART WITH 1`);
    return;
  }

  await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3, true)', [tableName, 'id', maxId]);
}

async function resetDataToolIdentities(client: DbClient): Promise<void> {
  for (const tableName of [
    'daily_checklist_items',
    'items',
    'recipes',
    'habitats',
    'wiki_edit_logs',
    'entity_image_uploads',
    'entity_discussion_comments'
  ]) {
    await resetIdentity(client, tableName);
  }
}

async function deleteGenericEntityRows(client: DbClient, entityTypes: string[]): Promise<void> {
  await client.query('DELETE FROM entity_discussion_comments WHERE entity_type = ANY($1::text[])', [entityTypes]);
  await client.query('DELETE FROM entity_image_uploads WHERE entity_type = ANY($1::text[])', [entityTypes]);
  await client.query('DELETE FROM wiki_edit_logs WHERE entity_type = ANY($1::text[])', [entityTypes]);
  await client.query('DELETE FROM entity_translations WHERE entity_type = ANY($1::text[])', [entityTypes]);
}

async function wipeRecipesData(client: DbClient): Promise<void> {
  await deleteGenericEntityRows(client, ['recipes']);
  await client.query('DELETE FROM recipe_acquisition_methods');
  await client.query('DELETE FROM recipe_materials');
  await client.query('DELETE FROM recipes');
}

async function wipeItemsData(client: DbClient): Promise<void> {
  await wipeRecipesData(client);
  await deleteGenericEntityRows(client, ['items']);
  await client.query('DELETE FROM item_acquisition_methods');
  await client.query('DELETE FROM item_favorite_things');
  await client.query('DELETE FROM habitat_recipe_items');
  await client.query('DELETE FROM pokemon_skill_item_drops');
  await client.query('DELETE FROM pokemon_trading_items');
  await client.query('DELETE FROM items');
}

async function wipeAncientArtifactsData(client: DbClient): Promise<void> {
  await client.query(`
    DELETE FROM entity_discussion_comments
    WHERE entity_type = 'ancient-artifacts'
      AND entity_id IN (SELECT id FROM items WHERE ancient_artifact_category_key IS NOT NULL)
  `);
  await client.query(`
    UPDATE items
    SET ancient_artifact_category_key = NULL,
        updated_at = now()
    WHERE ancient_artifact_category_key IS NOT NULL
  `);
}

async function wipePokemonData(client: DbClient): Promise<void> {
  await deleteGenericEntityRows(client, ['pokemon']);
  await client.query('DELETE FROM habitat_pokemon');
  await client.query('DELETE FROM pokemon_skill_item_drops');
  await client.query('DELETE FROM pokemon_pokemon_types');
  await client.query('DELETE FROM pokemon_skills');
  await client.query('DELETE FROM pokemon_favorite_things');
  await client.query('DELETE FROM pokemon_trading_items');
  await client.query('DELETE FROM pokemon');
}

async function wipeHabitatsData(client: DbClient): Promise<void> {
  await deleteGenericEntityRows(client, ['habitats']);
  await client.query('DELETE FROM habitat_recipe_items');
  await client.query('DELETE FROM habitat_pokemon');
  await client.query('DELETE FROM habitats');
}

async function wipeChecklistData(client: DbClient): Promise<void> {
  await deleteGenericEntityRows(client, ['daily-checklist-items']);
  await client.query('DELETE FROM daily_checklist_items');
}

async function wipeDataToolScopes(client: DbClient, scopes: DataToolScope[], resetIdentities = true): Promise<void> {
  const scopeSet = new Set(scopes);
  if (scopeSet.has('items')) {
    await wipeItemsData(client);
  } else if (scopeSet.has('recipes')) {
    await wipeRecipesData(client);
  }
  if (scopeSet.has('artifacts')) {
    await wipeAncientArtifactsData(client);
  }
  if (scopeSet.has('pokemon')) {
    await wipePokemonData(client);
  }
  if (scopeSet.has('habitats')) {
    await wipeHabitatsData(client);
  }
  if (scopeSet.has('checklist')) {
    await wipeChecklistData(client);
  }
  if (resetIdentities) {
    await resetDataToolIdentities(client);
  }
}

async function exportGenericScopeData(client: DbClient, entityType: string, includeImages: boolean): Promise<DataToolScopeData> {
  const data: DataToolScopeData = {
    translations: await tableRows(client, 'SELECT * FROM entity_translations WHERE entity_type = $1 ORDER BY entity_id, locale, field_name', [entityType]),
    editLogs: await tableRows(client, 'SELECT * FROM wiki_edit_logs WHERE entity_type = $1 ORDER BY id', [entityType]),
    discussionComments: await tableRows(
      client,
      `
        SELECT *
        FROM entity_discussion_comments
        WHERE entity_type = $1
        ORDER BY parent_comment_id NULLS FIRST, id
      `,
      [entityType]
    ),
    discussionCommentLikes: await tableRows(
      client,
      `
        SELECT edcl.*
        FROM entity_discussion_comment_likes edcl
        JOIN entity_discussion_comments edc ON edc.id = edcl.comment_id
        WHERE edc.entity_type = $1
        ORDER BY edcl.comment_id, edcl.user_id
      `,
      [entityType]
    )
  };

  if (includeImages) {
    data.imageUploads = await tableRows(client, 'SELECT * FROM entity_image_uploads WHERE entity_type = $1 ORDER BY id', [entityType]);
  }

  return data;
}

async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<DataToolScopeData> {
  if (scope === 'pokemon') {
    return {
      pokemon: await tableRows(client, 'SELECT * FROM pokemon ORDER BY id'),
      pokemonTypeLinks: await tableRows(client, 'SELECT * FROM pokemon_pokemon_types ORDER BY pokemon_id, slot_order'),
      pokemonSkills: await tableRows(client, 'SELECT * FROM pokemon_skills ORDER BY pokemon_id, skill_id'),
      pokemonFavoriteThings: await tableRows(client, 'SELECT * FROM pokemon_favorite_things ORDER BY pokemon_id, favorite_thing_id'),
      pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
      pokemonTradingItems: await tableRows(client, 'SELECT * FROM pokemon_trading_items ORDER BY pokemon_id, preference, item_id'),
      habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'),
      ...(await exportGenericScopeData(client, 'pokemon', true))
    };
  }

  if (scope === 'habitats') {
    return {
      habitats: await tableRows(client, 'SELECT * FROM habitats ORDER BY sort_order, id'),
      habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'),
      habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'),
      ...(await exportGenericScopeData(client, 'habitats', true))
    };
  }

  if (scope === 'items') {
    return {
      items: await tableRows(client, 'SELECT * FROM items ORDER BY sort_order, id'),
      itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'),
      itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'),
      pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
      pokemonTradingItems: await tableRows(client, 'SELECT * FROM pokemon_trading_items ORDER BY pokemon_id, preference, item_id'),
      habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'),
      ...(await exportGenericScopeData(client, 'items', true))
    };
  }

  if (scope === 'artifacts') {
    return {
      artifacts: await tableRows(client, 'SELECT * FROM items WHERE ancient_artifact_category_key IS NOT NULL ORDER BY sort_order, id'),
      itemFavoriteThings: await tableRows(
        client,
        `
          SELECT ift.*
          FROM item_favorite_things ift
          JOIN items i ON i.id = ift.item_id
          WHERE i.ancient_artifact_category_key IS NOT NULL
          ORDER BY ift.item_id, ift.favorite_thing_id
        `
      ),
      translations: await tableRows(
        client,
        `
          SELECT et.*
          FROM entity_translations et
          JOIN items i ON i.id = et.entity_id
          WHERE et.entity_type = 'items'
            AND i.ancient_artifact_category_key IS NOT NULL
          ORDER BY et.entity_id, et.locale, et.field_name
        `
      ),
      editLogs: await tableRows(
        client,
        `
          SELECT wel.*
          FROM wiki_edit_logs wel
          JOIN items i ON i.id = wel.entity_id
          WHERE wel.entity_type = 'items'
            AND i.ancient_artifact_category_key IS NOT NULL
          ORDER BY wel.id
        `
      ),
      imageUploads: await tableRows(
        client,
        `
          SELECT eiu.*
          FROM entity_image_uploads eiu
          JOIN items i ON i.id = eiu.entity_id
          WHERE eiu.entity_type = 'items'
            AND i.ancient_artifact_category_key IS NOT NULL
          ORDER BY eiu.id
        `
      ),
      discussionComments: await tableRows(
        client,
        `
          SELECT edc.*
          FROM entity_discussion_comments edc
          JOIN items i ON i.id = edc.entity_id
          WHERE edc.entity_type = 'ancient-artifacts'
            AND i.ancient_artifact_category_key IS NOT NULL
          ORDER BY edc.parent_comment_id NULLS FIRST, edc.id
        `
      ),
      discussionCommentLikes: await tableRows(
        client,
        `
          SELECT edcl.*
          FROM entity_discussion_comment_likes edcl
          JOIN entity_discussion_comments edc ON edc.id = edcl.comment_id
          JOIN items i ON i.id = edc.entity_id
          WHERE edc.entity_type = 'ancient-artifacts'
            AND i.ancient_artifact_category_key IS NOT NULL
          ORDER BY edcl.comment_id, edcl.user_id
        `
      )
    };
  }

  if (scope === 'recipes') {
    return {
      recipes: await tableRows(client, 'SELECT * FROM recipes ORDER BY sort_order, id'),
      recipeAcquisitionMethods: await tableRows(client, 'SELECT * FROM recipe_acquisition_methods ORDER BY recipe_id, acquisition_method_id'),
      recipeMaterials: await tableRows(client, 'SELECT * FROM recipe_materials ORDER BY recipe_id, item_id'),
      ...(await exportGenericScopeData(client, 'recipes', false))
    };
  }

  return {
    checklist: await tableRows(client, 'SELECT * FROM daily_checklist_items ORDER BY sort_order, id'),
    translations: await tableRows(
      client,
      'SELECT * FROM entity_translations WHERE entity_type = $1 ORDER BY entity_id, locale, field_name',
      ['daily-checklist-items']
    ),
    editLogs: await tableRows(client, 'SELECT * FROM wiki_edit_logs WHERE entity_type = $1 ORDER BY id', ['daily-checklist-items'])
  };
}

async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
  const itemData = bundle.data.items;
  const artifactData = bundle.data.artifacts;
  const pokemonData = bundle.data.pokemon;
  const habitatData = bundle.data.habitats;
  const checklistData = bundle.data.checklist;
  const recipeData = bundle.data.recipes;

  await insertRows(client, 'items', dataToolColumns.items, dataToolTableRows(itemData, 'items'));
  await upsertRowsById(client, 'items', dataToolColumns.items, dataToolTableRows(artifactData, 'artifacts'));
  await insertRows(client, 'pokemon', dataToolColumns.pokemon, dataToolTableRows(pokemonData, 'pokemon'));
  await insertRows(client, 'habitats', dataToolColumns.habitats, dataToolTableRows(habitatData, 'habitats'));
  await insertRows(client, 'daily_checklist_items', dataToolColumns.checklist, dataToolTableRows(checklistData, 'checklist'));
  await insertRows(client, 'recipes', dataToolColumns.recipes, dataToolTableRows(recipeData, 'recipes'));
}

async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
  const itemData = bundle.data.items;
  const artifactData = bundle.data.artifacts;
  const pokemonData = bundle.data.pokemon;
  const habitatData = bundle.data.habitats;
  const recipeData = bundle.data.recipes;
  const pokemonDropData = dataToolDataWithRows('pokemonSkillItemDrops', pokemonData, itemData);
  const pokemonTradingData = dataToolDataWithRows('pokemonTradingItems', pokemonData, itemData);
  const habitatRecipeData = dataToolDataWithRows('habitatRecipeItems', habitatData, itemData);
  const habitatPokemonData = dataToolDataWithRows('habitatPokemon', habitatData, pokemonData);

  await insertRows(client, 'item_acquisition_methods', dataToolColumns.itemAcquisitionMethods, dataToolTableRows(itemData, 'itemAcquisitionMethods'));
  await insertRows(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(itemData, 'itemFavoriteThings'));
  await insertRowsIgnoreConflicts(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(artifactData, 'itemFavoriteThings'));
  await insertRows(client, 'pokemon_pokemon_types', dataToolColumns.pokemonTypeLinks, dataToolTableRows(pokemonData, 'pokemonTypeLinks'));
  await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
  await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));
  await insertRows(client, 'pokemon_skill_item_drops', dataToolColumns.pokemonSkillItemDrops, dataToolTableRows(pokemonDropData, 'pokemonSkillItemDrops'));
  await insertRows(client, 'pokemon_trading_items', dataToolColumns.pokemonTradingItems, dataToolTableRows(pokemonTradingData, 'pokemonTradingItems'));
  await insertRows(client, 'recipe_acquisition_methods', dataToolColumns.recipeAcquisitionMethods, dataToolTableRows(recipeData, 'recipeAcquisitionMethods'));
  await insertRows(client, 'recipe_materials', dataToolColumns.recipeMaterials, dataToolTableRows(recipeData, 'recipeMaterials'));
  await insertRows(client, 'habitat_recipe_items', dataToolColumns.habitatRecipeItems, dataToolTableRows(habitatRecipeData, 'habitatRecipeItems'));
  await insertRows(client, 'habitat_pokemon', dataToolColumns.habitatPokemon, dataToolTableRows(habitatPokemonData, 'habitatPokemon'));
}

async function importGenericScopeRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
  for (const scope of bundle.scopes) {
    const data = bundle.data[scope];
    await insertRows(client, 'entity_translations', dataToolColumns.translations, dataToolTableRows(data, 'translations'));
    await insertRows(client, 'wiki_edit_logs', dataToolColumns.editLogs, dataToolTableRows(data, 'editLogs'));
    await insertRows(client, 'entity_image_uploads', dataToolColumns.imageUploads, dataToolTableRows(data, 'imageUploads'));
    await insertRows(client, 'entity_discussion_comments', dataToolColumns.discussionComments, dataToolTableRows(data, 'discussionComments'));
    await insertRows(
      client,
      'entity_discussion_comment_likes',
      dataToolColumns.discussionCommentLikes,
      dataToolTableRows(data, 'discussionCommentLikes')
    );
  }
}

async function importDataToolsBundle(client: DbClient, bundle: DataToolsBundle): Promise<void> {
  await importScopeMainRows(client, bundle);
  await importScopeRelationRows(client, bundle);
  await importGenericScopeRows(client, bundle);
  await resetDataToolIdentities(client);
}

export async function getAdminDataToolsSummary(): Promise<{ scopes: DataToolScopeSummary[] }> {
  const scopes: DataToolScopeSummary[] = [];
  for (const scope of dataToolScopes) {
    const result = await queryOne<{ count: number }>(
      scope === 'artifacts'
        ? 'SELECT COUNT(*)::integer AS count FROM items WHERE ancient_artifact_category_key IS NOT NULL'
        : `SELECT COUNT(*)::integer AS count FROM ${dataToolMainTables[scope]}`
    );
    scopes.push({ scope, count: result?.count ?? 0 });
  }
  return { scopes };
}

export async function exportAdminData(payload: Record<string, unknown>): Promise<DataToolsBundle> {
  const scopes = cleanDataToolScopes(payload.scopes);
  return withTransaction(async (client) => {
    const data: DataToolsBundle['data'] = {};
    for (const scope of scopes) {
      data[scope] = await exportScopeData(client, scope);
    }
    return { version: 1, exportedAt: new Date().toISOString(), scopes, data };
  });
}

export async function importAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
  const bundle = cleanDataToolsBundle(payload.bundle);
  await withTransaction(async (client) => {
    await wipeDataToolScopes(client, bundle.scopes, false);
    await importDataToolsBundle(client, bundle);
  });
  return getAdminDataToolsSummary();
}

export async function importAdminItemsCsv(payload: Record<string, unknown>, userId: number): Promise<{ scopes: DataToolScopeSummary[] }> {
  const rows = cleanItemsCsvRows(payload.csv);
  const names = rows.map((row) => csvText(row, 'name'));

  await withTransaction(async (client) => {
    const existing = await client.query<{ name: string }>('SELECT name FROM items WHERE name = ANY($1::text[])', [names]);
    if (existing.rowCount && existing.rowCount > 0) {
      throw validationError('server.validation.dataToolItemsCsvInvalid');
    }

    const firstSortOrder = await nextSortOrder(client, 'items');
    for (const [index, row] of rows.entries()) {
      const result = await client.query<{ id: number }>(
        `
          INSERT INTO items (
            name,
            details,
            category_key,
            image_path,
            sort_order,
            created_by_user_id,
            updated_by_user_id
          )
          VALUES ($1, $2, $3, $4, $5, $6, $6)
          RETURNING id
        `,
        [
          csvText(row, 'name'),
          itemsCsvDetails(row),
          itemsCsvCategoryKey(csvText(row, 'category')),
          itemsCsvImagePath(csvText(row, 'image_file_name')),
          firstSortOrder + index * 10,
          userId
        ]
      );
      await recordEditLog(client, 'items', result.rows[0].id, 'create', userId);
    }

    await resetIdentity(client, 'items');
  });

  return getAdminDataToolsSummary();
}

export async function importAdminHabitatsCsv(payload: Record<string, unknown>, userId: number): Promise<{ scopes: DataToolScopeSummary[] }> {
  const rows = cleanHabitatsCsvRows(payload.csv);
  const names = rows.map((row) => csvText(row, 'name'));

  await withTransaction(async (client) => {
    const existing = await client.query<{ name: string }>('SELECT name FROM habitats WHERE name = ANY($1::text[])', [names]);
    if (existing.rowCount && existing.rowCount > 0) {
      throw validationError('server.validation.dataToolHabitatsCsvInvalid');
    }

    const firstSortOrder = await nextSortOrder(client, 'habitats');
    for (const [index, row] of rows.entries()) {
      const { isEventItem } = habitatsCsvId(csvText(row, 'id'));
      const result = await client.query<{ id: number }>(
        `
          INSERT INTO habitats (
            name,
            is_event_item,
            image_path,
            sort_order,
            created_by_user_id,
            updated_by_user_id
          )
          VALUES ($1, $2, $3, $4, $5, $5)
          RETURNING id
        `,
        [
          csvText(row, 'name'),
          isEventItem,
          habitatsCsvImagePath(csvText(row, 'image_file_name')),
          firstSortOrder + index * 10,
          userId
        ]
      );
      await recordEditLog(client, 'habitats', result.rows[0].id, 'create', userId);
    }

    await resetIdentity(client, 'habitats');
  });

  return getAdminDataToolsSummary();
}

const defaultThreadLimit = 20;
const maxThreadLimit = 50;
const defaultThreadMessageLimit = 30;
const maxThreadMessageLimit = 80;
const threadEmojiReactionPattern = /(?:\p{Extended_Pictographic}|\p{Regional_Indicator})/u;

type ThreadCursor = { value: string; id: number };
type ThreadMessageCursor = { createdAt: string; id: number };

function emptyThreadReactionCounts(): ThreadReactionCounts {
  return {};
}

function cleanThreadReactionType(value: unknown): ThreadReactionType {
  const reactionType = typeof value === 'string' ? value.trim() : '';
  if (
    !reactionType ||
    reactionType.length > 24 ||
    /\s/.test(reactionType) ||
    /[\p{Letter}\p{Number}]/u.test(reactionType) ||
    !threadEmojiReactionPattern.test(reactionType)
  ) {
    throw validationError('server.validation.reactionInvalid');
  }
  return reactionType;
}

function cleanThreadLimit(value: QueryValue, fallback = defaultThreadLimit, max = maxThreadLimit): number {
  const raw = Number(asString(value));
  return Number.isInteger(raw) && raw > 0 ? Math.min(raw, max) : fallback;
}

function encodeCursor(value: unknown): string {
  return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
}

function decodeThreadCursor(value: QueryValue): ThreadCursor | null {
  const cursor = asString(value);
  if (!cursor) return null;
  try {
    const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
    if (payload && typeof payload === 'object') {
      const record = payload as Record<string, unknown>;
      if (typeof record.value === 'string' && Number.isInteger(Number(record.id))) {
        return { value: record.value, id: Number(record.id) };
      }
    }
  } catch {
    return null;
  }
  return null;
}

function decodeThreadMessageCursor(value: QueryValue): ThreadMessageCursor | null {
  const cursor = asString(value);
  if (!cursor) return null;
  try {
    const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
    if (payload && typeof payload === 'object') {
      const record = payload as Record<string, unknown>;
      if (typeof record.createdAt === 'string' && Number.isInteger(Number(record.id))) {
        return { createdAt: record.createdAt, id: Number(record.id) };
      }
    }
  } catch {
    return null;
  }
  return null;
}

function cleanThreadTitle(value: unknown): string {
  const title = cleanName(value, 'server.validation.titleRequired');
  if (title.length > 140) {
    throw validationError('server.validation.valueTooLong');
  }
  return title;
}

function cleanThreadMessageBody(value: unknown): string {
  const body = cleanName(value, 'server.validation.commentRequired');
  if (body.length > 2000) {
    throw validationError('server.validation.commentTooLong');
  }
  return body;
}

function cleanThreadLanguageCode(value: unknown): string {
  const languageCode = cleanModerationLanguageCode(value);
  if (!languageCode) {
    throw validationError('server.validation.languageInvalid');
  }
  return languageCode;
}

function cleanThreadTagIds(value: unknown): number[] {
  if (!Array.isArray(value)) {
    return [];
  }
  return cleanIds(value).slice(0, 8);
}

async function publicThreadChannels(userId: number | null): Promise<ThreadChannel[]> {
  const rows = await query<{
    id: number;
    name: string;
    allowUserThreads: boolean;
    sortOrder: number;
    tags: ThreadChannelTag[] | null;
    languages: Array<{ code: string; name: string }> | null;
    unreadCount: number;
  }>(
    `
      SELECT
        tc.id,
        tc.name,
        tc.allow_user_threads AS "allowUserThreads",
        tc.sort_order AS "sortOrder",
        COALESCE(tags.items, '[]'::json) AS tags,
        COALESCE(channel_languages.items, fallback_languages.items, '[]'::json) AS languages,
        CASE
          WHEN $1::integer IS NULL THEN 0
          ELSE COALESCE(unread.count, 0)
        END AS "unreadCount"
      FROM thread_channels tc
      LEFT JOIN LATERAL (
        SELECT json_agg(json_build_object('id', tct.id, 'name', tct.name, 'sortOrder', tct.sort_order) ORDER BY tct.sort_order, tct.id) AS items
        FROM thread_channel_tags tct
        WHERE tct.channel_id = tc.id
      ) tags ON true
      LEFT JOIN LATERAL (
        SELECT json_agg(json_build_object('code', l.code, 'name', l.name) ORDER BY tcl.sort_order, l.sort_order, l.code) AS items
        FROM thread_channel_languages tcl
        JOIN languages l ON l.code = tcl.language_code
        WHERE tcl.channel_id = tc.id
          AND l.enabled = true
      ) channel_languages ON true
      LEFT JOIN LATERAL (
        SELECT json_agg(json_build_object('code', l.code, 'name', l.name) ORDER BY l.sort_order, l.code) AS items
        FROM languages l
        WHERE l.enabled = true
      ) fallback_languages ON true
      LEFT JOIN LATERAL (
        SELECT COUNT(*)::integer AS count
        FROM thread_follows tf
        JOIN threads t ON t.id = tf.thread_id
        LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = tf.user_id
        WHERE tf.user_id = $1::integer
          AND t.channel_id = tc.id
          AND t.deleted_at IS NULL
          AND t.last_message_id IS NOT NULL
          AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)
      ) unread ON true
      ORDER BY tc.sort_order, tc.id
    `,
    [userId]
  );

  return rows.map((row) => ({
    id: row.id,
    name: row.name,
    allowUserThreads: row.allowUserThreads,
    sortOrder: row.sortOrder,
    tags: row.tags ?? [],
    languages: row.languages ?? [],
    unreadCount: row.unreadCount
  }));
}

export async function listThreadChannels(userId: number | null): Promise<ThreadChannel[]> {
  return publicThreadChannels(userId);
}

export async function listAdminThreadChannels(): Promise<ThreadChannel[]> {
  return publicThreadChannels(null);
}

async function channelAllowsLanguage(channelId: number, languageCode: string): Promise<boolean> {
  const row = await queryOne<{ allowed: boolean }>(
    `
      SELECT CASE
        WHEN EXISTS (SELECT 1 FROM thread_channel_languages WHERE channel_id = $1) THEN EXISTS (
          SELECT 1
          FROM thread_channel_languages tcl
          JOIN languages l ON l.code = tcl.language_code
          WHERE tcl.channel_id = $1 AND tcl.language_code = $2 AND l.enabled = true
        )
        ELSE EXISTS (SELECT 1 FROM languages WHERE code = $2 AND enabled = true)
      END AS allowed
    `,
    [channelId, languageCode]
  );
  return row?.allowed === true;
}

async function validateThreadTags(channelId: number, tagIds: number[]): Promise<void> {
  if (!tagIds.length) return;
  const rows = await query<{ id: number }>(
    'SELECT id FROM thread_channel_tags WHERE channel_id = $1 AND id = ANY($2::integer[])',
    [channelId, tagIds]
  );
  if (rows.length !== tagIds.length) {
    throw validationError('server.validation.invalidField');
  }
}

async function threadReactionCounts(threadIds: number[], userId: number | null): Promise<{
  counts: Map<number, ThreadReactionCounts>;
  mine: Map<number, ThreadReactionType[]>;
}> {
  const counts = new Map<number, ThreadReactionCounts>();
  const mine = new Map<number, ThreadReactionType[]>();
  for (const id of threadIds) counts.set(id, emptyThreadReactionCounts());
  if (!threadIds.length) return { counts, mine };

  const countRows = await query<{ threadId: number; reactionType: ThreadReactionType; count: number }>(
    `
      SELECT thread_id AS "threadId", reaction_type AS "reactionType", COUNT(*)::integer AS count
      FROM thread_reactions
      WHERE thread_id = ANY($1::integer[])
      GROUP BY thread_id, reaction_type
    `,
    [threadIds]
  );
  for (const row of countRows) {
    const item = counts.get(row.threadId);
    if (item) {
      item[row.reactionType] = row.count;
    }
  }

  if (userId !== null) {
    const myRows = await query<{ threadId: number; reactionType: ThreadReactionType }>(
      `
        SELECT thread_id AS "threadId", reaction_type AS "reactionType"
        FROM thread_reactions
        WHERE user_id = $1
          AND thread_id = ANY($2::integer[])
      `,
      [userId, threadIds]
    );
    for (const row of myRows) {
      mine.set(row.threadId, [...(mine.get(row.threadId) ?? []), row.reactionType]);
    }
  }

  return { counts, mine };
}

async function threadMessageReactionCounts(messageIds: number[], userId: number | null): Promise<{
  counts: Map<number, ThreadReactionCounts>;
  mine: Map<number, ThreadReactionType[]>;
}> {
  const counts = new Map<number, ThreadReactionCounts>();
  const mine = new Map<number, ThreadReactionType[]>();
  for (const id of messageIds) counts.set(id, emptyThreadReactionCounts());
  if (!messageIds.length) return { counts, mine };

  const countRows = await query<{ messageId: number; reactionType: ThreadReactionType; count: number }>(
    `
      SELECT message_id AS "messageId", reaction_type AS "reactionType", COUNT(*)::integer AS count
      FROM thread_message_reactions
      WHERE message_id = ANY($1::integer[])
      GROUP BY message_id, reaction_type
    `,
    [messageIds]
  );
  for (const row of countRows) {
    const item = counts.get(row.messageId);
    if (item) {
      item[row.reactionType] = row.count;
    }
  }

  if (userId !== null) {
    const myRows = await query<{ messageId: number; reactionType: ThreadReactionType }>(
      `
        SELECT message_id AS "messageId", reaction_type AS "reactionType"
        FROM thread_message_reactions
        WHERE user_id = $1
          AND message_id = ANY($2::integer[])
      `,
      [userId, messageIds]
    );
    for (const row of myRows) {
      mine.set(row.messageId, [...(mine.get(row.messageId) ?? []), row.reactionType]);
    }
  }

  return { counts, mine };
}

async function hydrateThreads(rows: Array<ThreadSummary & { lastActiveCursor: string }>, userId: number | null): Promise<ThreadSummary[]> {
  const ids = rows.map((row) => row.id);
  const tags = await query<{ threadId: number; id: number; name: string; sortOrder: number }>(
    `
      SELECT ttl.thread_id AS "threadId", tct.id, tct.name, tct.sort_order AS "sortOrder"
      FROM thread_tag_links ttl
      JOIN thread_channel_tags tct ON tct.id = ttl.tag_id
      WHERE ttl.thread_id = ANY($1::integer[])
      ORDER BY tct.sort_order, tct.id
    `,
    [ids]
  );
  const tagsByThread = new Map<number, ThreadChannelTag[]>();
  for (const tag of tags) {
    tagsByThread.set(tag.threadId, [...(tagsByThread.get(tag.threadId) ?? []), { id: tag.id, name: tag.name, sortOrder: tag.sortOrder }]);
  }
  const reactions = await threadReactionCounts(ids, userId);
  return rows.map((row) => ({
    id: row.id,
    channelId: row.channelId,
    title: row.title,
    languageCode: row.languageCode,
    tags: tagsByThread.get(row.id) ?? [],
    locked: row.locked,
    messageCount: row.messageCount,
    lastActiveAt: row.lastActiveAt,
    createdAt: row.createdAt,
    author: row.author,
    reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
    myReactions: reactions.mine.get(row.id) ?? [],
    followed: row.followed,
    unread: row.unread
  }));
}

export async function listThreads(paramsQuery: QueryParams, userId: number | null): Promise<ThreadsPage> {
  const limit = cleanThreadLimit(paramsQuery.limit);
  const channelId = optionalPositiveInteger(asString(paramsQuery.channelId), 'server.validation.invalidField');
  const tagId = optionalPositiveInteger(asString(paramsQuery.tagId), 'server.validation.invalidField');
  const language = asString(paramsQuery.language);
  const sort = asString(paramsQuery.sort) ?? 'last-active';
  const cursor = decodeThreadCursor(paramsQuery.cursor);
  const conditions = ['t.deleted_at IS NULL'];
  const params: unknown[] = [];

  if (channelId !== null) {
    params.push(channelId);
    conditions.push(`t.channel_id = $${params.length}`);
  }
  if (tagId !== null) {
    params.push(tagId);
    conditions.push(`EXISTS (SELECT 1 FROM thread_tag_links ttl WHERE ttl.thread_id = t.id AND ttl.tag_id = $${params.length})`);
  }
  if (language && language !== 'all') {
    params.push(cleanThreadLanguageCode(language));
    conditions.push(`t.language_code = $${params.length}`);
  }

  const orderField = sort === 'latest' ? 't.created_at' : sort === 'most-discussed' ? 't.message_count' : 't.last_active_at';
  const orderCursorField = sort === 'latest' ? 'created_at' : sort === 'most-discussed' ? 'message_count' : 'last_active_at';
  if (cursor) {
    params.push(cursor.value, cursor.id);
    conditions.push(`(${orderField}, t.id) < ($${params.length - 1}::${sort === 'most-discussed' ? 'integer' : 'timestamptz'}, $${params.length}::integer)`);
  }
  params.push(limit + 1, userId);
  const limitParam = params.length - 1;
  const userParam = params.length;

  const rows = await query<ThreadSummary & { lastActiveCursor: string; createdAtCursor: string; messageCountCursor: number }>(
    `
      SELECT
        t.id,
        t.channel_id AS "channelId",
        t.title,
        t.language_code AS "languageCode",
        t.locked,
        t.message_count AS "messageCount",
        t.last_active_at AS "lastActiveAt",
        t.last_active_at::text AS "lastActiveCursor",
        t.created_at AS "createdAt",
        t.created_at::text AS "createdAtCursor",
        t.message_count AS "messageCountCursor",
        CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author,
        ($${userParam}::integer IS NOT NULL AND tf.user_id IS NOT NULL) AS followed,
        ($${userParam}::integer IS NOT NULL AND t.last_message_id IS NOT NULL AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)) AS unread
      FROM threads t
      LEFT JOIN users u ON u.id = t.created_by_user_id
      LEFT JOIN thread_follows tf ON tf.thread_id = t.id AND tf.user_id = $${userParam}::integer
      LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = $${userParam}::integer
      WHERE ${conditions.join(' AND ')}
      ORDER BY ${orderField} DESC, t.id DESC
      LIMIT $${limitParam}
    `,
    params
  );
  const items = await hydrateThreads(rows.slice(0, limit), userId);
  const last = rows.slice(0, limit).at(-1) as (typeof rows)[number] | undefined;
  const nextValue = last ? (sort === 'latest' ? last.createdAtCursor : sort === 'most-discussed' ? String(last.messageCountCursor) : last.lastActiveCursor) : null;
  return {
    items,
    nextCursor: rows.length > limit && last && nextValue ? encodeCursor({ value: nextValue, id: last.id }) : null,
    hasMore: rows.length > limit
  };
}

export async function getThread(threadIdValue: number, userId: number | null): Promise<ThreadSummary | null> {
  const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
  const rows = await query<ThreadSummary & { lastActiveCursor: string }>(
    `
      SELECT
        t.id,
        t.channel_id AS "channelId",
        t.title,
        t.language_code AS "languageCode",
        t.locked,
        t.message_count AS "messageCount",
        t.last_active_at AS "lastActiveAt",
        t.last_active_at::text AS "lastActiveCursor",
        t.created_at AS "createdAt",
        CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author,
        ($2::integer IS NOT NULL AND tf.user_id IS NOT NULL) AS followed,
        ($2::integer IS NOT NULL AND t.last_message_id IS NOT NULL AND (tr.last_read_message_id IS NULL OR t.last_message_id > tr.last_read_message_id)) AS unread
      FROM threads t
      LEFT JOIN users u ON u.id = t.created_by_user_id
      LEFT JOIN thread_follows tf ON tf.thread_id = t.id AND tf.user_id = $2::integer
      LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = $2::integer
      WHERE t.id = $1
        AND t.deleted_at IS NULL
    `,
    [threadId, userId]
  );
  return (await hydrateThreads(rows, userId))[0] ?? null;
}

async function getThreadMessageById(messageId: number, userId: number | null, canViewAll = false): Promise<ThreadMessage | null> {
  const rows = await query<ThreadMessage>(
    `
      SELECT
        tm.id,
        tm.thread_id AS "threadId",
        tm.body,
        tm.ai_moderation_status AS "moderationStatus",
        tm.ai_moderation_language_code AS "moderationLanguageCode",
        tm.ai_moderation_reason AS "moderationReason",
        tm.created_at AS "createdAt",
        tm.updated_at AS "updatedAt",
        CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author
      FROM thread_messages tm
      LEFT JOIN users u ON u.id = tm.created_by_user_id
      WHERE tm.id = $1
        AND tm.deleted_at IS NULL
        AND ${moderationVisibilitySql('tm', 'tm.created_by_user_id', userId, canViewAll)}
    `,
    [messageId]
  );
  const row = rows[0];
  if (!row) return null;
  const reactions = await threadMessageReactionCounts([row.id], userId);
  return {
    ...row,
    reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
    myReactions: reactions.mine.get(row.id) ?? []
  };
}

export async function listThreadMessages(
  threadIdValue: number,
  paramsQuery: QueryParams,
  userId: number | null,
  canViewAll = false
): Promise<ThreadMessagesPage | null> {
  const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
  const thread = await getThread(threadId, userId);
  if (!thread) return null;
  const limit = cleanThreadLimit(paramsQuery.limit, defaultThreadMessageLimit, maxThreadMessageLimit);
  const before = decodeThreadMessageCursor(paramsQuery.before);
  const conditions = ['tm.thread_id = $1', 'tm.deleted_at IS NULL'];
  const params: unknown[] = [threadId];
  if (before) {
    params.push(before.createdAt, before.id);
    conditions.push(`(tm.created_at, tm.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
  }
  if (!canViewAll) {
    if (userId !== null) {
      params.push(userId);
      conditions.push(`(tm.ai_moderation_status = 'approved' OR tm.created_by_user_id = $${params.length})`);
    } else {
      conditions.push("tm.ai_moderation_status = 'approved'");
    }
  }
  params.push(limit + 1);

  const rows = await query<ThreadMessage & { createdAtCursor: string }>(
    `
      SELECT
        tm.id,
        tm.thread_id AS "threadId",
        tm.body,
        tm.ai_moderation_status AS "moderationStatus",
        tm.ai_moderation_language_code AS "moderationLanguageCode",
        tm.ai_moderation_reason AS "moderationReason",
        tm.created_at AS "createdAt",
        tm.created_at::text AS "createdAtCursor",
        tm.updated_at AS "updatedAt",
        CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author
      FROM thread_messages tm
      LEFT JOIN users u ON u.id = tm.created_by_user_id
      WHERE ${conditions.join(' AND ')}
      ORDER BY tm.created_at DESC, tm.id DESC
      LIMIT $${params.length}
    `,
    params
  );
  const pageRows = rows.slice(0, limit).reverse();
  const reactions = await threadMessageReactionCounts(pageRows.map((row) => row.id), userId);
  const items = pageRows.map((row) => ({
    id: row.id,
    threadId: row.threadId,
    body: row.body,
    moderationStatus: row.moderationStatus,
    moderationLanguageCode: row.moderationLanguageCode,
    moderationReason: row.moderationReason,
    createdAt: row.createdAt,
    updatedAt: row.updatedAt,
    author: row.author,
    reactionCounts: reactions.counts.get(row.id) ?? emptyThreadReactionCounts(),
    myReactions: reactions.mine.get(row.id) ?? []
  }));
  const oldest = rows.slice(0, limit).at(-1);
  return {
    items,
    beforeCursor: rows.length > limit && oldest ? encodeCursor({ createdAt: oldest.createdAtCursor, id: oldest.id }) : null,
    hasMoreBefore: rows.length > limit
  };
}

export async function createThread(payload: Record<string, unknown>, userId: number): Promise<ThreadSummary> {
  const channelId = requirePositiveInteger(payload.channelId, 'server.validation.invalidField');
  const title = cleanThreadTitle(payload.title);
  const languageCode = cleanThreadLanguageCode(payload.languageCode);
  const tagIds = cleanThreadTagIds(payload.tagIds);
  const messageBody = cleanThreadMessageBody(payload.body);
  const channel = await queryOne<{ id: number; allowUserThreads: boolean }>(
    'SELECT id, allow_user_threads AS "allowUserThreads" FROM thread_channels WHERE id = $1',
    [channelId]
  );
  if (!channel || !channel.allowUserThreads) {
    throw validationError('server.validation.invalidField');
  }
  if (!(await channelAllowsLanguage(channelId, languageCode))) {
    throw validationError('server.validation.languageInvalid');
  }
  await validateThreadTags(channelId, tagIds);

  const ids = await withTransaction(async (client) => {
    const threadResult = await client.query<{ id: number }>(
      `
        INSERT INTO threads (channel_id, title, language_code, created_by_user_id, updated_by_user_id)
        VALUES ($1, $2, $3, $4, $4)
        RETURNING id
      `,
      [channelId, title, languageCode, userId]
    );
    const threadId = threadResult.rows[0].id;
    for (const tagId of tagIds) {
      await client.query('INSERT INTO thread_tag_links (thread_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, tagId]);
    }
    const messageResult = await client.query<{ id: number }>(
      `
        INSERT INTO thread_messages (thread_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
        VALUES ($1, $2, 'unreviewed', $3, $4)
        RETURNING id
      `,
      [threadId, messageBody, languageCode, userId]
    );
    await client.query('INSERT INTO thread_follows (thread_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, userId]);
    return { threadId, messageId: messageResult.rows[0].id };
  });

  await requestAiModerationReview({ type: 'thread-message', id: ids.messageId }, { languageCode, resetRetries: true });
  return (await getThread(ids.threadId, userId)) as ThreadSummary;
}

export async function updateThread(
  threadIdValue: number,
  payload: Record<string, unknown>,
  userId: number,
  canUpdateAny = false
): Promise<ThreadSummary | null> {
  const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
  const title = cleanThreadTitle(payload.title);
  const tagIds = cleanThreadTagIds(payload.tagIds);
  const thread = await queryOne<{ id: number; channelId: number; createdByUserId: number }>(
    `
      SELECT id, channel_id AS "channelId", created_by_user_id AS "createdByUserId"
      FROM threads
      WHERE id = $1
        AND deleted_at IS NULL
    `,
    [threadId]
  );
  if (!thread) return null;
  if (!canUpdateAny && thread.createdByUserId !== userId) {
    throw forbiddenError();
  }
  await validateThreadTags(thread.channelId, tagIds);

  await withTransaction(async (client) => {
    await client.query('UPDATE threads SET title = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3', [title, userId, threadId]);
    await client.query('DELETE FROM thread_tag_links WHERE thread_id = $1', [threadId]);
    for (const tagId of tagIds) {
      await client.query('INSERT INTO thread_tag_links (thread_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, tagId]);
    }
  });

  return getThread(threadId, userId);
}

async function refreshThreadMessageAggregates(threadId: number): Promise<void> {
  await pool.query(
    `
      UPDATE threads t
      SET message_count = (
            SELECT COUNT(*)::integer
            FROM thread_messages tm
            WHERE tm.thread_id = t.id
              AND tm.deleted_at IS NULL
              AND tm.ai_moderation_status = 'approved'
          ),
          last_message_id = (
            SELECT tm.id
            FROM thread_messages tm
            WHERE tm.thread_id = t.id
              AND tm.deleted_at IS NULL
              AND tm.ai_moderation_status = 'approved'
            ORDER BY tm.created_at DESC, tm.id DESC
            LIMIT 1
          ),
          updated_at = now()
      WHERE t.id = $1
    `,
    [threadId]
  );
}

export async function createThreadMessage(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
  const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
  const body = cleanThreadMessageBody(payload.body);
  const thread = await queryOne<{ id: number; locked: boolean; languageCode: string }>(
    'SELECT id, locked, language_code AS "languageCode" FROM threads WHERE id = $1 AND deleted_at IS NULL',
    [threadId]
  );
  if (!thread) return null;
  if (thread.locked) {
    throw validationError('server.validation.invalidField');
  }
  const result = await queryOne<{ id: number }>(
    `
      INSERT INTO thread_messages (thread_id, body, ai_moderation_status, ai_moderation_language_code, created_by_user_id)
      VALUES ($1, $2, 'unreviewed', $3, $4)
      RETURNING id
    `,
    [threadId, body, thread.languageCode, userId]
  );
  if (!result) return null;
  await requestAiModerationReview({ type: 'thread-message', id: result.id }, { languageCode: thread.languageCode, resetRetries: true });
  return getThreadMessageById(result.id, userId, false);
}

export async function updateThreadMessage(
  messageIdValue: number,
  payload: Record<string, unknown>,
  userId: number,
  canUpdateAny = false
): Promise<ThreadMessage | null> {
  const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
  const body = cleanThreadMessageBody(payload.body);
  const message = await queryOne<{ id: number; threadId: number; languageCode: string; createdByUserId: number }>(
    `
      SELECT
        tm.id,
        tm.thread_id AS "threadId",
        t.language_code AS "languageCode",
        tm.created_by_user_id AS "createdByUserId"
      FROM thread_messages tm
      JOIN threads t ON t.id = tm.thread_id
      WHERE tm.id = $1
        AND tm.deleted_at IS NULL
        AND t.deleted_at IS NULL
    `,
    [messageId]
  );
  if (!message) return null;
  if (!canUpdateAny && message.createdByUserId !== userId) {
    throw forbiddenError();
  }

  const result = await queryOne<{ id: number }>(
    `
      UPDATE thread_messages
      SET body = $1,
          ai_moderation_status = 'reviewing',
          ai_moderation_language_code = NULL,
          ai_moderation_reason = NULL,
          ai_moderation_content_hash = NULL,
          ai_moderation_checked_at = NULL,
          ai_moderation_retry_count = 0,
          ai_moderation_updated_at = now(),
          updated_at = now()
      WHERE id = $2
        AND deleted_at IS NULL
      RETURNING id
    `,
    [body, messageId]
  );
  if (!result) return null;

  await refreshThreadMessageAggregates(message.threadId);
  await publishThreadMessageModeration(message.threadId, messageId, null);
  await requestAiModerationReview({ type: 'thread-message', id: messageId }, { languageCode: message.languageCode, resetRetries: true });
  return getThreadMessageById(messageId, userId, canUpdateAny);
}

export async function retryThreadMessageModeration(
  messageIdValue: number,
  userId: number,
  canRetryAny = false
): Promise<ThreadMessage | null> {
  const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
  const message = await queryOne<{ id: number; createdByUserId: number }>(
    `
      SELECT tm.id, tm.created_by_user_id AS "createdByUserId"
      FROM thread_messages tm
      JOIN threads t ON t.id = tm.thread_id
      WHERE tm.id = $1
        AND tm.deleted_at IS NULL
        AND t.deleted_at IS NULL
        AND tm.ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
    `,
    [messageId]
  );
  if (!message) return null;
  if (!canRetryAny && message.createdByUserId !== userId) {
    throw forbiddenError();
  }

  await requestAiModerationReview({ type: 'thread-message', id: messageId }, { incrementRetries: true });
  return getThreadMessageById(messageId, userId, canRetryAny);
}

export async function markThreadRead(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
  const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
  const row = await queryOne<{ lastMessageId: number | null }>(
    'SELECT last_message_id AS "lastMessageId" FROM threads WHERE id = $1 AND deleted_at IS NULL',
    [threadId]
  );
  if (!row) return null;
  await pool.query(
    `
      INSERT INTO thread_reads (thread_id, user_id, last_read_message_id, last_read_at)
      VALUES ($1, $2, $3, now())
      ON CONFLICT (thread_id, user_id)
      DO UPDATE SET last_read_message_id = EXCLUDED.last_read_message_id,
                    last_read_at = now()
    `,
    [threadId, userId, row.lastMessageId]
  );
  const thread = await getThread(threadId, userId);
  await publishThreadReadUpdated(userId, threadId, thread?.unread ?? false, 0);
  return thread;
}

export async function followThread(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
  const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
  const thread = await getThread(threadId, userId);
  if (!thread) return null;
  await pool.query('INSERT INTO thread_follows (thread_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [threadId, userId]);
  return getThread(threadId, userId);
}

export async function unfollowThread(threadIdValue: number, userId: number): Promise<ThreadSummary | null> {
  const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
  const thread = await getThread(threadId, userId);
  if (!thread) return null;
  await pool.query('DELETE FROM thread_follows WHERE thread_id = $1 AND user_id = $2', [threadId, userId]);
  return getThread(threadId, userId);
}

export async function setThreadReaction(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadSummary | null> {
  const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
  const reactionType = cleanThreadReactionType(payload.reactionType);
  const thread = await getThread(threadId, userId);
  if (!thread) return null;
  await pool.query(
    `
      INSERT INTO thread_reactions (thread_id, user_id, reaction_type)
      VALUES ($1, $2, $3)
      ON CONFLICT (thread_id, user_id, reaction_type)
      DO UPDATE SET updated_at = now()
    `,
    [threadId, userId, reactionType]
  );
  const updated = await getThread(threadId, userId);
  if (updated) {
    await publishThreadReactionUpdated(userId, {
      type: 'thread.reactions.updated',
      target: 'thread',
      threadId,
      messageId: null,
      reactionCounts: updated.reactionCounts,
      myReactions: updated.myReactions
    });
  }
  return updated;
}

export async function deleteThreadReaction(threadIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadSummary | null> {
  const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
  const reactionType = cleanThreadReactionType(payload.reactionType);
  const thread = await getThread(threadId, userId);
  if (!thread) return null;
  await pool.query('DELETE FROM thread_reactions WHERE thread_id = $1 AND user_id = $2 AND reaction_type = $3', [threadId, userId, reactionType]);
  const updated = await getThread(threadId, userId);
  if (updated) {
    await publishThreadReactionUpdated(userId, {
      type: 'thread.reactions.updated',
      target: 'thread',
      threadId,
      messageId: null,
      reactionCounts: updated.reactionCounts,
      myReactions: updated.myReactions
    });
  }
  return updated;
}

export async function setThreadMessageReaction(messageIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
  const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
  const reactionType = cleanThreadReactionType(payload.reactionType);
  const message = await getThreadMessageById(messageId, userId);
  if (!message || message.moderationStatus !== 'approved') return null;
  await pool.query(
    `
      INSERT INTO thread_message_reactions (message_id, user_id, reaction_type)
      VALUES ($1, $2, $3)
      ON CONFLICT (message_id, user_id, reaction_type)
      DO UPDATE SET updated_at = now()
    `,
    [messageId, userId, reactionType]
  );
  const updated = await getThreadMessageById(messageId, userId);
  if (updated) {
    await publishThreadReactionUpdated(userId, {
      type: 'thread.reactions.updated',
      target: 'message',
      threadId: updated.threadId,
      messageId,
      reactionCounts: updated.reactionCounts,
      myReactions: updated.myReactions
    });
  }
  return updated;
}

export async function deleteThreadMessageReaction(messageIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadMessage | null> {
  const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
  const reactionType = cleanThreadReactionType(payload.reactionType);
  const message = await getThreadMessageById(messageId, userId);
  if (!message) return null;
  await pool.query('DELETE FROM thread_message_reactions WHERE message_id = $1 AND user_id = $2 AND reaction_type = $3', [messageId, userId, reactionType]);
  const updated = await getThreadMessageById(messageId, userId);
  if (updated) {
    await publishThreadReactionUpdated(userId, {
      type: 'thread.reactions.updated',
      target: 'message',
      threadId: updated.threadId,
      messageId,
      reactionCounts: updated.reactionCounts,
      myReactions: updated.myReactions
    });
  }
  return updated;
}

export async function applyApprovedThreadMessage(messageId: number): Promise<void> {
  const row = await queryOne<{ threadId: number }>(
    `
      UPDATE threads t
      SET last_message_id = tm.id,
          message_count = (
            SELECT COUNT(*)::integer
            FROM thread_messages visible_message
            WHERE visible_message.thread_id = t.id
              AND visible_message.deleted_at IS NULL
              AND visible_message.ai_moderation_status = 'approved'
          ),
          last_active_at = GREATEST(t.last_active_at, tm.created_at),
          updated_at = now()
      FROM thread_messages tm
      WHERE tm.id = $1
        AND tm.thread_id = t.id
        AND tm.deleted_at IS NULL
        AND tm.ai_moderation_status = 'approved'
      RETURNING t.id AS "threadId"
    `,
    [messageId]
  );
  if (!row) return;
  const message = await getThreadMessageById(messageId, null, true);
  const thread = await getThread(row.threadId, null);
  if (message && thread) {
    await publishThreadMessageCreated(thread, message);
  } else {
    await publishThreadMessageModeration(row.threadId, messageId, message);
  }
}

export async function createAdminThreadChannel(payload: Record<string, unknown>, userId: number): Promise<ThreadChannel[]> {
  const name = cleanName(payload.name, 'server.validation.nameRequired');
  const allowUserThreads = payload.allowUserThreads !== false;
  const tagNames = Array.isArray(payload.tags) ? payload.tags.map((tag) => cleanName(tag, 'server.validation.nameRequired')).slice(0, 20) : [];
  const languageCodes = Array.isArray(payload.languages) ? payload.languages.map(cleanThreadLanguageCode).slice(0, 20) : [];
  await withTransaction(async (client) => {
    const sortOrder = await nextSortOrder(client, 'thread_channels');
    const result = await client.query<{ id: number }>(
      `
        INSERT INTO thread_channels (name, allow_user_threads, sort_order, created_by_user_id, updated_by_user_id)
        VALUES ($1, $2, $3, $4, $4)
        RETURNING id
      `,
      [name, allowUserThreads, sortOrder, userId]
    );
    await replaceThreadChannelConfig(client, result.rows[0].id, tagNames, languageCodes);
  });
  return listAdminThreadChannels();
}

async function replaceThreadChannelConfig(client: DbClient, channelId: number, tagNames: string[], languageCodes: string[]): Promise<void> {
  await client.query('DELETE FROM thread_channel_tags WHERE channel_id = $1', [channelId]);
  await client.query('DELETE FROM thread_channel_languages WHERE channel_id = $1', [channelId]);
  for (const [index, tagName] of [...new Set(tagNames)].entries()) {
    await client.query('INSERT INTO thread_channel_tags (channel_id, name, sort_order) VALUES ($1, $2, $3)', [channelId, tagName, (index + 1) * 10]);
  }
  for (const [index, languageCode] of [...new Set(languageCodes)].entries()) {
    await client.query('INSERT INTO thread_channel_languages (channel_id, language_code, sort_order) VALUES ($1, $2, $3)', [
      channelId,
      languageCode,
      (index + 1) * 10
    ]);
  }
}

export async function updateAdminThreadChannel(channelIdValue: number, payload: Record<string, unknown>, userId: number): Promise<ThreadChannel[] | null> {
  const channelId = requirePositiveInteger(channelIdValue, 'server.validation.recordInvalid');
  const name = cleanName(payload.name, 'server.validation.nameRequired');
  const allowUserThreads = payload.allowUserThreads !== false;
  const tagNames = Array.isArray(payload.tags) ? payload.tags.map((tag) => cleanName(tag, 'server.validation.nameRequired')).slice(0, 20) : [];
  const languageCodes = Array.isArray(payload.languages) ? payload.languages.map(cleanThreadLanguageCode).slice(0, 20) : [];
  const updated = await withTransaction(async (client) => {
    const result = await client.query(
      `
        UPDATE thread_channels
        SET name = $1, allow_user_threads = $2, updated_by_user_id = $3, updated_at = now()
        WHERE id = $4
      `,
      [name, allowUserThreads, userId, channelId]
    );
    if (!result.rowCount) return false;
    await replaceThreadChannelConfig(client, channelId, tagNames, languageCodes);
    return true;
  });
  return updated ? listAdminThreadChannels() : null;
}

export async function deleteAdminThreadChannel(channelIdValue: number): Promise<boolean> {
  const channelId = requirePositiveInteger(channelIdValue, 'server.validation.recordInvalid');
  const result = await pool.query('DELETE FROM thread_channels WHERE id = $1', [channelId]);
  return Boolean(result.rowCount);
}

export async function updateThreadLock(threadIdValue: number, locked: boolean, userId: number): Promise<ThreadSummary | null> {
  const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
  const result = await pool.query(
    'UPDATE threads SET locked = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3 AND deleted_at IS NULL',
    [locked, userId, threadId]
  );
  return result.rowCount ? getThread(threadId, userId) : null;
}

export async function deleteThread(threadIdValue: number, userId: number): Promise<boolean> {
  const threadId = requirePositiveInteger(threadIdValue, 'server.validation.recordInvalid');
  const result = await pool.query(
    'UPDATE threads SET deleted_at = now(), deleted_by_user_id = $1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL',
    [userId, threadId]
  );
  return Boolean(result.rowCount);
}

export async function deleteThreadMessage(messageIdValue: number, userId: number): Promise<boolean> {
  const messageId = requirePositiveInteger(messageIdValue, 'server.validation.recordInvalid');
  const result = await pool.query<{ threadId: number }>(
    `
      UPDATE thread_messages
      SET deleted_at = now(), deleted_by_user_id = $1, updated_at = now()
      WHERE id = $2
        AND deleted_at IS NULL
      RETURNING thread_id AS "threadId"
    `,
    [userId, messageId]
  );
  if (!result.rowCount) return false;
  await pool.query(
    `
      UPDATE threads t
      SET message_count = (
            SELECT COUNT(*)::integer
            FROM thread_messages tm
            WHERE tm.thread_id = t.id
              AND tm.deleted_at IS NULL
              AND tm.ai_moderation_status = 'approved'
          ),
          last_message_id = (
            SELECT tm.id
            FROM thread_messages tm
            WHERE tm.thread_id = t.id
              AND tm.deleted_at IS NULL
              AND tm.ai_moderation_status = 'approved'
            ORDER BY tm.created_at DESC, tm.id DESC
            LIMIT 1
          ),
          updated_at = now()
      WHERE t.id = $1
    `,
    [result.rows[0].threadId]
  );
  return true;
}

export async function createThreadsWsTicketForUser(userId: number): Promise<{ ticket: string; expiresAt: Date }> {
  return createThreadWebSocketTicket(userId);
}

export async function wipeAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
  const scopes = cleanDataToolScopes(payload.scopes);
  await withTransaction(async (client) => {
    await wipeDataToolScopes(client, scopes);
  });
  return getAdminDataToolsSummary();
}
