const fs = require('fs/promises')
const path = require('path')
const mysql = require('mysql2/promise')
const { nanoid } = require('nanoid')

const DB_HOST = process.env.DB_HOST || '127.0.0.1'
const DB_PORT = process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306
const DB_USER = process.env.DB_USER || 'root'
const DB_PASSWORD = process.env.DB_PASSWORD || ''
const DB_NAME = process.env.DB_NAME || 'tier_cursor'
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
const FREEFORM_TOPIC_ID = 'freeform'
const TOPIC_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/

let poolPromise = null
let initPromise = null

function now() {
  return Date.now()
}

function parseJson(value, fallback) {
  if (!value) return fallback
  try {
    return JSON.parse(value)
  } catch (e) {
    return fallback
  }
}

function serializeJson(value) {
  return JSON.stringify(value || [])
}

function normalizeTags(tags) {
  const values = Array.isArray(tags)
    ? tags
    : typeof tags === 'string'
      ? tags.split(',')
      : []
  return Array.from(
    new Set(
      values
        .map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, 40))
        .filter(Boolean)
    )
  ).slice(0, 30)
}

function normalizeTopicSlug(value) {
  return String(value || '').trim().toLowerCase()
}

function assertTopicSlug(slug) {
  const normalized = normalizeTopicSlug(slug)
  if (!normalized || normalized.length > 120 || !TOPIC_SLUG_PATTERN.test(normalized)) {
    const err = new Error('topic_slug_invalid')
    err.code = 'TOPIC_SLUG_INVALID'
    throw err
  }
  return normalized
}

function collectUploadSrcsFromItems(items, bucket) {
  for (const item of items || []) {
    if (typeof item?.src === 'string' && item.src.startsWith('/uploads/')) {
      bucket.add(item.src)
    }
  }
}

function resolveMonthRange(month) {
  if (typeof month !== 'string') return null
  const match = month.trim().match(/^(\d{4})-(\d{2})$/)
  if (!match) return null

  const year = Number(match[1])
  const monthIndex = Number(match[2]) - 1
  if (!Number.isInteger(year) || monthIndex < 0 || monthIndex > 11) return null

  return {
    start: new Date(year, monthIndex, 1).getTime(),
    end: new Date(year, monthIndex + 1, 1).getTime(),
  }
}

function mapUserRow(row) {
  if (!row) return null
  return {
    id: row.id,
    email: row.email,
    nickname: row.nickname || '',
    emailVerified: row.email_verified == null ? true : !!row.email_verified,
    isAdmin: !!row.is_admin,
    avatarSrc: row.avatar_src || '',
    createdAt: Number(row.created_at),
    tierListCount: Number(row.tierlist_count || 0),
    followerCount: Number(row.follower_count || 0),
    receivedFavoriteCount: Number(row.received_favorite_count || 0),
    lastLoginAt: Number(row.last_login_at || 0),
    recentActivityAt: Number(row.recent_activity_at || row.created_at || 0),
  }
}

function mapTopicRow(row) {
  if (!row) return null
  return {
    id: row.id,
    slug: row.slug || row.id,
    name: row.name,
    topicId: row.id,
    topicSlug: row.slug || row.id,
    topicName: row.name,
    thumbnailSrc: row.thumbnail_src || '',
    isPublic: row.is_public == null ? true : !!row.is_public,
    displayRank: row.display_rank == null ? null : Number(row.display_rank),
    createdAt: Number(row.created_at),
    tags: normalizeTags(parseJson(row.tags_json, [])),
  }
}

function mapTopicItemRow(row) {
  if (!row) return null
  return {
    id: row.id,
    topicId: row.topic_id,
    src: row.src,
    label: row.label,
    displayOrder: row.display_order == null ? null : Number(row.display_order),
    createdAt: Number(row.created_at),
    tags: normalizeTags(parseJson(row.tags_json, [])),
  }
}

function mapCustomItemRow(row) {
  if (!row) return null
  return {
    id: row.id,
    ownerId: row.owner_id,
    src: row.src,
    label: row.label,
    createdAt: Number(row.created_at),
    replacedByItemId: row.replaced_by_item_id || '',
    replacedBySrc: row.replaced_by_src || '',
    replacedByLabel: row.replaced_by_label || '',
    replacedAt: Number(row.replaced_at || 0),
    tags: normalizeTags(parseJson(row.tags_json, [])),
  }
}

function getSharedItemDisplayPriority(item) {
  if (!item) return 99
  if (item.sourceType === 'user' && !item.replacedAt) return 0
  if (item.sourceType === 'user') return 1
  if (item.sourceType === 'template') return 2
  if (item.sourceType === 'asset' || item.isAssetLibraryItem) return 3
  return 4
}

function collapseSharedLibraryItems(items) {
  const grouped = new Map()
  for (const item of items || []) {
    const key = String(item?.src || '').trim()
    if (!key) continue
    if (!grouped.has(key)) grouped.set(key, [])
    grouped.get(key).push(item)
  }

  return Array.from(grouped.values())
    .map((group) =>
      group
        .slice()
        .sort((a, b) => {
          const priorityDiff = getSharedItemDisplayPriority(a) - getSharedItemDisplayPriority(b)
          if (priorityDiff !== 0) return priorityDiff
          return Number(b.createdAt || 0) - Number(a.createdAt || 0)
        })[0]
    )
    .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
}

function mapImageAssetRow(row) {
  if (!row) return null
  return {
    id: row.id,
    contentHash: row.content_hash,
    src: row.src || '',
    labelOverride: row.label_override || '',
    mimeType: row.mime_type || 'image/webp',
    byteSize: Number(row.byte_size || 0),
    originalByteSize: Number(row.original_byte_size || 0),
    width: Number(row.width || 0),
    height: Number(row.height || 0),
    createdAt: Number(row.created_at || 0),
  }
}

function mapImageOptimizationJobRow(row) {
  if (!row) return null
  return {
    id: row.id,
    status: row.status,
    sourceCategory: row.source_category || '',
    targetDirectory: row.target_directory || '',
    originalByteSize: Number(row.original_byte_size || 0),
    optimizedByteSize: Number(row.optimized_byte_size || 0),
    reusedAsset: !!row.reused_asset,
    errorMessage: row.error_message || '',
    queuedAt: Number(row.queued_at || 0),
    startedAt: Number(row.started_at || 0),
    finishedAt: Number(row.finished_at || 0),
  }
}
function mapTierListRow(row) {
  if (!row) return null
  return {
    id: row.id,
    authorId: row.author_id,
    authorName: getUserDisplayName(row),
    authorAccountName: getUserAccountName(row),
    authorAvatarSrc: row.avatar_src || '',
    topicId: row.topic_id,
    topicSlug: row.topic_slug || row.topic_id,
    topicName: row.topic_name || '',
    title: row.title,
    thumbnailSrc: row.thumbnail_src || '',
    description: row.description || '',
    isPublic: !!row.is_public,
    isFeatured: !!row.is_featured,
    featuredAt: Number(row.featured_at || 0),
    featuredBy: row.featured_by || '',
    showCharacterNames: !!row.show_character_names,
    iconSize: Number(row.icon_size || 80),
    sourceTierListId: row.source_tierlist_id || '',
    sourceSnapshotTitle: row.source_snapshot_title || '',
    sourceSnapshotAuthor: row.source_snapshot_author || '',
    groups: parseJson(row.groups_json, []),
    pool: parseJson(row.pool_json, []),
    createdAt: Number(row.created_at),
    updatedAt: Number(row.updated_at),
  }
}

function mapTemplateRequestRow(row) {
  if (!row) return null
  return {
    id: row.id,
    type: row.request_type,
    requesterId: row.requester_id,
    requesterName: getUserDisplayName(row),
    requesterAccountName: getUserAccountName(row),
    requesterAvatarSrc: row.requester_avatar_src || '',
    sourceTierListId: row.source_tierlist_id || '',
    sourceTopicId: row.source_topic_id,
    sourceTopicSlug: row.source_topic_slug || row.source_topic_id,
    sourceTopicName: row.source_topic_name || '',
    sourceTierListTitle: row.title_snapshot || '',
    sourceDescription: row.description_snapshot || '',
    thumbnailSrc: row.thumbnail_src_snapshot || '',
    targetTopicId: row.target_topic_id || '',
    targetTopicSlug: row.target_topic_slug || row.target_topic_id || '',
    targetTopicName: row.target_topic_name || '',
    status: row.status,
    items: parseJson(row.items_json, []),
    snapshotGroups: parseJson(row.groups_json, []),
    snapshotItems: parseJson(row.board_items_json, []),
    snapshotShowCharacterNames: !!row.show_character_names_snapshot,
    createdAt: Number(row.created_at),
    updatedAt: Number(row.updated_at),
  }
}

function getUserDisplayName(row) {
  if (!row) return ''
  const nickname = (row.nickname || '').trim()
  if (nickname) return nickname
  const email = (row.email || '').trim()
  if (!email) return ''
  return email.split('@')[0] || email
}

function getUserAccountName(row) {
  if (!row) return ''
  const email = (row.email || '').trim()
  if (!email) return ''
  return email.split('@')[0] || email
}

function getAssetLibrarySourceLabel(src) {
  const normalizedSrc = String(src || '').trim()
  if (normalizedSrc.includes('/uploads/assets/avatars/')) return '프로필 아바타'
  if (normalizedSrc.includes('/uploads/assets/tierlists/')) return '티어표 썸네일'
  if (normalizedSrc.includes('/uploads/assets/topics/')) return '썸네일 이미지'
  return '보관 자산'
}

function getAssetLibraryKind(src) {
  const normalizedSrc = String(src || '').trim()
  if (normalizedSrc.includes('/uploads/assets/avatars/')) return 'avatar'
  if (normalizedSrc.includes('/uploads/assets/tierlists/') || normalizedSrc.includes('/uploads/assets/topics/')) return 'thumbnail'
  return 'asset'
}

