'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { getDb, hashPassword, isUuid, isValidTimeZone, normalizeEmail, NotFoundError, parseVenueSettings, Prisma } from '@pintrush/db';
import { actionError, actionOk, CONFLICT_MESSAGE, type ActionResult } from '@/components/host/action-result';
import { generateTempPassword } from '@/lib/auth/passwords';
import { requireAdmin } from '@/lib/auth/session';
import { JOIN_CODE_PATTERN, joinCodeCandidates, normalizeJoinCode, SLUG_PATTERN, slugify, uniqueSlug } from '@/lib/auth/venue-codes';
import { signHostToken } from '@/lib/server/tokens';
import { resetRoomTvLink } from '@/app/host/_lib/data';
import { adminAudit } from '../../_lib/audit';
import { ENTITLEMENT_STATUSES, VENUE_STATUSES } from '../../_lib/constants';
import { US_TIME_ZONE_VALUES } from '../../_lib/timezones';

const DAY_MS = 86_400_000;

function issuesToFields(error: z.ZodError): Record<string, string> {
  const fields: Record<string, string> = {};
  for (const issue of error.issues) fields[String(issue.path[0])] ??= issue.message;
  return fields;
}

function str(formData: FormData, name: string): string {
  const v = formData.get(name);
  return typeof v === 'string' ? v : '';
}

function isUniqueViolation(err: unknown): err is Prisma.PrismaClientKnownRequestError {
  return err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002';
}

/** Which unique field a P2002 hit (Prisma reports the constraint's columns). */
function uniqueTarget(err: Prisma.PrismaClientKnownRequestError): string {
  const target = (err.meta as { target?: unknown } | undefined)?.target;
  return Array.isArray(target) ? target.join(',') : String(target ?? err.message);
}

const EmailSchema = z.string().trim().min(3).max(254).pipe(z.email('Enter a valid email address'));
const PersonNameSchema = z.string().trim().min(1, 'Enter a name').max(80);

// ───────────────────────────── Create venue ─────────────────────────────

const CreateVenueSchema = z.object({
  name: z.string().trim().min(2, 'Venue name is required').max(80),
  slug: z.string().trim().max(48).refine((s) => s === '' || SLUG_PATTERN.test(s), 'Use lower-case letters, numbers and single hyphens'),
  joinCode: z.string().transform(normalizeJoinCode).pipe(z.string().regex(JOIN_CODE_PATTERN, '4–8 letters or digits, e.g. HARBOR')),
  joinCodeAuto: z.boolean(),
  timezone: z.string().refine((tz) => US_TIME_ZONE_VALUES.has(tz) && isValidTimeZone(tz), 'Pick a time zone from the list'),
  address: z.string().trim().max(200),
  roomName: z.string().trim().min(1, 'Name the first room').max(60),
  ownerName: PersonNameSchema,
  ownerEmail: EmailSchema,
  planKey: z.string().min(1, 'Pick a plan').max(40),
  trialDays: z.coerce.number().int('Whole days only').min(0).max(365, 'At most 365 days'),
});

