import { Prisma, type Db } from '@pintrush/db';
import { SYMMETRIES, nicknameKey, type DomainEvent, type GameState } from '@pintrush/engine';
import { log } from '../logger';
import { toEventRecord } from './prismaCatalog';
import { StaleSessionError, type EventRecord, type PersistBatch, type PlayerRecord, type RoomRecord, type SessionRecord, type SessionStore, type VenueRecord } from './types';

type Tx = Parameters<Parameters<Db['$transaction']>[0]>[0];

function isUniqueViolation(err: unknown): boolean {
  return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'P2002';
}

const json = (v: unknown) => JSON.parse(JSON.stringify(v)) as object;

export class PrismaSessionStore implements SessionStore {
  readonly durable = true;

  constructor(private readonly db: Db) {}

  async ensureAlwaysOnEvent(room: RoomRecord, venue: VenueRecord, businessDate: string): Promise<EventRecord> {
    const existing = await this.db.event.findFirst({ where: { roomId: room.id, kind: 'ALWAYS_ON', businessDate } });
    if (existing) return toEventRecord(existing);
    try {
      const created = await this.db.event.create({
        data: {
          venueId: venue.id,
          roomId: room.id,
          kind: 'ALWAYS_ON',
          status: 'SCHEDULED',
          name: `Always-on · ${businessDate}`,
          businessDate,
          config: { mergeSolos: venue.settings.mergeSolos },
        },
      });
      return toEventRecord(created);
    } catch (err) {
      // Another process won the race for the (roomId, kind, businessDate) unique key.
      if (!isUniqueViolation(err)) throw err;
      const row = await this.db.event.findFirstOrThrow({ where: { roomId: room.id, kind: 'ALWAYS_ON', businessDate } });
      return toEventRecord(row);
    }
  }

  async findSessionByEvent(eventId: string): Promise<SessionRecord | null> {
    const s = await this.db.gameSession.findUnique({ where: { eventId } });
    return s ? this.toSession(s) : null;
  }

  async createGameSession(event: EventRecord, state: GameState): Promise<SessionRecord> {
    const s = await this.db.gameSession.create({
      data: {
        id: state.sessionId,
        venueId: event.venueId,
        roomId: event.roomId,
        eventId: event.id,
        status: state.status,
        state: json(state),
      },
    });
    return this.toSession(s);
  }

  async markEventCancelled(eventId: string): Promise<void> {
    await this.db.event.updateMany({ where: { id: eventId, status: { notIn: ['CANCELLED'] } }, data: { status: 'CANCELLED', version: { increment: 1 } } });
  }

  async markEventLive(eventId: string): Promise<void> {
    await this.db.event.updateMany({ where: { id: eventId, status: { in: ['SCHEDULED', 'PAUSED'] } }, data: { status: 'LIVE', version: { increment: 1 } } });
  }

  async loadLiveSessions() {
    const rows = await this.db.gameSession.findMany({
      where: { status: { in: ['WAITING', 'RUNNING', 'PAUSED'] }, state: { not: undefined } },
      include: { event: true },
    });
    return rows.filter((r) => r.state !== null).map((r) => ({ session: this.toSession(r), event: toEventRecord(r.event) }));
  }

  async listPlayers(gameSessionId: string): Promise<PlayerRecord[]> {
    const rows = await this.db.playerSession.findMany({
      where: { gameSessionId },
      select: { id: true, nickname: true, deviceHash: true, tokenVersion: true, status: true },
    });
    return rows;
  }

  async usedQuestionIds(gameSessionId: string): Promise<string[]> {
    const rounds = await this.db.round.findMany({ where: { gameSessionId }, select: { questionIds: true } });
    return rounds.flatMap((r) => r.questionIds);
  }

  async createPlayerSession(input: Parameters<SessionStore['createPlayerSession']>[0]) {
    try {
      const row = await this.db.playerSession.create({ data: input, select: { id: true } });
      return row;
    } catch (err) {
      if (isUniqueViolation(err)) return 'NICKNAME_TAKEN' as const;
      throw err;
    }
  }

  async bumpTokenVersion(playerSessionId: string): Promise<number> {
    const row = await this.db.playerSession.update({
      where: { id: playerSessionId },
      data: { tokenVersion: { increment: 1 }, lastSeenAt: new Date() },
      select: { tokenVersion: true },
    });
    return row.tokenVersion;
  }

