/** Tenant isolation, optimistic concurrency and metrics for the venue-scoped helpers. */
import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { ZodError } from 'zod';
import { authenticateVenueUser, hashPassword } from '../src/auth';
import type { Db } from '../src/client';
import { ConflictError, NotFoundError } from '../src/errors';
import { assertVenueOwns, forVenue } from '../src/tenant';
import {
  createPlayer,
  createSessionFixture,
  createTestDb,
  createVenueFixture,
  deleteVenues,
  ensureTestSchema,
  uniqueSuffix,
  type SessionFixture,
  type VenueFixture,
} from './helpers';

let db: Db;
let A: VenueFixture;
let B: VenueFixture;
let sessionA: SessionFixture;
let sessionB: SessionFixture;
let eventB: string;

beforeAll(async () => {
  ensureTestSchema();
  db = createTestDb();
  A = await createVenueFixture(db, 'tenant-a');
  B = await createVenueFixture(db, 'tenant-b');
  sessionA = await createSessionFixture(db, A);
  sessionB = await createSessionFixture(db, B);
  eventB = (
    await forVenue(db, B.venueId).events.createChampionship({
      roomId: B.roomId,
      name: 'B Championship',
      scheduledAt: new Date('2026-11-06T02:00:00Z'),
    })
  ).id;
  await createPlayer(db, A, sessionA, { nickname: 'Alice' });
  await createPlayer(db, B, sessionB, { nickname: 'Bob' });
});

afterAll(async () => {
  await deleteVenues(db, [A.venueId, B.venueId]);
  await db.$disconnect();
});

describe('tenant isolation', () => {
  it('scopes venue and room reads', async () => {
    const a = forVenue(db, A.venueId);
    expect((await a.venue.get())?.id).toBe(A.venueId);
    expect((await a.rooms.list()).map((r) => r.id)).toEqual([A.roomId]);
    expect((await a.rooms.get(A.roomId))?.id).toBe(A.roomId);
    expect(await a.rooms.get(B.roomId)).toBeNull();
    expect(await a.rooms.get('not-a-uuid')).toBeNull();
    expect(await a.rooms.get(randomUUID())).toBeNull();
  });

  it('never lists or returns another venue’s events', async () => {
    const a = forVenue(db, A.venueId);
    const events = await a.events.list();
    expect(events.length).toBeGreaterThan(0);
    expect(events.every((e) => e.venueId === A.venueId)).toBe(true);
    expect(events.map((e) => e.id)).not.toContain(eventB);
    expect(await a.events.get(eventB)).toBeNull();
    expect(await a.events.list({ roomId: B.roomId })).toEqual([]);
  });

  it('never updates another venue’s event', async () => {
    const a = forVenue(db, A.venueId);
    expect(await a.events.update(eventB, { name: 'Hijacked', status: 'CANCELLED' }, 0)).toBeNull();
    const untouched = await db.event.findUniqueOrThrow({ where: { id: eventB } });
    expect(untouched).toMatchObject({ name: 'B Championship', status: 'SCHEDULED', version: 0 });
  });

  it('refuses to create events in another venue’s room or credit another venue’s user', async () => {
    const a = forVenue(db, A.venueId);
    const scheduledAt = new Date('2026-11-06T02:00:00Z');
    await expect(a.events.createChampionship({ roomId: B.roomId, name: 'Sneaky', scheduledAt })).rejects.toBeInstanceOf(NotFoundError);
    const userB = await db.venueUser.create({
      data: { venueId: B.venueId, email: `b-${uniqueSuffix()}@test.local`, name: 'B host', passwordHash: 'x' },
    });
    await expect(
      a.events.createChampionship({ roomId: A.roomId, name: 'Sneaky', scheduledAt, createdById: userB.id }),
    ).rejects.toBeInstanceOf(NotFoundError);
    expect(await db.event.count({ where: { name: 'Sneaky' } })).toBe(0);
  });

  it('only shows this venue’s players, without device hashes', async () => {
    const players = await forVenue(db, A.venueId).players.recent();
    expect(players.map((p) => p.nickname)).toEqual(['Alice']);
    expect(players[0]).not.toHaveProperty('deviceHash');
  });

  it('assertVenueOwns accepts own rows and rejects foreign or malformed ids', async () => {
    await expect(assertVenueOwns(db, A.venueId, { roomId: A.roomId })).resolves.toBeUndefined();
    await expect(assertVenueOwns(db, A.venueId, { eventId: sessionA.eventId })).resolves.toBeUndefined();
    await expect(assertVenueOwns(db, A.venueId, { roomId: B.roomId })).rejects.toBeInstanceOf(NotFoundError);
    await expect(assertVenueOwns(db, A.venueId, { eventId: eventB })).rejects.toBeInstanceOf(NotFoundError);
    await expect(assertVenueOwns(db, A.venueId, { roomId: 'garbage' })).rejects.toBeInstanceOf(NotFoundError);
  });

  it('rejects a malformed venue id up front', () => {
    expect(() => forVenue(db, 'nope')).toThrow(TypeError);
  });
});

