// Door check-in screen (admin/scanner) API.
//
// At the door, check-in and ticket creation are the same action, so everything
// here is written for one-tap speed on a phone with unreliable venue wifi:
//
//   GET  /:eventId/door-attendees   full attendee list, fetched once and searched
//                                   client-side so typing never hits the network
//   POST /:eventId/door-checkin     the single write endpoint — checks in, settles
//                                   payment, or creates a walk-in, atomically
//   POST /:eventId/door-checkin/undo  reverses exactly what one keyed action did
//   GET  /:eventId/door-summary     end-of-night cash-up + pre-sale/door revenue split
//
// Every write carries a client-generated idempotencyKey. The key is inserted in
// the same transaction as the writes, so a double tap or a retry after a timeout
// can never produce a second ticket, a second payment or a double check-in — the
// replay returns the original response instead.

import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq, and, inArray, sql } from 'drizzle-orm';
import {
  db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys,
} from '../db/index.js';
import { requireAuth } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate } from '../lib/utils.js';
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
import { seatHolderCountQuery } from '../lib/capacity.js';
import {
  DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference,
  paymentStatusForMethod, type DoorPaymentMethod,
} from '../lib/doorPayments.js';
import emailService from '../lib/email.js';

const doorRouter = new Hono();

const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
const IDEMPOTENCY_SCOPE = 'door-checkin';

// ==================== Shared helpers ====================

const num = (v: any): number => {
  const n = typeof v === 'string' ? parseFloat(v) : Number(v);
  return Number.isFinite(n) ? n : 0;
};

const iso = (v: any): string | null => {
  if (!v) return null;
  return v instanceof Date ? v.toISOString() : String(v);
};

