const crypto = require('crypto');
const db = require('../db/models');

const { Op } = db.Sequelize;

const TIMER_PROFILES = {
  normal: 8000,
  insane: 5000,
};

const MODE_SELECTIONS = {
  beatmap_only: 'beatmap',
  mapper_only: 'mapper',
  artist_only: 'artist',
  mixed: 'mixed',
};

const ROUND_MODES = ['beatmap', 'mapper', 'artist'];
const RECENT_ENTITY_COOLDOWN = 20;
const CANDIDATE_SEARCH_PLANS = [
  {
    limit: 120,
    strategy: 'strict_session',
  },
  {
    limit: 180,
    strategy: 'recent_cooldown',
  },
  {
    limit: 260,
    strategy: 'recent_beatmap_cooldown',
  },
  {
    limit: 320,
    strategy: 'open_pool',
  },
];

const CANDIDATE_INCLUDE = [
  {
    model: db.beatmap_sets,
    as: 'beatmap_set',
  },
  {
    model: db.mappers,
    as: 'mapper',
    include: [
      {
        model: db.mapper_stats,
        as: 'mapper_stats_mapper',
        required: false,
      },
    ],
  },
  {
    model: db.artists,
    as: 'artist',
    include: [
      {
        model: db.artist_stats,
        as: 'artist_stats_artist',
        required: false,
      },
    ],
  },
];

const ROUND_VISUAL_INCLUDE = [
  {
    model: db.beatmap_sets,
    as: 'beatmap_set',
  },
  {
    model: db.mappers,
    as: 'mapper',
  },
  {
    model: db.artists,
    as: 'artist',
  },
];

function gameplayError(message, code = 400) {
  const error = new Error(message);
  error.code = code;
  return error;
}

function normalizeNumber(value) {
  if (value === null || value === undefined) {
    return 0;
  }

  const normalized = Number(value);
  return Number.isFinite(normalized) ? normalized : 0;
}

function isProbablyPlaceholderImage(url) {
  return !url || url.includes('cdn.example.com');
}

function getBeatmapVisual(beatmap) {
  return (
    beatmap?.beatmap_set?.background_image_url ||
    beatmap?.beatmap_set?.cover_image_url ||
    null
  );
}

function getMaxStatTotal(statRows = []) {
  if (!Array.isArray(statRows) || !statRows.length) {
    return 0;
  }

  return statRows.reduce((highestValue, statRow) => {
    const totalPlays = normalizeNumber(statRow?.total_plays);
    return totalPlays > highestValue ? totalPlays : highestValue;
  }, 0);
}

function getMapperTotalPlays(beatmap) {
  return getMaxStatTotal(beatmap?.mapper?.mapper_stats_mapper);
}

function getArtistTotalPlays(beatmap) {
  return getMaxStatTotal(beatmap?.artist?.artist_stats_artist);
}

function getRoundValue(beatmap, roundMode) {
  if (roundMode === 'mapper') {
    return getMapperTotalPlays(beatmap);
  }

  if (roundMode === 'artist') {
    return getArtistTotalPlays(beatmap);
  }

  return normalizeNumber(beatmap?.playcount);
}

function getModeLabel(roundMode) {
  if (roundMode === 'mapper') {
    return 'Mapper total plays';
  }

  if (roundMode === 'artist') {
    return 'Artist total plays';
  }

  return 'Beatmap playcount';
}

function getValueSuffix(roundMode) {
  if (roundMode === 'beatmap') {
    return 'plays';
  }

  return 'total plays';
}

function pickRoundMode(modeSelection) {
  if (!MODE_SELECTIONS[modeSelection]) {
    throw gameplayError('Unsupported mode selection.');
  }

  if (modeSelection === 'mixed') {
    return ROUND_MODES[Math.floor(Math.random() * ROUND_MODES.length)];
  }

  return MODE_SELECTIONS[modeSelection];
}

function shuffle(items) {
  const cloned = [...items];

  for (let index = cloned.length - 1; index > 0; index -= 1) {
    const swapIndex = Math.floor(Math.random() * (index + 1));
    [cloned[index], cloned[swapIndex]] = [cloned[swapIndex], cloned[index]];
  }

  return cloned;
}