async function createPool() {
  const rootConnection = await mysql.createConnection({
    host: DB_HOST,
    port: DB_PORT,
    user: DB_USER,
    password: DB_PASSWORD,
    multipleStatements: true,
  })

  await rootConnection.query(
    `CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
  )
  await rootConnection.end()

  return mysql.createPool({
    host: DB_HOST,
    port: DB_PORT,
    user: DB_USER,
    password: DB_PASSWORD,
    database: DB_NAME,
    connectionLimit: DB_CONNECTION_LIMIT,
    charset: 'utf8mb4',
  })
}

async function getPool() {
  if (!poolPromise) {
    poolPromise = createPool()
  }
  return poolPromise
}

async function query(sql, params = []) {
  const pool = await getPool()
  const [rows] = await pool.execute(sql, params)
  return rows
}

async function closePool() {
  if (!poolPromise) return
  const pool = await poolPromise
  await pool.end()
  poolPromise = null
  initPromise = null
}

async function ensureSchema() {
  if (initPromise) return initPromise
  initPromise = (async () => {
    await query(`
      CREATE TABLE IF NOT EXISTS users (
        id VARCHAR(64) PRIMARY KEY,
        email VARCHAR(255) NOT NULL UNIQUE,
        nickname VARCHAR(80) NOT NULL DEFAULT '',
        password_hash VARCHAR(255) NOT NULL,
        email_verified TINYINT(1) NOT NULL DEFAULT 1,
        is_admin TINYINT(1) NOT NULL DEFAULT 0,
        avatar_src VARCHAR(255) NOT NULL DEFAULT '',
        last_login_at BIGINT NOT NULL DEFAULT 0,
        created_at BIGINT NOT NULL
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)

    await query(`
      CREATE TABLE IF NOT EXISTS email_verification_tokens (
        id VARCHAR(64) PRIMARY KEY,
        user_id VARCHAR(64) NOT NULL,
        token_hash CHAR(64) NOT NULL UNIQUE,
        expires_at BIGINT NOT NULL,
        consumed_at BIGINT NOT NULL DEFAULT 0,
        created_at BIGINT NOT NULL,
        INDEX idx_email_verification_user (user_id, consumed_at, expires_at),
        INDEX idx_email_verification_expires (expires_at),
        CONSTRAINT fk_email_verification_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)

    await query(`
      CREATE TABLE IF NOT EXISTS password_reset_tokens (
        id VARCHAR(64) PRIMARY KEY,
        user_id VARCHAR(64) NOT NULL,
        token_hash CHAR(64) NOT NULL UNIQUE,
        expires_at BIGINT NOT NULL,
        consumed_at BIGINT NOT NULL DEFAULT 0,
        created_at BIGINT NOT NULL,
        INDEX idx_password_reset_user (user_id, consumed_at, expires_at),
        INDEX idx_password_reset_expires (expires_at),
        CONSTRAINT fk_password_reset_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)

    await query(`
      CREATE TABLE IF NOT EXISTS topics (
        id VARCHAR(120) PRIMARY KEY,
        slug VARCHAR(120) NOT NULL,
        name VARCHAR(120) NOT NULL,
        thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
        tags_json LONGTEXT NULL,
        is_public TINYINT(1) NOT NULL DEFAULT 1,
        display_rank INT NULL DEFAULT NULL,
        created_at BIGINT NOT NULL,
        UNIQUE KEY uq_topics_slug (slug)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)

    await query(`
      CREATE TABLE IF NOT EXISTS topic_items (
        id VARCHAR(64) PRIMARY KEY,
        topic_id VARCHAR(120) NOT NULL,
        src VARCHAR(255) NOT NULL,
        label VARCHAR(120) NOT NULL,
        tags_json LONGTEXT NULL,
        display_order INT NULL DEFAULT NULL,
        created_at BIGINT NOT NULL,
        INDEX idx_topic_items_topic_id (topic_id),
        CONSTRAINT fk_topic_items_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)

    await query(`
      CREATE TABLE IF NOT EXISTS custom_items (
        id VARCHAR(64) PRIMARY KEY,
        owner_id VARCHAR(64) NOT NULL,
        src VARCHAR(255) NOT NULL,
        label VARCHAR(120) NOT NULL,
        tags_json LONGTEXT NULL,
        replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '',
        replaced_by_src VARCHAR(255) NOT NULL DEFAULT '',
        replaced_by_label VARCHAR(120) NOT NULL DEFAULT '',
        replaced_at BIGINT NOT NULL DEFAULT 0,
        created_at BIGINT NOT NULL,
        INDEX idx_custom_items_owner_id (owner_id),
        CONSTRAINT fk_custom_items_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)

    await query(`ALTER TABLE topics ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER thumbnail_src`)
    await query(`ALTER TABLE topic_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
    await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
    await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '' AFTER label`)
    await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_src VARCHAR(255) NOT NULL DEFAULT '' AFTER replaced_by_item_id`)
    await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_label VARCHAR(120) NOT NULL DEFAULT '' AFTER replaced_by_src`)
    await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_at BIGINT NOT NULL DEFAULT 0 AFTER replaced_by_label`)

    await query(`
      CREATE TABLE IF NOT EXISTS tierlists (
        id VARCHAR(64) PRIMARY KEY,
        author_id VARCHAR(64) NOT NULL,
        topic_id VARCHAR(120) NOT NULL,
        title VARCHAR(120) NOT NULL,
        thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
        description TEXT NOT NULL,
        is_public TINYINT(1) NOT NULL DEFAULT 0,
        is_featured TINYINT(1) NOT NULL DEFAULT 0,
        featured_at BIGINT NOT NULL DEFAULT 0,
        featured_by VARCHAR(64) NOT NULL DEFAULT '',
        show_character_names TINYINT(1) NOT NULL DEFAULT 0,
        icon_size INT NOT NULL DEFAULT 80,
        source_tierlist_id VARCHAR(64) NULL DEFAULT NULL,
        source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '',
        source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '',
        groups_json LONGTEXT NOT NULL,
        pool_json LONGTEXT NOT NULL,
        created_at BIGINT NOT NULL,
        updated_at BIGINT NOT NULL,
        INDEX idx_tierlists_author_id (author_id),
        INDEX idx_tierlists_topic_id (topic_id),
        INDEX idx_tierlists_public_topic_updated (is_public, topic_id, updated_at),
        INDEX idx_tierlists_featured_topic (is_public, is_featured, topic_id, featured_at),
        CONSTRAINT fk_tierlists_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
        CONSTRAINT fk_tierlists_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)

    await query(`
      CREATE TABLE IF NOT EXISTS favorite_tierlists (
        user_id VARCHAR(64) NOT NULL,
        tierlist_id VARCHAR(64) NOT NULL,
        created_at BIGINT NOT NULL,
        PRIMARY KEY (user_id, tierlist_id),
        INDEX idx_favorite_tierlists_tierlist_id (tierlist_id),
        CONSTRAINT fk_favorite_tierlists_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
        CONSTRAINT fk_favorite_tierlists_tierlist FOREIGN KEY (tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)

    await query(`
      CREATE TABLE IF NOT EXISTS favorite_topics (
        user_id VARCHAR(64) NOT NULL,
        topic_id VARCHAR(120) NOT NULL,
        created_at BIGINT NOT NULL,
        PRIMARY KEY (user_id, topic_id),
        INDEX idx_favorite_topics_topic_id (topic_id),
        CONSTRAINT fk_favorite_topics_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
        CONSTRAINT fk_favorite_topics_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)

    await query(`
      CREATE TABLE IF NOT EXISTS user_follows (
        follower_id VARCHAR(64) NOT NULL,
        following_id VARCHAR(64) NOT NULL,
        created_at BIGINT NOT NULL,
        PRIMARY KEY (follower_id, following_id),
        INDEX idx_user_follows_following (following_id, created_at),
        CONSTRAINT fk_user_follows_follower FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
        CONSTRAINT fk_user_follows_following FOREIGN KEY (following_id) REFERENCES users(id) ON DELETE CASCADE
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)

    await query(`
      CREATE TABLE IF NOT EXISTS image_assets (
        id VARCHAR(64) PRIMARY KEY,
        content_hash CHAR(64) NOT NULL UNIQUE,
        src VARCHAR(255) NOT NULL UNIQUE,
        label_override VARCHAR(120) NOT NULL DEFAULT '',
        mime_type VARCHAR(32) NOT NULL DEFAULT 'image/webp',
        byte_size INT UNSIGNED NOT NULL,
        original_byte_size INT UNSIGNED NOT NULL,
        width INT UNSIGNED NOT NULL DEFAULT 0,
        height INT UNSIGNED NOT NULL DEFAULT 0,
        created_at BIGINT NOT NULL,
        INDEX idx_image_assets_created_at (created_at)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)

    await query(`
      CREATE TABLE IF NOT EXISTS image_optimization_jobs (
        id VARCHAR(64) PRIMARY KEY,
        status VARCHAR(20) NOT NULL DEFAULT 'queued',
        source_category VARCHAR(40) NOT NULL DEFAULT '',
        target_directory VARCHAR(40) NOT NULL DEFAULT '',
        original_byte_size INT UNSIGNED NOT NULL DEFAULT 0,
        optimized_byte_size INT UNSIGNED NOT NULL DEFAULT 0,
        reused_asset TINYINT(1) NOT NULL DEFAULT 0,
        error_message VARCHAR(255) NOT NULL DEFAULT '',
        queued_at BIGINT NOT NULL,
        started_at BIGINT NOT NULL DEFAULT 0,
        finished_at BIGINT NOT NULL DEFAULT 0,
        INDEX idx_image_optimization_jobs_status_queued (status, queued_at),
        INDEX idx_image_optimization_jobs_finished_at (finished_at)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)
    await query(`
      CREATE TABLE IF NOT EXISTS template_requests (
        id VARCHAR(64) PRIMARY KEY,
        request_type VARCHAR(20) NOT NULL,
        requester_id VARCHAR(64) NOT NULL,
        source_tierlist_id VARCHAR(64) NULL DEFAULT NULL,
        source_topic_id VARCHAR(120) NOT NULL,
        target_topic_id VARCHAR(120) NOT NULL DEFAULT '',
        status VARCHAR(20) NOT NULL DEFAULT 'pending',
        title_snapshot VARCHAR(120) NOT NULL,
        description_snapshot TEXT NOT NULL,
        thumbnail_src_snapshot VARCHAR(255) NOT NULL DEFAULT '',
        items_json LONGTEXT NOT NULL,
        groups_json LONGTEXT NOT NULL,
        board_items_json LONGTEXT NOT NULL,
        show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0,
        created_at BIGINT NOT NULL,
        updated_at BIGINT NOT NULL,
        INDEX idx_template_requests_status_created (status, created_at),
        INDEX idx_template_requests_source_tierlist (source_tierlist_id),
        INDEX idx_template_requests_requester (requester_id),
        CONSTRAINT fk_template_requests_requester FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE CASCADE,
        CONSTRAINT fk_template_requests_source_tierlist FOREIGN KEY (source_tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `)

    await query(
      `
        INSERT INTO topics (id, slug, name, thumbnail_src, tags_json, is_public, created_at)
        VALUES (?, ?, ?, ?, ?, ?, ?)
        ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), tags_json = VALUES(tags_json), is_public = VALUES(is_public)
      `,
      [FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', '[]', 1, now()]
    )
  })()

  return initPromise
}

async function ensureData() {
  await ensureSchema()
}

async function countUsers() {
  const rows = await query('SELECT COUNT(*) AS count FROM users')
  return Number(rows[0]?.count || 0)
}

async function findUserByEmail(email) {
  const rows = await query(
    'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
    [email]
  )
  const row = rows[0]
  if (!row) return null
  return { ...mapUserRow(row), passwordHash: row.password_hash }
}

async function findUserByNickname(nickname, excludeUserId = '') {
  const normalized = String(nickname || '').trim()
  if (!normalized) return null
  const rows = excludeUserId
    ? await query(
        `
          SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
          FROM users
          WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
          LIMIT 1
        `,
        [normalized, excludeUserId]
      )
    : await query(
        `
          SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
          FROM users
          WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
          LIMIT 1
        `,
        [normalized]
      )
  const row = rows[0]
  if (!row) return null
  return { ...mapUserRow(row), passwordHash: row.password_hash }
}

async function findUserById(id) {
  const rows = await query(
    'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1',
    [id]
  )
  return mapUserRow(rows[0])
}

async function createUser({ id, email, nickname, passwordHash, emailVerified = true, isAdmin }) {
  const createdAt = now()
  await query(
    `
      INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
    `,
    [id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
  )
  return findUserById(id)
}

async function touchUserLastLoginAt(userId, timestamp = now()) {
  const lastLoginAt = Number(timestamp) || now()
  const staleBefore = lastLoginAt - 10 * 60 * 1000
  await query(
    'UPDATE users SET last_login_at = ? WHERE id = ? AND last_login_at < ?',
    [lastLoginAt, userId, staleBefore]
  )
  return findUserById(userId)
}

async function updateUserPassword({ id, passwordHash }) {
  await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id])
  return findUserById(id)
}

async function verifyUserEmail(userId) {
  await query('UPDATE users SET email_verified = 1 WHERE id = ?', [userId])
  return findUserById(userId)
}

async function createEmailVerificationToken({ id, userId, tokenHash, expiresAt }) {
  const createdAt = now()
  await query('UPDATE email_verification_tokens SET consumed_at = ? WHERE user_id = ? AND consumed_at = 0', [createdAt, userId])
  await query(
    `
      INSERT INTO email_verification_tokens (id, user_id, token_hash, expires_at, consumed_at, created_at)
      VALUES (?, ?, ?, ?, ?, ?)
    `,
    [id, userId, tokenHash, expiresAt, 0, createdAt]
  )
  return {
    id,
    userId,
    tokenHash,
    expiresAt,
    consumedAt: 0,
    createdAt,
  }
}

async function findEmailVerificationTokenByHash(tokenHash) {
  const rows = await query(
    `
      SELECT
        id,
        user_id,
        token_hash,
        expires_at,
        consumed_at,
        created_at
      FROM email_verification_tokens
      WHERE token_hash = ?
      LIMIT 1
    `,
    [tokenHash]
  )
  const row = rows[0]
  if (!row) return null
  return {
    id: row.id,
    userId: row.user_id,
    tokenHash: row.token_hash,
    expiresAt: Number(row.expires_at || 0),
    consumedAt: Number(row.consumed_at || 0),
    createdAt: Number(row.created_at || 0),
  }
}

async function consumeEmailVerificationToken(tokenId) {
  await query('UPDATE email_verification_tokens SET consumed_at = ? WHERE id = ? AND consumed_at = 0', [now(), tokenId])
}

async function createPasswordResetToken({ id, userId, tokenHash, expiresAt }) {
  const createdAt = now()
  await query('UPDATE password_reset_tokens SET consumed_at = ? WHERE user_id = ? AND consumed_at = 0', [createdAt, userId])
  await query(
    `
      INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at, consumed_at, created_at)
      VALUES (?, ?, ?, ?, ?, ?)
    `,
    [id, userId, tokenHash, expiresAt, 0, createdAt]
  )
  return {
    id,
    userId,
    tokenHash,
    expiresAt,
    consumedAt: 0,
    createdAt,
  }
}

async function findPasswordResetTokenByHash(tokenHash) {
  const rows = await query(
    `
      SELECT
        id,
        user_id,
        token_hash,
        expires_at,
        consumed_at,
        created_at
      FROM password_reset_tokens
      WHERE token_hash = ?
      LIMIT 1
    `,
    [tokenHash]
  )
  const row = rows[0]
  if (!row) return null
  return {
    id: row.id,
    userId: row.user_id,
    tokenHash: row.token_hash,
    expiresAt: Number(row.expires_at || 0),
    consumedAt: Number(row.consumed_at || 0),
    createdAt: Number(row.created_at || 0),
  }
}

async function consumePasswordResetToken(tokenId) {
  await query('UPDATE password_reset_tokens SET consumed_at = ? WHERE id = ? AND consumed_at = 0', [now(), tokenId])
}

async function updateUserProfile({ id, nickname, avatarSrc }) {
  if (typeof avatarSrc === 'string') {
    await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id])
  } else {
    await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id])
  }
  return findUserById(id)
}