function fullName(ticket: any): string {
  return `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
}

/**
 * The row shape the door screen renders. Returned both by the preload list and
 * by every write, so the client can splice an updated attendee straight back
 * into its in-memory list without a refetch.
 */
function toDoorAttendee(
  ticket: any,
  opts: { price: number; groupBookingIds: Set<string>; adminNames: Map<string, string>; doorMethod?: string | null } ,
) {
  return {
    ticketId: ticket.id,
    firstName: ticket.attendeeFirstName,
    lastName: ticket.attendeeLastName || null,
    fullName: fullName(ticket),
    email: ticket.attendeeEmail || null,
    phone: ticket.attendeePhone || null,
    status: ticket.status,
    paymentStatus: ticket.paymentStatus,
    isGuest: !!ticket.isGuest,
    checkedIn: ticket.status === 'checked_in',
    checkinAt: iso(ticket.checkinAt),
    checkedInBy: ticket.checkedInByAdminId ? opts.adminNames.get(ticket.checkedInByAdminId) || null : null,
    bookingId: ticket.bookingId || null,
    isGroupBooking: !!(ticket.bookingId && opts.groupBookingIds.has(ticket.bookingId)),
    amountDue: ticket.paymentStatus === 'unpaid' ? opts.price : 0,
    doorMethod: opts.doorMethod ?? null,
    qrCode: ticket.qrCode || null,
    createdAt: iso(ticket.createdAt),
  };
}

async function loadEvent(eventId: string) {
  const event = await dbGet<any>(
    (db as any).select().from(events).where(eq((events as any).id, eventId))
  );
  if (!event) return null;
  return {
    ...event,
    price: num(event.price),
    capacity: Number(event.capacity),
  };
}

/** Names of the admins/staff referenced by the given check-in rows, in one query. */
async function loadAdminNames(adminIds: string[]): Promise<Map<string, string>> {
  const unique = [...new Set(adminIds.filter(Boolean))];
  if (unique.length === 0) return new Map();
  const rows = await dbAll<any>(
    (db as any)
      .select({ id: (users as any).id, name: (users as any).name })
      .from(users)
      .where(inArray((users as any).id, unique))
  );
  return new Map(rows.map((r: any) => [r.id, r.name]));
}

/** Seats currently held for an event, used only to warn (never to block) at the door. */
async function seatsHeld(eventId: string): Promise<number> {
  const row = await dbGet<any>(seatHolderCountQuery(db, eventId));
  return Number(row?.count || 0);
}

// ==================== GET /:eventId/door-attendees ====================
// One payload, fetched on load and refreshed every ~30s by the client. Cancelled
// tickets are included on purpose: staff must be able to see and reactivate them.

doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async (c) => {
  const eventId = c.req.param('eventId');

  const event = await loadEvent(eventId);
  if (!event) return c.json({ error: 'Event not found' }, 404);

  const rows = await dbAll<any>(
    (db as any).select().from(tickets).where(eq((tickets as any).eventId, eventId))
  );

  // A booking id shared by more than one ticket marks a group booking, which is
  // the usual reason an otherwise-confirmed attendee still shows as unpaid.
  const bookingCounts = new Map<string, number>();
  for (const t of rows) {
    if (t.bookingId) bookingCounts.set(t.bookingId, (bookingCounts.get(t.bookingId) || 0) + 1);
  }
  const groupBookingIds = new Set(
    [...bookingCounts.entries()].filter(([, n]) => n > 1).map(([id]) => id)
  );

  const adminNames = await loadAdminNames(rows.map((t: any) => t.checkedInByAdminId));

  // Door tender per ticket, so a row already settled at the door shows how.
  // Joined on the event rather than on a list of ticket ids: the id list would
  // grow with the guest list and eventually blow the statement parameter limit.
  const doorMethods = new Map<string, string>();
  const doorPayments = await dbAll<any>(
    (db as any)
      .select({ ticketId: (payments as any).ticketId, method: (payments as any).method })
      .from(payments)
      .innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
      .where(and(
        eq((tickets as any).eventId, eventId),
        eq((payments as any).source, 'door')
      ))
  );
  for (const p of doorPayments) if (p.method) doorMethods.set(p.ticketId, p.method);

  const attendees = rows
    .map((t: any) => toDoorAttendee(t, {
      price: event.price,
      groupBookingIds,
      adminNames,
      doorMethod: doorMethods.get(t.id) || null,
    }))
    .sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }));

  const checkedIn = attendees.filter((a) => a.checkedIn).length;
  const totalActive = attendees.filter((a) => a.status === 'confirmed' || a.status === 'checked_in').length;

  return c.json({
    event: {
      id: event.id,
      title: event.title,
      price: event.price,
      currency: event.currency,
      capacity: event.capacity,
    },
    attendees,
    stats: { checkedIn, totalActive, capacity: event.capacity },
  });
});

// ==================== POST /:eventId/door-checkin ====================

const doorCheckinSchema = z.object({
  // Existing ticket to check in (and optionally settle), or…
  ticketId: z.string().optional(),
  // …a walk-in to create. Only a first name is ever required.
  attendee: z.object({
    firstName: z.string().trim().min(1).max(255),
    lastName: z.string().trim().max(255).optional().or(z.literal('')),
    phone: z.string().trim().max(50).optional().or(z.literal('')),
    email: z.string().trim().email().optional().or(z.literal('')),
    ruc: z.string().trim().max(15).optional().or(z.literal('')),
  }).optional(),
  payment: z.object({
    method: z.enum(DOOR_PAYMENT_METHODS),
    // Omitted means "one ticket at event price"; a multiple covers someone
    // paying for their whole group in one go.
    amount: z.number().min(0).optional(),
  }).optional(),
  // How the attendee reached this action, for the session feed.
  entryMethod: z.enum(['scan', 'search', 'walkin']).optional(),
  idempotencyKey: z.string().min(8).max(128),
}).refine((d) => !!d.ticketId || !!d.attendee, {
  message: 'Either ticketId or attendee is required',
  path: ['ticketId'],
});

/** Undo instructions recorded alongside each processed idempotency key. */
type UndoState =
  | {
      kind: 'created';
      ticketId: string;
      paymentId: string;
    }
  | {
      kind: 'existing';
      ticketId: string;
      prevTicket: { status: string; checkinAt: string | null; checkedInByAdminId: string | null; paymentStatus: string; isGuest: boolean };
      createdPaymentId?: string;
      prevPayment?: {
        id: string; provider: string; amount: number; status: string; reference: string | null;
        paidAt: string | null; paidByAdminId: string | null; source: string; method: string | null;
      };
    };

/** A replay of a key we already processed returns the original response verbatim. */
async function findProcessedKey(key: string) {
  return dbGet<any>(
    (db as any).select().from(idempotencyKeys).where(eq((idempotencyKeys as any).key, key))
  );
}

doorRouter.post(
  '/:eventId/door-checkin',
  requireAuth([...STAFF_ROLES]),
  zValidator('json', doorCheckinSchema),
  async (c) => {
    const eventId = c.req.param('eventId');
    const data = c.req.valid('json');
    const adminUser = (c as any).get('user');

    const existingKey = await findProcessedKey(data.idempotencyKey);
    if (existingKey) {
      return c.json({ ...JSON.parse(existingKey.result), replayed: true, undone: !!existingKey.undoneAt });
    }

    const event = await loadEvent(eventId);
    if (!event) return c.json({ error: 'Event not found' }, 404);

    const now = getNow();
    const nowIso = new Date().toISOString();
    const method = data.payment?.method as DoorPaymentMethod | undefined;
    const requestedAmount = data.payment?.amount ?? event.price;

    const ops: TxOp[] = [];
    let undoState: UndoState;
    let action: 'checkin' | 'walkin';
    let ticketRow: any;
    let paymentSummary: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null = null;
    let emailTicketId: string | null = null;

    if (data.ticketId) {
      // ---- Existing ticket: settle (optionally) and check in ----
      const ticket = await dbGet<any>(
        (db as any).select().from(tickets).where(eq((tickets as any).id, data.ticketId))
      );
      if (!ticket) return c.json({ error: 'Ticket not found' }, 404);
      if (ticket.eventId !== eventId) {
        return c.json({ error: 'Ticket belongs to a different event', code: 'WRONG_EVENT' }, 400);
      }

      action = 'checkin';
      const prevTicket = {
        status: ticket.status,
        checkinAt: iso(ticket.checkinAt),
        checkedInByAdminId: ticket.checkedInByAdminId || null,
        paymentStatus: ticket.paymentStatus,
        isGuest: !!ticket.isGuest,
      };
      const undo: UndoState = { kind: 'existing', ticketId: ticket.id, prevTicket };

      const ticketUpdate: Record<string, any> = {};

      if (method) {
        const amount = amountForMethod(method, requestedAmount);
        const tender = DOOR_TENDERS[method];
        ticketUpdate.paymentStatus = paymentStatusForMethod(method);
        if (method === 'guest') ticketUpdate.isGuest = toDbBool(true);

        const existingPayment = await dbGet<any>(
          (db as any).select().from(payments).where(eq((payments as any).ticketId, ticket.id))
        );

        if (existingPayment) {
          undo.prevPayment = {
            id: existingPayment.id,
            provider: existingPayment.provider,
            amount: num(existingPayment.amount),
            status: existingPayment.status,
            reference: existingPayment.reference || null,
            paidAt: iso(existingPayment.paidAt),
            paidByAdminId: existingPayment.paidByAdminId || null,
            source: existingPayment.source || 'presale',
            method: existingPayment.method || null,
          };
          ops.push(updateOp(payments, {
            provider: tender.provider,
            amount,
            currency: event.currency,
            status: 'paid',
            reference: doorReference(method),
            paidAt: now,
            paidByAdminId: adminUser?.id || null,
            source: 'door',
            method,
            updatedAt: now,
          }, eq((payments as any).id, existingPayment.id)));
          paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency };
        } else {
          const paymentId = generateId();
          undo.createdPaymentId = paymentId;
          ops.push(insertOp(payments, {
            id: paymentId,
            ticketId: ticket.id,
            provider: tender.provider,
            amount,
            currency: event.currency,
            status: 'paid',
            reference: doorReference(method),
            paidAt: now,
            paidByAdminId: adminUser?.id || null,
            source: 'door',
            method,
            createdAt: now,
            updatedAt: now,
          }));
          paymentSummary = { id: paymentId, method, amount, currency: event.currency };
        }
      }

      // Check in. An already-checked-in ticket keeps its original timestamp so
      // staff can still tell the person when they actually entered.
      if (ticket.status !== 'checked_in') {
        ticketUpdate.status = 'checked_in';
        ticketUpdate.checkinAt = now;
        ticketUpdate.checkedInByAdminId = adminUser?.id || null;
      }

      if (Object.keys(ticketUpdate).length > 0) {
        ops.push(updateOp(tickets, ticketUpdate, eq((tickets as any).id, ticket.id)));
      }

      undoState = undo;
      ticketRow = { ...ticket, ...ticketUpdate, checkinAt: ticketUpdate.checkinAt ?? ticket.checkinAt };
    } else {
      // ---- Walk-in: born confirmed, settled and checked in, in one write ----
      const attendee = data.attendee!;
      action = 'walkin';
      const tenderMethod: DoorPaymentMethod = method || 'cash';
      const tender = DOOR_TENDERS[tenderMethod];
      const amount = amountForMethod(tenderMethod, requestedAmount);
      const hasEmail = !!(attendee.email && attendee.email.trim());
      const firstNameValue = attendee.firstName.trim();
      const lastNameValue = attendee.lastName?.trim() || null;
      const displayName = lastNameValue ? `${firstNameValue} ${lastNameValue}` : firstNameValue;

      // No email is the fast path; a placeholder keeps the users.email unique
      // constraint satisfied without ever mailing anyone.
      const accountEmail = hasEmail
        ? attendee.email!.trim()
        : `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`;

      let user = hasEmail
        ? await dbGet<any>((db as any).select().from(users).where(eq((users as any).email, accountEmail)))
        : null;

      if (!user) {
        const userId = generateId();
        user = { id: userId, email: accountEmail };
        ops.push(insertOp(users, {
          id: userId,
          email: accountEmail,
          password: null,
          name: displayName,
          phone: attendee.phone?.trim() || null,
          role: 'user',
          languagePreference: null,
          isClaimed: toDbBool(false),
          accountStatus: 'unclaimed',
          emailVerified: false,
          createdAt: now,
          updatedAt: now,
        }));
      }

      const ticketId = generateId();
      const paymentId = generateId();
      const newTicket = {
        id: ticketId,
        bookingId: null,
        userId: user.id,
        eventId,
        attendeeFirstName: firstNameValue,
        attendeeLastName: lastNameValue,
        attendeeEmail: hasEmail ? attendee.email!.trim() : null,
        attendeePhone: attendee.phone?.trim() || null,
        attendeeRuc: attendee.ruc?.trim() || null,
        preferredLanguage: null,
        status: 'checked_in',
        paymentStatus: paymentStatusForMethod(tenderMethod),
        isGuest: toDbBool(tenderMethod === 'guest'),
        qrCode: generateTicketCode(),
        checkinAt: now,
        checkedInByAdminId: adminUser?.id || null,
        adminNote: null,
        createdAt: now,
      };
      ops.push(insertOp(tickets, newTicket));
      ops.push(insertOp(payments, {
        id: paymentId,
        ticketId,
        provider: tender.provider,
        amount,
        currency: event.currency,
        status: 'paid',
        reference: doorReference(tenderMethod),
        paidAt: now,
        paidByAdminId: adminUser?.id || null,
        source: 'door',
        method: tenderMethod,
        createdAt: now,
        updatedAt: now,
      }));

      paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency };
      undoState = { kind: 'created', ticketId, paymentId };
      ticketRow = newTicket;
      // Only mail people who actually gave an address; no QR for the rest.
      if (hasEmail) emailTicketId = ticketId;
    }

    // Staff at the door is the authority: a full event is a warning, never a block.
    const held = await seatsHeld(eventId);
    const atCapacity = event.capacity > 0 && held >= event.capacity;

    const adminNames = await loadAdminNames([ticketRow.checkedInByAdminId]);
    const responseBody = {
      ok: true,
      action,
      attendee: toDoorAttendee(ticketRow, {
        price: event.price,
        groupBookingIds: new Set(ticketRow.bookingId ? [ticketRow.bookingId] : []),
        adminNames,
        doorMethod: paymentSummary?.method || null,
      }),
      payment: paymentSummary,
      warnings: atCapacity ? ['at_capacity'] : [],
      idempotencyKey: data.idempotencyKey,
      processedAt: nowIso,
    };

    // The key row goes in with the writes, so two concurrent replays of the same
    // key cannot both commit — the loser hits the primary-key conflict below.
    ops.unshift(insertOp(idempotencyKeys, {
      key: data.idempotencyKey,
      scope: IDEMPOTENCY_SCOPE,
      result: JSON.stringify(responseBody),
      undoState: JSON.stringify(undoState),
      undoneAt: null,
      createdAt: now,
    }));

    try {
      await runOps(ops);
    } catch (err: any) {
      const replay = await findProcessedKey(data.idempotencyKey);
      if (replay) {
        return c.json({ ...JSON.parse(replay.result), replayed: true, undone: !!replay.undoneAt });
      }
      throw err;
    }

    if (emailTicketId) {
      emailService.sendBookingConfirmation(emailTicketId).catch((err) => {
        console.error('[Email] Failed to send door walk-in confirmation:', err);
      });
    }

    return c.json(responseBody, 201);
  }
);

// ==================== POST /:eventId/door-checkin/undo ====================
// Reverses exactly what the keyed action did — nothing more. This is what makes
// the door screen safe to run without a single confirm dialog.

doorRouter.post(
  '/:eventId/door-checkin/undo',
  requireAuth([...STAFF_ROLES]),
  zValidator('json', z.object({ idempotencyKey: z.string().min(8).max(128) })),
  async (c) => {
    const { idempotencyKey } = c.req.valid('json');

    const record = await findProcessedKey(idempotencyKey);
    if (!record) return c.json({ error: 'Nothing to undo for this action' }, 404);
    if (record.undoneAt) return c.json({ ok: true, alreadyUndone: true });

    const undo = JSON.parse(record.undoState || 'null') as UndoState | null;
    if (!undo) return c.json({ error: 'This action cannot be undone' }, 400);

    const now = getNow();
    const ops: TxOp[] = [];

    if (undo.kind === 'created') {
      // Walk-ins created here are cancelled, not deleted: the row stays as an
      // audit trail and can be reactivated from the same screen.
      ops.push(updateOp(tickets, {
        status: 'cancelled',
        checkinAt: null,
        checkedInByAdminId: null,
      }, eq((tickets as any).id, undo.ticketId)));
      ops.push(updateOp(payments, {
        status: 'cancelled',
        paidAt: null,
        updatedAt: now,
      }, eq((payments as any).id, undo.paymentId)));
    } else {
      ops.push(updateOp(tickets, {
        status: undo.prevTicket.status,
        checkinAt: undo.prevTicket.checkinAt ? toDbDate(undo.prevTicket.checkinAt) : null,
        checkedInByAdminId: undo.prevTicket.checkedInByAdminId,
        paymentStatus: undo.prevTicket.paymentStatus,
        isGuest: toDbBool(undo.prevTicket.isGuest),
      }, eq((tickets as any).id, undo.ticketId)));

      if (undo.createdPaymentId) {
        ops.push(deleteOp(payments, eq((payments as any).id, undo.createdPaymentId)));
      } else if (undo.prevPayment) {
        const prev = undo.prevPayment;
        ops.push(updateOp(payments, {
          provider: prev.provider,
          amount: prev.amount,
          status: prev.status,
          reference: prev.reference,
          paidAt: prev.paidAt ? toDbDate(prev.paidAt) : null,
          paidByAdminId: prev.paidByAdminId,
          source: prev.source,
          method: prev.method,
          updatedAt: now,
        }, eq((payments as any).id, prev.id)));
      }
    }

    ops.push(updateOp(idempotencyKeys, { undoneAt: now }, eq((idempotencyKeys as any).key, idempotencyKey)));

    await runOps(ops);

    return c.json({ ok: true, ticketId: undo.ticketId, reverted: undo.kind });
  }
);

// ==================== GET /:eventId/door-summary ====================
// End-of-night reconciliation: what was taken at the door, by tender, plus the
// pre-sale/door split the event dashboard shows.

doorRouter.get('/:eventId/door-summary', requireAuth([...STAFF_ROLES]), async (c) => {
  const eventId = c.req.param('eventId');

  const event = await loadEvent(eventId);
  if (!event) return c.json({ error: 'Event not found' }, 404);

  // Door payments settled for this event, with the attendee attached so the
  // session feed can show who each line belongs to.
  const rows = await dbAll<any>(
    (db as any)
      .select({
        paymentId: (payments as any).id,
        ticketId: (tickets as any).id,
        method: (payments as any).method,
        amount: (payments as any).amount,
        paidAt: (payments as any).paidAt,
        firstName: (tickets as any).attendeeFirstName,
        lastName: (tickets as any).attendeeLastName,
        ticketStatus: (tickets as any).status,
      })
      .from(payments)
      .innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
      .where(and(
        eq((tickets as any).eventId, eventId),
        eq((payments as any).source, 'door'),
        eq((payments as any).status, 'paid')
      ))
  );

  const byMethod: Record<string, { count: number; total: number }> = {};
  for (const m of DOOR_PAYMENT_METHODS) byMethod[m] = { count: 0, total: 0 };

  let doorTotal = 0;
  for (const r of rows) {
    const key = (r.method && byMethod[r.method]) ? r.method : 'cash';
    const amount = num(r.amount);
    byMethod[key].count += 1;
    byMethod[key].total += amount;
    doorTotal += amount;
  }

  // Pre-sale revenue keeps the dashboard's existing definition — settled tickets
  // at event price — minus anything that was actually taken at the door.
  const doorTicketIds = new Set(rows.map((r: any) => r.ticketId));
  const settled = await dbAll<any>(
    (db as any)
      .select({ id: (tickets as any).id })
      .from(tickets)
      .where(and(
        eq((tickets as any).eventId, eventId),
        eq((tickets as any).paymentStatus, 'paid'),
        sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
      ))
  );
  const presaleCount = settled.filter((t: any) => !doorTicketIds.has(t.id)).length;
  const presaleTotal = presaleCount * event.price;

  return c.json({
    eventId,
    currency: event.currency,
    price: event.price,
    door: {
      count: rows.length,
      total: doorTotal,
      byMethod,
      lines: rows
        .map((r: any) => ({
          paymentId: r.paymentId,
          ticketId: r.ticketId,
          name: `${r.firstName} ${r.lastName || ''}`.trim(),
          method: r.method || 'cash',
          amount: num(r.amount),
          paidAt: iso(r.paidAt),
        }))
        .sort((a: any, b: any) => (b.paidAt || '').localeCompare(a.paidAt || '')),
    },
    presale: { count: presaleCount, total: presaleTotal },
    total: presaleTotal + doorTotal,
  });
});

export default doorRouter;