export async function createVenue(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
  const { admin } = await requireAdmin();
  const parsed = CreateVenueSchema.safeParse({
    name: str(formData, 'name'),
    slug: str(formData, 'slug'),
    joinCode: str(formData, 'joinCode'),
    joinCodeAuto: str(formData, 'joinCodeAuto') === '1',
    timezone: str(formData, 'timezone'),
    address: str(formData, 'address'),
    roomName: str(formData, 'roomName'),
    ownerName: str(formData, 'ownerName'),
    ownerEmail: str(formData, 'ownerEmail'),
    planKey: str(formData, 'planKey'),
    trialDays: str(formData, 'trialDays') || '30',
  });
  if (!parsed.success) return actionError('Please check the highlighted fields.', issuesToFields(parsed.error));
  const input = parsed.data;
  const db = getDb();
  const email = normalizeEmail(input.ownerEmail);

  const [plan, emailTaken] = await Promise.all([
    db.plan.findUnique({ where: { key: input.planKey }, select: { id: true, name: true } }),
    db.venueUser.findUnique({ where: { email }, select: { id: true } }),
  ]);
  if (!plan) return actionError('Please check the highlighted fields.', { planKey: 'Pick a plan from the list.' });
  if (emailTaken) return actionError('Please check the highlighted fields.', { ownerEmail: 'This email already belongs to a venue user.' });

  // Slug: the admin's choice must be free; an automatic one takes the next free suffix.
  const baseSlug = input.slug || slugify(input.name);
  const takenSlugs = new Set((await db.venue.findMany({ where: { slug: { startsWith: baseSlug } }, select: { slug: true } })).map((v) => v.slug));
  if (input.slug && takenSlugs.has(input.slug)) return actionError('Please check the highlighted fields.', { slug: 'This slug is already used by another venue.' });
  const slug = input.slug || uniqueSlug(baseSlug, takenSlugs);

  // Join code: a code the admin typed must be free; the suggested one falls back to the next free candidate.
  let joinCode = input.joinCode;
  if (await db.venue.findUnique({ where: { joinCode }, select: { id: true } })) {
    if (!input.joinCodeAuto) return actionError('Please check the highlighted fields.', { joinCode: 'This join code is already in use.' });
    const candidates = joinCodeCandidates(input.name, 24).filter((c) => c !== joinCode);
    const taken = new Set((await db.venue.findMany({ where: { joinCode: { in: candidates } }, select: { joinCode: true } })).map((v) => v.joinCode));
    const free = candidates.find((c) => !taken.has(c));
    if (!free) return actionError('Please check the highlighted fields.', { joinCode: 'Could not find a free code automatically — type one.' });
    joinCode = free;
  }

  const tempPassword = generateTempPassword();
  const passwordHash = await hashPassword(tempPassword);
  const trialEndsAt = new Date(Date.now() + input.trialDays * DAY_MS);

  let venueId: string;
  try {
    venueId = await db.$transaction(async (tx) => {
      const venue = await tx.venue.create({
        data: {
          name: input.name,
          slug,
          joinCode,
          timezone: input.timezone,
          address: input.address || null,
          status: 'PILOT',
          settings: JSON.parse(JSON.stringify(parseVenueSettings({}))) as Prisma.InputJsonValue,
        },
        select: { id: true },
      });
      const room = await tx.room.create({ data: { venueId: venue.id, name: input.roomName, slug: slugify(input.roomName, 'main') }, select: { id: true } });
      const owner = await tx.venueUser.create({ data: { venueId: venue.id, email, name: input.ownerName, role: 'OWNER', passwordHash }, select: { id: true } });
      await tx.entitlement.create({ data: { venueId: venue.id, planId: plan.id, status: 'TRIALING', trialEndsAt } });
      await adminAudit(
        tx,
        admin.id,
        'admin.venue.created',
        { name: input.name, slug, joinCode, timezone: input.timezone, roomId: room.id, ownerUserId: owner.id, ownerEmail: email, plan: input.planKey, trialEndsAt },
        { venueId: venue.id, roomId: room.id },
      );
      return venue.id;
    });
  } catch (err) {
    if (isUniqueViolation(err)) {
      const target = uniqueTarget(err);
      const field = target.includes('joinCode') ? 'joinCode' : target.includes('slug') ? 'slug' : target.includes('email') ? 'ownerEmail' : null;
      return actionError('Someone else just took one of these values. Please check the highlighted field.', field ? { [field]: 'Already in use — pick another.' } : undefined);
    }
    console.error('[admin] create venue failed', err);
    return actionError('The venue could not be created. Nothing was saved — please try again.');
  }
  revalidatePath('/admin', 'layout');
  return actionOk(`${input.name} is set up with join code ${joinCode}, room “${input.roomName}” and a ${plan.name} trial until ${trialEndsAt.toDateString()}.`, {
    secret: { label: `Temporary password for ${email}`, value: tempPassword },
    link: { href: `/admin/venues/${venueId}`, label: 'Open the venue' },
  });
}