async function findPrimaryAdminUser() {
  const rows = await query(
    'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
  )
  return mapUserRow(rows[0])
}

async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' } = {}) {
  const where = []
  const params = []
  const trimmedQuery = typeof queryText === 'string' ? queryText.trim() : ''

  if (trimmedQuery) {
    where.push('(u.email LIKE ? OR u.nickname LIKE ?)')
    params.push(`%${trimmedQuery}%`, `%${trimmedQuery}%`)
  }

  const isAsc = direction === 'asc'
  const orderBy =
    sort === 'created'
      ? isAsc
        ? 'u.created_at ASC, recent_activity_at ASC, u.email ASC'
        : 'u.created_at DESC, recent_activity_at DESC, u.email ASC'
      : sort === 'tierlists'
        ? isAsc
          ? 'tierlist_count ASC, recent_activity_at ASC, u.email ASC'
          : 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
        : sort === 'followers'
          ? isAsc
            ? 'follower_count ASC, recent_activity_at ASC, u.email ASC'
            : 'follower_count DESC, recent_activity_at DESC, u.email ASC'
          : sort === 'favorites'
            ? isAsc
              ? 'received_favorite_count ASC, recent_activity_at ASC, u.email ASC'
              : 'received_favorite_count DESC, recent_activity_at DESC, u.email ASC'
            : sort === 'lastLogin'
              ? isAsc
                ? 'u.last_login_at ASC, recent_activity_at ASC, u.email ASC'
                : 'u.last_login_at DESC, recent_activity_at DESC, u.email ASC'
        : isAsc
          ? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
          : 'recent_activity_at DESC, u.created_at ASC, u.email ASC'

  const rows = await query(
    `
      SELECT
        u.id,
        u.email,
        u.nickname,
        u.email_verified,
        u.is_admin,
        u.avatar_src,
        u.last_login_at,
        u.created_at,
        COUNT(DISTINCT t.id) AS tierlist_count,
        COUNT(DISTINCT uf.follower_id) AS follower_count,
        COUNT(DISTINCT ft.user_id, ft.tierlist_id) AS received_favorite_count,
        GREATEST(
          u.created_at,
          COALESCE(MAX(t.updated_at), 0)
        ) AS recent_activity_at
      FROM users u
      LEFT JOIN tierlists t ON t.author_id = u.id
      LEFT JOIN user_follows uf ON uf.following_id = u.id
      LEFT JOIN favorite_tierlists ft ON ft.tierlist_id = t.id
      ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
      GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.last_login_at, u.created_at
      ORDER BY ${orderBy}
    `,
    params
  )
  return rows.map(mapUserRow)
}

async function adminUpdateUser({ id, email, nickname, isAdmin, avatarSrc }) {
  if (typeof avatarSrc === 'string') {
    await query('UPDATE users SET email = ?, nickname = ?, is_admin = ?, avatar_src = ? WHERE id = ?', [
      email,
      nickname || '',
      isAdmin ? 1 : 0,
      avatarSrc,
      id,
    ])
  } else {
    await query('UPDATE users SET email = ?, nickname = ?, is_admin = ? WHERE id = ?', [
      email,
      nickname || '',
      isAdmin ? 1 : 0,
      id,
    ])
  }
  return findUserById(id)
}

async function adminUpdateUserPassword({ id, passwordHash }) {
  await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id])
  return findUserById(id)
}

async function adminDeleteUser(id) {
  await query('DELETE FROM users WHERE id = ?', [id])
}

async function listTopics(currentUserId = '', options = {}) {
  const includePrivate = !!options.includePrivate
  const rows = await query(
    `
      SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at
      FROM topics
      WHERE id <> ?
      ${includePrivate ? '' : 'AND is_public = 1'}
      ORDER BY
        CASE WHEN display_rank IS NULL THEN 1 ELSE 0 END ASC,
        display_rank ASC,
        created_at DESC,
        name ASC
    `,
    [FREEFORM_TOPIC_ID]
  )
  const topics = rows.map(mapTopicRow)
  if (!currentUserId) return topics.map((topic) => ({ ...topic, isFavorited: false }))

  const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId])
  const favoriteSet = new Set(favoriteRows.map((row) => row.topic_id))
  return topics.map((topic) => ({
    ...topic,
    isFavorited: favoriteSet.has(topic.id),
  }))
}

async function findTopicById(id) {
  const rows = await query(
    'SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1',
    [id]
  )
  return mapTopicRow(rows[0])
}

async function findTopicBySlug(slug) {
  const normalizedSlug = normalizeTopicSlug(slug)
  if (!normalizedSlug) return null
  const rows = await query(
    'SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE slug = ? LIMIT 1',
    [normalizedSlug]
  )
  return mapTopicRow(rows[0])
}

async function findTopicByIdentifier(topicRef) {
  const rawRef = String(topicRef || '').trim()
  if (!rawRef) return null
  if (rawRef === FREEFORM_TOPIC_ID) return findTopicById(FREEFORM_TOPIC_ID)

  const rows = await query(
    `
      SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at
      FROM topics
      WHERE id = ? OR slug = ?
      ORDER BY
        CASE WHEN id = ? THEN 0 ELSE 1 END ASC
      LIMIT 1
    `,
    [rawRef, normalizeTopicSlug(rawRef), rawRef]
  )
  return mapTopicRow(rows[0])
}

async function listTopicItems(topicId) {
  const rows = await query(
    `
      SELECT id, topic_id, src, label, tags_json, display_order, created_at
      FROM topic_items
      WHERE topic_id = ?
      ORDER BY
        CASE WHEN display_order IS NULL THEN 1 ELSE 0 END ASC,
        display_order ASC,
        created_at DESC,
        id DESC
    `,
    [topicId]
  )
  return rows.map(mapTopicItemRow)
}

async function findTopicItemById(itemId) {
  const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
  return mapTopicItemRow(rows[0])
}

async function getTopicDetail(topicRef) {
  const topic = await findTopicByIdentifier(topicRef)
  if (!topic) return null
  const items = await listTopicItems(topic.id)
  return { topic, template: topic, items }
}

async function createTopic({ slug, name, tags = [], isPublic = true }) {
  const topicId = nanoid()
  const topicSlug = assertTopicSlug(slug)
  await query('INSERT INTO topics (id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [
    topicId,
    topicSlug,
    name,
    '',
    serializeJson(normalizeTags(tags)),
    isPublic ? 1 : 0,
    null,
    now(),
  ])
  return findTopicById(topicId)
}

async function updateTopicMeta(topicId, { slug, name, tags = [], isPublic }) {
  const topicSlug = assertTopicSlug(slug)
  await query('UPDATE topics SET slug = ?, name = ?, tags_json = ?, is_public = ? WHERE id = ?', [
    topicSlug,
    name,
    serializeJson(normalizeTags(tags)),
    isPublic ? 1 : 0,
    topicId,
  ])
  return findTopicById(topicId)
}

async function updateTopicThumbnail(topicId, thumbnailSrc) {
  await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, topicId])
  return findTopicById(topicId)
}

async function updateTopicVisibility(topicId, isPublic) {
  await query('UPDATE topics SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, topicId])
  return findTopicById(topicId)
}

async function findImageAssetByHash(contentHash) {
  const rows = await query(
    'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1',
    [contentHash]
  )
  return mapImageAssetRow(rows[0])
}

async function findImageAssetBySrc(src) {
  const rows = await query(
    'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE src = ? LIMIT 1',
    [src]
  )
  return mapImageAssetRow(rows[0])
}

async function createImageAsset({ id, contentHash, src, mimeType = "image/webp", byteSize, originalByteSize, width, height }) {
  const createdAt = now()
  await query(
    'INSERT INTO image_assets (id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
    [id, contentHash, src, mimeType, byteSize, originalByteSize, width, height, createdAt]
  )
  return findImageAssetByHash(contentHash)
}

async function createImageOptimizationJob({ id, sourceCategory, targetDirectory, originalByteSize }) {
  const queuedAt = now()
  await query(
    'INSERT INTO image_optimization_jobs (id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
    [id, 'queued', sourceCategory || '', targetDirectory || '', originalByteSize || 0, 0, 0, '', queuedAt, 0, 0]
  )
  return findImageOptimizationJobById(id)
}

async function findImageOptimizationJobById(id) {
  const rows = await query(
    'SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at FROM image_optimization_jobs WHERE id = ? LIMIT 1',
    [id]
  )
  return mapImageOptimizationJobRow(rows[0])
}

async function updateImageOptimizationJobStatus({ id, status, optimizedByteSize = 0, reusedAsset = false, errorMessage = '', startedAt, finishedAt }) {
  const fields = ['status = ?', 'optimized_byte_size = ?', 'reused_asset = ?', 'error_message = ?']
  const params = [status, optimizedByteSize, reusedAsset ? 1 : 0, errorMessage.slice(0, 255)]

  if (typeof startedAt === 'number') {
    fields.push('started_at = ?')
    params.push(startedAt)
  }
  if (typeof finishedAt === 'number') {
    fields.push('finished_at = ?')
    params.push(finishedAt)
  }

  params.push(id)
  await query(`UPDATE image_optimization_jobs SET ${fields.join(', ')} WHERE id = ?`, params)
  return findImageOptimizationJobById(id)
}

async function listRecentImageOptimizationJobs(limit = 20, { month } = {}) {
  const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20))
  const range = resolveMonthRange(month)
  const where = []
  const params = []

  if (range) {
    where.push('queued_at >= ? AND queued_at < ?')
    params.push(range.start, range.end)
  }

  const rows = await query(
    `SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at
     FROM image_optimization_jobs
     ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
     ORDER BY queued_at DESC
     LIMIT ${safeLimit}`,
    params
  )
  return rows.map(mapImageOptimizationJobRow)
}

async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
  const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100))
  const safeMinAgeHours = Math.max(0, Number(minAgeHours) || 24)
  const cutoff = now() - safeMinAgeHours * 60 * 60 * 1000

  const assets = (await query(
    `SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`,
    [cutoff]
  )).map(mapImageAssetRow)

  if (!assets.length) return []

  const referencedSrcs = new Set()

  const [userRows, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
    query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
    query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
    query("SELECT src FROM topic_items WHERE src <> ''"),
    query("SELECT src FROM custom_items WHERE src <> ''"),
    query("SELECT thumbnail_src, pool_json FROM tierlists"),
    query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
  ])

  for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src)
  for (const row of topicRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
  for (const row of topicItemRows) if (row.src) referencedSrcs.add(row.src)
  for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src)

  for (const row of tierListRows) {
    if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
    collectUploadSrcsFromItems(parseJson(row.pool_json, []), referencedSrcs)
  }

  for (const row of templateRequestRows) {
    if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
    collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
    collectUploadSrcsFromItems(parseJson(row.board_items_json, []), referencedSrcs)
  }

  return assets.filter((asset) => !referencedSrcs.has(asset.src))
}

async function deleteImageAssets(ids) {
  const uniqueIds = Array.from(new Set((ids || []).filter(Boolean)))
  if (!uniqueIds.length) return []
  const placeholders = uniqueIds.map(() => '?').join(', ')
  const rows = await query(
    `SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`,
    uniqueIds
  )
  await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds)
  return rows.map(mapImageAssetRow)
}

async function listReferencedUploadSources() {
  const usage = await listReferencedUploadUsage()
  return usage.map((entry) => entry.src)
}

async function listReferencedUploadUsage() {
  const usageMap = new Map()
  const addUsage = (src, role) => {
    if (typeof src !== 'string' || !src.startsWith('/uploads/')) return
    if (!usageMap.has(src)) usageMap.set(src, new Set())
    usageMap.get(src).add(role)
  }

  const [userRows, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
    query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
    query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
    query("SELECT src FROM topic_items WHERE src <> ''"),
    query("SELECT src FROM custom_items WHERE src <> ''"),
    query("SELECT id, thumbnail_src, pool_json FROM tierlists"),
    query("SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
  ])

  for (const row of userRows) addUsage(row.avatar_src, 'avatar')
  for (const row of topicRows) addUsage(row.thumbnail_src, 'topic-thumbnail')
  for (const row of topicItemRows) addUsage(row.src, 'topic-item')
  for (const row of customItemRows) addUsage(row.src, 'custom-item')

  for (const row of tierListRows) {
    addUsage(row.thumbnail_src, 'tierlist-thumbnail')
    for (const item of parseJson(row.pool_json, [])) addUsage(item?.src, 'tierlist-pool')
  }

  for (const row of templateRequestRows) {
    addUsage(row.thumbnail_src_snapshot, 'template-thumbnail')
    for (const item of parseJson(row.items_json, [])) addUsage(item?.src, 'template-item')
    for (const item of parseJson(row.board_items_json, [])) addUsage(item?.src, 'template-board-item')
  }

  return Array.from(usageMap.entries())
    .map(([src, roles]) => ({ src, roles: Array.from(roles).sort() }))
    .sort((a, b) => a.src.localeCompare(b.src))
}