function serializeCard(beatmap, roundMode) {
  const fallbackVisual = getBeatmapVisual(beatmap);

  if (roundMode === 'mapper') {
    return {
      name:
        beatmap?.mapper?.osu_username ||
        beatmap?.beatmap_set?.creator_username ||
        'Unknown mapper',
      avatarUrl: beatmap?.mapper?.avatar_url || null,
      bannerUrl: beatmap?.mapper?.banner_url || fallbackVisual,
      fallbackVisual,
    };
  }

  if (roundMode === 'artist') {
    const artistImage = isProbablyPlaceholderImage(beatmap?.artist?.image_url)
      ? fallbackVisual
      : beatmap?.artist?.image_url;

    return {
      name: beatmap?.artist?.name || beatmap?.beatmap_set?.artist_name || 'Unknown artist',
      imageUrl: artistImage || fallbackVisual,
      fallbackVisual,
    };
  }

  return {
    title: beatmap?.beatmap_set?.title || 'Unknown beatmap',
    artistName: beatmap?.artist?.name || beatmap?.beatmap_set?.artist_name || 'Unknown artist',
    mapperName:
      beatmap?.mapper?.osu_username ||
      beatmap?.beatmap_set?.creator_username ||
      'Unknown mapper',
    mapperAvatarUrl: beatmap?.mapper?.avatar_url || null,
    difficultyName: beatmap?.difficulty_name || 'Unknown difficulty',
    status: beatmap?.status || 'ranked',
    backgroundImageUrl: fallbackVisual,
  };
}

function serializePendingRound(roundRecord) {
  return {
    id: roundRecord.id,
    roundNumber: roundRecord.round_number,
    timeLimitMs: roundRecord.time_limit_ms,
    presentedAt: roundRecord.presented_at,
    expiresAt: new Date(
      new Date(roundRecord.presented_at).getTime() + roundRecord.time_limit_ms,
    ).toISOString(),
    cards: {
      a: serializeCard(roundRecord.option_a_beatmap, roundRecord.round_mode),
      b: serializeCard(roundRecord.option_b_beatmap, roundRecord.round_mode),
    },
  };
}

function serializeResolvedRound(roundRecord) {
  const winningChoice = normalizeNumber(roundRecord.value_a) > normalizeNumber(roundRecord.value_b)
    ? 'a'
    : 'b';

  return {
    ...serializePendingRound(roundRecord),
    reveal: {
      roundMode: roundRecord.round_mode,
      modeLabel: getModeLabel(roundRecord.round_mode),
      valueSuffix: getValueSuffix(roundRecord.round_mode),
      correctChoice: roundRecord.correct_choice,
      winningChoice,
      playerChoice: roundRecord.player_choice,
      isCorrect: Boolean(roundRecord.is_correct),
      loseReason: roundRecord.lose_reason,
      values: {
        a: normalizeNumber(roundRecord.value_a),
        b: normalizeNumber(roundRecord.value_b),
      },
      answeredAt: roundRecord.answered_at,
    },
  };
}

async function loadBeatmapVisual(beatmapId, transaction) {
  return db.beatmaps.findOne({
    where: { id: beatmapId },
    include: ROUND_VISUAL_INCLUDE,
    transaction,
  });
}

async function hydrateRound(roundRecord, transaction) {
  if (!roundRecord) {
    return null;
  }

  const plainRound = roundRecord.toJSON ? roundRecord.toJSON() : roundRecord;
  const [optionABeatmap, optionBBeatmap] = await Promise.all([
    loadBeatmapVisual(plainRound.option_a_beatmapId, transaction),
    loadBeatmapVisual(plainRound.option_b_beatmapId, transaction),
  ]);

  return {
    ...plainRound,
    option_a_beatmap: optionABeatmap,
    option_b_beatmap: optionBBeatmap,
  };
}

async function loadRound(roundId, transaction) {
  const roundRecord = await db.game_rounds.findOne({
    where: { id: roundId },
    transaction,
  });

  return hydrateRound(roundRecord, transaction);
}

async function getPendingRound(sessionId, transaction) {
  const roundRecord = await db.game_rounds.findOne({
    where: {
      sessionId,
      state: 'pending',
    },
    order: [['round_number', 'DESC']],
    transaction,
  });

  return hydrateRound(roundRecord, transaction);
}

async function getSessionMetrics(sessionId, transaction) {
  const [roundsPlayed, correctGuesses] = await Promise.all([
    db.game_rounds.count({
      where: {
        sessionId,
        state: {
          [Op.not]: 'pending',
        },
      },
      transaction,
    }),
    db.game_rounds.count({
      where: {
        sessionId,
        is_correct: true,
      },
      transaction,
    }),
  ]);

  return {
    roundsPlayed,
    correctGuesses,
  };
}

