/**
 * Server-side data helpers for the host dashboard (and the admin views that reuse it).
 *
 * Venue-owned rows are always filtered by `venueId` here, exactly like forVenue() in @pintrush/db;
 * these cover the few reads/writes forVenue does not offer (TV link reset, daily join series, final
 * standings). Server-only: imported by server components and server actions.
 */
import { z } from 'zod';
import { getDb, isUuid, NotFoundError } from '@pintrush/db';
import type { Standing } from '@pintrush/engine';

/** Where the web server reaches the game server (an internal URL can differ from the public one). */
export function gameServerBaseUrl(): string {
  return (process.env.GAME_SERVER_INTERNAL_URL || process.env.NEXT_PUBLIC_GAME_SERVER_URL || 'http://localhost:4000').replace(/\/$/, '');
}

const RoomPublicSchema = z.object({
  roomId: z.string(),
  active: z.boolean(),
  activeEventKind: z.enum(['ALWAYS_ON', 'CHAMPIONSHIP']).nullable(),
  activeEventName: z.string().nullable(),
});

export type RoomLiveStatus =
  | { reachable: true; active: boolean; activeEventKind: 'ALWAYS_ON' | 'CHAMPIONSHIP' | null; activeEventName: string | null }
  | { reachable: false };

/** Live status from the game server, with a short timeout so a down game server never hangs the page. */
export async function roomLiveStatus(roomId: string, timeoutMs = 1500): Promise<RoomLiveStatus> {
  if (!isUuid(roomId)) return { reachable: false };
  try {
    const res = await fetch(`${gameServerBaseUrl()}/rooms/${roomId}/public`, { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
    if (!res.ok) return { reachable: false };
    const parsed = RoomPublicSchema.safeParse(await res.json());
    if (!parsed.success) return { reachable: false };
    const { active, activeEventKind, activeEventName } = parsed.data;
    return { reachable: true, active, activeEventKind, activeEventName };
  } catch {
    return { reachable: false };
  }
}

export interface PackOption {
  key: string;
  name: string;
  kind: 'GENERAL' | 'MUSIC';
  questionCount: number;
  ownedByVenue: boolean;
}

/** PUBLISHED packs a venue may use: platform packs (venueId null) plus the venue's own. */
export async function visiblePacks(venueId: string): Promise<PackOption[]> {
  const rows = await getDb().questionPack.findMany({
    where: { status: 'PUBLISHED', OR: [{ venueId: null }, { venueId }] },
    select: { key: true, name: true, kind: true, venueId: true, _count: { select: { questions: { where: { status: 'ACTIVE' } } } } },
    orderBy: [{ kind: 'asc' }, { name: 'asc' }],
  });
  return rows.map((p) => ({ key: p.key, name: p.name, kind: p.kind, questionCount: p._count.questions, ownedByVenue: p.venueId !== null }));
}

/**
 * Revoke every TV link of a room by bumping its token version (venue-scoped). TVs using an old link
 * are refused the next time they connect. Throws NotFoundError for a room of another venue.
 */
export async function resetRoomTvLink(venueId: string, roomId: string): Promise<{ name: string; tvTokenVersion: number }> {
  if (!isUuid(venueId) || !isUuid(roomId)) throw new NotFoundError('Room', roomId);
  const db = getDb();
  return db.$transaction(async (tx) => {
    const { count } = await tx.room.updateMany({
      where: { id: roomId, venueId },
      data: { tvTokenVersion: { increment: 1 }, version: { increment: 1 } },
    });
    if (count === 0) throw new NotFoundError('Room', roomId);
    return tx.room.findFirstOrThrow({ where: { id: roomId, venueId }, select: { name: true, tvTokenVersion: true } });
  });
}

export interface DailyPoint {
  /** Venue business date (YYYY-MM-DD, 5 am cutoff). */
  date: string;
  joins: number;
  guests: number;
}

/** Joins and distinct guests per venue business date in [from, to). */
export async function dailyJoins(venueId: string, timeZone: string, from: Date, to: Date): Promise<DailyPoint[]> {
  if (!isUuid(venueId)) return [];
  // Same UTC → venue-local conversion and 5 am cutoff as forVenue().metrics().
  const rows = await getDb().$queryRaw<{ d: Date; joins: number; guests: number }[]>`
    SELECT ((("joinedAt" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}::text) - make_interval(hours => 5))::date AS d,
           COUNT(*)::int AS joins,
           COUNT(DISTINCT "deviceHash")::int AS guests
    FROM "PlayerSession"
    WHERE "venueId" = ${venueId}::uuid
      AND "joinedAt" >= (${from.toISOString()}::timestamptz AT TIME ZONE 'UTC')
      AND "joinedAt" < (${to.toISOString()}::timestamptz AT TIME ZONE 'UTC')
    GROUP BY d
    ORDER BY d`;
  return rows.map((r) => ({ date: r.d.toISOString().slice(0, 10), joins: Number(r.joins), guests: Number(r.guests) }));
}

/** Final standings saved when an event's session completed (null if it never ran). */
export async function finalStandings(venueId: string, eventId: string): Promise<Standing[] | null> {
  if (!isUuid(venueId) || !isUuid(eventId)) return null;
  const db = getDb();
  const session = await db.gameSession.findFirst({ where: { eventId, venueId }, select: { id: true } });
  if (!session) return null;
  const snap = await db.leaderboardSnapshot.findFirst({
    where: { venueId, gameSessionId: session.id, scope: { in: ['EVENT', 'NIGHTLY'] } },
    orderBy: { createdAt: 'desc' },
    select: { entries: true },
  });
  return Array.isArray(snap?.entries) ? (snap.entries as unknown as Standing[]) : null;
}

/** Player count of an event's session (for listings), venue-scoped. */
export async function eventPlayerCounts(venueId: string, eventIds: string[]): Promise<Map<string, number>> {
  const ids = eventIds.filter(isUuid);
  if (!isUuid(venueId) || ids.length === 0) return new Map();
  const rows = await getDb().playerSession.groupBy({ by: ['eventId'], where: { venueId, eventId: { in: ids } }, _count: { _all: true } });
  return new Map(rows.map((r) => [r.eventId, r._count._all]));
}