// ───────────────────────────── Venue status ─────────────────────────────

export async function setVenueStatus(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
  const { admin } = await requireAdmin();
  const parsed = z
    .object({ venueId: z.uuid(), status: z.enum(VENUE_STATUSES), expectedVersion: z.coerce.number().int().min(0) })
    .safeParse({ venueId: str(formData, 'venueId'), status: str(formData, 'status'), expectedVersion: str(formData, 'expectedVersion') });
  if (!parsed.success) return actionError('Pick a status.');
  const { venueId, status, expectedVersion } = parsed.data;
  const db = getDb();
  try {
    await db.$transaction(async (tx) => {
      const before = await tx.venue.findUnique({ where: { id: venueId }, select: { status: true, version: true } });
      if (!before) throw new NotFoundError('Venue', venueId);
      const { count } = await tx.venue.updateMany({ where: { id: venueId, version: expectedVersion }, data: { status, version: { increment: 1 } } });
      if (count === 0) throw new ConflictMarker();
      await adminAudit(tx, admin.id, 'admin.venue.status_changed', { from: before.status, to: status }, { venueId });
    });
  } catch (err) {
    if (err instanceof ConflictMarker) return actionError(CONFLICT_MESSAGE);
    if (err instanceof NotFoundError) return actionError('That venue no longer exists.');
    throw err;
  }
  revalidatePath('/admin', 'layout');
  return actionOk(
    status === 'CANCELLED'
      ? 'Venue cancelled. Its hosts can no longer sign in and always-on games stop within about 15 seconds.'
      : status === 'PAUSED'
        ? 'Venue paused. Hosts can still sign in; always-on games stop within about 15 seconds.'
        : `Status set to ${status.toLowerCase()}.`,
  );
}

/** Internal signal for an optimistic-concurrency miss inside a transaction. */
class ConflictMarker extends Error {}

// ───────────────────────────── Rooms ─────────────────────────────

export async function addRoom(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
  const { admin } = await requireAdmin();
  const parsed = z
    .object({ venueId: z.uuid(), name: z.string().trim().min(1, 'Name the room').max(60) })
    .safeParse({ venueId: str(formData, 'venueId'), name: str(formData, 'name') });
  if (!parsed.success) return actionError('Please check the highlighted fields.', issuesToFields(parsed.error));
  const { venueId, name } = parsed.data;
  const db = getDb();
  try {
    await db.$transaction(async (tx) => {
      const venue = await tx.venue.findUnique({ where: { id: venueId }, select: { id: true } });
      if (!venue) throw new NotFoundError('Venue', venueId);
      const taken = new Set((await tx.room.findMany({ where: { venueId }, select: { slug: true } })).map((r) => r.slug));
      const room = await tx.room.create({ data: { venueId, name, slug: uniqueSlug(slugify(name, 'room'), taken) }, select: { id: true } });
      await adminAudit(tx, admin.id, 'admin.room.created', { name }, { venueId, roomId: room.id });
    });
  } catch (err) {
    if (err instanceof NotFoundError) return actionError('That venue no longer exists.');
    if (isUniqueViolation(err)) return actionError('A room with a similar name was just added. Try a different name.', { name: 'Try a different name.' });
    throw err;
  }
  revalidatePath('/admin', 'layout');
  return actionOk(`Room “${name}” added — its TV link is in the rooms table.`);
}

export async function adminResetTvLink(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
  const { admin } = await requireAdmin();
  const venueId = str(formData, 'venueId');
  const roomId = str(formData, 'roomId');
  if (!isUuid(venueId) || !isUuid(roomId)) return actionError('That room no longer exists.');
  try {
    const room = await resetRoomTvLink(venueId, roomId);
    await adminAudit(getDb(), admin.id, 'admin.room.tv_link_reset', { name: room.name, tvTokenVersion: room.tvTokenVersion }, { venueId, roomId });
    revalidatePath('/admin', 'layout');
    return actionOk(`New TV link for ${room.name}. TVs using the old link are signed out when they next reconnect.`);
  } catch (err) {
    if (err instanceof NotFoundError) return actionError('That room no longer exists.');
    throw err;
  }
}