function serializeSession(sessionRecord, metrics = { roundsPlayed: 0, correctGuesses: 0 }) {
  return {
    id: sessionRecord.id,
    status: sessionRecord.status,
    modeSelection: sessionRecord.mode_selection,
    timerProfile: sessionRecord.timer_profile,
    sessionToken: sessionRecord.session_token,
    startingLives: sessionRecord.starting_lives,
    livesRemaining: sessionRecord.lives_remaining,
    streak: sessionRecord.streak,
    bestStreak: sessionRecord.best_streak,
    startedAt: sessionRecord.started_at,
    endedAt: sessionRecord.ended_at,
    roundsPlayed: metrics.roundsPlayed,
    correctGuesses: metrics.correctGuesses,
  };
}

function limitedSetAdd(set, value, limit = null) {
  if (!value) {
    return;
  }

  if (limit !== null && set.size >= limit) {
    return;
  }

  set.add(value);
}

function getRoundEntityId(beatmap, roundMode) {
  if (roundMode === 'mapper') {
    return beatmap?.mapperId || beatmap?.mapper?.id || null;
  }

  if (roundMode === 'artist') {
    return beatmap?.artistId || beatmap?.artist?.id || null;
  }

  return beatmap?.beatmap_setId || beatmap?.beatmap_set?.id || beatmap?.id || null;
}

function dedupeCandidatesByRoundEntity(candidates, roundMode) {
  const seenEntityIds = new Set();

  return candidates.filter((beatmap) => {
    const entityId = getRoundEntityId(beatmap, roundMode) || beatmap?.id;

    if (!entityId || seenEntityIds.has(entityId)) {
      return false;
    }

    seenEntityIds.add(entityId);
    return true;
  });
}

async function getSessionUsage(sessionId, transaction) {
  const rounds = await db.game_rounds.findAll({
    where: { sessionId },
    attributes: ['id', 'round_number'],
    include: [
      {
        model: db.beatmaps,
        as: 'option_a_beatmap',
        attributes: ['id', 'beatmap_setId', 'mapperId', 'artistId'],
        required: false,
      },
      {
        model: db.beatmaps,
        as: 'option_b_beatmap',
        attributes: ['id', 'beatmap_setId', 'mapperId', 'artistId'],
        required: false,
      },
    ],
    order: [['round_number', 'DESC']],
    transaction,
  });

  const usage = {
    beatmapIds: new Set(),
    beatmapSetIds: new Set(),
    mapperIds: new Set(),
    artistIds: new Set(),
    recentBeatmapIds: new Set(),
    recentBeatmapSetIds: new Set(),
    recentMapperIds: new Set(),
    recentArtistIds: new Set(),
  };

  rounds.forEach((roundRecord) => {
    [roundRecord.option_a_beatmap, roundRecord.option_b_beatmap].forEach((beatmap) => {
      if (!beatmap) {
        return;
      }

      limitedSetAdd(usage.beatmapIds, beatmap.id);
      limitedSetAdd(usage.beatmapSetIds, beatmap.beatmap_setId);
      limitedSetAdd(usage.mapperIds, beatmap.mapperId);
      limitedSetAdd(usage.artistIds, beatmap.artistId);
      limitedSetAdd(usage.recentBeatmapIds, beatmap.id, RECENT_ENTITY_COOLDOWN);
      limitedSetAdd(
        usage.recentBeatmapSetIds,
        beatmap.beatmap_setId,
        RECENT_ENTITY_COOLDOWN,
      );
      limitedSetAdd(usage.recentMapperIds, beatmap.mapperId, RECENT_ENTITY_COOLDOWN);
      limitedSetAdd(usage.recentArtistIds, beatmap.artistId, RECENT_ENTITY_COOLDOWN);
    });
  });

  return usage;
}