describe('championship events with optimistic concurrency', () => {
  it('creates with the default plan and bumps the version on every update', async () => {
    const a = forVenue(db, A.venueId);
    const ev = await a.events.createChampionship({ roomId: A.roomId, name: '  Trivia Night ', scheduledAt: new Date('2026-11-07T00:00:00Z') });
    expect(ev).toMatchObject({ kind: 'CHAMPIONSHIP', status: 'SCHEDULED', name: 'Trivia Night', version: 0, room: { id: A.roomId } });
    expect((ev.config as { plan: unknown[] }).plan).toHaveLength(9);

    const updated = await a.events.update(ev.id, { name: 'Trivia Night II', config: { mergeSolos: true } }, 0);
    expect(updated).toMatchObject({ name: 'Trivia Night II', version: 1 });
    expect(updated?.config).toMatchObject({ mergeSolos: true });

    const stale = a.events.update(ev.id, { name: 'Stale write' }, 0);
    await expect(stale).rejects.toBeInstanceOf(ConflictError);
    await expect(a.events.update(ev.id, { name: 'Stale write' }, 0)).rejects.toMatchObject({ expectedVersion: 0, actualVersion: 1 });
    expect((await a.events.get(ev.id))?.name).toBe('Trivia Night II');
  });

  it('lets exactly one of two concurrent writers win', async () => {
    const a = forVenue(db, A.venueId);
    const ev = await a.events.createChampionship({ roomId: A.roomId, name: 'Race', scheduledAt: new Date('2026-11-07T00:00:00Z') });
    const results = await Promise.allSettled([
      a.events.update(ev.id, { name: 'Writer 1' }, 0),
      a.events.update(ev.id, { name: 'Writer 2' }, 0),
    ]);
    expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
    const rejected = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
    expect(rejected).toHaveLength(1);
    expect(rejected[0]!.reason).toBeInstanceOf(ConflictError);
    expect((await a.events.get(ev.id))?.version).toBe(1);
  });

  it('validates input', async () => {
    const a = forVenue(db, A.venueId);
    const scheduledAt = new Date('2026-11-07T00:00:00Z');
    await expect(a.events.createChampionship({ roomId: A.roomId, name: '   ', scheduledAt })).rejects.toBeInstanceOf(ZodError);
    await expect(
      a.events.createChampionship({ roomId: A.roomId, name: 'Bad plan', scheduledAt, config: { plan: [] } }),
    ).rejects.toBeInstanceOf(ZodError);
    await expect(
      a.events.createChampionship({ roomId: A.roomId, name: 'Bad date', scheduledAt: new Date('nope') }),
    ).rejects.toBeInstanceOf(ZodError);
  });

  it('filters events by status', async () => {
    const a = forVenue(db, A.venueId);
    const ev = await a.events.createChampionship({ roomId: A.roomId, name: 'To cancel', scheduledAt: new Date('2026-12-01T00:00:00Z') });
    await a.events.update(ev.id, { status: 'CANCELLED' }, 0);
    const cancelled = await a.events.list({ status: 'CANCELLED' });
    expect(cancelled.map((e) => e.id)).toEqual([ev.id]);
    const open = await a.events.list({ status: ['SCHEDULED', 'LIVE'] });
    expect(open.map((e) => e.id)).not.toContain(ev.id);
  });

  it('updates venue settings with a version check', async () => {
    const a = forVenue(db, A.venueId);
    const venue = await a.venue.get();
    const next = await a.venue.updateSettings({ mergeSolos: true }, venue!.version);
    expect(next.version).toBe(venue!.version + 1);
    expect((await a.venue.settings())?.mergeSolos).toBe(true);
    await expect(a.venue.updateSettings({ mergeSolos: false }, venue!.version)).rejects.toBeInstanceOf(ConflictError);
  });
});