function replaceItemSrc(items, fromSrc, toSrc, toLabel = '') {
  let changed = false
  const nextItems = (items || []).map((item) => {
    if (item?.src !== fromSrc) return item
    changed = true
    return {
      ...item,
      src: toSrc,
      ...(typeof toLabel === 'string' && toLabel.trim() ? { label: toLabel.trim().slice(0, 60) } : {}),
    }
  })
  return { changed, items: nextItems }
}

function replaceItemById(items, itemId, nextSrc, nextLabel = '') {
  let changed = false
  const normalizedLabel = typeof nextLabel === 'string' ? nextLabel.trim().slice(0, 60) : ''
  const nextItems = (items || []).map((item) => {
    if (item?.id !== itemId) return item
    changed = true
    return {
      ...item,
      ...(typeof nextSrc === 'string' && nextSrc ? { src: nextSrc } : {}),
      ...(normalizedLabel ? { label: normalizedLabel } : {}),
    }
  })
  return { changed, items: nextItems }
}

async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '', updateCustomItemsBySrc = true }) {
  if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
  const normalizedLabel = typeof toLabel === 'string' ? toLabel.trim().slice(0, 60) : ''

  const [userResult, topicResult, topicItemResult, customItemResult] = await Promise.all([
    query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
    query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
    normalizedLabel
      ? query('UPDATE topic_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc])
      : query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
    updateCustomItemsBySrc
      ? normalizedLabel
        ? query('UPDATE custom_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc])
        : query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc])
      : Promise.resolve({ affectedRows: 0 }),
  ])

  let updatedRows = Number(userResult.affectedRows || 0) + Number(topicResult.affectedRows || 0) + Number(topicItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)

  const tierListRows = await query('SELECT id, thumbnail_src, pool_json FROM tierlists')
  for (const row of tierListRows) {
    let nextThumbnail = row.thumbnail_src
    let changed = false
    if (row.thumbnail_src === fromSrc) {
      nextThumbnail = toSrc
      changed = true
    }

    const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc, normalizedLabel)
    if (replacedPool.changed) changed = true

    if (changed) {
      await query('UPDATE tierlists SET thumbnail_src = ?, pool_json = ?, updated_at = ? WHERE id = ?', [
        nextThumbnail || '',
        serializeJson(replacedPool.items),
        now(),
        row.id,
      ])
      updatedRows += 1
    }
  }

  const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests')
  for (const row of requestRows) {
    let nextThumbnail = row.thumbnail_src_snapshot
    let changed = false
    if (row.thumbnail_src_snapshot === fromSrc) {
      nextThumbnail = toSrc
      changed = true
    }

    const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc, normalizedLabel)
    const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc, normalizedLabel)
    if (replacedItems.changed || replacedBoardItems.changed) changed = true

    if (changed) {
      await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', [
        nextThumbnail || '',
        serializeJson(replacedItems.items),
        serializeJson(replacedBoardItems.items),
        now(),
        row.id,
      ])
      updatedRows += 1
    }
  }

  return { updatedRows }
}

async function updateCustomItemDisplayReferences({ itemId, src = '', label = '' }) {
  if (!itemId) return { updatedRows: 0 }
  const normalizedLabel = typeof label === 'string' ? label.trim().slice(0, 60) : ''
  let updatedRows = 0

  const tierListRows = await query('SELECT id, pool_json FROM tierlists')
  for (const row of tierListRows) {
    const replacedPool = replaceItemById(parseJson(row.pool_json, []), itemId, src, normalizedLabel)
    if (!replacedPool.changed) continue
    await query('UPDATE tierlists SET pool_json = ?, updated_at = ? WHERE id = ?', [
      serializeJson(replacedPool.items),
      now(),
      row.id,
    ])
    updatedRows += 1
  }

  const requestRows = await query('SELECT id, items_json, board_items_json FROM template_requests')
  for (const row of requestRows) {
    const replacedItems = replaceItemById(parseJson(row.items_json, []), itemId, src, normalizedLabel)
    const replacedBoardItems = replaceItemById(parseJson(row.board_items_json, []), itemId, src, normalizedLabel)
    if (!replacedItems.changed && !replacedBoardItems.changed) continue
    await query('UPDATE template_requests SET items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', [
      serializeJson(replacedItems.items),
      serializeJson(replacedBoardItems.items),
      now(),
      row.id,
    ])
    updatedRows += 1
  }

  return { updatedRows }
}

async function listImageAssets() {
  const rows = await query(
    'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC'
  )
  return rows.map(mapImageAssetRow)
}

async function findImageAssetById(id) {
  const rows = await query(
    'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1',
    [id]
  )
  return mapImageAssetRow(rows[0])
}

async function updateImageAssetSrc({ fromSrc, toSrc }) {
  if (!fromSrc || !toSrc || fromSrc === toSrc) return null
  await query('UPDATE image_assets SET src = ? WHERE src = ?', [toSrc, fromSrc])
  return findImageAssetBySrc(toSrc)
}

async function getReferencedUploadFootprint() {
  const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()])
  const assetMap = new Map(assets.map((asset) => [asset.src, asset]))
  let totalReferencedByteSize = 0
  let trackedReferencedByteSize = 0
  let legacyReferencedByteSize = 0
  let trackedReferencedCount = 0
  let legacyReferencedCount = 0
  let missingCount = 0

  for (const src of referencedSrcs) {
    if (typeof src !== 'string' || !src.startsWith('/uploads/')) continue
    const absolutePath = path.join(__dirname, '..', src.replace(/^\//, ''))

    try {
      const stat = await fs.stat(absolutePath)
      const size = Number(stat.size || 0)
      totalReferencedByteSize += size
      if (assetMap.has(src)) {
        trackedReferencedCount += 1
        trackedReferencedByteSize += size
      } else {
        legacyReferencedCount += 1
        legacyReferencedByteSize += size
      }
    } catch (error) {
      if (error?.code === 'ENOENT') missingCount += 1
    }
  }

  return {
    referencedCount: referencedSrcs.length,
    totalReferencedByteSize,
    trackedReferencedCount,
    trackedReferencedByteSize,
    legacyReferencedCount,
    legacyReferencedByteSize,
    missingCount,
  }
}

async function fileExistsForUploadSrc(src) {
  if (typeof src !== 'string' || !src.startsWith('/uploads/')) return true
  const absolutePath = path.join(__dirname, '..', src.replace(/^\//, ''))
  try {
    await fs.stat(absolutePath)
    return true
  } catch (error) {
    if (error?.code === 'ENOENT') return false
    throw error
  }
}

function stripItemIdsFromGroups(groups, missingItemIds) {
  let changed = false
  const nextGroups = (groups || []).map((group) => {
    const nextItemIds = (group?.itemIds || []).filter((itemId) => !missingItemIds.has(itemId))
    if (nextItemIds.length !== (group?.itemIds || []).length) changed = true
    return {
      ...group,
      itemIds: nextItemIds,
    }
  })
  return { changed, groups: nextGroups }
}

function stripMissingItems(items, missingItemIds, missingSrcs) {
  let changed = false
  const nextItems = (items || []).filter((item) => {
    const shouldRemove =
      (item?.id && missingItemIds.has(item.id)) ||
      (typeof item?.src === 'string' && missingSrcs.has(item.src))
    if (shouldRemove) changed = true
    return !shouldRemove
  })
  return { changed, items: nextItems }
}

async function cleanupMissingUploadReferences() {
  const stats = {
    clearedAvatars: 0,
    clearedTopicThumbnails: 0,
    clearedTierListThumbnails: 0,
    clearedTemplateRequestThumbnails: 0,
    deletedTopicItems: 0,
    updatedTierLists: 0,
    updatedTemplateRequests: 0,
    deletedCustomItems: 0,
  }

  const [userRows, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
    query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"),
    query("SELECT id, thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
    query("SELECT id, src FROM topic_items WHERE src <> ''"),
    query("SELECT id, src FROM custom_items WHERE src <> ''"),
    query("SELECT id, thumbnail_src, groups_json, pool_json FROM tierlists"),
    query("SELECT id, thumbnail_src_snapshot, groups_json, items_json, board_items_json FROM template_requests"),
  ])

  for (const row of userRows) {
    if (await fileExistsForUploadSrc(row.avatar_src)) continue
    await query('UPDATE users SET avatar_src = ? WHERE id = ?', ['', row.id])
    stats.clearedAvatars += 1
  }

  for (const row of topicRows) {
    if (await fileExistsForUploadSrc(row.thumbnail_src)) continue
    await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', ['', row.id])
    stats.clearedTopicThumbnails += 1
  }

  for (const row of topicItemRows) {
    if (await fileExistsForUploadSrc(row.src)) continue
    await deleteTopicItem(row.id)
    stats.deletedTopicItems += 1
  }

  const missingCustomItemIds = new Set()
  const missingCustomSrcs = new Set()
  for (const row of customItemRows) {
    if (await fileExistsForUploadSrc(row.src)) continue
    missingCustomItemIds.add(row.id)
    missingCustomSrcs.add(row.src)
  }

  for (const row of tierListRows) {
    const groups = parseJson(row.groups_json, [])
    const pool = parseJson(row.pool_json, [])
    let changed = false
    let nextThumbnail = row.thumbnail_src || ''

    if (row.thumbnail_src && !(await fileExistsForUploadSrc(row.thumbnail_src))) {
      nextThumbnail = ''
      changed = true
      stats.clearedTierListThumbnails += 1
    }

    const strippedPool = stripMissingItems(pool, missingCustomItemIds, missingCustomSrcs)
    const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds)
    if (strippedPool.changed || strippedGroups.changed) changed = true

    if (changed) {
      await query('UPDATE tierlists SET thumbnail_src = ?, groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ?', [
        nextThumbnail,
        serializeJson(strippedGroups.groups),
        serializeJson(strippedPool.items),
        now(),
        row.id,
      ])
      stats.updatedTierLists += 1
    }
  }

  for (const row of templateRequestRows) {
    const groups = parseJson(row.groups_json, [])
    const items = parseJson(row.items_json, [])
    const boardItems = parseJson(row.board_items_json, [])
    let changed = false
    let nextThumbnail = row.thumbnail_src_snapshot || ''

    if (row.thumbnail_src_snapshot && !(await fileExistsForUploadSrc(row.thumbnail_src_snapshot))) {
      nextThumbnail = ''
      changed = true
      stats.clearedTemplateRequestThumbnails += 1
    }

    const strippedItems = stripMissingItems(items, missingCustomItemIds, missingCustomSrcs)
    const strippedBoardItems = stripMissingItems(boardItems, missingCustomItemIds, missingCustomSrcs)
    const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds)
    if (strippedItems.changed || strippedBoardItems.changed || strippedGroups.changed) changed = true

    if (changed) {
      await query(
        'UPDATE template_requests SET thumbnail_src_snapshot = ?, groups_json = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?',
        [
          nextThumbnail,
          serializeJson(strippedGroups.groups),
          serializeJson(strippedItems.items),
          serializeJson(strippedBoardItems.items),
          now(),
          row.id,
        ]
      )
      stats.updatedTemplateRequests += 1
    }
  }

  if (missingCustomItemIds.size) {
    await deleteCustomItems(Array.from(missingCustomItemIds))
    stats.deletedCustomItems = missingCustomItemIds.size
  }

  return stats
}

async function getImageAssetStats({ month } = {}) {
  const range = resolveMonthRange(month)
  const jobWhere = []
  const jobParams = []

  if (range) {
    jobWhere.push('queued_at >= ? AND queued_at < ?')
    jobParams.push(range.start, range.end)
  }

  const [assetRows, jobRows, footprint] = await Promise.all([
    query(
      `SELECT COUNT(*) AS asset_count, COALESCE(SUM(byte_size), 0) AS total_byte_size, COALESCE(SUM(original_byte_size), 0) AS total_original_byte_size FROM image_assets`
    ),
    query(
      `SELECT
        COALESCE(SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END), 0) AS queued_count,
        COALESCE(SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END), 0) AS processing_count,
        COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed_count,
        COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_count,
        COALESCE(SUM(CASE WHEN status = 'completed' AND reused_asset = 1 THEN 1 ELSE 0 END), 0) AS reused_count
       FROM image_optimization_jobs
       ${jobWhere.length ? `WHERE ${jobWhere.join(' AND ')}` : ''}`,
      jobParams
    ),
    getReferencedUploadFootprint(),
  ])

  const asset = assetRows[0] || {}
  const jobs = jobRows[0] || {}
  const totalByteSize = Number(asset.total_byte_size || 0)
  const totalOriginalByteSize = Number(asset.total_original_byte_size || 0)
  const savedByteSize = Math.max(0, totalOriginalByteSize - totalByteSize)

  return {
    assetCount: Number(asset.asset_count || 0),
    totalByteSize,
    totalOriginalByteSize,
    savedByteSize,
    savingsRatio: totalOriginalByteSize > 0 ? savedByteSize / totalOriginalByteSize : 0,
    referencedCount: Number(footprint.referencedCount || 0),
    referencedByteSize: Number(footprint.totalReferencedByteSize || 0),
    trackedReferencedCount: Number(footprint.trackedReferencedCount || 0),
    trackedReferencedByteSize: Number(footprint.trackedReferencedByteSize || 0),
    legacyReferencedCount: Number(footprint.legacyReferencedCount || 0),
    legacyReferencedByteSize: Number(footprint.legacyReferencedByteSize || 0),
    missingReferencedCount: Number(footprint.missingCount || 0),
    queuedCount: Number(jobs.queued_count || 0),
    processingCount: Number(jobs.processing_count || 0),
    completedCount: Number(jobs.completed_count || 0),
    failedCount: Number(jobs.failed_count || 0),
    reusedCount: Number(jobs.reused_count || 0),
  }
}

async function clearImageOptimizationJobs({ month } = {}) {
  const range = resolveMonthRange(month)
  if (range) {
    const result = await query('DELETE FROM image_optimization_jobs WHERE queued_at >= ? AND queued_at < ?', [range.start, range.end])
    return Number(result.affectedRows || 0)
  }

  const result = await query('DELETE FROM image_optimization_jobs')
  return Number(result.affectedRows || 0)
}
async function createTopicItem({ id, topicId, src, label, tags = [] }) {
  const createdAt = now()
  const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [topicId])
  const nextDisplayOrder =
    minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1
  await query('INSERT INTO topic_items (id, topic_id, src, label, tags_json, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [
    id,
    topicId,
    src,
    label,
    serializeJson(normalizeTags(tags)),
    nextDisplayOrder,
    createdAt,
  ])
  const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id])
  return mapTopicItemRow(rows[0])
}