// ───────────────────────────── Venue users ─────────────────────────────

export async function addVenueUser(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
  const { admin } = await requireAdmin();
  const parsed = z
    .object({ venueId: z.uuid(), name: PersonNameSchema, email: EmailSchema, role: z.enum(['OWNER', 'HOST']) })
    .safeParse({ venueId: str(formData, 'venueId'), name: str(formData, 'name'), email: str(formData, 'email'), role: str(formData, 'role') });
  if (!parsed.success) return actionError('Please check the highlighted fields.', issuesToFields(parsed.error));
  const { venueId, name, role } = parsed.data;
  const email = normalizeEmail(parsed.data.email);
  const db = getDb();
  if (await db.venueUser.findUnique({ where: { email }, select: { id: true } })) {
    return actionError('Please check the highlighted fields.', { email: 'This email already belongs to a venue user.' });
  }
  const tempPassword = generateTempPassword();
  const passwordHash = await hashPassword(tempPassword);
  try {
    await db.$transaction(async (tx) => {
      const venue = await tx.venue.findUnique({ where: { id: venueId }, select: { id: true } });
      if (!venue) throw new NotFoundError('Venue', venueId);
      const user = await tx.venueUser.create({ data: { venueId, name, email, role, passwordHash }, select: { id: true } });
      await adminAudit(tx, admin.id, 'admin.venue_user.created', { userId: user.id, email, role }, { venueId });
    });
  } catch (err) {
    if (err instanceof NotFoundError) return actionError('That venue no longer exists.');
    if (isUniqueViolation(err)) return actionError('Please check the highlighted fields.', { email: 'This email already belongs to a venue user.' });
    throw err;
  }
  revalidatePath('/admin', 'layout');
  return actionOk(`${name} can now sign in at /host/login as ${role === 'OWNER' ? 'an owner' : 'a host'}.`, {
    secret: { label: `Temporary password for ${email}`, value: tempPassword },
  });
}

export async function resetVenueUserPassword(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
  const { admin } = await requireAdmin();
  const venueId = str(formData, 'venueId');
  const userId = str(formData, 'userId');
  if (!isUuid(venueId) || !isUuid(userId)) return actionError('That user no longer exists.');
  const tempPassword = generateTempPassword();
  const passwordHash = await hashPassword(tempPassword);
  const db = getDb();
  const user = await db.$transaction(async (tx) => {
    const found = await tx.venueUser.findFirst({ where: { id: userId, venueId }, select: { id: true, email: true } });
    if (!found) return null;
    await tx.venueUser.updateMany({ where: { id: userId, venueId }, data: { passwordHash, version: { increment: 1 } } });
    await adminAudit(tx, admin.id, 'admin.venue_user.password_reset', { userId, email: found.email }, { venueId });
    return found;
  });
  if (!user) return actionError('That user no longer exists.');
  revalidatePath('/admin', 'layout');
  return actionOk('Password reset. The old password stops working immediately and they are signed out on every device.', {
    secret: { label: `New temporary password for ${user.email}`, value: tempPassword },
  });
}

/** Internal signal: the change would leave an open venue without an owner. */
class LastOwnerMarker extends Error {}

/**
 * Lock the venue row for the rest of the transaction so two admins changing owners at the same
 * time can't each see "another owner exists" and together remove the last one.
 */
async function lockVenue(tx: Prisma.TransactionClient, venueId: string): Promise<{ name: string; status: string } | null> {
  const rows = await tx.$queryRaw<{ name: string; status: string }[]>`SELECT "name", "status"::text AS "status" FROM "Venue" WHERE "id" = ${venueId}::uuid FOR UPDATE`;
  return rows[0] ?? null;
}