function getCandidateExclusions(roundMode, sessionUsage, strategy) {
  if (strategy === 'strict_session') {
    return {
      excludedBeatmapIds: Array.from(sessionUsage.beatmapIds),
      excludedBeatmapSetIds:
        roundMode === 'beatmap' ? Array.from(sessionUsage.beatmapSetIds) : [],
      excludedMapperIds:
        roundMode === 'mapper' ? Array.from(sessionUsage.mapperIds) : [],
      excludedArtistIds:
        roundMode === 'artist' ? Array.from(sessionUsage.artistIds) : [],
    };
  }

  if (strategy === 'recent_cooldown') {
    return {
      excludedBeatmapIds: Array.from(sessionUsage.recentBeatmapIds),
      excludedBeatmapSetIds:
        roundMode === 'beatmap' ? Array.from(sessionUsage.recentBeatmapSetIds) : [],
      excludedMapperIds:
        roundMode === 'mapper' ? Array.from(sessionUsage.recentMapperIds) : [],
      excludedArtistIds:
        roundMode === 'artist' ? Array.from(sessionUsage.recentArtistIds) : [],
    };
  }

  if (strategy === 'recent_beatmap_cooldown') {
    return {
      excludedBeatmapIds: Array.from(sessionUsage.recentBeatmapIds),
      excludedBeatmapSetIds:
        roundMode === 'beatmap' ? Array.from(sessionUsage.recentBeatmapSetIds) : [],
      excludedMapperIds: [],
      excludedArtistIds: [],
    };
  }

  return {
    excludedBeatmapIds: [],
    excludedBeatmapSetIds: [],
    excludedMapperIds: [],
    excludedArtistIds: [],
  };
}

async function findCandidates({
  roundMode,
  limit = 30,
  excludedBeatmapIds = [],
  excludedBeatmapSetIds = [],
  excludedMapperIds = [],
  excludedArtistIds = [],
  transaction,
}) {
  const where = {
    mode: 'osu',
    status: {
      [Op.in]: ['ranked', 'loved'],
    },
  };

  if (excludedBeatmapIds.length) {
    where.id = {
      [Op.notIn]: excludedBeatmapIds,
    };
  }

  if (roundMode === 'beatmap' && excludedBeatmapSetIds.length) {
    where.beatmap_setId = {
      [Op.notIn]: excludedBeatmapSetIds,
    };
  }

  if (roundMode === 'mapper' && excludedMapperIds.length) {
    where.mapperId = {
      [Op.notIn]: excludedMapperIds,
    };
  }

  if (roundMode === 'artist' && excludedArtistIds.length) {
    where.artistId = {
      [Op.notIn]: excludedArtistIds,
    };
  }

  const candidates = await db.beatmaps.findAll({
    where,
    include: CANDIDATE_INCLUDE,
    limit,
    order: db.sequelize.random(),
    transaction,
  });

  return dedupeCandidatesByRoundEntity(
    candidates.filter((beatmap) => beatmap?.beatmap_set && beatmap?.mapper && beatmap?.artist),
    roundMode,
  );
}

function canComparePair(firstBeatmap, secondBeatmap, roundMode) {
  if (!firstBeatmap || !secondBeatmap || firstBeatmap.id === secondBeatmap.id) {
    return false;
  }

  if (roundMode === 'mapper' && firstBeatmap.mapperId === secondBeatmap.mapperId) {
    return false;
  }

  if (roundMode === 'artist' && firstBeatmap.artistId === secondBeatmap.artistId) {
    return false;
  }

  const firstValue = getRoundValue(firstBeatmap, roundMode);
  const secondValue = getRoundValue(secondBeatmap, roundMode);

  return firstValue > 0 && secondValue > 0 && firstValue !== secondValue;
}

function pickComparablePair(candidates, roundMode) {
  const shuffled = shuffle(candidates);

  for (let outerIndex = 0; outerIndex < shuffled.length; outerIndex += 1) {
    for (
      let innerIndex = outerIndex + 1;
      innerIndex < shuffled.length;
      innerIndex += 1
    ) {
      const firstBeatmap = shuffled[outerIndex];
      const secondBeatmap = shuffled[innerIndex];

      if (!canComparePair(firstBeatmap, secondBeatmap, roundMode)) {
        continue;
      }

      const valueA = getRoundValue(firstBeatmap, roundMode);
      const valueB = getRoundValue(secondBeatmap, roundMode);

      return {
        firstBeatmap,
        secondBeatmap,
        valueA,
        valueB,
      };
    }
  }

  throw gameplayError(
    'No comparable beatmap pair is available for the selected mode. Please sync more osu! data.',
    503,
  );
}

function generateSessionToken() {
  return `sess_${crypto.randomBytes(8).toString('hex')}`;
}