async function updateTopicItemLabel(itemId, label) {
  await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId])
  const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
  return mapTopicItemRow(rows[0])
}

async function updateTopicItemMeta(itemId, { label, tags = [] }) {
  await query('UPDATE topic_items SET label = ?, tags_json = ? WHERE id = ?', [
    label,
    serializeJson(normalizeTags(tags)),
    itemId,
  ])
  const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
  return mapTopicItemRow(rows[0])
}

async function updateTopicItemDisplayOrder(topicId, itemIds) {
  const normalizedIds = Array.from(new Set((itemIds || []).filter(Boolean)))
  const existingItems = await listTopicItems(topicId)
  const existingIdSet = new Set(existingItems.map((item) => item.id))
  const orderedIds = normalizedIds.filter((id) => existingIdSet.has(id))
  const remainingIds = existingItems.map((item) => item.id).filter((id) => !orderedIds.includes(id))
  const finalIds = [...orderedIds, ...remainingIds]

  await Promise.all(
    finalIds.map((itemId, index) => query('UPDATE topic_items SET display_order = ? WHERE id = ? AND topic_id = ?', [index + 1, itemId, topicId]))
  )

  return listTopicItems(topicId)
}

async function updateCustomItemLabel(itemId, label) {
  await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId])
  const rows = await query(`
    SELECT c.id, c.owner_id, c.src, c.label, c.tags_json, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, c.created_at, u.nickname, u.email
    FROM custom_items c
    INNER JOIN users u ON u.id = c.owner_id
    WHERE c.id = ?
    LIMIT 1
  `, [itemId])
  const row = rows[0]
  if (!row) return null
  return {
    ...mapCustomItemRow(row),
    ownerName: row.nickname || row.email,
    ownerEmail: row.email,
  }
}

async function updateCustomItemMeta(itemId, { label, tags = [] }) {
  await query('UPDATE custom_items SET label = ?, tags_json = ? WHERE id = ?', [
    label,
    serializeJson(normalizeTags(tags)),
    itemId,
  ])
  const rows = await query(`
    SELECT c.id, c.owner_id, c.src, c.label, c.tags_json, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, c.created_at, u.nickname, u.email
    FROM custom_items c
    INNER JOIN users u ON u.id = c.owner_id
    WHERE c.id = ?
    LIMIT 1
  `, [itemId])
  const row = rows[0]
  if (!row) return null
  return {
    ...mapCustomItemRow(row),
    ownerName: row.nickname || row.email,
    ownerEmail: row.email,
  }
}

async function markCustomItemReplaced({ itemId, replacedByItemId = '', replacedBySrc = '', replacedByLabel = '' }) {
  await query(
    'UPDATE custom_items SET replaced_by_item_id = ?, replaced_by_src = ?, replaced_by_label = ?, replaced_at = ? WHERE id = ?',
    [replacedByItemId || '', replacedBySrc || '', replacedByLabel || '', now(), itemId]
  )
  return findCustomItemById(itemId)
}

async function clearCustomItemReplacement(itemId) {
  await query(
    'UPDATE custom_items SET replaced_by_item_id = ?, replaced_by_src = ?, replaced_by_label = ?, replaced_at = 0 WHERE id = ?',
    ['', '', '', itemId]
  )
  return findCustomItemById(itemId)
}

async function updateImageAssetLabel(assetId, label) {
  await query('UPDATE image_assets SET label_override = ? WHERE id = ?', [label, assetId])
  const rows = await query('SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1', [assetId])
  return mapImageAssetRow(rows[0])
}

async function countTierListsUsingTopicItem(itemId) {
  if (!itemId) return { totalCount: 0, publicCount: 0, privateCount: 0 }

  const rows = await query(
    `
      SELECT id, is_public, groups_json
      FROM tierlists
    `
  )

  let totalCount = 0
  let publicCount = 0
  let privateCount = 0

  rows.forEach((row) => {
    const groups = parseJson(row.groups_json, [])
    const inGroups = groups.some((group) => (group?.itemIds || []).includes(itemId))
    if (!inGroups) return
    totalCount += 1
    if (row.is_public) publicCount += 1
    else privateCount += 1
  })

  return { totalCount, publicCount, privateCount }
}

async function deleteTopicItem(itemId) {
  await query('DELETE FROM topic_items WHERE id = ?', [itemId])
}

async function deleteTopic(topicId) {
  await query('DELETE FROM topics WHERE id = ?', [topicId])
}

async function updateTopicDisplayOrder(topicIds) {
  const normalizedIds = Array.from(new Set((topicIds || []).filter((id) => id && id !== FREEFORM_TOPIC_ID))).slice(0, 50)

  await query('UPDATE topics SET display_rank = NULL WHERE id <> ?', [FREEFORM_TOPIC_ID])

  await Promise.all(
    normalizedIds.map((topicId, index) =>
      query('UPDATE topics SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, topicId, FREEFORM_TOPIC_ID])
    )
  )

  return listTopics()
}

async function createCustomItem({ id, ownerId, src, label, tags = [] }) {
  const createdAt = now()
  await query('INSERT INTO custom_items (id, owner_id, src, label, tags_json, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
    id,
    ownerId,
    src,
    label,
    serializeJson(normalizeTags(tags)),
    createdAt,
  ])
  return { id, ownerId, src, label, tags: normalizeTags(tags), origin: 'custom', createdAt }
}

async function syncOwnedCustomItemLabels({ ownerId, items }) {
  const customItems = Array.from(
    new Map(
      (items || [])
        .filter((item) => item?.origin === 'custom' && item?.id && typeof item.label === 'string')
        .map((item) => [item.id, item])
    ).values()
  )

  if (!customItems.length) return

  await Promise.all(
    customItems.map((item) =>
      query('UPDATE custom_items SET label = ? WHERE id = ? AND owner_id = ?', [item.label.trim().slice(0, 60), item.id, ownerId])
    )
  )
}

async function findCustomItemById(id) {
  const rows = await query(
    `
      SELECT id, owner_id, src, label, tags_json, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at
      FROM custom_items
      WHERE id = ?
      LIMIT 1
    `,
    [id]
  )

  const row = rows[0]
  return mapCustomItemRow(row)
}

async function getCustomItemUsageMeta() {
  const rows = await query(
    `
      SELECT t.topic_id, tp.name AS topic_name, t.groups_json
      FROM tierlists t
      LEFT JOIN topics tp ON tp.id = t.topic_id
    `
  )
  const usageMap = new Map()
  const linkedTemplatesMap = new Map()

  rows.forEach((row) => {
    const groups = parseJson(row.groups_json, [])
    const seenItemIds = new Set()

    groups.forEach((group) => {
      ;(group?.itemIds || []).forEach((itemId) => {
        usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
        if (itemId) seenItemIds.add(itemId)
      })
    })

    if (!row.topic_id) return

    seenItemIds.forEach((itemId) => {
      if (!linkedTemplatesMap.has(itemId)) linkedTemplatesMap.set(itemId, new Map())
      linkedTemplatesMap.get(itemId).set(row.topic_id, {
        id: row.topic_id,
        name: row.topic_name || row.topic_id,
      })
    })
  })

  return {
    usageMap,
    linkedTemplatesMap: new Map(Array.from(linkedTemplatesMap.entries()).map(([itemId, templateMap]) => [itemId, Array.from(templateMap.values())])),
  }
}