/** An open venue must keep at least one owner; a cancelled one may be left without. */
async function assertAnotherOwner(tx: Prisma.TransactionClient, venue: { status: string }, venueId: string, userId: string): Promise<void> {
  if (venue.status === 'CANCELLED') return;
  const others = await tx.venueUser.count({ where: { venueId, role: 'OWNER', id: { not: userId } } });
  if (others === 0) throw new LastOwnerMarker();
}

const VenueUserTargetSchema = z.object({ venueId: z.uuid(), userId: z.uuid() });

/**
 * Remove a venue user (e.g. a bartender who left). Their row is deleted, so every session and the
 * next host-token check fails; events they created stay (createdById is set to null).
 */
export async function removeVenueUser(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
  const { admin } = await requireAdmin();
  const parsed = VenueUserTargetSchema.safeParse({ venueId: str(formData, 'venueId'), userId: str(formData, 'userId') });
  if (!parsed.success) return actionError('That user no longer exists.');
  const { venueId, userId } = parsed.data;
  let removedEmail: string;
  try {
    removedEmail = await getDb().$transaction(async (tx) => {
      const venue = await lockVenue(tx, venueId);
      if (!venue) throw new NotFoundError('Venue', venueId);
      const user = await tx.venueUser.findFirst({ where: { id: userId, venueId }, select: { email: true, role: true } });
      if (!user) throw new NotFoundError('VenueUser', userId);
      if (user.role === 'OWNER') await assertAnotherOwner(tx, venue, venueId, userId);
      await tx.venueUser.deleteMany({ where: { id: userId, venueId } });
      await adminAudit(tx, admin.id, 'admin.venue_user.removed', { userId, email: user.email, role: user.role }, { venueId });
      return user.email;
    });
  } catch (err) {
    if (err instanceof LastOwnerMarker) {
      return actionError('This is the venue’s only owner. Make another user an owner first (or cancel the venue), then remove them.');
    }
    if (err instanceof NotFoundError) return actionError('That user no longer exists.');
    throw err;
  }
  revalidatePath('/admin', 'layout');
  // The removed user's table row (and the button that would show a result) disappears, so the venue
  // page shows the confirmation instead. redirect() throws, so it stays outside the try/catch.
  redirect(`/admin/venues/${venueId}?userRemoved=${encodeURIComponent(removedEmail)}`);
}

/** Promote a host to owner or demote an owner to host. Bumps the user's version: they must sign in again. */
export async function changeVenueUserRole(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
  const { admin } = await requireAdmin();
  const parsed = VenueUserTargetSchema.extend({ role: z.enum(['OWNER', 'HOST']) }).safeParse({
    venueId: str(formData, 'venueId'),
    userId: str(formData, 'userId'),
    role: str(formData, 'role'),
  });
  if (!parsed.success) return actionError('Pick a role for this user.');
  const { venueId, userId, role } = parsed.data;
  let changed: { name: string } | null;
  try {
    changed = await getDb().$transaction(async (tx) => {
      const venue = await lockVenue(tx, venueId);
      if (!venue) throw new NotFoundError('Venue', venueId);
      const user = await tx.venueUser.findFirst({ where: { id: userId, venueId }, select: { name: true, email: true, role: true } });
      if (!user) throw new NotFoundError('VenueUser', userId);
      if (user.role === role) return null;
      if (user.role === 'OWNER') await assertAnotherOwner(tx, venue, venueId, userId);
      await tx.venueUser.updateMany({ where: { id: userId, venueId }, data: { role, version: { increment: 1 } } });
      await adminAudit(tx, admin.id, 'admin.venue_user.role_changed', { userId, email: user.email, from: user.role, to: role }, { venueId });
      return { name: user.name };
    });
  } catch (err) {
    if (err instanceof LastOwnerMarker) return actionError('This is the venue’s only owner. Make another user an owner first, then change this role.');
    if (err instanceof NotFoundError) return actionError('That user no longer exists.');
    throw err;
  }
  if (!changed) return actionOk(`Nothing to change: they are already ${role === 'OWNER' ? 'an owner' : 'a host'}.`);
  revalidatePath('/admin', 'layout');
  return actionOk(`${changed.name} is now ${role === 'OWNER' ? 'an owner' : 'a host'}. They were signed out everywhere and see the new access when they sign in again.`);
}

