/**
 * Venue-scoped data access for the host dashboard and host commands.
 *
 * Every query here filters by `venueId`, and every by-id lookup returns null (or throws
 * NotFoundError for writes) for a row that belongs to another venue, so a host can never read or
 * change another tenant's data by guessing ids. Prefer these helpers over raw `db.*` calls in
 * anything a venue user can reach.
 */
import { z } from 'zod';
import type { PrismaClient } from './generated/prisma/client';
import type { EventKind, EventStatus, Prisma } from './generated/prisma/client';
import { ConflictError, NotFoundError } from './errors';
import {
  parseEventConfig,
  parseVenueSettings,
  type EventConfigInput,
  type VenueSettings,
  type VenueSettingsInput,
} from './settings';

type Db = PrismaClient;

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/** True for a syntactically valid UUID. Ids from URLs are checked first so Postgres never sees garbage. */
export function isUuid(value: unknown): value is string {
  return typeof value === 'string' && UUID_RE.test(value);
}

/** Business-date cutoff used for repeat-player metrics (same rule as always-on nights). */
const BUSINESS_DAY_CUTOFF_HOUR = 5;

const EVENT_KINDS = ['ALWAYS_ON', 'CHAMPIONSHIP'] as const satisfies readonly EventKind[];

const EventNameSchema = z.string().trim().min(1, 'Event name is required').max(80);
const ScheduledAtSchema = z.date().refine((d) => !Number.isNaN(d.getTime()), 'Invalid date');
const EVENT_STATUSES = ['SCHEDULED', 'LIVE', 'PAUSED', 'COMPLETED', 'CANCELLED'] as const satisfies readonly EventStatus[];
const EventStatusSchema = z.enum(EVENT_STATUSES);
const VersionSchema = z.int().min(0);

const eventInclude = {
  room: { select: { id: true, name: true, slug: true } },
} as const satisfies Prisma.EventInclude;

export type VenueEvent = Prisma.EventGetPayload<{ include: typeof eventInclude }>;

const recentPlayerSelect = {
  id: true,
  nickname: true,
  status: true,
  roomId: true,
  eventId: true,
  gameSessionId: true,
  joinedAt: true,
  lastSeenAt: true,
} as const satisfies Prisma.PlayerSessionSelect;

export type RecentPlayer = Prisma.PlayerSessionGetPayload<{ select: typeof recentPlayerSelect }>;

export interface EventListOptions {
  status?: EventStatus | EventStatus[];
  kind?: EventKind;
  roomId?: string;
  /** Sort by scheduledAt (nulls last); default 'desc'. */
  order?: 'asc' | 'desc';
  limit?: number;
}

export interface CreateChampionshipInput {
  roomId: string;
  name: string;
  scheduledAt: Date;
  /** Defaults are filled in (plan = default championship plan, mergeSolos = false). */
  config?: EventConfigInput;
  createdById?: string | null;
}

export interface EventUpdate {
  name?: string;
  scheduledAt?: Date | null;
  config?: EventConfigInput;
  status?: EventStatus;
}

/** Half-open time range [from, to). */
export interface MetricsRange {
  from: Date;
  to: Date;
}

export interface VenueMetrics {
  range: MetricsRange;
  /** Distinct device hashes among players who joined in the range. */
  uniqueGuests: number;
  /** PlayerSession rows created in the range. */
  joins: number;
  /** Rounds completed in the range. */
  roundsCompleted: number;
  /** Devices seen on two or more distinct venue business dates (5 am cutoff) within the range. */
  repeatPlayers: number;
  /** Events that started in the range, by kind. */
  eventsByKind: Record<EventKind, number>;
}

/** Round-trip through JSON so optional `undefined` fields never reach a Json column. */
function toJson(value: unknown): Prisma.InputJsonValue {
  return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}

function clampLimit(limit: number | undefined, fallback: number, max: number): number {
  if (limit === undefined || !Number.isFinite(limit)) return fallback;
  return Math.min(max, Math.max(1, Math.floor(limit)));
}

function assertRange(range: MetricsRange): void {
  const from = range.from.getTime();
  const to = range.to.getTime();
  if (Number.isNaN(from) || Number.isNaN(to) || from > to) {
    throw new RangeError('metrics range must have valid dates with from <= to');
  }
}