async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all', collapseShared = false } = {}) {
  const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
  const normalizedPage = Math.max(Number(page) || 1, 1)
  const searchText = (queryText || '').trim()
  const hasQuery = !!searchText
  const search = `%${searchText}%`

  const [customRows, topicItemRows, assetRows, usageMeta] = await Promise.all([
    query(
      `
        SELECT
          c.id,
          c.owner_id,
          c.src,
          c.label,
          c.tags_json,
          c.replaced_by_item_id,
          c.replaced_by_src,
          c.replaced_by_label,
          c.replaced_at,
          c.created_at,
          u.nickname,
          u.email
        FROM custom_items c
        INNER JOIN users u ON u.id = c.owner_id
        ${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR c.tags_json LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
        ORDER BY c.created_at DESC
      `,
      hasQuery ? [search, search, search, search, search] : []
    ),
    query(
      `
        SELECT
          gi.id,
          gi.topic_id,
          gi.src,
          gi.label,
          gi.tags_json,
          gi.created_at,
          tp.name AS topic_name
        FROM topic_items gi
        INNER JOIN topics tp ON tp.id = gi.topic_id
        ${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.tags_json LIKE ? OR gi.topic_id LIKE ? OR tp.name LIKE ?' : ''}
        ORDER BY gi.created_at DESC
      `,
      hasQuery ? [search, search, search, search, search] : []
    ),
    query(
      `
        SELECT ia.id, ia.src, ia.label_override, ia.created_at
        FROM image_assets ia
        WHERE ia.src LIKE '/uploads/assets/%'
        ${hasQuery ? 'AND ia.src LIKE ?' : ''}
        ORDER BY ia.created_at DESC
      `,
      hasQuery ? [search] : []
    ),
    getCustomItemUsageMeta(),
  ])

  const [userAvatarRows, topicThumbnailRows, tierListThumbnailRows, templateRequestThumbnailRows] = await Promise.all([
    query("SELECT avatar_src AS src FROM users WHERE avatar_src <> ''"),
    query("SELECT thumbnail_src AS src FROM topics WHERE thumbnail_src <> ''"),
    query("SELECT thumbnail_src AS src FROM tierlists WHERE thumbnail_src <> ''"),
    query("SELECT thumbnail_src_snapshot AS src FROM template_requests WHERE thumbnail_src_snapshot <> ''"),
  ])

  const avatarSrcSet = new Set(userAvatarRows.map((row) => row.src).filter(Boolean))
  const thumbnailSrcSet = new Set([
    ...topicThumbnailRows.map((row) => row.src).filter(Boolean),
    ...tierListThumbnailRows.map((row) => row.src).filter(Boolean),
    ...templateRequestThumbnailRows.map((row) => row.src).filter(Boolean),
  ])

  const resolveLibraryAssetKind = (src) => {
    if (avatarSrcSet.has(src)) return 'avatar'
    if (thumbnailSrcSet.has(src)) return 'thumbnail'
    return getAssetLibraryKind(src)
  }

  const resolveLibraryAssetLabel = (src) => {
    if (avatarSrcSet.has(src)) return '프로필 아바타'
    if (thumbnailSrcSet.has(src)) return '썸네일 이미지'
    return getAssetLibrarySourceLabel(src)
  }

  const templateLinkedBySrc = new Map()
  topicItemRows.forEach((row) => {
    if (!row?.src) return
    if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
    templateLinkedBySrc.get(row.src).set(row.topic_id, {
      id: row.topic_id,
      name: row.topic_name || row.topic_id,
    })
  })

  const customItems = customRows.map((row) => {
    const linkedTemplates = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
    return {
      id: row.id,
      ...mapCustomItemRow(row),
      ownerName: row.nickname || row.email,
      ownerEmail: row.email,
      usageCount: usageMeta.usageMap.get(row.id) || 0,
      linkedTemplates,
      assetKind: resolveLibraryAssetKind(row.src),
      sourceType: 'user',
      sourceLabel: Number(row.replaced_at || 0) > 0 ? '대체된 사용자 아이템' : '사용자 아이템',
      canDelete: true,
    }
  })

  const templateSrcSet = new Set(topicItemRows.map((row) => row.src).filter(Boolean))
  const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
  const assetLibraryItems = assetRows
    .filter((row) => {
      if (!row?.src) return false
      if (avatarSrcSet.has(row.src) || thumbnailSrcSet.has(row.src)) return true
      return !templateSrcSet.has(row.src) && !customSrcSet.has(row.src)
    })
    .map((row) => ({
      id: `asset:${row.id}`,
      assetId: row.id,
      ownerId: '',
      src: row.src,
      label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
      tags: [],
      createdAt: Number(row.created_at || 0),
      ownerName: '관리자 미사용 이미지',
      ownerEmail: '',
      usageCount: 0,
      linkedTemplates: [],
      sourceType: 'asset',
      sourceLabel: resolveLibraryAssetLabel(row.src),
      assetKind: resolveLibraryAssetKind(row.src),
      canDelete: true,
      sourceTopicId: '',
      sourceTopicName: '',
      isAssetLibraryItem: true,
    }))

  const templateItems = topicItemRows.map((row) => ({
    id: row.id,
    ownerId: '',
    src: row.src,
    label: row.label,
    tags: normalizeTags(parseJson(row.tags_json, [])),
    createdAt: Number(row.created_at),
    ownerName: row.topic_name || row.topic_id,
    ownerEmail: '',
    usageCount: usageMeta.usageMap.get(row.id) || 0,
    linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
    assetKind: resolveLibraryAssetKind(row.src),
    sourceType: 'template',
    sourceLabel: '템플릿 아이템',
    canDelete: true,
    sourceTopicId: row.topic_id,
    sourceTopicName: row.topic_name || row.topic_id,
  }))

  const baseItems = [...customItems, ...templateItems, ...assetLibraryItems]
  const groupedBySrc = new Map()
  for (const item of baseItems) {
    if (!item?.src) continue
    if (!groupedBySrc.has(item.src)) groupedBySrc.set(item.src, [])
    groupedBySrc.get(item.src).push(item)
  }

  const allItems = baseItems
    .map((item) => {
      const siblings = groupedBySrc.get(item.src) || [item]
      const linkedTemplates = new Map()
      let userReferenceCount = 0
      let templateReferenceCount = 0
      let assetReferenceCount = 0

      siblings.forEach((entry) => {
        if (entry.sourceType === 'user') userReferenceCount += 1
        else if (entry.sourceType === 'asset' || entry.isAssetLibraryItem) assetReferenceCount += 1
        else templateReferenceCount += 1
        ;(entry.linkedTemplates || []).forEach((template) => {
          if (template?.id) linkedTemplates.set(template.id, template)
        })
      })

      return {
        ...item,
        sharedReferenceCount: siblings.length,
        sharedUserReferenceCount: userReferenceCount,
        sharedTemplateReferenceCount: templateReferenceCount,
        sharedAssetReferenceCount: assetReferenceCount,
        sharedLinkedTemplateCount: linkedTemplates.size,
        sharedEntries: siblings
          .slice()
          .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
          .map((entry) => ({
            id: entry.id,
            label: entry.label,
            sourceLabel: entry.sourceLabel,
            sourceType: entry.sourceType,
            assetKind: entry.assetKind || '',
            ownerName: entry.ownerName,
            createdAt: entry.createdAt,
            sourceTopicId: entry.sourceTopicId || '',
            sourceTopicName: entry.sourceTopicName || '',
            usageCount: entry.usageCount || 0,
            linkedTemplates: entry.linkedTemplates || [],
            isAssetLibraryItem: !!entry.isAssetLibraryItem,
            tags: entry.tags || [],
            replacedByItemId: entry.replacedByItemId || '',
            replacedBySrc: entry.replacedBySrc || '',
            replacedByLabel: entry.replacedByLabel || '',
            replacedAt: entry.replacedAt || 0,
          })),
      }
    })
    .filter((item) => {
      switch (filterMode) {
        case 'user':
          return item.sourceType === 'user'
        case 'template':
          return item.sourceType === 'template' && !item.isAssetLibraryItem
        case 'asset':
          return item.sourceType === 'asset' || !!item.isAssetLibraryItem
        case 'thumbnail':
          return item.assetKind === 'thumbnail'
        case 'avatar':
          return item.assetKind === 'avatar'
        case 'library':
          return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem)
        case 'unused':
          return (item.sourceType === 'user' && ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt)) || item.sourceType === 'asset' || !!item.isAssetLibraryItem
        case 'unused-user':
          return item.sourceType === 'user' && ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt)
        case 'replaced-user':
          return item.sourceType === 'user' && !!item.replacedAt
        case 'unused-admin':
          return item.sourceType === 'asset' || !!item.isAssetLibraryItem
        default:
          return true
      }
    })
    .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))

  const visibleItems = collapseShared ? collapseSharedLibraryItems(allItems) : allItems
  const total = visibleItems.length
  const offset = (normalizedPage - 1) * normalizedLimit
  const pagedItems = visibleItems.slice(offset, offset + normalizedLimit)

  return {
    items: pagedItems,
    total,
    page: normalizedPage,
    limit: normalizedLimit,
  }
}

async function findUnusedCustomItems({ queryText = '' } = {}) {
  const hasQuery = !!(queryText || '').trim()
  const search = `%${(queryText || '').trim()}%`

  const [rows, topicItemRows, usageMeta] = await Promise.all([
    query(
      `
        SELECT
          c.id,
          c.owner_id,
          c.src,
          c.label,
          c.tags_json,
          c.replaced_by_item_id,
          c.replaced_by_src,
          c.replaced_by_label,
          c.replaced_at,
          c.created_at,
          u.nickname,
          u.email
        FROM custom_items c
        INNER JOIN users u ON u.id = c.owner_id
        ${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR c.tags_json LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
        ORDER BY c.created_at DESC
      `,
      hasQuery ? [search, search, search, search, search] : []
    ),
    query(
      `
        SELECT ti.topic_id, tp.name AS topic_name, ti.src
        FROM topic_items ti
        LEFT JOIN topics tp ON tp.id = ti.topic_id
      `
    ),
    getCustomItemUsageMeta(),
  ])

  const templateLinkedBySrc = new Map()
  topicItemRows.forEach((row) => {
    if (!row?.src) return
    if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
    templateLinkedBySrc.get(row.src).set(row.topic_id, {
      id: row.topic_id,
      name: row.topic_name || row.topic_id,
    })
  })

  return rows
    .map((row) => ({
      ...mapCustomItemRow(row),
      ownerName: row.nickname || row.email,
      ownerEmail: row.email,
      usageCount: usageMeta.usageMap.get(row.id) || 0,
      linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
    }))
    .filter((item) => ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt))
}

async function getFavoriteStatsForTierListIds(tierListIds, userId = '') {
  const ids = Array.from(new Set((tierListIds || []).filter(Boolean)))
  const countMap = new Map()
  const favoritedSet = new Set()
  if (!ids.length) return { countMap, favoritedSet }

  const placeholders = ids.map(() => '?').join(', ')
  const countRows = await query(
    `
      SELECT tierlist_id, COUNT(*) AS favorite_count
      FROM favorite_tierlists
      WHERE tierlist_id IN (${placeholders})
      GROUP BY tierlist_id
    `,
    ids
  )

  countRows.forEach((row) => {
    countMap.set(row.tierlist_id, Number(row.favorite_count || 0))
  })

  if (userId) {
    const favoriteRows = await query(
      `
        SELECT tierlist_id
        FROM favorite_tierlists
        WHERE user_id = ? AND tierlist_id IN (${placeholders})
      `,
      [userId, ...ids]
    )
    favoriteRows.forEach((row) => favoritedSet.add(row.tierlist_id))
  }

  return { countMap, favoritedSet }
}

function applyFavoriteMetaToTierLists(tierLists, favoriteStats) {
  return tierLists.map((tierList) => ({
    ...tierList,
    favoriteCount: favoriteStats.countMap.get(tierList.id) || 0,
    isFavorited: favoriteStats.favoritedSet.has(tierList.id),
  }))
}

async function listPublicTierLists(topicId, currentUserId = '', queryText = '') {
  const params = []
  let whereClause = 'WHERE t.is_public = 1'
  if (topicId) {
    const topic = await findTopicByIdentifier(topicId)
    if (!topic) {
      return {
        featuredTierLists: [],
        tierLists: [],
      }
    }
    whereClause += ' AND t.topic_id = ?'
    params.push(topic.id)
  }
  if ((queryText || '').trim()) {
    const search = `%${queryText.trim()}%`
    whereClause += ' AND (t.title LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)'
    params.push(search, search, search)
  }

  const rows = await query(
    `
      SELECT
        t.id,
        t.topic_id,
        tp.slug AS topic_slug,
        tp.name AS topic_name,
        t.title,
        t.thumbnail_src,
        t.is_featured,
        t.featured_at,
        t.featured_by,
        t.created_at,
        t.updated_at,
        t.author_id,
        u.nickname,
        u.email,
        u.avatar_src
      FROM tierlists t
      INNER JOIN users u ON u.id = t.author_id
      INNER JOIN topics tp ON tp.id = t.topic_id
      ${whereClause}
      ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC
      LIMIT 200
    `,
    params
  )

  const tierLists = rows.map((row) => ({
    id: row.id,
    topicId: row.topic_id,
    topicSlug: row.topic_slug || row.topic_id,
    topicName: row.topic_name || '',
    title: row.title,
    thumbnailSrc: row.thumbnail_src || '',
    isFeatured: !!row.is_featured,
    featuredAt: Number(row.featured_at || 0),
    createdAt: Number(row.created_at),
    updatedAt: Number(row.updated_at),
    authorId: row.author_id,
    authorName: getUserDisplayName(row),
    authorAccountName: getUserAccountName(row),
    authorAvatarSrc: row.avatar_src || '',
  }))

  const favoriteStats = await getFavoriteStatsForTierListIds(
    tierLists.map((tierList) => tierList.id),
    currentUserId
  )
  const mergedTierLists = applyFavoriteMetaToTierLists(tierLists, favoriteStats)
  const featuredTierLists = mergedTierLists
    .filter((tierList) => tierList.isFeatured)
    .slice()
    .sort(
      (a, b) =>
        Number(b.featuredAt || 0) - Number(a.featuredAt || 0) ||
        Number(b.favoriteCount || 0) - Number(a.favoriteCount || 0) ||
        Number(b.updatedAt || 0) - Number(a.updatedAt || 0)
    )
    .slice(0, 16)

  return {
    featuredTierLists,
    tierLists: mergedTierLists.filter((tierList) => !tierList.isFeatured),
  }
}

async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited' } = {}) {
  const allowedSort = new Set(['favorited', 'updated', 'favorites'])
  const normalizedSort = allowedSort.has(sort) ? sort : 'favorited'
  const params = [userId]
  let whereClause = 'WHERE f.user_id = ?'

  if ((queryText || '').trim()) {
    const search = `%${queryText.trim()}%`
    whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)'
    params.push(search, search, search, search)
  }

  const orderClause =
    normalizedSort === 'updated'
      ? 'ORDER BY t.updated_at DESC, f.created_at DESC'
      : normalizedSort === 'favorites'
        ? 'ORDER BY favorite_count DESC, t.updated_at DESC'
        : 'ORDER BY f.created_at DESC, t.updated_at DESC'

  const rows = await query(
    `
      SELECT
        t.id,
        t.author_id,
        t.topic_id,
        tp.slug AS topic_slug,
        tp.name AS topic_name,
        t.title,
        t.thumbnail_src,
        t.description,
        t.is_public,
        t.is_featured,
        t.featured_at,
        t.featured_by,
        t.show_character_names,
        t.icon_size,
        t.source_tierlist_id,
        t.source_snapshot_title,
        t.source_snapshot_author,
        t.groups_json,
        t.pool_json,
        t.created_at,
        t.updated_at,
        f.created_at AS favorited_at,
        u.nickname,
        u.email,
        u.avatar_src,
        (
          SELECT COUNT(*)
          FROM favorite_tierlists ff
          WHERE ff.tierlist_id = t.id
        ) AS favorite_count
      FROM favorite_tierlists f
      INNER JOIN tierlists t ON t.id = f.tierlist_id
      INNER JOIN users u ON u.id = t.author_id
      INNER JOIN topics tp ON tp.id = t.topic_id
      ${whereClause}
      ${orderClause}
    `,
    params
  )

  return rows.map((row) => ({
    ...mapTierListRow(row),
    favoritedAt: Number(row.favorited_at || 0),
    favoriteCount: Number(row.favorite_count || 0),
    isFavorited: true,
  }))
}