  async touchPlayer(playerSessionId: string, at: number): Promise<void> {
    await this.db.playerSession.updateMany({ where: { id: playerSessionId }, data: { lastSeenAt: new Date(at) } });
  }

  async persist(batches: PersistBatch[], expectedVersion: number): Promise<number> {
    if (batches.length === 0) return expectedVersion;
    const last = batches[batches.length - 1]!;
    await this.db.$transaction(
      async (tx) => {
        for (const batch of batches) {
          await this.applyBatch(tx, batch);
        }
        // Optimistic concurrency: only the process that last wrote this snapshot may write the next one.
        const r = await tx.gameSession.updateMany({
          where: { id: last.ctx.gameSessionId, stateVersion: expectedVersion },
          data: { state: json(last.state), seq: last.seq, status: last.state.status, stateVersion: { increment: 1 } },
        });
        if (r.count === 0) throw new StaleSessionError(last.ctx.gameSessionId);
      },
      { timeout: 20_000, maxWait: 10_000 },
    );
    return expectedVersion + 1;
  }

  /**
   * Spec §10: guest data is purged after 90 days. Nicknames and player ids also live in session
   * snapshots, leaderboard snapshots and game audit rows, so those go too.
   */
  async purgeGuestData(olderThanMs: number) {
    const cutoff = new Date(olderThanMs);
    // Cascades remove boards, tiles, answers, scores, power-ups and team memberships.
    const players = await this.db.playerSession.deleteMany({ where: { joinedAt: { lt: cutoff } } });
    const audits = await this.db.auditEvent.deleteMany({
      where: { createdAt: { lt: cutoff }, OR: [{ actorType: 'GUEST' }, { gameSessionId: { not: null } }] },
    });
    await this.db.leaderboardSnapshot.deleteMany({ where: { createdAt: { lt: cutoff } } });
    await this.db.gameSession.updateMany({ where: { status: 'COMPLETED', endedAt: { lt: cutoff } }, data: { state: Prisma.DbNull } });
    await this.db.team.deleteMany({ where: { createdAt: { lt: cutoff } } });
    return { players: players.count, audits: audits.count };
  }

  // ───────────────────────────── event → row mapping ─────────────────────────────

  private async applyBatch(tx: Tx, batch: PersistBatch): Promise<void> {
    const { ctx, command, actor, events } = batch;
    await tx.auditEvent.create({
      data: {
        venueId: ctx.venueId,
        roomId: ctx.roomId,
        gameSessionId: ctx.gameSessionId,
        seq: batch.seq,
        actorType: actor.type,
        actorId: actor.id,
        type: command.type,
        payload: json({ command, events: events.map((e) => e.type) }),
      },
    });
    for (const ev of events) await this.applyEvent(tx, batch, ev);
  }