// ───────────────────────────── Entitlement ─────────────────────────────

export async function saveEntitlement(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
  const { admin } = await requireAdmin();
  const parsed = z
    .object({
      venueId: z.uuid(),
      entitlementId: z.union([z.uuid(), z.literal('')]),
      expectedVersion: z.coerce.number().int().min(0),
      planKey: z.string().min(1, 'Pick a plan'),
      status: z.enum(ENTITLEMENT_STATUSES),
      trialEndsAt: z.union([z.literal(''), z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Pick a date')]),
    })
    .safeParse({
      venueId: str(formData, 'venueId'),
      entitlementId: str(formData, 'entitlementId'),
      expectedVersion: str(formData, 'expectedVersion') || '0',
      planKey: str(formData, 'planKey'),
      status: str(formData, 'status'),
      trialEndsAt: str(formData, 'trialEndsAt'),
    });
  if (!parsed.success) return actionError('Please check the highlighted fields.', issuesToFields(parsed.error));
  const input = parsed.data;
  // Trial end is a calendar date; store end of that day in UTC so "trial until Oct 31" holds all day in the US.
  const trialEndsAt = input.trialEndsAt ? new Date(`${input.trialEndsAt}T23:59:59.000Z`) : null;
  if (input.status === 'TRIALING' && !trialEndsAt) return actionError('Please check the highlighted fields.', { trialEndsAt: 'A trial needs an end date.' });
  const db = getDb();
  const plan = await db.plan.findUnique({ where: { key: input.planKey }, select: { id: true } });
  if (!plan) return actionError('Please check the highlighted fields.', { planKey: 'Pick a plan from the list.' });

  try {
    await db.$transaction(async (tx) => {
      const venue = await tx.venue.findUnique({ where: { id: input.venueId }, select: { id: true } });
      if (!venue) throw new NotFoundError('Venue', input.venueId);
      const data = { planId: plan.id, status: input.status, trialEndsAt };
      if (input.entitlementId) {
        const { count } = await tx.entitlement.updateMany({
          where: { id: input.entitlementId, venueId: input.venueId, version: input.expectedVersion },
          data: { ...data, version: { increment: 1 } },
        });
        if (count === 0) throw new ConflictMarker();
      } else {
        await tx.entitlement.create({ data: { venueId: input.venueId, ...data } });
      }
      await adminAudit(tx, admin.id, 'admin.entitlement.saved', { plan: input.planKey, status: input.status, trialEndsAt }, { venueId: input.venueId });
    });
  } catch (err) {
    if (err instanceof ConflictMarker) return actionError(CONFLICT_MESSAGE);
    if (err instanceof NotFoundError) return actionError('That venue no longer exists.');
    throw err;
  }
  revalidatePath('/admin', 'layout');
  return actionOk('Plan and subscription status saved. (Billing is not connected yet: this records the entitlement only.)');
}

// ───────────────────────────── Acting as a venue host ─────────────────────────────

/**
 * Mint a 12 h host token so a platform admin can run a venue's event from the shared live-control
 * screen. Done on an explicit click (not on page render) so every hand-over is audited.
 */
export async function issueAdminHostToken(venueId: string, eventId: string): Promise<{ ok: true; token: string } | { ok: false; message: string }> {
  const { admin } = await requireAdmin();
  if (!isUuid(venueId) || !isUuid(eventId)) return { ok: false, message: 'That event no longer exists.' };
  const db = getDb();
  const event = await db.event.findFirst({ where: { id: eventId, venueId }, select: { id: true, roomId: true, name: true } });
  if (!event) return { ok: false, message: 'That event no longer exists.' };
  await adminAudit(db, admin.id, 'admin.host_control.started', { eventId, eventName: event.name }, { venueId, roomId: event.roomId });
  return { ok: true, token: await signHostToken({ sub: admin.id, venueId, role: 'OWNER' }) };
}