async function listUserTierLists(userId) {
  const rows = await query(
    `
      SELECT
        t.id,
        t.topic_id,
        tp.slug AS topic_slug,
        tp.name AS topic_name,
        t.title,
        t.thumbnail_src,
        t.created_at,
        t.updated_at,
        t.is_public,
        u.nickname,
        u.email,
        u.avatar_src
      FROM tierlists t
      INNER JOIN users u ON u.id = t.author_id
      INNER JOIN topics tp ON tp.id = t.topic_id
      WHERE t.author_id = ?
      ORDER BY updated_at DESC
    `,
    [userId]
  )

  const tierLists = rows.map((row) => ({
    id: row.id,
    topicId: row.topic_id,
    topicSlug: row.topic_slug || row.topic_id,
    topicName: row.topic_name || '',
    title: row.title,
    thumbnailSrc: row.thumbnail_src || '',
    createdAt: Number(row.created_at),
    updatedAt: Number(row.updated_at),
    isPublic: !!row.is_public,
    authorName: getUserDisplayName(row),
    authorAccountName: getUserAccountName(row),
    authorAvatarSrc: row.avatar_src || '',
  }))

  const favoriteStats = await getFavoriteStatsForTierListIds(
    tierLists.map((tierList) => tierList.id),
    userId
  )
  return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}

async function findUserProfileById(userId, currentUserId = '') {
  const rows = await query(
    `
      SELECT
        u.id,
        u.email,
        u.nickname,
        u.avatar_src,
        u.created_at,
        (
          SELECT COUNT(*)
          FROM tierlists t
          WHERE t.author_id = u.id AND t.is_public = 1
        ) AS public_tierlist_count,
        (
          SELECT COUNT(*)
          FROM user_follows uf
          WHERE uf.following_id = u.id
        ) AS follower_count,
        (
          SELECT COUNT(*)
          FROM user_follows uf
          WHERE uf.follower_id = u.id
        ) AS following_count,
        ${
          currentUserId
            ? `EXISTS(
                SELECT 1
                FROM user_follows uf
                WHERE uf.follower_id = ? AND uf.following_id = u.id
              )`
            : '0'
        } AS is_following
      FROM users u
      WHERE u.id = ?
      LIMIT 1
    `,
    currentUserId ? [currentUserId, userId] : [userId]
  )
  const row = rows[0]
  if (!row) return null
  return {
    id: row.id,
    nickname: row.nickname || '',
    accountName: getUserAccountName(row),
    avatarSrc: row.avatar_src || '',
    createdAt: Number(row.created_at || 0),
    publicTierListCount: Number(row.public_tierlist_count || 0),
    followerCount: Number(row.follower_count || 0),
    followingCount: Number(row.following_count || 0),
    isFollowing: !!row.is_following,
    isSelf: !!currentUserId && currentUserId === row.id,
  }
}

async function followUser({ followerId, followingId }) {
  await query('INSERT IGNORE INTO user_follows (follower_id, following_id, created_at) VALUES (?, ?, ?)', [
    followerId,
    followingId,
    now(),
  ])
}

async function unfollowUser({ followerId, followingId }) {
  await query('DELETE FROM user_follows WHERE follower_id = ? AND following_id = ?', [followerId, followingId])
}

async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryText = '') {
  const params = [authorId]
  let whereClause = 'WHERE t.author_id = ? AND t.is_public = 1'
  if ((queryText || '').trim()) {
    const search = `%${queryText.trim()}%`
    whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ?)'
    params.push(search, search)
  }

  const rows = await query(
    `
      SELECT
        t.id,
        t.topic_id,
        tp.slug AS topic_slug,
        tp.name AS topic_name,
        t.title,
        t.thumbnail_src,
        t.is_public,
        t.is_featured,
        t.featured_at,
        t.featured_by,
        t.created_at,
        t.updated_at,
        t.author_id,
        u.nickname,
        u.email,
        u.avatar_src
      FROM tierlists t
      INNER JOIN users u ON u.id = t.author_id
      INNER JOIN topics tp ON tp.id = t.topic_id
      ${whereClause}
      ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC
      LIMIT 200
    `,
    params
  )

  const tierLists = rows.map((row) => ({
    id: row.id,
    topicId: row.topic_id,
    topicSlug: row.topic_slug || row.topic_id,
    topicName: row.topic_name || '',
    title: row.title,
    thumbnailSrc: row.thumbnail_src || '',
    isPublic: !!row.is_public,
    isFeatured: !!row.is_featured,
    featuredAt: Number(row.featured_at || 0),
    createdAt: Number(row.created_at),
    updatedAt: Number(row.updated_at),
    authorId: row.author_id,
    authorName: getUserDisplayName(row),
    authorAccountName: getUserAccountName(row),
    authorAvatarSrc: row.avatar_src || '',
  }))

  const favoriteStats = await getFavoriteStatsForTierListIds(
    tierLists.map((tierList) => tierList.id),
    currentUserId
  )
  return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}

async function listFollowingTierLists(userId, queryText = '') {
  const params = [userId]
  let whereClause = 'WHERE uf.follower_id = ? AND t.is_public = 1'
  if ((queryText || '').trim()) {
    const search = `%${queryText.trim()}%`
    whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)'
    params.push(search, search, search, search)
  }

  const rows = await query(
    `
      SELECT
        t.id,
        t.topic_id,
        tp.slug AS topic_slug,
        tp.name AS topic_name,
        t.title,
        t.thumbnail_src,
        t.is_public,
        t.is_featured,
        t.featured_at,
        t.featured_by,
        t.created_at,
        t.updated_at,
        t.author_id,
        uf.created_at AS followed_at,
        u.nickname,
        u.email,
        u.avatar_src
      FROM user_follows uf
      INNER JOIN tierlists t ON t.author_id = uf.following_id
      INNER JOIN users u ON u.id = t.author_id
      INNER JOIN topics tp ON tp.id = t.topic_id
      ${whereClause}
      ORDER BY t.updated_at DESC, uf.created_at DESC
      LIMIT 200
    `,
    params
  )

  const tierLists = rows.map((row) => ({
    id: row.id,
    topicId: row.topic_id,
    topicSlug: row.topic_slug || row.topic_id,
    topicName: row.topic_name || '',
    title: row.title,
    thumbnailSrc: row.thumbnail_src || '',
    isPublic: !!row.is_public,
    isFeatured: !!row.is_featured,
    featuredAt: Number(row.featured_at || 0),
    createdAt: Number(row.created_at),
    updatedAt: Number(row.updated_at),
    authorId: row.author_id,
    authorName: getUserDisplayName(row),
    authorAccountName: getUserAccountName(row),
    authorAvatarSrc: row.avatar_src || '',
    isFavorited: false,
  }))

  const favoriteStats = await getFavoriteStatsForTierListIds(
    tierLists.map((tierList) => tierList.id),
    userId
  )
  return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}

function uniqueTierListItems(poolItems) {
  const map = new Map()
  ;(poolItems || []).forEach((item) => {
    if (!item?.id || map.has(item.id)) return
    map.set(item.id, {
      id: item.id,
      src: item.src || '',
      label: item.label || 'item',
      origin: item.origin || 'template',
    })
  })
  return Array.from(map.values())
}

function getAutoThumbnailSrc(groups = [], pool = []) {
  const itemMap = new Map((pool || []).filter((item) => item?.id && item?.src).map((item) => [item.id, item]))

  for (const group of groups || []) {
    for (const itemId of group?.itemIds || []) {
      const item = itemMap.get(itemId)
      if (item?.src) return item.src
    }
  }

  const fallbackItem = (pool || []).find((item) => item?.src)
  return fallbackItem?.src || ''
}

async function listAdminTierLists({
  queryText = '',
  topicId = '',
  page = 1,
  limit = 50,
  sort = 'recent',
  minFavorites = 0,
  currentUserId = '',
} = {}) {
  const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
  const normalizedPage = Math.max(Number(page) || 1, 1)
  const normalizedMinFavorites = Math.max(Number(minFavorites) || 0, 0)
  const hasQuery = !!(queryText || '').trim()
  const resolvedTopicId = (topicId || '').trim()
  const hasTopicId = !!resolvedTopicId
  const search = `%${(queryText || '').trim()}%`
  const whereParts = []
  const params = []

  if (hasTopicId) {
    whereParts.push('t.topic_id = ?')
    params.push(resolvedTopicId)
  }

  if (hasQuery) {
    whereParts.push(`(
      t.title LIKE ?
      OR tp.name LIKE ?
      OR tp.slug LIKE ?
      OR tp.id LIKE ?
      OR u.email LIKE ?
      OR u.nickname LIKE ?
    )`)
    params.push(search, search, search, search, search, search)
  }

  const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''

  const rows = await query(
    `
      SELECT
        t.id,
        t.author_id,
        t.topic_id,
        tp.slug AS topic_slug,
        tp.name AS topic_name,
        t.title,
        t.thumbnail_src,
        t.description,
        t.is_public,
        t.is_featured,
        t.featured_at,
        t.featured_by,
        t.show_character_names,
        t.icon_size,
        t.source_tierlist_id,
        t.source_snapshot_title,
        t.source_snapshot_author,
        t.groups_json,
        t.pool_json,
        t.created_at,
        t.updated_at,
        u.nickname,
        u.email,
        u.avatar_src
      FROM tierlists t
      INNER JOIN users u ON u.id = t.author_id
      INNER JOIN topics tp ON tp.id = t.topic_id
      ${whereClause}
      ORDER BY t.updated_at DESC, t.created_at DESC
    `,
    params
  )

  const baseItems = rows.map((row) => {
    const tierList = mapTierListRow(row)
    const poolItems = uniqueTierListItems(tierList.pool)
    const extraItems = poolItems.filter((item) => item.origin === 'custom')
    return {
      ...tierList,
      itemCount: poolItems.length,
      extraItemCount: extraItems.length,
      extraItems,
    }
  })
  const favoriteStats = await getFavoriteStatsForTierListIds(
    baseItems.map((tierList) => tierList.id),
    currentUserId
  )
  const filteredItems = applyFavoriteMetaToTierLists(baseItems, favoriteStats)
    .filter((tierList) => Number(tierList.favoriteCount || 0) >= normalizedMinFavorites)
    .sort((a, b) => {
      if (sort === 'favorites') {
        return (
          Number(b.favoriteCount || 0) - Number(a.favoriteCount || 0) ||
          Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ||
          Number(b.createdAt || 0) - Number(a.createdAt || 0)
        )
      }
      if (sort === 'created') {
        return (
          Number(b.createdAt || 0) - Number(a.createdAt || 0) ||
          Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ||
          String(a.title || '').localeCompare(String(b.title || ''))
        )
      }
      return (
        Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ||
        Number(b.createdAt || 0) - Number(a.createdAt || 0) ||
        String(a.title || '').localeCompare(String(b.title || ''))
      )
    })

  const total = filteredItems.length
  const offset = (normalizedPage - 1) * normalizedLimit
  const pagedTierLists = filteredItems.slice(offset, offset + normalizedLimit)
  return {
    tierLists: pagedTierLists,
    total,
    page: normalizedPage,
    limit: normalizedLimit,
  }
}

async function summarizeAdminTierLists({ queryText = '', topicId = '', minFavorites = 0 } = {}) {
  const normalizedMinFavorites = Math.max(Number(minFavorites) || 0, 0)
  const hasQuery = !!(queryText || '').trim()
  const resolvedTopicId = (topicId || '').trim()
  const hasTopicId = !!resolvedTopicId
  const search = `%${(queryText || '').trim()}%`
  const whereParts = []
  const params = []

  if (hasTopicId) {
    whereParts.push('t.topic_id = ?')
    params.push(resolvedTopicId)
  }

  if (hasQuery) {
    whereParts.push(`(
      t.title LIKE ?
      OR tp.name LIKE ?
      OR tp.slug LIKE ?
      OR tp.id LIKE ?
      OR u.email LIKE ?
      OR u.nickname LIKE ?
    )`)
    params.push(search, search, search, search, search, search)
  }

  const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
  const rows = await query(
    `
      SELECT t.is_public, t.is_featured
      , t.id
      FROM tierlists t
      INNER JOIN users u ON u.id = t.author_id
      INNER JOIN topics tp ON tp.id = t.topic_id
      ${whereClause}
    `,
    params
  )

  const favoriteStats = normalizedMinFavorites > 0
    ? await getFavoriteStatsForTierListIds(rows.map((row) => row.id), '')
    : { countMap: new Map(), favoritedSet: new Set() }
  const scopedRows = normalizedMinFavorites > 0
    ? rows.filter((row) => Number(favoriteStats.countMap.get(row.id) || 0) >= normalizedMinFavorites)
    : rows

  const total = scopedRows.length
  const publicCount = scopedRows.filter((row) => Number(row.is_public) === 1).length
  const featuredCount = scopedRows.filter((row) => Number(row.is_featured) === 1).length
  return {
    total,
    publicCount,
    privateCount: Math.max(0, total - publicCount),
    featuredCount,
  }
}