  private async applyEvent(tx: Tx, batch: PersistBatch, ev: DomainEvent): Promise<void> {
    const { ctx } = batch;
    const venueId = ctx.venueId;
    switch (ev.type) {
      case 'SessionStarted':
        await tx.gameSession.update({ where: { id: ctx.gameSessionId }, data: { status: 'RUNNING', startedAt: new Date(ev.at) } });
        await tx.event.update({ where: { id: ctx.eventId }, data: { status: 'LIVE', startedAt: new Date(ev.at), version: { increment: 1 } } });
        return;
      case 'SessionPaused':
        await tx.event.update({ where: { id: ctx.eventId }, data: { status: 'PAUSED', version: { increment: 1 } } });
        return;
      case 'SessionResumed':
        await tx.event.update({ where: { id: ctx.eventId }, data: { status: 'LIVE', version: { increment: 1 } } });
        return;
      case 'SessionCompleted': {
        await tx.gameSession.update({ where: { id: ctx.gameSessionId }, data: { status: 'COMPLETED', endedAt: new Date(ev.at) } });
        await tx.event.update({ where: { id: ctx.eventId }, data: { status: 'COMPLETED', endedAt: new Date(ev.at), version: { increment: 1 } } });
        if (ev.cancelledRoundId) {
          await tx.round.updateMany({ where: { id: ev.cancelledRoundId }, data: { status: 'CANCELLED', completedAt: new Date(ev.at) } });
        }
        await tx.leaderboardSnapshot.create({
          data: {
            venueId,
            roomId: ctx.roomId,
            gameSessionId: ctx.gameSessionId,
            scope: ctx.eventKind === 'ALWAYS_ON' ? 'NIGHTLY' : 'EVENT',
            scopeRef: ctx.eventKind === 'ALWAYS_ON' ? (ctx.businessDate ?? ctx.eventId) : ctx.eventId,
            entries: json(ev.standings),
          },
        });
        return;
      }
      case 'RoundStarted': {
        if (batch.command.type !== 'START_ROUND') return;
        const setup = batch.command.round;
        const pack = await tx.questionPack.findUniqueOrThrow({ where: { key: setup.packKey }, select: { id: true } });
        await tx.round.create({
          data: {
            id: ev.roundId,
            venueId,
            gameSessionId: ctx.gameSessionId,
            eventId: ctx.eventId,
            index: ev.index,
            kind: ev.kind,
            packId: pack.id,
            seed: setup.seed,
            baseLayoutRow: ev.baseLayoutRow,
            questionIds: setup.questions.map((q) => q.id),
            status: 'RUNNING',
            startedAt: new Date(ev.at),
          },
        });
        return;
      }
      case 'RoundCompleted':
        await tx.round.update({ where: { id: ev.roundId }, data: { status: 'COMPLETED', completedAt: new Date(ev.at) } });
        await tx.leaderboardSnapshot.create({
          data: {
            venueId,
            roomId: ctx.roomId,
            gameSessionId: ctx.gameSessionId,
            scope: 'ROUND',
            scopeRef: ev.roundId,
            entries: json({ summary: ev.summary, standings: ev.standings }),
          },
        });
        return;
      case 'TeamCreated':
        await tx.team.createMany({
          data: [{ id: ev.teamId, venueId, gameSessionId: ctx.gameSessionId, name: ev.name, nameKey: nicknameKey(ev.name) }],
          skipDuplicates: true,
        });
        return;
      case 'PlayerJoined':
        // The PlayerSession row is created before JOIN is dispatched; only team membership is new here.
        if (ev.teamId) await this.setMembership(tx, venueId, ev.playerId, ev.teamId);
        return;
      case 'PlayerTeamChanged':
        if (ev.teamId) await this.setMembership(tx, venueId, ev.playerId, ev.teamId);
        else await tx.teamMembership.deleteMany({ where: { playerSessionId: ev.playerId } });
        return;
      case 'PlayerRenamed':
        await tx.playerSession.update({
          where: { id: ev.playerId },
          data: { nickname: ev.nickname, nicknameKey: nicknameKey(ev.nickname), version: { increment: 1 } },
        });
        return;
      case 'PlayerKicked':
        await tx.playerSession.update({ where: { id: ev.playerId }, data: { status: 'KICKED', tokenVersion: { increment: 1000 }, version: { increment: 1 } } });
        return;
      case 'PlayerLeft':
        await tx.playerSession.update({ where: { id: ev.playerId }, data: { status: 'LEFT', version: { increment: 1 } } });
        return;
      case 'BoardDealt': {
        const board = await tx.playerBoard.upsert({
          where: { playerSessionId_roundId: { playerSessionId: ev.playerId, roundId: ev.roundId } },
          create: {
            venueId,
            playerSessionId: ev.playerId,
            roundId: ev.roundId,
            templateKey: SYMMETRIES[ev.symmetry]?.key ?? String(ev.symmetry),
            baseLayoutRow: ev.baseLayoutRow,
          },
          update: {},
          select: { id: true },
        });
        await tx.boardTile.createMany({
          data: ev.tiles.map((t) => ({ venueId, playerBoardId: board.id, position: t.position, category: t.category })),
          skipDuplicates: true,
        });
        return;
      }
      case 'AnswerAccepted': {
        const r = await tx.answerSubmission.createMany({
          data: [
            {
              venueId,
              playerSessionId: ev.playerId,
              roundId: ev.roundId,
              questionId: ev.questionId,
              optionId: ev.optionId,
              wildcardPosition: ev.wildcardPosition,
              openedAt: new Date(ev.openedAt),
              receivedAt: new Date(ev.receivedAt),
              elapsedMs: ev.elapsedMs,
              idempotencyKey: ev.idempotencyKey,
            },
          ],
          skipDuplicates: true,
        });
        // 0 rows means a row for this answer (or key) already existed: expected on a replayed batch, but
        // worth knowing about otherwise (answer history and latency metrics read these rows).
        if (r.count === 0) log.warn('answer row already existed; nothing inserted', { roundId: ev.roundId, questionId: ev.questionId, playerId: ev.playerId });
        return;
      }
      case 'AnswerGraded':
        await tx.answerSubmission.updateMany({
          where: { playerSessionId: ev.playerId, roundId: ev.roundId, questionId: ev.questionId },
          data: { isCorrect: ev.correct, points: ev.points, speedBonus: ev.speedBonus },
        });
        return;
      case 'TileActivated':
        await tx.boardTile.updateMany({
          where: { board: { playerSessionId: ev.playerId, roundId: ev.roundId }, position: ev.position, activatedAt: null },
          data: { activatedAt: new Date(ev.at), activationSource: ev.source, questionId: ev.questionId },
        });
        return;
      case 'ScoreAwarded':
        await tx.scoreEvent.createMany({
          data: [
            {
              venueId,
              playerSessionId: ev.playerId,
              gameSessionId: ctx.gameSessionId,
              roundId: ev.roundId,
              kind: ev.kind,
              refId: ev.refId,
              points: ev.points,
              createdAt: new Date(ev.at),
            },
          ],
          skipDuplicates: true,
        });
        return;
      case 'PowerUpGranted':
        await tx.powerUpGrant.createMany({
          data: [
            {
              venueId,
              playerSessionId: ev.playerId,
              eventId: ctx.eventId,
              gameSessionId: ctx.gameSessionId,
              roundId: ev.roundId,
              sourceRef: ev.sourceRef,
            },
          ],
          skipDuplicates: true,
        });
        return;
      case 'PowerUpUsed': {
        await tx.powerUpUse.createMany({
          data: [
            {
              venueId,
              playerSessionId: ev.playerId,
              eventId: ctx.eventId,
              type: ev.powerUp,
              roundId: ev.roundId,
              questionId: ev.questionId,
              payload: json(ev.payload),
              idempotencyKey: ev.idempotencyKey,
            },
          ],
          skipDuplicates: true,
        });
        if (ev.powerUp === 'CATEGORY_SWAP' && ev.payload.from !== undefined && ev.payload.to !== undefined) {
          await this.persistSwap(tx, batch.state, ev.playerId, ev.roundId, ev.payload.from, ev.payload.to);
        }
        return;
      }
      // Pure timeline/presentation events: covered by the audit row and the snapshot.
      case 'RoundDue':
      case 'QuestionPending':
      case 'QuestionOpened':
      case 'QuestionLocked':
      case 'QuestionRevealed':
      case 'QuestionCompleted':
      case 'PredictionMade':
      case 'RoundOver':
        return;
      default: {
        const exhaustive: never = ev;
        void exhaustive;
      }
    }
  }

  private async setMembership(tx: Tx, venueId: string, playerSessionId: string, teamId: string) {
    await tx.teamMembership.upsert({
      where: { playerSessionId },
      create: { venueId, teamId, playerSessionId },
      update: { teamId },
    });
  }

  /** Mirror the swapped categories from the authoritative engine state. */
  private async persistSwap(tx: Tx, state: GameState, playerId: string, roundId: string, from: number, to: number) {
    const board = state.round?.id === roundId ? state.round.boards[playerId] : undefined;
    if (!board) return;
    for (const pos of [from, to]) {
      const tile = board.tiles[pos];
      if (!tile) continue;
      await tx.boardTile.updateMany({
        where: { board: { playerSessionId: playerId, roundId }, position: pos },
        data: { category: tile.category },
      });
    }
  }

  private toSession(s: { id: string; venueId: string; roomId: string; eventId: string; status: SessionRecord['status']; seq: number; stateVersion: number; state: unknown }): SessionRecord {
    return {
      id: s.id,
      venueId: s.venueId,
      roomId: s.roomId,
      eventId: s.eventId,
      status: s.status,
      seq: s.seq,
      stateVersion: s.stateVersion,
      state: (s.state as GameState | null) ?? null,
    };
  }
}