async function selectComparablePairForSession(sessionRecord, transaction) {
  const roundMode = pickRoundMode(sessionRecord.mode_selection);
  const sessionUsage = await getSessionUsage(sessionRecord.id, transaction);
  let lastError = null;

  for (const searchPlan of CANDIDATE_SEARCH_PLANS) {
    const candidates = await findCandidates({
      roundMode,
      limit: searchPlan.limit,
      transaction,
      ...getCandidateExclusions(roundMode, sessionUsage, searchPlan.strategy),
    });

    try {
      return {
        roundMode,
        ...pickComparablePair(candidates, roundMode),
      };
    } catch (error) {
      lastError = error;
    }
  }

  throw (
    lastError ||
    gameplayError(
      'No comparable beatmap pair is available for the selected mode. Please sync more osu! data.',
      503,
    )
  );
}

async function createRoundForSession(sessionRecord, currentUser, transaction) {
  const { roundMode, firstBeatmap, secondBeatmap, valueA, valueB } =
    await selectComparablePairForSession(sessionRecord, transaction);

  const nextRoundNumber =
    (await db.game_rounds.count({
      where: { sessionId: sessionRecord.id },
      transaction,
    })) + 1;

  const round = await db.game_rounds.create(
    {
      sessionId: sessionRecord.id,
      round_number: nextRoundNumber,
      round_mode: roundMode,
      state: 'pending',
      time_limit_ms: TIMER_PROFILES[sessionRecord.timer_profile],
      presented_at: new Date(),
      player_choice: 'none',
      correct_choice: valueA > valueB ? 'a' : 'b',
      is_correct: false,
      lose_reason: 'none',
      value_a: valueA,
      value_b: valueB,
      option_a_beatmapId: firstBeatmap.id,
      option_b_beatmapId: secondBeatmap.id,
      createdById: currentUser.id,
      updatedById: currentUser.id,
    },
    { transaction },
  );

  const roundRecord = await loadRound(round.id, transaction);
  return serializePendingRound(roundRecord);
}

async function createLeaderboardEntryIfNeeded(sessionRecord, currentUser, transaction) {
  const existingEntry = await db.leaderboard_entries.findOne({
    where: {
      sessionId: sessionRecord.id,
      board_type: 'all_time',
    },
    transaction,
  });

  if (existingEntry) {
    return existingEntry;
  }

  const metrics = await getSessionMetrics(sessionRecord.id, transaction);

  return db.leaderboard_entries.create(
    {
      board_type: 'all_time',
      seed_key: 'all_time',
      score: metrics.correctGuesses,
      rounds_survived: metrics.roundsPlayed,
      best_streak: sessionRecord.best_streak,
      achieved_at: sessionRecord.ended_at || new Date(),
      sessionId: sessionRecord.id,
      userId: currentUser.id,
      createdById: currentUser.id,
      updatedById: currentUser.id,
    },
    { transaction },
  );
}

function isRoundExpired(roundRecord) {
  const presentedAt = new Date(roundRecord.presented_at).getTime();
  return Date.now() > presentedAt + normalizeNumber(roundRecord.time_limit_ms);
}