async function findTierListById(id, currentUserId = '') {
  const rows = await query(
    `
      SELECT
        t.id,
        t.author_id,
        t.topic_id,
        tp.slug AS topic_slug,
        tp.name AS topic_name,
        t.title,
        t.thumbnail_src,
        t.description,
        t.is_public,
        t.is_featured,
        t.featured_at,
        t.featured_by,
        t.show_character_names,
        t.icon_size,
        t.source_tierlist_id,
        t.source_snapshot_title,
        t.source_snapshot_author,
        t.groups_json,
        t.pool_json,
        t.created_at,
        t.updated_at,
        u.nickname,
        u.email,
        u.avatar_src
      FROM tierlists t
      INNER JOIN users u ON u.id = t.author_id
      INNER JOIN topics tp ON tp.id = t.topic_id
      WHERE t.id = ?
      LIMIT 1
    `,
    [id]
  )
  const tierList = mapTierListRow(rows[0])
  if (!tierList) return null
  const favoriteStats = await getFavoriteStatsForTierListIds([tierList.id], currentUserId)
  return applyFavoriteMetaToTierLists([tierList], favoriteStats)[0]
}

async function findPendingTemplateRequestByTierList({ sourceTierListId, type }) {
  const rows = await query(
    `
      SELECT id, request_type, status
      FROM template_requests
      WHERE source_tierlist_id = ? AND request_type = ? AND status = 'pending'
      LIMIT 1
    `,
    [sourceTierListId, type]
  )
  return rows[0] || null
}

async function createTemplateRequest({
  id,
  type,
  requesterId,
  sourceTierListId = '',
  sourceTopicId,
  targetTopicId = '',
  title,
  description = '',
  thumbnailSrc = '',
  items = [],
  groups = [],
  boardItems = [],
  showCharacterNames = false,
}) {
  if (sourceTierListId) {
    const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
    if (existing) {
      const err = new Error('template_request_exists')
      err.code = 'TEMPLATE_REQUEST_EXISTS'
      throw err
    }
  }

  const createdAt = now()
  await query(
    `
      INSERT INTO template_requests (
        id,
        request_type,
        requester_id,
        source_tierlist_id,
        source_topic_id,
        target_topic_id,
        status,
        title_snapshot,
        description_snapshot,
        thumbnail_src_snapshot,
        items_json,
        groups_json,
        board_items_json,
        show_character_names_snapshot,
        created_at,
        updated_at
      )
      VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `,
    [
      id,
      type,
      requesterId,
      sourceTierListId || null,
      sourceTopicId,
      targetTopicId,
      title,
      description,
      thumbnailSrc,
      serializeJson(items),
      serializeJson(groups),
      serializeJson(boardItems),
      showCharacterNames ? 1 : 0,
      createdAt,
      createdAt,
    ]
  )
  return findTemplateRequestById(id)
}

async function findTemplateRequestById(id) {
  const rows = await query(
    `
      SELECT
        tr.id,
        tr.request_type,
        tr.requester_id,
        tr.source_tierlist_id,
        tr.source_topic_id,
        tr.target_topic_id,
        tr.status,
        tr.title_snapshot,
        tr.description_snapshot,
        tr.thumbnail_src_snapshot,
        tr.items_json,
        tr.groups_json,
        tr.board_items_json,
        tr.show_character_names_snapshot,
        tr.created_at,
        tr.updated_at,
        u.nickname,
        u.email,
        u.avatar_src AS requester_avatar_src,
        sg.slug AS source_topic_slug,
        sg.name AS source_topic_name,
        tg.slug AS target_topic_slug,
        tg.name AS target_topic_name
      FROM template_requests tr
      INNER JOIN users u ON u.id = tr.requester_id
      LEFT JOIN topics sg ON sg.id = tr.source_topic_id
      LEFT JOIN topics tg ON tg.id = tr.target_topic_id
      WHERE tr.id = ?
      LIMIT 1
    `,
    [id]
  )

  return mapTemplateRequestRow(rows[0])
}

async function listAdminTemplateRequests({ status = 'pending', statuses = [] } = {}) {
  const requestedStatuses = Array.isArray(statuses) && statuses.length ? statuses : [status]
  const validStatuses = requestedStatuses.filter((entry) => typeof entry === 'string' && entry.trim())
  const normalizedStatuses = validStatuses.length ? validStatuses : ['pending']
  const placeholders = normalizedStatuses.map(() => '?').join(', ')
  const rows = await query(
    `
      SELECT
        tr.id,
        tr.request_type,
        tr.requester_id,
        tr.source_tierlist_id,
        tr.source_topic_id,
        tr.target_topic_id,
        tr.status,
        tr.title_snapshot,
        tr.description_snapshot,
        tr.thumbnail_src_snapshot,
        tr.items_json,
        tr.groups_json,
        tr.board_items_json,
        tr.show_character_names_snapshot,
        tr.created_at,
        tr.updated_at,
        u.nickname,
        u.email,
        u.avatar_src AS requester_avatar_src,
        sg.slug AS source_topic_slug,
        sg.name AS source_topic_name,
        tg.slug AS target_topic_slug,
        tg.name AS target_topic_name
      FROM template_requests tr
      INNER JOIN users u ON u.id = tr.requester_id
      LEFT JOIN topics sg ON sg.id = tr.source_topic_id
      LEFT JOIN topics tg ON tg.id = tr.target_topic_id
      WHERE tr.status IN (${placeholders})
      ORDER BY
        CASE tr.status
          WHEN 'pending' THEN 0
          WHEN 'reviewing' THEN 1
          ELSE 2
        END,
        tr.created_at DESC
    `,
    normalizedStatuses
  )

  return rows.map(mapTemplateRequestRow)
}

async function updateTemplateRequestStatus({ id, status }) {
  await query('UPDATE template_requests SET status = ?, updated_at = ? WHERE id = ?', [status, now(), id])
  return findTemplateRequestById(id)
}

async function updateTemplateRequestTargetTopic({ id, targetTopicId }) {
  await query('UPDATE template_requests SET target_topic_id = ?, updated_at = ? WHERE id = ?', [targetTopicId || '', now(), id])
  return findTemplateRequestById(id)
}

async function deleteTierList(id) {
  await query('DELETE FROM tierlists WHERE id = ?', [id])
}

async function updateAdminTierListMeta({ id, title, description = '', isPublic }) {
  const nextUpdatedAt = now()
  if (!isPublic) {
    await query(
      `
        UPDATE tierlists
        SET title = ?, description = ?, is_public = 0, is_featured = 0, featured_at = 0, featured_by = '', updated_at = ?
        WHERE id = ?
      `,
      [title, description || '', nextUpdatedAt, id]
    )
    return findTierListById(id)
  }

  await query(
    `
      UPDATE tierlists
      SET title = ?, description = ?, is_public = ?, updated_at = ?
      WHERE id = ?
    `,
    [title, description || '', 1, nextUpdatedAt, id]
  )
  return findTierListById(id)
}

async function updateTierListFeaturedStatus({ id, isFeatured, adminUserId }) {
  await query(
    `
      UPDATE tierlists
      SET is_featured = ?, featured_at = ?, featured_by = ?
      WHERE id = ?
    `,
    [isFeatured ? 1 : 0, isFeatured ? now() : 0, isFeatured ? adminUserId || '' : '', id]
  )
  return findTierListById(id)
}

async function findCustomItemsByIds(ids) {
  if (!ids.length) return []
  const placeholders = ids.map(() => '?').join(', ')
  const rows = await query(
    `
      SELECT id, owner_id, src, label, tags_json, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at
      FROM custom_items
      WHERE id IN (${placeholders})
    `,
    ids
  )

  return rows.map(mapCustomItemRow)
}

async function deleteCustomItems(ids) {
  if (!ids.length) return
  const placeholders = ids.map(() => '?').join(', ')
  await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids)
}

async function saveTierList({
  id,
  authorId,
  topicId,
  title,
  thumbnailSrc = '',
  description,
  isPublic,
  showCharacterNames = false,
  iconSize = 80,
  sourceTierListId = '',
  sourceSnapshotTitle = '',
  sourceSnapshotAuthor = '',
  groups,
  pool,
}) {
  const existing = id ? await findTierListById(id, authorId) : null
  await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool })
  const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool)

  if (existing) {
    await query(
      `
        UPDATE tierlists
        SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, icon_size = ?, source_tierlist_id = ?, source_snapshot_title = ?, source_snapshot_author = ?, groups_json = ?, pool_json = ?, updated_at = ?
        WHERE id = ?
      `,
      [title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), now(), existing.id]
    )
    return findTierListById(existing.id, authorId)
  }

  const nextId = id || nanoid()
  const createdAt = now()
  await query(
    `
      INSERT INTO tierlists (
        id, author_id, topic_id, title, thumbnail_src, description, is_public, show_character_names, icon_size, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at
      )
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `,
    [nextId, authorId, topicId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
  )
  return findTierListById(nextId, authorId)
}

async function duplicateTierListForUser({ tierList, targetUserId }) {
  const { nanoid } = require('nanoid')
  const duplicateId = nanoid()
  const baseTitle = (tierList.title || '티어표').trim() || '티어표'
  const copyTitle = baseTitle.endsWith(' 복사본') ? baseTitle : `${baseTitle} 복사본`
  return saveTierList({
    id: duplicateId,
    authorId: targetUserId,
    topicId: tierList.topicId,
    title: copyTitle,
    thumbnailSrc: tierList.thumbnailSrc || '',
    description: tierList.description || '',
    isPublic: false,
    showCharacterNames: !!tierList.showCharacterNames,
    iconSize: Number(tierList.iconSize || 80),
    sourceTierListId: tierList.id,
    sourceSnapshotTitle: tierList.title || '',
    sourceSnapshotAuthor: tierList.authorName || tierList.authorAccountName || '',
    groups: JSON.parse(JSON.stringify(tierList.groups || [])),
    pool: JSON.parse(JSON.stringify(tierList.pool || [])),
  })
}

async function favoriteTierList({ userId, tierListId }) {
  await query('INSERT IGNORE INTO favorite_tierlists (user_id, tierlist_id, created_at) VALUES (?, ?, ?)', [userId, tierListId, now()])
}

async function unfavoriteTierList({ userId, tierListId }) {
  await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId])
}

async function favoriteTopic({ userId, topicId }) {
  await query('INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at) VALUES (?, ?, ?)', [userId, topicId, now()])
}

async function unfavoriteTopic({ userId, topicId }) {
  await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, topicId])
}

module.exports = {
  DB_NAME,
  ensureData,
  closePool,
  countUsers,
  findUserByEmail,
  findUserByNickname,
  findUserById,
  findUserProfileById,
  createUser,
  touchUserLastLoginAt,
  updateUserPassword,
  verifyUserEmail,
  createEmailVerificationToken,
  findEmailVerificationTokenByHash,
  consumeEmailVerificationToken,
  createPasswordResetToken,
  findPasswordResetTokenByHash,
  consumePasswordResetToken,
  updateUserProfile,
  findPrimaryAdminUser,
  listUsers,
  adminUpdateUser,
  adminUpdateUserPassword,
  adminDeleteUser,
  listTopics,
  findTopicById,
  findTopicBySlug,
  findTopicByIdentifier,
  listTopicItems,
  findTopicItemById,
  getTopicDetail,
  createTopic,
  updateTopicMeta,
  updateTopicThumbnail,
  updateTopicVisibility,
  findImageAssetByHash,
  findImageAssetBySrc,
  findImageAssetById,
  updateImageAssetSrc,
  createImageAsset,
  createImageOptimizationJob,
  findImageOptimizationJobById,
  updateImageOptimizationJobStatus,
  listRecentImageOptimizationJobs,
  listUnusedImageAssets,
  deleteImageAssets,
  listImageAssets,
  listReferencedUploadSources,
  listReferencedUploadUsage,
  replaceUploadSourceReferences,
  updateCustomItemDisplayReferences,
  clearImageOptimizationJobs,
  getImageAssetStats,
  cleanupMissingUploadReferences,
  createTopicItem,
  updateTopicItemLabel,
  updateTopicItemMeta,
  updateTopicItemDisplayOrder,
  countTierListsUsingTopicItem,
  deleteTopicItem,
  deleteTopic,
  updateTopicDisplayOrder,
  updateCustomItemLabel,
  updateCustomItemMeta,
  clearCustomItemReplacement,
  markCustomItemReplaced,
  updateImageAssetLabel,
  createCustomItem,
  findCustomItemById,
  listCustomItems,
  findUnusedCustomItems,
  listPublicTierLists,
  listPublicTierListsByAuthor,
  listFollowingTierLists,
  listFavoriteTierLists,
  listUserTierLists,
  listAdminTierLists,
  summarizeAdminTierLists,
  findTierListById,
  updateAdminTierListMeta,
  updateTierListFeaturedStatus,
  favoriteTopic,
  unfavoriteTopic,
  followUser,
  unfollowUser,
  favoriteTierList,
  unfavoriteTierList,
  deleteTierList,
  findCustomItemsByIds,
  deleteCustomItems,
  saveTierList,
  duplicateTierListForUser,
  createTemplateRequest,
  findTemplateRequestById,
  listAdminTemplateRequests,
  updateTemplateRequestStatus,
  updateTemplateRequestTargetTopic,
}