describe('metrics', () => {
  it('counts guests, joins, rounds, repeat players and events for one venue only', async () => {
    const venue = await createVenueFixture(db, 'metrics-a');
    const other = await createVenueFixture(db, 'metrics-b');
    try {
      const champ = await createSessionFixture(db, venue, { kind: 'CHAMPIONSHIP', startedAt: new Date('2026-10-02T23:00:00Z') });
      const nightly = await createSessionFixture(db, venue, {
        kind: 'ALWAYS_ON',
        businessDate: '2026-10-03',
        startedAt: new Date('2026-10-03T15:00:00Z'),
      });
      await createSessionFixture(db, venue, { kind: 'CHAMPIONSHIP', startedAt: new Date('2026-09-15T23:00:00Z') });
      const otherSession = await createSessionFixture(db, other, { kind: 'CHAMPIONSHIP', startedAt: new Date('2026-10-05T23:00:00Z') });

      const d = (iso: string) => new Date(iso);
      // dev-1: Fri 20:00 and Sat 01:30 EDT → one business date (Oct 2).
      await createPlayer(db, venue, champ, { deviceHash: 'dev-1', joinedAt: d('2026-10-03T00:00:00Z') });
      await createPlayer(db, venue, nightly, { deviceHash: 'dev-1', joinedAt: d('2026-10-03T05:30:00Z') });
      // dev-2: Oct 2 and Oct 3 evenings → repeat player.
      await createPlayer(db, venue, champ, { deviceHash: 'dev-2', joinedAt: d('2026-10-03T00:00:00Z') });
      await createPlayer(db, venue, nightly, { deviceHash: 'dev-2', joinedAt: d('2026-10-04T00:00:00Z') });
      // dev-3: once in range, once before it.
      await createPlayer(db, venue, nightly, { deviceHash: 'dev-3', joinedAt: d('2026-10-10T00:00:00Z') });
      await createPlayer(db, venue, champ, { deviceHash: 'dev-3', joinedAt: d('2026-09-20T00:00:00Z') });
      // No device hash: counts as a join, not as a unique guest.
      await createPlayer(db, venue, nightly, { deviceHash: null, joinedAt: d('2026-10-10T01:00:00Z') });
      // Other venue: same and new devices on several dates must not leak into venue's metrics.
      await createPlayer(db, other, otherSession, { deviceHash: 'dev-2', joinedAt: d('2026-10-05T00:00:00Z') });
      await createPlayer(db, other, otherSession, { deviceHash: 'dev-9', joinedAt: d('2026-10-06T00:00:00Z') });
      await createPlayer(db, other, otherSession, { deviceHash: 'dev-9', joinedAt: d('2026-10-09T00:00:00Z') });

      await db.round.update({ where: { id: champ.roundId }, data: { status: 'COMPLETED', completedAt: d('2026-10-02T23:30:00Z') } });
      await db.round.update({ where: { id: nightly.roundId }, data: { status: 'COMPLETED', completedAt: d('2026-10-03T16:00:00Z') } });
      await db.round.update({ where: { id: otherSession.roundId }, data: { status: 'COMPLETED', completedAt: d('2026-10-05T23:30:00Z') } });

      const range = { from: d('2026-10-01T00:00:00Z'), to: d('2026-11-01T00:00:00Z') };
      const m = await forVenue(db, venue.venueId).metrics(range);
      expect(m).toEqual({
        range,
        uniqueGuests: 3,
        joins: 6,
        roundsCompleted: 2,
        repeatPlayers: 1,
        eventsByKind: { ALWAYS_ON: 1, CHAMPIONSHIP: 1 },
      });

      const otherMetrics = await forVenue(db, other.venueId).metrics(range);
      expect(otherMetrics).toMatchObject({ uniqueGuests: 2, joins: 3, roundsCompleted: 1, repeatPlayers: 1 });

      const empty = await forVenue(db, venue.venueId).metrics({ from: d('2027-01-01T00:00:00Z'), to: d('2027-02-01T00:00:00Z') });
      expect(empty).toMatchObject({ uniqueGuests: 0, joins: 0, roundsCompleted: 0, repeatPlayers: 0, eventsByKind: { ALWAYS_ON: 0, CHAMPIONSHIP: 0 } });
    } finally {
      await deleteVenues(db, [venue.venueId, other.venueId]);
    }
  });
});

describe('host sign-in', () => {
  it('authenticates by normalized e-mail and password, stamping lastLoginAt', async () => {
    const email = `host-${uniqueSuffix()}@test.local`;
    await db.venueUser.create({
      data: { venueId: A.venueId, email, name: 'Host', role: 'OWNER', passwordHash: await hashPassword('right-password') },
    });
    const user = await authenticateVenueUser(db, `  ${email.toUpperCase()} `, 'right-password');
    expect(user?.email).toBe(email);
    expect(user?.lastLoginAt).toBeInstanceOf(Date);
    expect(await authenticateVenueUser(db, email, 'wrong-password')).toBeNull();
    expect(await authenticateVenueUser(db, `nobody-${uniqueSuffix()}@test.local`, 'right-password')).toBeNull();
  });
});