module.exports = class GameplayService {
  static async startSession(payload, currentUser) {
    const modeSelection = payload?.modeSelection || 'mixed';
    const timerProfile = payload?.timerProfile || 'normal';

    if (!MODE_SELECTIONS[modeSelection]) {
      throw gameplayError('Mode selection must be beatmap_only, mapper_only, artist_only, or mixed.');
    }

    if (!TIMER_PROFILES[timerProfile]) {
      throw gameplayError('Timer profile must be normal or insane.');
    }

    const transaction = await db.sequelize.transaction();

    try {
      const now = new Date();

      await db.game_sessions.update(
        {
          status: 'abandoned',
          ended_at: now,
          updatedById: currentUser.id,
        },
        {
          where: {
            userId: currentUser.id,
            status: 'active',
          },
          transaction,
        },
      );

      const sessionRecord = await db.game_sessions.create(
        {
          session_token: generateSessionToken(),
          status: 'active',
          starting_lives: 2,
          lives_remaining: 2,
          streak: 0,
          best_streak: 0,
          timer_profile: timerProfile,
          mode_selection: modeSelection,
          started_at: now,
          userId: currentUser.id,
          createdById: currentUser.id,
          updatedById: currentUser.id,
        },
        { transaction },
      );

      const currentRound = await createRoundForSession(
        sessionRecord,
        currentUser,
        transaction,
      );

      const metrics = await getSessionMetrics(sessionRecord.id, transaction);

      await transaction.commit();

      return {
        session: serializeSession(sessionRecord, metrics),
        currentRound,
      };
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }

  static async answerRound(sessionId, roundId, payload, currentUser) {
    const submittedChoice = payload?.choice;

    if (!['a', 'b', 'none'].includes(submittedChoice)) {
      throw gameplayError('Choice must be a, b, or none.');
    }

    const transaction = await db.sequelize.transaction();

    try {
      const sessionRecord = await db.game_sessions.findOne({
        where: {
          id: sessionId,
          userId: currentUser.id,
        },
        transaction,
        lock: transaction.LOCK.UPDATE,
      });

      if (!sessionRecord) {
        throw gameplayError('Game session not found.', 404);
      }

      if (sessionRecord.status !== 'active') {
        throw gameplayError('This session is no longer active.');
      }

      const roundRecord = await db.game_rounds.findOne({
        where: {
          id: roundId,
          sessionId,
        },
        transaction,
        lock: transaction.LOCK.UPDATE,
      });

      if (!roundRecord) {
        throw gameplayError('Round not found.', 404);
      }

      if (roundRecord.state !== 'pending') {
        throw gameplayError('This round has already been resolved.');
      }

      const answeredAt = new Date();
      const timedOut =
        submittedChoice === 'none' ||
        answeredAt.getTime() >
          new Date(roundRecord.presented_at).getTime() + roundRecord.time_limit_ms;
      const playerChoice = timedOut ? 'none' : submittedChoice;
      const isCorrect = !timedOut && playerChoice === roundRecord.correct_choice;
      const nextLivesRemaining = isCorrect
        ? sessionRecord.lives_remaining
        : Math.max(sessionRecord.lives_remaining - 1, 0);
      const streak = isCorrect ? sessionRecord.streak + 1 : 0;
      const bestStreak = Math.max(sessionRecord.best_streak || 0, streak);
      const nextStatus = nextLivesRemaining > 0 ? 'active' : 'finished';

      await roundRecord.update(
        {
          player_choice: playerChoice,
          answered_at: answeredAt,
          is_correct: isCorrect,
          lose_reason: timedOut ? 'timeout' : isCorrect ? 'none' : 'wrong',
          state: timedOut ? 'timed_out' : 'answered',
          updatedById: currentUser.id,
        },
        { transaction },
      );

      await sessionRecord.update(
        {
          lives_remaining: nextLivesRemaining,
          streak,
          best_streak: bestStreak,
          status: nextStatus,
          ended_at: nextStatus === 'finished' ? answeredAt : sessionRecord.ended_at,
          updatedById: currentUser.id,
        },
        { transaction },
      );

      const resolvedRound = await loadRound(roundRecord.id, transaction);
      let nextRound = null;

      if (nextStatus === 'active') {
        nextRound = await createRoundForSession(sessionRecord, currentUser, transaction);
      } else {
        await createLeaderboardEntryIfNeeded(sessionRecord, currentUser, transaction);
      }

      const metrics = await getSessionMetrics(sessionRecord.id, transaction);

      await transaction.commit();

      return {
        session: serializeSession(sessionRecord, metrics),
        result: serializeResolvedRound(resolvedRound),
        nextRound,
        gameOver: nextStatus !== 'active',
      };
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }

  static async getActiveSession(currentUser) {
    const sessionRecord = await db.game_sessions.findOne({
      where: {
        userId: currentUser.id,
        status: 'active',
      },
      order: [['started_at', 'DESC']],
    });

    if (!sessionRecord) {
      return {
        activeSession: false,
      };
    }

    const pendingRound = await getPendingRound(sessionRecord.id);

    if (!pendingRound) {
      const transaction = await db.sequelize.transaction();

      try {
        const lockedSession = await db.game_sessions.findOne({
          where: { id: sessionRecord.id },
          transaction,
          lock: transaction.LOCK.UPDATE,
        });
        const nextRound = await createRoundForSession(
          lockedSession,
          currentUser,
          transaction,
        );
        const metrics = await getSessionMetrics(sessionRecord.id, transaction);
        await transaction.commit();

        return {
          activeSession: true,
          session: serializeSession(lockedSession, metrics),
          currentRound: nextRound,
        };
      } catch (error) {
        await transaction.rollback();
        throw error;
      }
    }

    if (isRoundExpired(pendingRound)) {
      const resolvedSession = await this.answerRound(
        sessionRecord.id,
        pendingRound.id,
        { choice: 'none' },
        currentUser,
      );

      return {
        activeSession: true,
        autoResolved: true,
        ...resolvedSession,
      };
    }

    const metrics = await getSessionMetrics(sessionRecord.id);

    return {
      activeSession: true,
      session: serializeSession(sessionRecord, metrics),
      currentRound: serializePendingRound(pendingRound),
    };
  }

  static async listSessions(currentUser) {
    const sessions = await db.game_sessions.findAll({
      where: {
        userId: currentUser.id,
      },
      order: [['started_at', 'DESC']],
      limit: 12,
    });

    const sessionIds = sessions.map((session) => session.id);

    const [rounds, entries, leaderboard] = await Promise.all([
      sessionIds.length
        ? db.game_rounds.findAll({
            where: {
              sessionId: {
                [Op.in]: sessionIds,
              },
            },
            attributes: ['sessionId', 'id', 'is_correct', 'state'],
          })
        : [],
      sessionIds.length
        ? db.leaderboard_entries.findAll({
            where: {
              sessionId: {
                [Op.in]: sessionIds,
              },
              board_type: 'all_time',
            },
          })
        : [],
      db.leaderboard_entries.findAll({
        where: {
          board_type: 'all_time',
        },
        include: [
          {
            model: db.users,
            as: 'user',
            attributes: ['id', 'firstName', 'email'],
          },
        ],
        order: [
          ['score', 'DESC'],
          ['best_streak', 'DESC'],
          ['achieved_at', 'ASC'],
        ],
        limit: 10,
      }),
    ]);

    const metricsBySession = rounds.reduce((accumulator, round) => {
      if (!accumulator[round.sessionId]) {
        accumulator[round.sessionId] = {
          roundsPlayed: 0,
          correctGuesses: 0,
        };
      }

      if (round.state !== 'pending') {
        accumulator[round.sessionId].roundsPlayed += 1;
      }

      if (round.is_correct) {
        accumulator[round.sessionId].correctGuesses += 1;
      }

      return accumulator;
    }, {});

    const entryBySession = entries.reduce((accumulator, entry) => {
      accumulator[entry.sessionId] = entry;
      return accumulator;
    }, {});

    return {
      sessions: sessions.map((session) => ({
        ...serializeSession(session, metricsBySession[session.id]),
        leaderboardEntry: entryBySession[session.id]
          ? {
              score: entryBySession[session.id].score,
              bestStreak: entryBySession[session.id].best_streak,
            }
          : null,
      })),
      leaderboard: leaderboard.map((entry) => ({
        id: entry.id,
        score: entry.score,
        bestStreak: entry.best_streak,
        roundsSurvived: entry.rounds_survived,
        achievedAt: entry.achieved_at,
        user: {
          id: entry.user?.id,
          name: entry.user?.firstName || entry.user?.email || 'Unknown player',
        },
      })),
    };
  }

  static async getSessionDetail(sessionId, currentUser) {
    const sessionRecord = await db.game_sessions.findOne({
      where: {
        id: sessionId,
        userId: currentUser.id,
      },
    });

    if (!sessionRecord) {
      throw gameplayError('Game session not found.', 404);
    }

    const [metrics, rounds, leaderboardEntry] = await Promise.all([
      getSessionMetrics(sessionId),
      db.game_rounds.findAll({
        where: {
          sessionId,
          state: {
            [Op.not]: 'pending',
          },
        },
        order: [['round_number', 'ASC']],
      }),
      db.leaderboard_entries.findOne({
        where: {
          sessionId,
          board_type: 'all_time',
        },
      }),
    ]);

    return {
      session: {
        ...serializeSession(sessionRecord, metrics),
        leaderboardEntry: leaderboardEntry
          ? {
              score: leaderboardEntry.score,
              bestStreak: leaderboardEntry.best_streak,
              roundsSurvived: leaderboardEntry.rounds_survived,
              achievedAt: leaderboardEntry.achieved_at,
            }
          : null,
      },
      rounds: await Promise.all(
        rounds.map(async (roundRecord) => serializeResolvedRound(await hydrateRound(roundRecord))),
      ),
    };
  }
};