/** Venue-scoped helpers. Throws TypeError for a malformed venue id (a programming error). */
export function forVenue(db: Db, venueId: string) {
  if (!isUuid(venueId)) throw new TypeError(`forVenue: invalid venue id ${JSON.stringify(venueId)}`);

  const venue = {
    get() {
      return db.venue.findUnique({ where: { id: venueId } });
    },

    async settings(): Promise<VenueSettings | null> {
      const row = await db.venue.findUnique({ where: { id: venueId }, select: { settings: true } });
      return row ? parseVenueSettings(row.settings) : null;
    },

    /** Replace the venue's settings (validated, defaults filled). Throws ConflictError on a stale version. */
    async updateSettings(input: VenueSettingsInput, expectedVersion: number) {
      const settings = parseVenueSettings(input);
      const version = VersionSchema.parse(expectedVersion);
      return db.$transaction(async (tx) => {
        const { count } = await tx.venue.updateMany({
          where: { id: venueId, version },
          data: { settings: toJson(settings), version: { increment: 1 } },
        });
        if (count === 0) {
          const current = await tx.venue.findUnique({ where: { id: venueId }, select: { version: true } });
          if (!current) throw new NotFoundError('Venue', venueId);
          throw new ConflictError('Venue', venueId, version, current.version);
        }
        return tx.venue.findUniqueOrThrow({ where: { id: venueId } });
      });
    },
  };

  const rooms = {
    list() {
      return db.room.findMany({ where: { venueId }, orderBy: [{ createdAt: 'asc' }, { name: 'asc' }] });
    },

    async get(id: string) {
      if (!isUuid(id)) return null;
      return db.room.findFirst({ where: { id, venueId } });
    },
  };

  const events = {
    list(options: EventListOptions = {}) {
      const where: Prisma.EventWhereInput = { venueId };
      if (options.status !== undefined) {
        where.status = Array.isArray(options.status) ? { in: options.status } : options.status;
      }
      if (options.kind !== undefined) where.kind = options.kind;
      if (options.roomId !== undefined) {
        if (!isUuid(options.roomId)) return Promise.resolve([] as VenueEvent[]);
        where.roomId = options.roomId;
      }
      const sort = options.order ?? 'desc';
      return db.event.findMany({
        where,
        include: eventInclude,
        orderBy: [{ scheduledAt: { sort, nulls: 'last' } }, { createdAt: sort }],
        take: clampLimit(options.limit, 100, 500),
      });
    },

    async get(id: string): Promise<VenueEvent | null> {
      if (!isUuid(id)) return null;
      return db.event.findFirst({ where: { id, venueId }, include: eventInclude });
    },

    /** Schedule a championship in one of this venue's rooms. Throws NotFoundError for a foreign room. */
    async createChampionship(input: CreateChampionshipInput): Promise<VenueEvent> {
      const name = EventNameSchema.parse(input.name);
      const scheduledAt = ScheduledAtSchema.parse(input.scheduledAt);
      const config = parseEventConfig(input.config ?? {});
      await assertVenueOwns(db, venueId, { roomId: input.roomId });
      const createdById = input.createdById ?? null;
      if (createdById !== null) {
        const user = isUuid(createdById)
          ? await db.venueUser.findFirst({ where: { id: createdById, venueId }, select: { id: true } })
          : null;
        if (!user) throw new NotFoundError('VenueUser', createdById);
      }
      return db.event.create({
        data: {
          venueId,
          roomId: input.roomId,
          kind: 'CHAMPIONSHIP',
          status: 'SCHEDULED',
          name,
          scheduledAt,
          config: toJson(config),
          createdById,
        },
        include: eventInclude,
      });
    },

    /**
     * Update an event if it still has `expectedVersion`. Returns null when the event does not exist
     * for this venue; throws ConflictError when someone else changed it first.
     */
    async update(id: string, patch: EventUpdate, expectedVersion: number): Promise<VenueEvent | null> {
      if (!isUuid(id)) return null;
      const version = VersionSchema.parse(expectedVersion);
      const data: Prisma.EventUpdateManyMutationInput = { version: { increment: 1 } };
      if (patch.name !== undefined) data.name = EventNameSchema.parse(patch.name);
      if (patch.scheduledAt !== undefined) {
        data.scheduledAt = patch.scheduledAt === null ? null : ScheduledAtSchema.parse(patch.scheduledAt);
      }
      if (patch.config !== undefined) data.config = toJson(parseEventConfig(patch.config));
      if (patch.status !== undefined) data.status = EventStatusSchema.parse(patch.status);

      // In one transaction the UPDATE's row lock is held until we re-read, so the returned row is ours.
      return db.$transaction(async (tx) => {
        const { count } = await tx.event.updateMany({ where: { id, venueId, version }, data });
        if (count === 0) {
          const current = await tx.event.findFirst({ where: { id, venueId }, select: { version: true } });
          if (!current) return null;
          throw new ConflictError('Event', id, version, current.version);
        }
        return tx.event.findFirstOrThrow({ where: { id, venueId }, include: eventInclude });
      });
    },
  };

  const players = {
    /** Most recent guest joins at this venue (no device hashes: the dashboard never needs them). */
    recent(options: { limit?: number; roomId?: string } = {}): Promise<RecentPlayer[]> {
      const where: Prisma.PlayerSessionWhereInput = { venueId };
      if (options.roomId !== undefined) {
        if (!isUuid(options.roomId)) return Promise.resolve([]);
        where.roomId = options.roomId;
      }
      return db.playerSession.findMany({
        where,
        select: recentPlayerSelect,
        orderBy: { joinedAt: 'desc' },
        take: clampLimit(options.limit, 50, 500),
      });
    },
  };

  async function metrics(range: MetricsRange): Promise<VenueMetrics> {
    assertRange(range);
    const row = await db.venue.findUnique({ where: { id: venueId }, select: { timezone: true } });
    if (!row) throw new NotFoundError('Venue', venueId);
    const fromIso = range.from.toISOString();
    const toIso = range.to.toISOString();

    const [joins, roundsCompleted, eventGroups, guestRows] = await Promise.all([
      db.playerSession.count({ where: { venueId, joinedAt: { gte: range.from, lt: range.to } } }),
      db.round.count({ where: { venueId, status: 'COMPLETED', completedAt: { gte: range.from, lt: range.to } } }),
      db.event.groupBy({
        by: ['kind'],
        where: { venueId, startedAt: { gte: range.from, lt: range.to } },
        _count: { _all: true },
      }),
      // Timestamps are stored as UTC in `timestamp` columns; convert explicitly so the session
      // time zone of the connection can never skew the range or the business date.
      db.$queryRaw<{ unique_guests: number; repeat_players: number }[]>`
        WITH visits AS (
          SELECT "deviceHash" AS device,
                 ((("joinedAt" AT TIME ZONE 'UTC') AT TIME ZONE ${row.timezone}::text)
                   - make_interval(hours => ${BUSINESS_DAY_CUTOFF_HOUR}::int))::date AS business_date
          FROM "PlayerSession"
          WHERE "venueId" = ${venueId}::uuid
            AND "deviceHash" IS NOT NULL
            AND "joinedAt" >= (${fromIso}::timestamptz AT TIME ZONE 'UTC')
            AND "joinedAt" < (${toIso}::timestamptz AT TIME ZONE 'UTC')
        ), per_device AS (
          SELECT device, COUNT(DISTINCT business_date) AS dates FROM visits GROUP BY device
        )
        SELECT COUNT(*)::int AS unique_guests,
               (COUNT(*) FILTER (WHERE dates >= 2))::int AS repeat_players
        FROM per_device`,
    ]);

    const eventsByKind = Object.fromEntries(EVENT_KINDS.map((k) => [k, 0])) as Record<EventKind, number>;
    for (const g of eventGroups) eventsByKind[g.kind] = g._count._all;
    const guests = guestRows[0];
    return {
      range: { from: range.from, to: range.to },
      uniqueGuests: Number(guests?.unique_guests ?? 0),
      joins,
      roundsCompleted,
      repeatPlayers: Number(guests?.repeat_players ?? 0),
      eventsByKind,
    };
  }

  return { venueId, venue, rooms, events, players, metrics };
}

export type VenueScope = ReturnType<typeof forVenue>;

export type OwnershipTarget = { roomId: string; eventId?: never } | { eventId: string; roomId?: never };

/**
 * Throw NotFoundError unless the room/event belongs to `venueId`. Used before acting on ids that
 * arrive from clients (host sockets, form posts).
 */
export async function assertVenueOwns(db: Db, venueId: string, target: OwnershipTarget): Promise<void> {
  if (target.roomId !== undefined) {
    const found =
      isUuid(venueId) && isUuid(target.roomId)
        ? await db.room.findFirst({ where: { id: target.roomId, venueId }, select: { id: true } })
        : null;
    if (!found) throw new NotFoundError('Room', target.roomId);
    return;
  }
  const eventId = target.eventId;
  const found =
    isUuid(venueId) && isUuid(eventId)
      ? await db.event.findFirst({ where: { id: eventId, venueId }, select: { id: true } })
      : null;
  if (!found) throw new NotFoundError('Event', eventId);
}
