import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import { nanoid } from 'nanoid';
import { prisma } from '../config/database';
import { AuthRequest } from '../middleware/auth';
import { generateQRCodeBuffer } from '../utils/qrcode';
import { saveUpload } from '../config/storage';
import { emitToOwner, emitToApprover, emitToCheckpoint, recordNotification, resolveAwaitingNotifications, deleteVisitorNotifications } from '../lib/events';
import { visitDateTimeIsInPast } from '../lib/visitTime';
import { defaultExpiry, expireOverdueVisitors } from '../lib/visitExpiry';
import { sendMail } from '../config/mailer';
import { renderTemplate, VISIT_DETAILS_SENTINEL, QR_CODE_SENTINEL } from '../lib/templates';
import { webAppBaseUrl } from '../lib/publicUrl';

// A visitor's details may only be edited while the visit is still upcoming.
// Once they arrive / check out / the pass is rejected, cancelled or expired,
// the record is historical and locked from edits.
const EDITABLE_VISITOR_STATUSES = ['EXPECTED', 'AWAITING_APPROVAL'] as const;

type AutomationKind = 'VISITOR_INVITE' | 'CHECK_IN_CONFIRMATION' | 'VISITOR_REMINDER' | 'APPROVER_REQUEST';

// Visible "Your visitor pass" headline per template type. Independent of the
// admin-editable template body so the section always reads sensibly even if
// the body is empty or oddly worded.
const HEADLINES: Record<AutomationKind, string> = {
  VISITOR_INVITE: 'Your visitor pass',
  CHECK_IN_CONFIRMATION: 'You\'re checked in',
  VISITOR_REMINDER: 'Reminder: your visit is tomorrow',
  APPROVER_REQUEST: 'Approval requested',
};

// Workspace automation row resolves the master gate per kind. Lazily upserts
// so an unconfigured workspace doesn't trip a null-row exception — same row
// the Settings UI edits.
async function isAutomationOn(ownerId: string, kind: AutomationKind): Promise<boolean> {
  const row = await prisma.emailAutomation.findUnique({ where: { ownerId } });
  if (!row) return false;
  if (kind === 'VISITOR_INVITE') return row.inviteEnabled;
  if (kind === 'CHECK_IN_CONFIRMATION') return row.checkInEnabled;
  if (kind === 'VISITOR_REMINDER') return row.reminderEnabled;
  return row.approverRequestEnabled;
}

// Shape every send function accepts. Aligned with the Prisma Visitor row
// so callers can pass the model directly without remapping. New fields
// added to Visitor only need to be propagated here when the email wants
// to display them.
type EmailVisitor = {
  name: string;
  email: string | null;
  mobile: string | null;
  shortId: string;
  visitDate: Date | null;
  visitTime: string | null;
  notes: string | null;
  reasonForVisit: string | null;
  // Optional identity photo. Used by the approver-request email so the host
  // can verify the visitor's face before approving/rejecting. Stored as a
  // relative /uploads path (or absolute/data URL) — resolved to an absolute
  // URL for email via absoluteUploadUrl().
  photoUrl?: string | null;
  // Optional — when present the invite email is signed by the actual
  // admin who created the visitor (not the workspace owner). Visitor
  // sees the name of the person they're actually visiting.
  createdByAdminId?: string | null;
};

// Resolve a stored photo path to an absolute URL usable inside an email.
// Emails have no request context, so relative /uploads paths (which the API
// normally rewrites per-request) must be anchored to PUBLIC_API_URL. Returns
// null when the value is empty or can't be made absolute (no env configured),
// in which case the email simply omits the photo.
function absoluteUploadUrl(photoUrl: string | null | undefined): string | null {
  if (!photoUrl) return null;
  if (/^(https?:|data:|blob:)/i.test(photoUrl)) return photoUrl;
  const base = (process.env.PUBLIC_API_URL || '').replace(/\/+$/, '');
  if (!base) return null;
  if (photoUrl.startsWith('/api/uploads/')) return `${base}${photoUrl}`;
  if (photoUrl.startsWith('/uploads/')) return `${base}/api${photoUrl}`;
  return null;
}

// Reusable build for all three transactional emails. Each kind ships the
// branded shell + the user-edited template body + a structured "Visit
// details" card so all the info captured on Add Visitor is visible to
// the recipient even if the template body is minimal. Only VISITOR_INVITE
// includes the inline QR card.
async function buildAndSendVisitorEmail(
  ownerId: string,
  kind: AutomationKind,
  visitor: EmailVisitor,
): Promise<void> {
  if (!visitor.email) return;
  if (!(await isAutomationOn(ownerId, kind))) return;
  const tpl = await prisma.emailTemplate.findUnique({ where: { ownerId_type: { ownerId, type: kind } } });
  if (!tpl) return;

  // Host = the person who actually invited the visitor. When an admin
  // created the visitor we sign the email as that admin (their name +
  // email). Owners signing the email use their own info. This way the
  // recipient sees the human they're coming to meet — not the
  // workspace super-admin.
  let hostName = '';
  let hostEmail = '';
  if (visitor.createdByAdminId) {
    const adminHost = await prisma.admin.findUnique({
      where: { id: visitor.createdByAdminId },
      select: { name: true, email: true },
    });
    if (adminHost) {
      hostName = adminHost.name || adminHost.email.split('@')[0];
      hostEmail = adminHost.email;
    }
  }
  if (!hostName) {
    const owner = await prisma.owner.findUnique({ where: { id: ownerId }, select: { name: true, email: true } });
    hostName = owner?.name || owner?.email?.split('@')[0] || 'Your host';
    hostEmail = owner?.email || '';
  }
  const visitDateStr = visitor.visitDate
    ? new Date(visitor.visitDate).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })
    : '';
  const visitTimeStr = visitor.visitTime ? ` at ${visitor.visitTime}` : '';
  const vars = {
    visitor_name: visitor.name,
    visitor_email: visitor.email,
    visitor_mobile: visitor.mobile || '',
    visitor_id: visitor.shortId,
    visit_date: visitDateStr,
    visit_time: visitTimeStr,
    visit_notes: visitor.notes || '',
    reason_for_visit: visitor.reasonForVisit || '',
    host_name: hostName,
    host_email: hostEmail,
    // Block placeholders — sentinels that escapeHtml leaves alone and
    // renderInviteHtml swaps for the real visit-details / QR HTML.
    visit_details: VISIT_DETAILS_SENTINEL,
    qr_code: QR_CODE_SENTINEL,
  };
  const subject = renderTemplate(tpl.subject, vars);
  const body = renderTemplate(tpl.body, vars);

  // Invite + reminder both include the QR. Reminder especially — that's
  // the most recent email in the visitor's inbox by the time they arrive,
  // so they often search "visitor portal" on the way in and need the QR
  // right there. Check-in confirmation skips it (they're already inside).
  const showQr = kind === 'VISITOR_INVITE' || kind === 'VISITOR_REMINDER';
  const qrBuffer = showQr ? await generateQRCodeBuffer(visitor.shortId) : null;

  const html = renderInviteHtml({
    headline: HEADLINES[kind],
    body,
    visitor,
    host: { name: vars.host_name, email: vars.host_email },
    visitDate: visitDateStr,
    visitTime: visitTimeStr,
    includeQr: showQr,
    style: extractStyle(tpl.designJson),
  });

  await sendMail(visitor.email, subject, html, {
    attachments: qrBuffer
      ? [{ filename: `visitor-pass-${visitor.shortId}.png`, content: qrBuffer, cid: 'visitor-qr', contentType: 'image/png' }]
      : undefined,
  });
}

// Public entry points used by the existing controllers. Each is a thin
// wrapper around buildAndSendVisitorEmail; the kind tag selects template +
// QR inclusion + workspace gate.
export async function sendVisitorInvite(ownerId: string, visitor: EmailVisitor): Promise<void> {
  return buildAndSendVisitorEmail(ownerId, 'VISITOR_INVITE', visitor);
}
export async function sendCheckInConfirmation(ownerId: string, visitor: EmailVisitor): Promise<void> {
  return buildAndSendVisitorEmail(ownerId, 'CHECK_IN_CONFIRMATION', visitor);
}
export async function sendVisitReminder(ownerId: string, visitor: EmailVisitor): Promise<void> {
  return buildAndSendVisitorEmail(ownerId, 'VISITOR_REMINDER', visitor);
}

// Notify the assigned host/approver by email when a visitor enters
// AWAITING_APPROVAL. Diverges from buildAndSendVisitorEmail because the
// recipient + host substitution differ — the email goes TO the approver,
// {{host_name}} resolves to them (so the body's "Hi {{host_name}}" greets
// the recipient), and no QR is attached. Resolves the approver via
// assignedAdminId first (post-merge path) then falls back to the legacy
// Approver row.
export async function sendApproverRequest(
  ownerId: string,
  visitor: EmailVisitor & { id: string; assignedAdminId?: string | null; assignedApproverId?: string | null },
  req?: Request,
): Promise<void> {
  const kind: AutomationKind = 'APPROVER_REQUEST';
  if (!(await isAutomationOn(ownerId, kind))) return;
  const tpl = await prisma.emailTemplate.findUnique({ where: { ownerId_type: { ownerId, type: kind } } });
  if (!tpl) return;

  // Resolve approver — admin path first (post-merge), then legacy approver.
  let approverName = '';
  let approverEmail = '';
  if (visitor.assignedAdminId) {
    const a = await prisma.admin.findUnique({ where: { id: visitor.assignedAdminId }, select: { name: true, email: true } });
    if (a) { approverName = a.name || a.email.split('@')[0]; approverEmail = a.email; }
  }
  if (!approverEmail && visitor.assignedApproverId) {
    const a = await prisma.approver.findUnique({ where: { id: visitor.assignedApproverId }, select: { name: true, email: true } });
    if (a) { approverName = a.name || a.email.split('@')[0]; approverEmail = a.email; }
  }
  if (!approverEmail) return; // No one to email — silently skip.

  // Mint a one-shot decision token + persist it on the visitor row. The
  // Approve / Reject buttons in the email point at /decide/<token> which
  // consumes it via the public decision endpoint. The link base is the WEB
  // app origin, resolved per-environment (APP_URL → the request's web Origin →
  // FRONTEND_URL) so staging/production emails carry a real domain instead of
  // localhost. See webAppBaseUrl().
  const { nanoid } = await import('nanoid');
  const decisionToken = nanoid(32);
  await prisma.visitor.update({ where: { id: visitor.id }, data: { decisionToken } });
  const appUrl = webAppBaseUrl(req);
  const approveUrl = `${appUrl}/decide/${decisionToken}?action=approve`;
  const rejectUrl = `${appUrl}/decide/${decisionToken}?action=reject`;

  const visitDateStr = visitor.visitDate
    ? new Date(visitor.visitDate).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })
    : '';
  const visitTimeStr = visitor.visitTime ? ` at ${visitor.visitTime}` : '';
  const vars = {
    visitor_name: visitor.name,
    visitor_email: visitor.email || '',
    visitor_mobile: visitor.mobile || '',
    visitor_id: visitor.shortId,
    visit_date: visitDateStr,
    visit_time: visitTimeStr,
    visit_notes: visitor.notes || '',
    reason_for_visit: visitor.reasonForVisit || '',
    // {{host_name}} in this template addresses the recipient — that's the
    // approver. Keep host_email as the approver's email too for consistency.
    host_name: approverName,
    host_email: approverEmail,
    visit_details: VISIT_DETAILS_SENTINEL,
    qr_code: QR_CODE_SENTINEL,
    approve_url: approveUrl,
    reject_url: rejectUrl,
  };
  const subject = renderTemplate(tpl.subject, vars);
  const body = renderTemplate(tpl.body, vars);

  const html = renderInviteHtml({
    headline: HEADLINES[kind],
    body,
    visitor,
    host: { name: approverName, email: approverEmail },
    visitDate: visitDateStr,
    visitTime: visitTimeStr,
    includeQr: false,
    // Show the visitor's face so the approver can verify identity — matters
    // most in RECEPTION capture mode, where the photo was just taken at the
    // desk, but harmless (and useful) in CREATION mode too.
    includePhoto: true,
    style: extractStyle(tpl.designJson),
  });

  await sendMail(approverEmail, subject, html);
}

// Branded HTML shell that hosts the user-edited template body PLUS a
// structured "Visit details" card that always renders the meta from the
// Visitor row (reason, time, notes, host contact). That way even a
// minimal one-line template body still produces a useful email. For
// invites the QR card is also appended so the recipient can show their
// pass at reception directly from the email.
// Owner-tunable color overrides. All three fields are optional — anything
// left undefined falls back to the legacy hardcoded value. Stored on
// EmailTemplate.designJson as `{ style: {...} }`.
export type TemplateStyle = {
  headerBg?: string;
  headerText?: string;
  infoBoxBg?: string;
};

// Pull a TemplateStyle out of a Prisma JsonValue. Forgives the column
// being null, a stringified blob, or shaped without a `style` key.
export function extractStyle(designJson: unknown): TemplateStyle | undefined {
  if (!designJson || typeof designJson !== 'object') return undefined;
  const style = (designJson as any).style;
  if (!style || typeof style !== 'object') return undefined;
  const pick = (v: unknown) => (typeof v === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(v) ? v : undefined);
  const out: TemplateStyle = {
    headerBg: pick(style.headerBg),
    headerText: pick(style.headerText),
    infoBoxBg: pick(style.infoBoxBg),
  };
  if (!out.headerBg && !out.headerText && !out.infoBoxBg) return undefined;
  return out;
}

function renderInviteHtml(opts: {
  headline: string;
  body: string;
  visitor: EmailVisitor;
  host: { name: string; email: string };
  visitDate: string;
  visitTime: string;
  includeQr: boolean;
  // When true, render the visitor's identity photo prominently near the top
  // — used by the approver-request email so the host can verify the face
  // before deciding. Silently omitted if the visitor has no resolvable photo.
  includePhoto?: boolean;
  // Optional override for the QR image's src. Real sends use the
  // cid: scheme (mail client inlines the attached PNG); previews pass a
  // data: URL so the QR is visible in a normal browser.
  qrSrc?: string;
  style?: TemplateStyle;
}): string {
  const headerBg = opts.style?.headerBg || '#2563eb';
  const headerText = opts.style?.headerText || '#ffffff';
  const infoBoxBg = opts.style?.infoBoxBg || '#f9fafb';
  const photoSrc = opts.includePhoto ? absoluteUploadUrl(opts.visitor.photoUrl) : null;
  // Centered avatar card shown above the visit details in the approver
  // email. Only renders when a photo is actually resolvable.
  const photoBlockHtml = photoSrc ? `
                <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 20px 0;">
                  <tr>
                    <td align="center">
                      <img src="${escapeHtml(photoSrc)}" alt="${escapeHtml(opts.visitor.name)}" width="120" height="120" style="display:block;width:120px;height:120px;border-radius:12px;object-fit:cover;border:1px solid #e5e7eb;background-color:#f3f4f6;" />
                      <p style="margin:10px 0 0 0;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:#9ca3af;">Visitor photo</p>
                    </td>
                  </tr>
                </table>` : '';
  const visitLine = [opts.visitDate, opts.visitTime].filter(Boolean).join('');
  // Each row in the details table — skipped silently if the underlying
  // visitor field is empty so the card never shows blank lines.
  const detailRows: Array<{ label: string; value: string }> = [
    { label: 'Date & time', value: visitLine },
    { label: 'Reason', value: opts.visitor.reasonForVisit || '' },
    { label: 'Notes', value: opts.visitor.notes || '' },
    { label: 'Host', value: opts.host.name },
    { label: 'Host email', value: opts.host.email },
    { label: 'Your mobile on file', value: opts.visitor.mobile || '' },
    { label: 'Pass ID', value: opts.visitor.shortId },
  ].filter((r) => !!r.value);
  const detailsTableHtml = detailRows.map((r, i) => `
                  <tr>
                    <td style="padding:${i === 0 ? '14px' : '8px'} 16px 8px 16px;vertical-align:top;width:36%;">
                      <p style="margin:0;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.6px;color:#9ca3af;">${escapeHtml(r.label)}</p>
                    </td>
                    <td style="padding:${i === 0 ? '14px' : '8px'} 16px 8px 0;vertical-align:top;">
                      <p style="margin:0;font-size:14px;color:#111827;line-height:1.45;">${escapeHtml(r.value)}</p>
                    </td>
                  </tr>`).join('');

  // Standalone HTML chunks for the two block placeholders. They're
  // wrapped in their own self-contained tables so they layout cleanly
  // whether they're rendered inline in the body's <div> (sentinel swap)
  // or appended as separate <tr>s for legacy templates.
  const detailsBlockHtml = detailRows.length > 0 ? `
                <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;background-color:${infoBoxBg};border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;">
                  <tr>
                    <td colspan="2" style="padding:14px 16px 0 16px;">
                      <p style="margin:0;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#6b7280;">Visit details</p>
                    </td>
                  </tr>
                  ${detailsTableHtml}
                  <tr><td colspan="2" style="height:8px;line-height:8px;">&nbsp;</td></tr>
                </table>` : '';
  // QR renders as a centered image only — labels and Pass ID belong in
  // the user-edited body now (the default templates put them in the
  // visit-details info box). Wrapper table is just for email-client
  // alignment, no card chrome.
  const qrBlockHtml = opts.includeQr ? `
                <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:12px 0;">
                  <tr>
                    <td align="center">
                      <img src="${opts.qrSrc || 'cid:visitor-qr'}" alt="Visitor pass QR" width="200" height="200" style="display:block;width:200px;height:200px;border-radius:8px;background-color:#ffffff;padding:8px;" />
                    </td>
                  </tr>
                </table>` : '';

  // Designer mode: body is a full HTML document from the Unlayer email
  // editor — user owns the header, footer, every section. We bypass the
  // branded shell entirely and just substitute the two block sentinels
  // with their HTML inline. Detection looks at the first ~200 chars for
  // a doctype or <html, which Unlayer always emits.
  if (isHtmlDocument(opts.body)) {
    let html = opts.body;
    html = html.split(VISIT_DETAILS_SENTINEL).join(detailsBlockHtml);
    html = html.split(QR_CODE_SENTINEL).join(qrBlockHtml);
    return html;
  }

  // Detect whether the body explicitly placed either block via the
  // sentinels. If neither is present we treat this as a legacy template
  // (e.g. one saved before this feature shipped) and auto-append both
  // blocks at the end so the email still has its visit details + QR.
  const hasDetailsToken = opts.body.includes(VISIT_DETAILS_SENTINEL);
  const hasQrToken = opts.body.includes(QR_CODE_SENTINEL);
  const userPlacedBlocks = hasDetailsToken || hasQrToken;

  // Fragment HTML (anything emitted by the rich-text editor: <br>, <p>,
  // <strong>, etc.) gets injected verbatim — escaping would turn the
  // tags into visible text. Plain-text bodies still flow through the
  // escape + \n→<br/> path so legacy templates render unchanged.
  const isFragment = isHtmlFragment(opts.body);
  let bodyHtml = isFragment
    ? opts.body
    : escapeHtml(opts.body).replace(/\n/g, '<br/>');
  // Sentinel strings survive escapeHtml (no special chars), so we can
  // swap them after escaping. Each gets replaced with the corresponding
  // standalone block; if a token is absent the block doesn't render at
  // its inline position.
  if (hasDetailsToken) {
    bodyHtml = bodyHtml.split(VISIT_DETAILS_SENTINEL).join(detailsBlockHtml);
  }
  if (hasQrToken) {
    bodyHtml = bodyHtml.split(QR_CODE_SENTINEL).join(qrBlockHtml);
  }
  return `<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>${escapeHtml(opts.headline)}</title>
  </head>
  <body style="margin:0;padding:0;background-color:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1f2937;">
    <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f4f5f7;padding:32px 16px;">
      <tr>
        <td align="center">
          <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:520px;background-color:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
            <tr>
              <td style="background-color:${headerBg};padding:24px 32px;text-align:left;">
                <table role="presentation" cellpadding="0" cellspacing="0" border="0">
                  <tr>
                    <td style="width:36px;height:36px;background-color:${headerText};border-radius:8px;text-align:center;vertical-align:middle;color:${headerBg};font-weight:700;font-size:13px;line-height:36px;">GP</td>
                    <td style="padding-left:12px;color:${headerText};font-size:16px;font-weight:600;letter-spacing:0.2px;">Gate Pass</td>
                  </tr>
                </table>
              </td>
            </tr>

            <tr>
              <td style="padding:32px 36px 8px 36px;">
                <p style="margin:0 0 16px 0;font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:1px;color:#6b7280;">${escapeHtml(opts.headline)}</p>
                ${photoBlockHtml}
                <div style="margin:0 0 20px 0;font-size:14px;line-height:1.6;color:#374151;">${bodyHtml}</div>
              </td>
            </tr>

            ${!userPlacedBlocks && detailRows.length > 0 ? `
            <tr>
              <td style="padding:0 36px 24px 36px;">
                <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:${infoBoxBg};border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;">
                  <tr>
                    <td colspan="2" style="padding:14px 16px 0 16px;">
                      <p style="margin:0;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#6b7280;">Visit details</p>
                    </td>
                  </tr>
                  ${detailsTableHtml}
                  <tr><td colspan="2" style="height:8px;line-height:8px;">&nbsp;</td></tr>
                </table>
              </td>
            </tr>
            ` : ''}

            ${!userPlacedBlocks && opts.includeQr ? `
            <tr>
              <td style="padding:0 36px 24px 36px;">
                <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:${infoBoxBg};border:1px solid #e5e7eb;border-radius:12px;">
                  <tr>
                    <td align="center" style="padding:24px 20px 8px 20px;">
                      <p style="margin:0 0 12px 0;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#6b7280;">Show this at reception</p>
                      <img src="${opts.qrSrc || 'cid:visitor-qr'}" alt="Visitor pass QR" width="200" height="200" style="display:block;width:200px;height:200px;border-radius:8px;background-color:#ffffff;padding:8px;" />
                    </td>
                  </tr>
                  <tr>
                    <td align="center" style="padding:8px 20px 24px 20px;">
                      <p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#9ca3af;">Pass ID</p>
                      <p style="margin:2px 0 0 0;font-family:'SF Mono',Consolas,Menlo,monospace;font-size:16px;font-weight:700;color:#111827;letter-spacing:2px;">${escapeHtml(opts.visitor.shortId)}</p>
                    </td>
                  </tr>
                </table>
              </td>
            </tr>

            <tr>
              <td style="padding:0 36px 28px 36px;">
                <p style="margin:0;font-size:13px;line-height:1.55;color:#6b7280;">If you can't scan the QR at reception, share the Pass ID above with the receptionist.</p>
              </td>
            </tr>
            ` : ''}

            <tr>
              <td style="padding:20px 36px 28px 36px;border-top:1px solid #f3f4f6;background-color:#fafafa;">
                <p style="margin:0;font-size:12px;line-height:1.5;color:#9ca3af;text-align:center;">
                  Sent on behalf of <strong style="color:#6b7280;">${escapeHtml(opts.host.name)}</strong>${opts.host.email ? ` · <a href="mailto:${escapeHtml(opts.host.email)}" style="color:#6b7280;text-decoration:none;">${escapeHtml(opts.host.email)}</a>` : ''}
                </p>
              </td>
            </tr>
          </table>

          <p style="margin:16px 0 0 0;font-size:11px;color:#9ca3af;">@${new Date().getFullYear()} Gatepass</p>
        </td>
      </tr>
    </table>
  </body>
</html>`;
}

// Public wrapper used by the email-template preview endpoint so the
// template editor can show the FULL branded email (header + Visit
// details card + QR card + footer) — not just the body text. Mirrors
// the args of renderInviteHtml but takes a friendlier shape from the
// preview controller's sample data.
export async function renderInviteHtmlPreview(opts: {
  kind: AutomationKind;
  body: string;
  visitor: EmailVisitor;
  hostName: string;
  hostEmail: string;
  visitDate: string;
  visitTime: string;
  style?: TemplateStyle;
}): Promise<string> {
  // Invite + reminder show the QR; check-in confirmation doesn't.
  const showQr = opts.kind === 'VISITOR_INVITE' || opts.kind === 'VISITOR_REMINDER';
  // Rasterize a sample QR to a data URL so the preview iframe can
  // actually render it (browsers can't follow `cid:` references).
  let qrSrc: string | undefined;
  if (showQr) {
    const buf = await generateQRCodeBuffer(opts.visitor.shortId);
    qrSrc = `data:image/png;base64,${buf.toString('base64')}`;
  }
  return renderInviteHtml({
    headline: HEADLINES[opts.kind],
    body: opts.body,
    visitor: opts.visitor,
    host: { name: opts.hostName, email: opts.hostEmail },
    visitDate: opts.visitDate,
    visitTime: opts.visitTime,
    includeQr: showQr,
    qrSrc,
    style: opts.style,
  });
}

function escapeHtml(s: string): string {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}

// Templates produced by the Unlayer designer always export a full HTML
// document. We sniff for the doctype / <html> tag near the start so we
// don't false-positive on a plain-text body that happens to mention "<html>".
function isHtmlDocument(s: string): boolean {
  const head = s.slice(0, 200).toLowerCase();
  return head.includes('<!doctype html') || head.includes('<html');
}

// Bodies emitted by the in-app rich-text editor are HTML fragments
// (paragraphs, breaks, lists, anchors). The moment the editor wraps the
// body in any of these tags we treat it as HTML so escapeHtml doesn't
// turn the tags into visible text. List kept conservative — random
// angle-bracketed text in a plain template should still be escaped.
function isHtmlFragment(s: string): boolean {
  return /<\/?(p|div|span|h1|h2|h3|h4|br|strong|em|b|i|u|ul|ol|li|a|hr|img|table|font|blockquote)\b/i.test(s);
}

export async function savePhotoIfDataUrl(
  photoUrl: string | undefined,
  shortId: string,
  req?: Request,
): Promise<string | null> {
  if (!photoUrl) return null;
  const match = /^data:(image\/[a-zA-Z]+);base64,(.+)$/.exec(photoUrl);
  if (!match) return photoUrl;
  const mime = match[1];
  const ext = mime.split('/')[1].replace('jpeg', 'jpg');
  const buffer = Buffer.from(match[2], 'base64');
  return saveUpload(buffer, `visitors/photo/${shortId}.${ext}`, mime, req);
}

export async function listVisitors(req: AuthRequest & { platformAdminId?: string }, res: Response) {
  try {
    // Lazy cron: before we read, sweep any EXPECTED/AWAITING_APPROVAL rows
    // whose pass has expired into CANCELLED. Cheap, idempotent, and means
    // no scheduled job is needed. Platform admins skip the sweep — they
    // could be looking at thousands of rows across orgs, and the per-org
    // sweep happens whenever a customer hits their own list.
    if (req.ownerId) {
      await expireOverdueVisitors(req.ownerId);
    }

    const { search } = req.query;

    // Scope filter ANDs with the search filter. Both come from
    // (createdByAdminId OR assignedAdminId) and (name OR mobile OR email),
    // so we nest them into the Prisma `AND` clause explicitly to avoid the
    // two OR groups merging.
    const scope = visitorScopeForRequest(req);
    const where: any = scope;
    if (search) {
      where.AND = [{
        OR: [
          { name: { contains: search as string, mode: 'insensitive' } },
          { mobile: { contains: search as string } },
          { email: { contains: search as string, mode: 'insensitive' } },
        ],
      }];
    }

    const visitors = await prisma.visitor.findMany({
      where,
      orderBy: { createdAt: 'desc' },
      include: {
        owner: { select: { id: true, name: true, email: true } },
        // Both legacy + new assignment relations. The frontend reads
        // `assignedApprover`, so project assignedAdmin into that key when the
        // legacy one is absent (e.g. when picker now writes the admin path).
        assignedApprover: { select: { id: true, name: true, email: true } },
        assignedAdmin: { select: { id: true, name: true, email: true } },
        createdByAdmin: { select: { id: true, name: true, email: true } },
        createdByApprover: { select: { id: true, name: true, email: true } },
        scanLogs: {
          include: { checkpoint: { select: { name: true, personName: true } } },
          orderBy: { scannedAt: 'desc' },
          take: 1,
        },
        // Flag walk-in visitors so the frontend can dedupe the Approvals list.
        visitorRequest: { select: { id: true } },
      },
    });

    res.json(visitors.map((v) => {
      // Surface the assignment under the frontend-expected key even when the
      // visitor was created in admin-only mode (no legacy approver mirror).
      const assigned = v.assignedApprover ?? v.assignedAdmin ?? null;
      return { ...v, assignedApprover: assigned };
    }));
  } catch (error) {
    console.error('listVisitors error:', error);
    res.status(500).json({ error: 'Failed to fetch visitors' });
  }
}

// Per-admin data scoping. Owner sees the whole workspace; an admin session
// only sees visitors they created OR are assigned to. Other admins'
// visitors stay invisible. Receptionists (sub-admins) ALSO see their
// parent admin's visitors — that's the whole reason they were created,
// so they can check the boss's guests in. Reception/scanner endpoints
// aren't affected — they auth via the checkpoint role.
//
// Platform-admin sessions (req.platformAdminId set) return an empty
// scope object so the query returns rows across every org. The
// controller decides whether to honour that — endpoints that aren't
// safe at platform level still gate on `requireAuth`.
function visitorScopeForRequest(req: AuthRequest & { platformAdminId?: string }): Record<string, any> {
  if (req.platformAdminId) return {};
  const base: Record<string, any> = { ownerId: req.ownerId };
  if (req.adminId) {
    // Front-desk admins (canSeeAllVisitors=true) see every visitor in
    // the workspace — drop the admin-tree filter, just keep the org
    // scope. They can also edit when paired with canManageVisitors;
    // edits are audit-logged so the originating admin can see what
    // changed.
    if (req.adminFlags?.canSeeAllVisitors) return base;
    const ids = [req.adminId];
    if (req.parentAdminId) ids.push(req.parentAdminId);
    base.OR = [
      { createdByAdminId: { in: ids } },
      { assignedAdminId: { in: ids } },
    ];
  }
  return base;
}

// The frontend picker sends the chosen Admin id in `assignedApproverId`
// (legacy name kept for now). Resolve it to the canonical assignedAdminId.
async function resolveAssignment(id: string | null | undefined): Promise<{ adminId: string | null }> {
  if (!id) return { adminId: null };
  const admin = await prisma.admin.findUnique({ where: { id } });
  return { adminId: admin?.id ?? null };
}

// Owner sessions can always backdate. Admin sessions need the explicit flag
// (granted from Settings → Admins). DB lookup because the flag isn't in the
// JWT.
async function canBackdate(req: AuthRequest): Promise<boolean> {
  if (!req.adminId) return true;
  const admin = await prisma.admin.findFirst({
    where: { id: req.adminId, ownerId: req.ownerId, isActive: true },
    select: { canBackdateVisitor: true },
  });
  return !!admin?.canBackdateVisitor;
}

// Resolve which check-in policy the create body represents, then verify the
// admin is allowed to pick it. Owners pass-through. Throws a helpful error
// when the policy isn't permitted so the caller can return 403.
async function ensurePolicyAllowed(
  req: AuthRequest,
  body: { requiresApproval?: unknown; isPreApproval?: unknown; isWalkIn?: unknown; isManualEntry?: unknown },
): Promise<void> {
  if (!req.adminId) return;
  // Pre-approval policy removed. New writes are coerced to walkIn, live or auto;
  // isPreApproval on the body is silently ignored. Manual entry is its own
  // logbook policy and is now gated by canPolicyManual.
  const policy: 'manual' | 'walkIn' | 'auto' | 'live' = body.isManualEntry
    ? 'manual'
    : body.isWalkIn && body.requiresApproval
      ? 'walkIn'
      : !body.requiresApproval ? 'auto' : 'live';
  const admin = await prisma.admin.findFirst({
    where: { id: req.adminId, ownerId: req.ownerId, isActive: true },
    select: { canPolicyAuto: true, canPolicyLive: true, canPolicyPre: true, canPolicyWalkIn: true, canPolicyManual: true },
  });
  if (!admin) throw new Error('Admin session not found');
  const ok =
    policy === 'auto' ? admin.canPolicyAuto
    : policy === 'live' ? admin.canPolicyLive
    : policy === 'walkIn' ? admin.canPolicyWalkIn
    : policy === 'manual' ? admin.canPolicyManual
    : false;
  if (!ok) {
    const labels: Record<string, string> = { auto: 'Auto check-in', live: 'Require my approval', walkIn: 'Walk-in (here now)', manual: 'Manual entry' };
    const err: any = new Error(`Your account isn't allowed to use the "${labels[policy]}" check-in policy. Ask the workspace owner to enable it under Settings → Admins.`);
    err.statusCode = 403;
    throw err;
  }
}

export async function createVisitor(req: AuthRequest, res: Response) {
  try {
    const { name, email, mobile, designation, companyName, reasonForVisit, notes, visitDate, visitTime, photoUrl, requiresApproval, assignedApproverId, expiresAt, isManualEntry, isPreApproval, isWalkIn, isFrequent, frequencyType, frequencyValidFrom, frequencyValidUntil, frequencyWeekdays, sendInviteEmail } = req.body;

    // Permission gate for sub-admins. Add is governed by canAddVisitors
    // specifically — canManageVisitors ("Edit visitors") no longer implies
    // Add on its own, so this always needs the DB lookup (canAddVisitors
    // isn't on the JWT).
    if (req.adminId) {
      const admin = await prisma.admin.findFirst({
        where: { id: req.adminId, ownerId: req.ownerId, isActive: true },
        select: { canAddVisitors: true },
      });
      if (!admin?.canAddVisitors) {
        res.status(403).json({ error: 'You do not have permission to add visitors' });
        return;
      }
    }

    if (!name) {
      res.status(400).json({ error: 'Name is required' });
      return;
    }
    if (!reasonForVisit || !String(reasonForVisit).trim()) {
      res.status(400).json({ error: 'Reason for visit is required' });
      return;
    }

    // The visitor identity photo is OPTIONAL at creation in both capture
    // stages: in CREATION mode it can be added on the Add Visitor form but is
    // never required, and in RECEPTION mode it's captured at the desk before
    // requesting approval (enforced in the scanner flow, not here).

    if (visitDateTimeIsInPast(visitDate, visitTime) && !(await canBackdate(req))) {
      res.status(400).json({ error: "Visit time can't be in the past. Ask the workspace owner to enable 'Allow backdating' for you." });
      return;
    }

    try {
      await ensurePolicyAllowed(req, { requiresApproval, isPreApproval, isWalkIn, isManualEntry });
    } catch (err: any) {
      res.status(err.statusCode || 403).json({ error: err.message });
      return;
    }

    const shortId = nanoid(10).toUpperCase();

    const qrBuffer = await generateQRCodeBuffer(shortId);
    const qrKey = `visitors/qr/${shortId}.png`;
    const qrUrl = await saveUpload(qrBuffer, qrKey, 'image/png', req);

    const savedPhotoUrl = await savePhotoIfDataUrl(photoUrl, shortId, req);

    // Manual entry = receptionist is logging an already-present visitor
    // (logbook style). Force the row into ARRIVED on creation and bypass the
    // approval policy entirely so it doesn't sit in any pending queue.
    const manual = Boolean(isManualEntry);
    // Pre-approval = approver decides BEFORE arrival. Visitor starts already
    // in AWAITING_APPROVAL so the approver sees them in their queue without
    // waiting for reception to scan a QR. Manual entries are mutually
    // exclusive — they skip approval entirely.
    // Pre-approval flow removed — every new approval-required visitor
    // is now live-approval (decision at scan time). isPreApproval on
    // the body is ignored. Legacy rows with isPreApproval=true still
    // work through the scanner's existing branches.
    //
    // EXCEPTION: the Add-Visitor form's "Walk-in (here now)" policy sends
    // isWalkIn=true. The visitor is physically at reception so the host
    // approval needs to fire immediately — no QR scan step. We reuse the
    // pre-approval branch (status=AWAITING_APPROVAL on creation, approveVisitorScan
    // flips it to ARRIVED without expecting a scan log) by stamping
    // isPreApproval=true server-side ONLY for this case. The checkpoint
    // walk-in QR flow (walkInQR.controller / visitor-scanner) is unaffected.
    void isPreApproval;
    const isWalkInRequest = Boolean(isWalkIn) && Boolean(requiresApproval) && !manual;
    const preApprove = isWalkInRequest;
    const now = new Date();

    // "My Self" path — when the admin who's creating the visitor picks the
    // default "decide it myself" option (no approver chosen), route the
    // approval to THEM, not leave it unassigned. Unassigned visitors are
    // effectively decided by the owner via workspace scope; an admin asking
    // for "my own approval" expects the notification + scan flow to come to
    // them. Owners themselves don't have an Admin row, so adminId stays
    // null for owner sessions — owner remains the decider via workspace
    // scope, same as before.
    const explicitAssignment = (!manual && requiresApproval)
      ? await resolveAssignment(assignedApproverId)
      : { adminId: null };
    const assignment =
      !manual && requiresApproval && !explicitAssignment.adminId && req.adminId
        ? { adminId: req.adminId }
        : explicitAssignment;

    const visitor = await prisma.visitor.create({
      data: {
        ownerId: req.ownerId!,
        shortId,
        name,
        email: email || null,
        mobile: mobile || null,
        designation: designation || null,
        companyName: companyName || null,
        reasonForVisit: String(reasonForVisit).trim(),
        notes: notes || null,
        visitDate: visitDate ? new Date(visitDate) : null,
        visitTime: visitTime || null,
        qrCodeUrl: qrUrl,
        photoUrl: savedPhotoUrl,
        isManualEntry: manual,
        isPreApproval: preApprove,
        // Frequency flag — a frequent visitor's QR never expires and
        // they're excluded from auto-checkout/auto-cancel + the
        // post-decision one-shot expiry. The configured check-in policy
        // (auto / live approval / walk-in) still gates each entry.
        isFrequent: Boolean(isFrequent) && !manual,
        // Cadence rule. DAILY/WEEKLY/MONTHLY are metadata; CUSTOM uses
        // the validFrom/validUntil/weekdays gate at scan time.
        frequencyType: (isFrequent && !manual && typeof frequencyType === 'string' && ['DAILY', 'WEEKLY', 'MONTHLY', 'CUSTOM'].includes(frequencyType))
          ? (frequencyType as 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'CUSTOM')
          : null,
        frequencyValidFrom: (isFrequent && frequencyValidFrom) ? new Date(frequencyValidFrom) : null,
        frequencyValidUntil: (isFrequent && frequencyValidUntil) ? new Date(frequencyValidUntil) : null,
        frequencyWeekdays: (isFrequent && Array.isArray(frequencyWeekdays))
          ? frequencyWeekdays.filter((d: unknown): d is number => typeof d === 'number' && d >= 0 && d <= 6)
          : [],
        status: manual ? 'ARRIVED' : preApprove ? 'AWAITING_APPROVAL' : 'EXPECTED',
        arrivedAt: manual ? now : null,
        approvalRequestedAt: preApprove ? now : null,
        requiresApproval: manual ? false : Boolean(requiresApproval),
        assignedAdminId: assignment.adminId,
        // Frequent visitors get a non-expiring QR (expiresAt=null).
        // Manual entries skip expiry too (already-arrived logbook rows).
        // Everyone else gets a 24h default if the form didn't set one.
        expiresAt: isFrequent
          ? null
          : expiresAt ? new Date(expiresAt) : (manual ? null : defaultExpiry()),
        // Track which sub-admin invited this visitor (null for owners themselves).
        createdByAdminId: req.adminId || null,
      },
    });

    // Per-visitor "Send invite email" toggle on the Add Visitor form.
    if (visitor.email && sendInviteEmail === true) {
      sendVisitorInvite(req.ownerId!, visitor).catch((e) =>
        console.error('sendVisitorInvite failed', e)
      );
    }

    // WhatsApp templates are never auto-sent — the host sends them manually
    // from the WhatsApp picker (POST /visitors/:id/whatsapp-send).

    // Walk-in visitors land in AWAITING_APPROVAL on creation — push a
    // live notification to the approver (and owner) so they can decide right
    // away. The visitor is already at reception; no QR scan happens, so
    // the host's decision is the only thing blocking check-in.
    if (preApprove) {
      emitToOwner(req.ownerId!, 'visitor.awaiting', { visitor });
      await recordNotification({
        recipientType: 'OWNER', recipientId: req.ownerId!,
        type: 'visitor.awaiting',
        title: `Approval needed for ${visitor.name}`,
        body: `Walk-in at reception — waiting for host approval · #${visitor.shortId}`,
        link: '/visitors',
        push: { data: { visitorId: visitor.id, shortId: visitor.shortId, kind: 'visitor.awaiting' } },
      });
      if (assignment.adminId) {
        emitToApprover(assignment.adminId, 'visitor.awaiting', { visitor });
        await recordNotification({
          recipientType: 'APPROVER', recipientId: assignment.adminId,
          type: 'visitor.awaiting',
          title: `${visitor.name} is at reception`,
          body: `Walk-in — tap to approve or reject · #${visitor.shortId}`,
          link: '/approver',
          push: { data: { visitorId: visitor.id, shortId: visitor.shortId, kind: 'visitor.awaiting' } },
        });
      }
    }

    res.status(201).json(visitor);
  } catch (error) {
    console.error('createVisitor error:', error);
    res.status(500).json({ error: 'Failed to create visitor' });
  }
}

// Bulk visitor import (CSV / Excel). Validates each row independently and
// returns both the rows that succeeded and any that failed so the UI can show
// the user exactly what didn't import and why. We don't wrap in a transaction
// — partial imports are usually preferable when a single bad row would
// otherwise cancel a clean batch of 50.
export async function bulkCreateVisitors(req: AuthRequest, res: Response): Promise<void> {
  try {
    // Same permission gate as createVisitor — sub-admin must have
    // canAddVisitors to import. Without this check a read-only sub-admin
    // could bypass createVisitor's gate by going through the bulk endpoint.
    if (req.adminId) {
      const admin = await prisma.admin.findFirst({
        where: { id: req.adminId, ownerId: req.ownerId, isActive: true },
        select: { canAddVisitors: true },
      });
      if (!admin?.canAddVisitors) {
        res.status(403).json({ error: 'You do not have permission to import visitors' });
        return;
      }
    }

    const rows = req.body?.visitors;
    if (!Array.isArray(rows) || rows.length === 0) {
      res.status(400).json({ error: 'visitors must be a non-empty array' });
      return;
    }
    if (rows.length > 500) {
      res.status(400).json({ error: 'Maximum 500 visitors per import' });
      return;
    }

    const created: any[] = [];
    const errors: { row: number; name?: string; message: string }[] = [];

    // Resolve once — past-date enforcement applies to every row but the
    // requester's permission doesn't change between rows.
    const backdateAllowed = await canBackdate(req);

    // Build a Date only when the input parses cleanly. Anything else returns
    // null so we never hand `new Date("Invalid Date")` to Prisma — that
    // throws and aborts the whole row.
    const safeDate = (v: unknown): Date | null => {
      if (!v) return null;
      const d = new Date(v as any);
      return isNaN(d.getTime()) ? null : d;
    };

    // Pre-build an email → admin-id lookup so we can resolve the approver
    // column in a single pass without N round-trips. Only admins with
    // isApprover=true qualify (and they're scoped to this owner).
    const approverAdmins = await prisma.admin.findMany({
      where: { ownerId: req.ownerId, isApprover: true },
      select: { id: true, email: true },
    });
    const approverByEmail = new Map<string, string>(
      approverAdmins.map((a) => [a.email.toLowerCase(), a.id]),
    );

    // Pre-load the owner's saved Reason-for-visit presets. Reasons that show
    // up in the import but aren't already in the list get auto-added so the
    // dropdown in the Add Visitor form grows over time. Comparison is
    // case-insensitive so "Vendor meeting" / "vendor meeting" don't dupe.
    const existingReasons = await prisma.visitorReason.findMany({
      where: { ownerId: req.ownerId },
      select: { name: true },
    });
    const seenReasonKeys = new Set(existingReasons.map((r) => r.name.toLowerCase()));

    for (let i = 0; i < rows.length; i++) {
      const r = rows[i];
      const rowNum = i + 1;
      try {
        const name = String(r.name || '').trim();
        const reasonForVisit = String(r.reasonForVisit || '').trim();
        if (!name) { errors.push({ row: rowNum, message: 'Name is required' }); continue; }
        if (!reasonForVisit) { errors.push({ row: rowNum, name, message: 'Reason for visit is required' }); continue; }

        // Reject obviously bad visitDate strings up front so we don't waste
        // a shortId / QR generation on a row that'll fail at write time.
        const visitDateParsed = safeDate(r.visitDate);
        if (r.visitDate && !visitDateParsed) {
          errors.push({ row: rowNum, name, message: `Invalid visitDate "${r.visitDate}" — use YYYY-MM-DD` });
          continue;
        }

        if (!backdateAllowed && visitDateTimeIsInPast(visitDateParsed, r.visitTime)) {
          errors.push({ row: rowNum, name, message: `Visit time is in the past — ask the owner to enable 'Allow backdating' for you to import this row.` });
          continue;
        }

        // Manual entries skip the approval policy entirely and land as ARRIVED.
        const manual = Boolean(r.isManualEntry);
        // Pre-approval flow removed — every approval-required import
        // row now lands as live-approval. r.isPreApproval is ignored.
        const preApprove = false;

        // Approval routing: if requiresApproval is on and an approverEmail was
        // supplied, resolve it via the lookup map. Unknown emails surface an
        // error rather than silently routing to "owner".
        let assignedAdminId: string | null = null;
        if (!manual && r.requiresApproval && r.approverEmail) {
          const emailKey = String(r.approverEmail).trim().toLowerCase();
          assignedAdminId = approverByEmail.get(emailKey) ?? null;
          if (!assignedAdminId) {
            errors.push({ row: rowNum, name, message: `No approver found with email ${emailKey}` });
            continue;
          }
        }
        // Pre-approval without an approver makes no sense — surface a hint.
        if (preApprove && !assignedAdminId) {
          errors.push({ row: rowNum, name, message: 'Pre-approval requires an approverEmail' });
          continue;
        }

        // Combine expiryDate + expiryTime into an ISO timestamp. Time defaults
        // to 23:59 (end of day) to match the Add Visitor form's behaviour.
        // Falls back to a 24h-from-now default when nothing was provided.
        let expiresAt: Date | null = null;
        if (!manual && r.expiryDate) {
          const [y, m, d] = String(r.expiryDate).split('-').map(Number);
          const [hh, mm] = String(r.expiryTime || '23:59').split(':').map(Number);
          if (y && m && d) expiresAt = new Date(y, m - 1, d, hh || 23, mm || 59);
        }
        if (!manual && !expiresAt) expiresAt = defaultExpiry();

        const shortId = nanoid(10).toUpperCase();
        const qrBuffer = await generateQRCodeBuffer(shortId);
        const qrUrl = await saveUpload(qrBuffer, `visitors/qr/${shortId}.png`, 'image/png', req);

        const now = new Date();
        const visitor = await prisma.visitor.create({
          data: {
            ownerId: req.ownerId!,
            shortId,
            name,
            email: r.email ? String(r.email).trim() : null,
            mobile: r.mobile ? String(r.mobile).trim() : null,
            reasonForVisit,
            notes: r.notes ? String(r.notes).trim() : null,
            visitDate: visitDateParsed,
            visitTime: r.visitTime ? String(r.visitTime).trim() : null,
            qrCodeUrl: qrUrl,
            isManualEntry: manual,
            isPreApproval: preApprove,
            status: manual ? 'ARRIVED' : preApprove ? 'AWAITING_APPROVAL' : 'EXPECTED',
            arrivedAt: manual ? now : null,
            approvalRequestedAt: preApprove ? now : null,
            requiresApproval: manual ? false : Boolean(r.requiresApproval),
            assignedAdminId,
            expiresAt,
            createdByAdminId: req.adminId || null,
          },
        });
        created.push(visitor);

        // Pre-approval visitors land in AWAITING_APPROVAL on creation — notify
        // the approver so they can decide before the visitor arrives. Skipped
        // for non-pre-approval rows so we don't spam the assignee with
        // notifications they don't act on.
        if (preApprove && assignedAdminId) {
          emitToApprover(assignedAdminId, 'visitor.awaiting', { visitor });
          await recordNotification({
            recipientType: 'APPROVER', recipientId: assignedAdminId,
            type: 'visitor.awaiting',
            title: `Pre-approval needed: ${visitor.name}`,
            body: `Approve in advance · #${visitor.shortId}`,
            link: '/approver',
            push: { data: { visitorId: visitor.id, shortId: visitor.shortId, kind: 'visitor.awaiting' } },
          });
        }

        // Auto-grow the saved reason-for-visit list. First sighting of a new
        // reason in this owner's workspace gets added so the dropdown in Add
        // Visitor and Settings reflects it next time around. Failures here
        // are non-fatal — we don't want a unique-constraint race to roll back
        // the visitor that was just created.
        const reasonKey = reasonForVisit.toLowerCase();
        if (!seenReasonKeys.has(reasonKey)) {
          seenReasonKeys.add(reasonKey);
          try {
            await prisma.visitorReason.create({
              data: { ownerId: req.ownerId!, name: reasonForVisit },
            });
          } catch (e) {
            // Another concurrent insert beat us to it — fine.
            console.warn('visitorReason auto-add skipped:', (e as Error).message);
          }
        }

        // Bulk import does NOT auto-send invite emails. The send decision is
        // a per-visitor choice on the Add Visitor form; the import path has
        // no row-level toggle, so we err on the side of silence. Users can
        // re-send manually from the visitor record if needed.
      } catch (err: any) {
        errors.push({ row: rowNum, name: r?.name, message: err?.message || 'Failed to create' });
      }
    }

    res.status(201).json({ created, errors, summary: { total: rows.length, created: created.length, failed: errors.length } });
  } catch (error) {
    console.error('bulkCreateVisitors error:', error);
    res.status(500).json({ error: 'Failed to import visitors' });
  }
}

export async function getOwnerCounts(req: AuthRequest & { platformAdminId?: string }, res: Response) {
  try {
    // Platform-admin call — aggregate across every org. Skip the lazy
    // expiry sweep here (it's per-org and would balloon the request).
    if (req.platformAdminId) {
      const [awaitingApproval, walkInPending] = await Promise.all([
        prisma.visitor.count({ where: { status: 'AWAITING_APPROVAL' } }),
        prisma.visitorRequest.count({ where: { status: 'PENDING' } }),
      ]);
      res.json({ awaitingApproval, walkInPending });
      return;
    }

    // Same lazy sweep before counting so the badges don't include expired
    // EXPECTED visitors that have already been swept on the list view.
    await expireOverdueVisitors(req.ownerId!);

    // Admin sessions get the slice they're allowed to act on (assignedAdminId
    // === me). Owner sees workspace-wide totals. Front-desk admins
    // (canSeeAllVisitors) also see the workspace-wide totals so the
    // counts match their visitor list.
    const visitorWhere: any = { ownerId: req.ownerId, status: 'AWAITING_APPROVAL' };
    const requestWhere: any = { ownerId: req.ownerId, status: 'PENDING' };
    if (req.adminId && !req.adminFlags?.canSeeAllVisitors) {
      visitorWhere.assignedAdminId = req.adminId;
      requestWhere.assignedApproverId = req.adminId;
    }
    const [awaitingApproval, walkInPending] = await Promise.all([
      prisma.visitor.count({ where: visitorWhere }),
      prisma.visitorRequest.count({ where: requestWhere }),
    ]);
    res.json({ awaitingApproval, walkInPending });
  } catch (error) {
    console.error('getOwnerCounts error:', error);
    res.status(500).json({ error: 'Failed to fetch counts' });
  }
}

// Lookup by short-id (the QR code value). Used by the notification → decision
// popup so we can resolve the visitor from a "#shortId" reference in the
// notification body without paging the whole visitor list.
export async function getVisitorByShortId(req: AuthRequest, res: Response) {
  try {
    const visitor = await prisma.visitor.findFirst({
      where: { shortId: req.params.shortId, ...visitorScopeForRequest(req) },
      include: {
        assignedApprover: { select: { id: true, name: true, email: true } },
        assignedAdmin: { select: { id: true, name: true, email: true } },
        scanLogs: {
          include: { checkpoint: { select: { name: true, personName: true } } },
          orderBy: { scannedAt: 'desc' },
          take: 1,
        },
      },
    });
    if (!visitor) {
      res.status(404).json({ error: 'Visitor not found' });
      return;
    }
    const assigned = visitor.assignedAdmin ?? visitor.assignedApprover ?? null;
    res.json({ ...visitor, assignedApprover: assigned });
  } catch (error) {
    console.error('getVisitorByShortId error:', error);
    res.status(500).json({ error: 'Failed to fetch visitor' });
  }
}

export async function getVisitor(req: AuthRequest, res: Response) {
  try {
    const visitor = await prisma.visitor.findFirst({
      where: { id: req.params.id, ...visitorScopeForRequest(req) },
      include: {
        assignedApprover: { select: { id: true, name: true, email: true } },
        assignedAdmin: { select: { id: true, name: true, email: true } },
      },
    });

    if (!visitor) {
      res.status(404).json({ error: 'Visitor not found' });
      return;
    }

    // Project the unified Admin assignment into the legacy approver shape so
    // the Edit form (which reads data.assignedApproverId) pre-fills the
    // "Who approves?" picker with the right value. Without this, every
    // newly-created visitor edits with an empty approver field.
    const assigned = visitor.assignedAdmin ?? visitor.assignedApprover ?? null;
    res.json({
      ...visitor,
      assignedApproverId: assigned?.id ?? visitor.assignedApproverId ?? null,
      assignedApprover: assigned,
    });
  } catch (error) {
    console.error('getVisitor error:', error);
    res.status(500).json({ error: 'Failed to fetch visitor' });
  }
}

export async function updateVisitor(req: AuthRequest, res: Response) {
  try {
    const existing = await prisma.visitor.findFirst({
      where: { id: req.params.id, ...visitorScopeForRequest(req) },
    });

    if (!existing) {
      res.status(404).json({ error: 'Visitor not found' });
      return;
    }

    // Permission gate for sub-admins. Without this, any admin who can SEE
    // a visitor (assigned to them or created by them) could edit any field,
    // including reasonForVisit and assignment. Edit is governed strictly by
    // canManageVisitors — already on the JWT, no DB lookup needed. An
    // Add-only admin (canAddVisitors) can no longer edit their own
    // creations; they need Edit granted explicitly.
    if (req.adminId && !req.adminFlags?.canManageVisitors) {
      res.status(403).json({ error: 'You do not have permission to edit this visitor' });
      return;
    }

    // Lifecycle gate: a visitor can only be edited while the visit is still
    // upcoming — status EXPECTED or AWAITING_APPROVAL. Once they've arrived,
    // checked out, or the pass was rejected / cancelled / expired, the record
    // is historical and locked. Status transitions themselves go through the
    // dedicated approve-scan / reject-scan / checkout endpoints, never here.
    if (!EDITABLE_VISITOR_STATUSES.includes(existing.status as (typeof EDITABLE_VISITOR_STATUSES)[number])) {
      res.status(409).json({
        error: 'This visitor can no longer be edited. Only Expected or Awaiting-approval visitors can be edited.',
      });
      return;
    }

    const { name, email, mobile, designation, companyName, reasonForVisit, notes, visitDate, visitTime, status, photoUrl, requiresApproval, assignedApproverId, expiresAt, isPreApproval } = req.body;

    // Block edits that would land the visit in the past unless the admin is
    // allowed to backdate. Resolves the effective date+time from the body
    // (falling back to the stored value where the body omits a field).
    const effectiveDate = visitDate !== undefined ? visitDate : existing.visitDate;
    const effectiveTime = visitTime !== undefined ? visitTime : existing.visitTime;
    if (visitDateTimeIsInPast(effectiveDate as any, effectiveTime) && !(await canBackdate(req))) {
      res.status(400).json({ error: "Visit time can't be in the past. Ask the workspace owner to enable 'Allow backdating' for you." });
      return;
    }

    const data: any = {};
    if (name !== undefined) data.name = name;
    if (email !== undefined) data.email = email;
    if (mobile !== undefined) data.mobile = mobile;
    if (designation !== undefined) data.designation = designation || null;
    if (companyName !== undefined) data.companyName = companyName || null;
    if (reasonForVisit !== undefined) {
      const trimmed = String(reasonForVisit).trim();
      if (!trimmed) {
        res.status(400).json({ error: 'Reason for visit cannot be empty' });
        return;
      }
      data.reasonForVisit = trimmed;
    }
    if (notes !== undefined) data.notes = notes;
    if (visitDate !== undefined) data.visitDate = visitDate ? new Date(visitDate) : null;
    if (visitTime !== undefined) data.visitTime = visitTime;
    if (status !== undefined) data.status = status;
    if (photoUrl !== undefined) data.photoUrl = photoUrl ? await savePhotoIfDataUrl(photoUrl, existing.shortId, req) : null;
    if (requiresApproval !== undefined) data.requiresApproval = Boolean(requiresApproval);
    // Pre-approval flow removed — never accept toggling this field
    // back on via edit. Legacy rows keep their stored value untouched.
    void isPreApproval;
    if (assignedApproverId !== undefined) {
      // Body still calls it assignedApproverId but the id refers to an Admin
      // now. Resolve to the canonical assignedAdminId.
      const assignment = await resolveAssignment(assignedApproverId);
      data.assignedAdminId = assignment.adminId;
    }
    if (expiresAt !== undefined) data.expiresAt = expiresAt ? new Date(expiresAt) : null;

    const visitor = await prisma.visitor.update({
      where: { id: req.params.id },
      data,
    });

    // Audit log — diff old vs new across a whitelist of user-editable
    // fields. Only fields that actually changed land in the log so the
    // history reads cleanly. Fire-and-forget so a logging failure
    // doesn't block the response.
    captureVisitorEditLog(req, existing, visitor).catch((e) =>
      console.error('captureVisitorEditLog failed', e)
    );

    res.json(visitor);
  } catch (error) {
    console.error('updateVisitor error:', error);
    res.status(500).json({ error: 'Failed to update visitor' });
  }
}

// Whitelist of fields the audit log tracks. Excludes internal columns
// (qrCodeUrl, photoUrl URL diffs after upload, updatedAt, etc.) — only
// the fields a human edited on the form land in the trail.
const AUDIT_FIELDS = [
  'name', 'email', 'mobile', 'designation', 'companyName', 'reasonForVisit', 'notes',
  'visitDate', 'visitTime', 'status',
  'requiresApproval', 'isPreApproval',
  'assignedAdminId', 'expiresAt',
] as const;

async function captureVisitorEditLog(
  req: AuthRequest,
  before: Record<string, any>,
  after: Record<string, any>,
): Promise<void> {
  const changes: Record<string, { from: any; to: any }> = {};
  for (const f of AUDIT_FIELDS) {
    const a = serializeForDiff(before[f]);
    const b = serializeForDiff(after[f]);
    if (a !== b) changes[f] = { from: before[f] ?? null, to: after[f] ?? null };
  }
  if (Object.keys(changes).length === 0) return;

  // Display-friendly actor snapshot — survives even if the admin row
  // is later deleted.
  let editedByName = 'Workspace owner';
  let editedByEmail = '';
  if (req.adminId) {
    const admin = await prisma.admin.findUnique({
      where: { id: req.adminId },
      select: { name: true, email: true },
    });
    editedByName = admin?.name || admin?.email?.split('@')[0] || 'Admin';
    editedByEmail = admin?.email || '';
  } else if (req.ownerId) {
    const owner = await prisma.owner.findUnique({
      where: { id: req.ownerId },
      select: { name: true, email: true },
    });
    editedByName = owner?.name || owner?.email?.split('@')[0] || 'Workspace owner';
    editedByEmail = owner?.email || '';
  }

  await prisma.visitorEditLog.create({
    data: {
      visitorId: after.id,
      editedByAdminId: req.adminId || null,
      editedByName,
      editedByEmail,
      changes,
    },
  });
}

// Normalise a value for diffing so Dates and nulls compare reliably.
function serializeForDiff(v: any): string {
  if (v === null || v === undefined) return '';
  if (v instanceof Date) return v.toISOString();
  return String(v);
}

// Manual visitor check-out. Reception (or any admin who can see the
// row) clicks "Check out" to flip status ARRIVED → CHECKED_OUT and
// stamp checkedOutAt for the audit trail. Idempotent — calling twice
// on a CHECKED_OUT row no-ops. Only ARRIVED rows are eligible;
// EXPECTED / AWAITING_APPROVAL etc. can't be checked out (they were
// never checked in).
export async function checkoutVisitor(req: AuthRequest, res: Response) {
  try {
    const existing = await prisma.visitor.findFirst({
      where: { id: req.params.id, ...visitorScopeForRequest(req) },
    });
    if (!existing) { res.status(404).json({ error: 'Visitor not found' }); return; }
    if (existing.status === 'CHECKED_OUT') {
      // Already out — return as-is so the client can reconcile.
      res.json(existing);
      return;
    }
    if (existing.status !== 'ARRIVED') {
      res.status(400).json({ error: 'Only arrived visitors can be checked out' });
      return;
    }
    const visitor = await prisma.visitor.update({
      where: { id: existing.id },
      data: { status: 'CHECKED_OUT', checkedOutAt: new Date() },
    });
    emitToOwner(existing.ownerId, 'visitor.checkedOut', { visitor });
    await recordNotification({
      recipientType: 'OWNER', recipientId: existing.ownerId,
      type: 'visitor.checkedOut',
      title: `${visitor.name} checked out`,
      body: `Visitor left the premises · #${visitor.shortId}`,
      link: '/visitors',
    });
    res.json(visitor);
  } catch (error) {
    console.error('checkoutVisitor error:', error);
    res.status(500).json({ error: 'Failed to check out visitor' });
  }
}

// Returns the edit history for a visitor — most recent first. Visible
// to anyone who can see the visitor row (same scope rule as get/edit),
// so the originating admin can audit what Front Desk changed.
export async function getVisitorEditHistory(req: AuthRequest, res: Response) {
  try {
    const visitor = await prisma.visitor.findFirst({
      where: { id: req.params.id, ...visitorScopeForRequest(req) },
      select: { id: true },
    });
    if (!visitor) {
      res.status(404).json({ error: 'Visitor not found' });
      return;
    }
    const logs = await prisma.visitorEditLog.findMany({
      where: { visitorId: visitor.id },
      orderBy: { createdAt: 'desc' },
      take: 100,
    });
    res.json(logs);
  } catch (error) {
    console.error('getVisitorEditHistory error:', error);
    res.status(500).json({ error: 'Failed to fetch edit history' });
  }
}

export async function deleteVisitor(req: AuthRequest, res: Response) {
  try {
    const existing = await prisma.visitor.findFirst({
      where: { id: req.params.id, ...visitorScopeForRequest(req) },
    });

    if (!existing) {
      res.status(404).json({ error: 'Visitor not found' });
      return;
    }

    // Delete is destructive — governed by its own dedicated flag,
    // independent of Add/Edit. Requires a DB lookup since canDeleteVisitors
    // isn't on the JWT.
    if (req.adminId) {
      const admin = await prisma.admin.findFirst({
        where: { id: req.adminId, ownerId: req.ownerId, isActive: true },
        select: { canDeleteVisitors: true },
      });
      if (!admin?.canDeleteVisitors) {
        res.status(403).json({ error: 'You do not have permission to delete this visitor' });
        return;
      }
    }

    await prisma.visitorScanLog.deleteMany({ where: { visitorId: req.params.id } });
    await prisma.visitor.delete({ where: { id: req.params.id } });
    // Drop the bell entries that reference this visitor so the recipient
    // isn't left tapping a notification that points nowhere.
    await deleteVisitorNotifications([existing.shortId]);
    res.json({ message: 'Visitor deleted' });
  } catch (error) {
    console.error('deleteVisitor error:', error);
    res.status(500).json({ error: 'Failed to delete visitor' });
  }
}

export async function listVisitorCheckpoints(req: AuthRequest, res: Response) {
  try {
    const checkpoints = await prisma.visitorCheckpoint.findMany({
      where: { ownerId: req.ownerId },
      orderBy: { createdAt: 'desc' },
      include: {
        _count: {
          select: { visitorRequests: true, scanLogs: true },
        },
      },
    });

    res.json(checkpoints);
  } catch (error) {
    console.error('listVisitorCheckpoints error:', error);
    res.status(500).json({ error: 'Failed to fetch visitor checkpoints' });
  }
}

export async function getCheckpointHistory(req: AuthRequest, res: Response) {
  try {
    const existing = await prisma.visitorCheckpoint.findFirst({
      where: { id: req.params.id, ownerId: req.ownerId },
    });
    if (!existing) {
      res.status(404).json({ error: 'Checkpoint not found' });
      return;
    }

    const [requests, scans] = await Promise.all([
      prisma.visitorRequest.findMany({
        where: { checkpointId: req.params.id, ownerId: req.ownerId },
        orderBy: { createdAt: 'desc' },
        take: 100,
      }),
      prisma.visitorScanLog.findMany({
        where: { checkpointId: req.params.id },
        orderBy: { scannedAt: 'desc' },
        take: 100,
        include: {
          visitor: { select: { name: true, shortId: true, status: true } },
        },
      }),
    ]);

    res.json({ requests, scans });
  } catch (error) {
    console.error('getCheckpointHistory error:', error);
    res.status(500).json({ error: 'Failed to fetch checkpoint history' });
  }
}

// ─── On-demand WhatsApp template send (visitor list picker) ─────────────────

// Map a Prisma visitor row to the WAVisitor shape the send pipeline expects.
function toWAVisitor(v: any) {
  return {
    id: v.id,
    name: v.name,
    email: v.email ?? null,
    mobile: v.mobile ?? null,
    shortId: v.shortId,
    visitDate: v.visitDate ?? null,
    visitTime: v.visitTime ?? null,
    notes: v.notes ?? null,
    reasonForVisit: v.reasonForVisit ?? null,
    createdByAdminId: v.createdByAdminId ?? null,
    assignedAdminId: v.assignedAdminId ?? null,
    assignedApproverId: v.assignedApproverId ?? null,
  };
}

// GET /visitors/:id/whatsapp-templates — templates offered for this visitor
// (toggle ON + status allowed), each with prefilled params.
export async function listVisitorWhatsAppTemplates(req: AuthRequest, res: Response) {
  try {
    const { listSendableTemplatesForVisitor } = await import('../lib/whatsappSend');
    const v = await prisma.visitor.findFirst({ where: { id: req.params.id, ...visitorScopeForRequest(req) } });
    if (!v) { res.status(404).json({ error: 'Visitor not found' }); return; }
    const templates = await listSendableTemplatesForVisitor(req.ownerId!, toWAVisitor(v), v.status, req);
    res.json({ mobile: v.mobile, status: v.status, templates });
  } catch (error) {
    console.error('listVisitorWhatsAppTemplates error:', error);
    res.status(500).json({ error: 'Failed to load WhatsApp templates' });
  }
}

// POST /visitors/:id/whatsapp-send { type } — dispatch a chosen template.
export async function sendVisitorWhatsAppTemplate(req: AuthRequest, res: Response) {
  try {
    const { sendTemplateForVisitor } = await import('../lib/whatsappSend');
    const type = req.body?.type;
    const to = typeof req.body?.to === 'string' ? req.body.to : undefined;
    const params = Array.isArray(req.body?.params) ? req.body.params.map((p: unknown) => String(p ?? '')) : undefined;
    if (!type) { res.status(400).json({ error: 'Template type is required.' }); return; }
    const v = await prisma.visitor.findFirst({ where: { id: req.params.id, ...visitorScopeForRequest(req) } });
    if (!v) { res.status(404).json({ error: 'Visitor not found' }); return; }
    const result = await sendTemplateForVisitor(req.ownerId!, toWAVisitor(v), type, v.status, req, to, params);
    if (!result.ok) { res.status(400).json({ error: result.error || 'Failed to send WhatsApp message.' }); return; }
    res.json({ ok: true });
  } catch (error) {
    console.error('sendVisitorWhatsAppTemplate error:', error);
    res.status(500).json({ error: 'Failed to send WhatsApp message' });
  }
}

export async function createVisitorCheckpoint(req: AuthRequest, res: Response) {
  try {
    const { name, personName, mobile, password } = req.body;

    if (!personName || !mobile || !password) {
      res.status(400).json({ error: 'Person name, mobile, and password are required' });
      return;
    }

    // Strip whitespace so the stored value lines up with what the scanner
    // login sees after its own normalisation — otherwise an admin who pastes
    // "98765 43210" creates a row the operator can never sign in to.
    const mobileClean = String(mobile).replace(/\s+/g, '');
    if (!mobileClean) {
      res.status(400).json({ error: 'Mobile is required' });
      return;
    }

    const passwordHash = bcrypt.hashSync(password, 10);

    const checkpoint = await prisma.visitorCheckpoint.create({
      data: {
        ownerId: req.ownerId!,
        name: name || 'Visitor Check-in',
        personName,
        mobile: mobileClean,
        passwordHash,
        plainPassword: password,
      },
    });

    res.status(201).json(checkpoint);
  } catch (error) {
    console.error('createVisitorCheckpoint error:', error);
    res.status(500).json({ error: 'Failed to create visitor checkpoint' });
  }
}

export async function updateVisitorCheckpoint(req: AuthRequest, res: Response) {
  try {
    const existing = await prisma.visitorCheckpoint.findFirst({
      where: { id: req.params.id, ownerId: req.ownerId },
    });

    if (!existing) {
      res.status(404).json({ error: 'Visitor checkpoint not found' });
      return;
    }

    const { name, personName, mobile, password, isActive } = req.body;

    const data: any = {};
    if (name !== undefined) data.name = name;
    if (personName !== undefined) data.personName = personName;
    if (mobile !== undefined) data.mobile = String(mobile).replace(/\s+/g, '');
    if (password) {
      data.passwordHash = bcrypt.hashSync(password, 10);
      data.plainPassword = password;
    }
    if (isActive !== undefined) data.isActive = isActive;

    const checkpoint = await prisma.visitorCheckpoint.update({
      where: { id: req.params.id },
      data,
    });

    res.json(checkpoint);
  } catch (error) {
    console.error('updateVisitorCheckpoint error:', error);
    res.status(500).json({ error: 'Failed to update visitor checkpoint' });
  }
}

export async function deleteVisitorCheckpoint(req: AuthRequest, res: Response) {
  try {
    const existing = await prisma.visitorCheckpoint.findFirst({
      where: { id: req.params.id, ownerId: req.ownerId },
    });

    if (!existing) {
      res.status(404).json({ error: 'Visitor checkpoint not found' });
      return;
    }

    await prisma.visitorRequest.deleteMany({ where: { checkpointId: req.params.id } });
    await prisma.visitorScanLog.deleteMany({ where: { checkpointId: req.params.id } });
    await prisma.visitorCheckpoint.delete({ where: { id: req.params.id } });
    res.json({ message: 'Visitor checkpoint deleted' });
  } catch (error) {
    console.error('deleteVisitorCheckpoint error:', error);
    res.status(500).json({ error: 'Failed to delete visitor checkpoint' });
  }
}

export async function listVisitorRequests(req: AuthRequest, res: Response) {
  try {
    const { status } = req.query;

    const where: any = { ownerId: req.ownerId };
    if (status) where.status = status;

    const requests = await prisma.visitorRequest.findMany({
      where,
      orderBy: { createdAt: 'desc' },
      include: {
        checkpoint: { select: { name: true, personName: true } },
      },
    });

    res.json(requests);
  } catch (error) {
    console.error('listVisitorRequests error:', error);
    res.status(500).json({ error: 'Failed to fetch visitor requests' });
  }
}

export async function approveVisitorRequest(req: AuthRequest, res: Response) {
  try {
    const { id } = req.params;
    const { ownerNote } = req.body;

    const existing = await prisma.visitorRequest.findFirst({
      where: { id, ownerId: req.ownerId },
    });

    if (!existing) {
      res.status(404).json({ error: 'Request not found' });
      return;
    }

    if (existing.status !== 'PENDING') {
      res.status(400).json({ error: 'Request already processed' });
      return;
    }

    const updated = await prisma.visitorRequest.update({
      where: { id },
      data: {
        status: 'APPROVED',
        ownerNote: ownerNote || null,
      },
      include: {
        checkpoint: { select: { name: true, personName: true } },
      },
    });

    // Flip the linked Visitor (created when the walk-in was submitted) to
    // ARRIVED. For legacy walk-ins without a linked visitor (pre-option-2
    // rows), materialise one now so the list still reflects the approval.
    let visitor;
    if (existing.visitorId) {
      visitor = await prisma.visitor.update({
        where: { id: existing.visitorId },
        data: { status: 'ARRIVED', arrivedAt: new Date(), approvalNote: ownerNote || null },
      });
    } else {
      const shortId = nanoid(10).toUpperCase();
      const qrBuffer = await generateQRCodeBuffer(shortId);
      const qrUrl = await saveUpload(qrBuffer, `visitors/qr/${shortId}.png`, 'image/png', req);
      visitor = await prisma.visitor.create({
        data: {
          ownerId: req.ownerId!,
          shortId,
          name: existing.name,
          email: existing.email || null,
          mobile: existing.phone || null,
          reasonForVisit: existing.reason,
          notes: existing.company ? `Walk-in from ${existing.company}` : null,
          visitDate: new Date(),
          qrCodeUrl: qrUrl,
          status: 'ARRIVED',
          arrivedAt: new Date(),
          approvalNote: ownerNote || null,
        },
      });
      await prisma.visitorScanLog.create({
        data: { visitorId: visitor.id, checkpointId: existing.checkpointId },
      });
      await prisma.visitorRequest.update({ where: { id: updated.id }, data: { visitorId: visitor.id } });
    }

    emitToCheckpoint(updated.checkpointId, 'request.decided', { request: updated, visitor });
    await recordNotification({
      recipientType: 'CHECKPOINT', recipientId: updated.checkpointId,
      type: 'request.decided',
      title: `${updated.name} approved`,
      body: updated.ownerNote
        ? `Host approved walk-in — ${updated.ownerNote}`
        : 'Host approved walk-in — please let them in',
      link: '/visitor-scanner',
      push: { data: { visitorId: visitor.id, shortId: visitor.shortId, kind: 'visitor.decided' } },
    });
    emitToOwner(req.ownerId!, 'visitor.arrived', { visitor });
    await recordNotification({
      recipientType: 'OWNER', recipientId: req.ownerId!,
      type: 'visitor.arrived',
      title: `${updated.name} approved`,
      body: updated.ownerNote
        ? `Walk-in approved — ${updated.ownerNote} · #${visitor.shortId}`
        : `Walk-in approved — visitor cleared for entry · #${visitor.shortId}`,
      link: '/visitors',
      push: { data: { visitorId: visitor.id, shortId: visitor.shortId, kind: 'visitor.arrived' } },
    });

    res.json({ ...updated, visitor });
  } catch (error) {
    console.error('approveVisitorRequest error:', error);
    res.status(500).json({ error: 'Failed to approve request' });
  }
}

export async function rejectVisitorRequest(req: AuthRequest, res: Response) {
  try {
    const { id } = req.params;
    const { ownerNote } = req.body;

    const existing = await prisma.visitorRequest.findFirst({
      where: { id, ownerId: req.ownerId },
    });

    if (!existing) {
      res.status(404).json({ error: 'Request not found' });
      return;
    }

    if (existing.status !== 'PENDING') {
      res.status(400).json({ error: 'Request already processed' });
      return;
    }

    const updated = await prisma.visitorRequest.update({
      where: { id },
      data: {
        status: 'REJECTED',
        ownerNote: ownerNote || null,
      },
      include: {
        checkpoint: { select: { name: true, personName: true } },
      },
    });

    // Flip the linked Visitor (if any) to REJECTED so the visitors list
    // reflects the decision.
    let visitor = null;
    if (existing.visitorId) {
      visitor = await prisma.visitor.update({
        where: { id: existing.visitorId },
        data: { status: 'REJECTED', approvalNote: ownerNote || null },
      });
      emitToOwner(req.ownerId!, 'visitor.decided', { visitor });
      await recordNotification({
        recipientType: 'OWNER', recipientId: req.ownerId!,
        type: 'visitor.decided',
        title: `${updated.name} rejected`,
        body: updated.ownerNote
          ? `Walk-in rejected — ${updated.ownerNote} · #${visitor.shortId}`
          : `Walk-in rejected — entry denied · #${visitor.shortId}`,
        link: '/visitors',
        push: { data: { visitorId: visitor.id, shortId: visitor.shortId, kind: 'visitor.decided' } },
      });
    }

    emitToCheckpoint(updated.checkpointId, 'request.decided', { request: updated, visitor });
    await recordNotification({
      recipientType: 'CHECKPOINT', recipientId: updated.checkpointId,
      type: 'request.decided',
      title: `${updated.name} rejected`,
      body: updated.ownerNote
        ? `Host rejected walk-in — ${updated.ownerNote}`
        : 'Host rejected walk-in — please deny entry',
      link: '/visitor-scanner',
      push: visitor ? { data: { visitorId: visitor.id, shortId: visitor.shortId, kind: 'visitor.decided' } } : undefined,
    });

    res.json({ ...updated, visitor });
  } catch (error) {
    console.error('rejectVisitorRequest error:', error);
    res.status(500).json({ error: 'Failed to reject request' });
  }
}

// --- Pre-registered approval-required scan: owner decision -------------------

export async function approveVisitorScan(req: AuthRequest, res: Response) {
  try {
    const { id } = req.params;
    const { approvalNote } = req.body;

    const existing = await prisma.visitor.findFirst({
      where: { id, ownerId: req.ownerId },
    });

    if (!existing) {
      res.status(404).json({ error: 'Visitor not found' });
      return;
    }

    // Admins can only decide on visitors assigned to them. Owners decide on
    // anyone (including unassigned visitors that default to them).
    if (req.adminId && existing.assignedAdminId !== req.adminId) {
      res.status(403).json({ error: 'This visitor is assigned to another admin — only they can approve it.' });
      return;
    }

    if (existing.status !== 'AWAITING_APPROVAL') {
      res.status(400).json({ error: 'Visitor is not awaiting approval' });
      return;
    }

    // Approval always lands at ARRIVED. Pre-approval = approver clears the
    // visitor from the dashboard (no scan needed); live approval = approver
    // clears at scan time. Either way the visitor is "in", so arrivedAt is
    // stamped now. isAdvance is kept around to label the notification.
    const isAdvance = existing.isPreApproval;
    const decisionAt = new Date();
    const visitor = await prisma.visitor.update({
      where: { id },
      data: {
        status: 'ARRIVED',
        arrivedAt: decisionAt,
        approvalNote: approvalNote || null,
        // One-shot QR: stamp expiresAt = now so any rescan after the
        // host's decision hits the scanner's expiresAt < now guard and
        // returns "Entry expired", instead of generating a fresh
        // approval request. A returning visitor needs a fresh invite.
        // Frequent visitors are exempt — their QR is meant to be reused
        // across many visits, and each entry goes through the configured
        // check-in policy anyway.
        ...(existing.isFrequent ? {} : { expiresAt: decisionAt }),
      },
      include: {
        scanLogs: {
          include: { checkpoint: { select: { name: true, personName: true } } },
          orderBy: { scannedAt: 'desc' },
          take: 1,
        },
      },
    });

    // Walk-ins carry a sibling VisitorRequest in PENDING. When the visitor
    // is approved via this endpoint (e.g. the table's inline Approve
    // button), the VisitorRequest would stay PENDING unless we sync it
    // here — which keeps it stuck in the "Walk-in requests" card on
    // /visitors across page refreshes. Keep them in lockstep.
    await prisma.visitorRequest.updateMany({
      where: { visitorId: id, status: 'PENDING' },
      data: { status: 'APPROVED', ownerNote: approvalNote || null },
    });

    // Resolve who actually pressed the button. Sub-admins are tracked via
    // req.adminId; otherwise it's the workspace owner. Name + label feed
    // into the notification copy below so the bell shows "by <actor>".
    const actorOwner = await prisma.owner.findUnique({ where: { id: req.ownerId! }, select: { name: true, email: true } });
    const actorAdmin = req.adminId
      ? await prisma.admin.findUnique({ where: { id: req.adminId }, select: { name: true, email: true } })
      : null;
    const actorName = actorAdmin?.name || actorAdmin?.email || actorOwner?.name || actorOwner?.email || 'Owner';

    // Tell the owner's own dashboard (which is what triggered this call) to
    // refresh — without this, the card stays in the "Awaiting" pile until a
    // manual reload because the owner room never received an event. We also
    // log the decision in the owner's notification list so they have a
    // history of "Devang Shah approved by Reception" without re-reading
    // visitor rows.
    emitToOwner(req.ownerId!, 'visitor.decided', { visitor });
    await recordNotification({
      recipientType: 'OWNER', recipientId: req.ownerId!,
      type: 'visitor.decided',
      title: `${visitor.name} approved`,
      body: `Approved by ${actorName} — visitor marked arrived · #${visitor.shortId}`,
      link: '/visitors',
    });

    // Notify the assigned approver (if any) so their dashboard updates;
    // and the checkpoint where the visitor was last scanned so the reception card flips green.
    // After the Admin/Approver merge the assignee can live on either FK.
    const decidedAssignees = [visitor.assignedApproverId, visitor.assignedAdminId].filter((x): x is string => !!x);
    for (const id of decidedAssignees) {
      emitToApprover(id, 'visitor.decided', { visitor });
      await recordNotification({
        recipientType: 'APPROVER', recipientId: id,
        type: 'visitor.decided',
        title: `${visitor.name} approved`,
        body: `${actorName} approved this visitor — no action needed · #${visitor.shortId}`,
        link: '/approver',
        push: { data: { visitorId: visitor.id, shortId: visitor.shortId, kind: 'visitor.decided' } },
      });
    }
    // Pre-approval has no scanner waiting yet — skip the checkpoint emit.
    if (!isAdvance) {
      const lastScan = visitor.scanLogs?.[0];
      if (lastScan?.checkpoint) {
        const cpId = (await prisma.visitorScanLog.findUnique({ where: { id: lastScan.id }, select: { checkpointId: true } }))?.checkpointId;
        if (cpId) {
          emitToCheckpoint(cpId, 'visitor.decided', { visitor });
          await recordNotification({
            recipientType: 'CHECKPOINT', recipientId: cpId,
            type: 'visitor.decided',
            title: `${visitor.name} approved`,
            body: `Host cleared entry — please let them in · #${visitor.shortId}`,
            link: '/visitor-scanner',
            push: { data: { visitorId: visitor.id, shortId: visitor.shortId, kind: 'visitor.decided' } },
          });
        }
      }
    }

    await resolveAwaitingNotifications(visitor.shortId);

    res.json(visitor);
  } catch (error) {
    console.error('approveVisitorScan error:', error);
    res.status(500).json({ error: 'Failed to approve visitor' });
  }
}

export async function rejectVisitorScan(req: AuthRequest, res: Response) {
  try {
    const { id } = req.params;
    const { approvalNote } = req.body;

    const existing = await prisma.visitor.findFirst({
      where: { id, ownerId: req.ownerId },
    });

    if (!existing) {
      res.status(404).json({ error: 'Visitor not found' });
      return;
    }

    if (req.adminId && existing.assignedAdminId !== req.adminId) {
      res.status(403).json({ error: 'This visitor is assigned to another admin — only they can reject it.' });
      return;
    }

    if (existing.status !== 'AWAITING_APPROVAL') {
      res.status(400).json({ error: 'Visitor is not awaiting approval' });
      return;
    }

    const decisionAt = new Date();
    const visitor = await prisma.visitor.update({
      where: { id },
      data: {
        status: 'REJECTED',
        approvalNote: approvalNote || null,
        // Same one-shot QR rule as the approve path — once denied, the
        // pass is dead. Frequent visitors are exempt; if their host
        // rejected this visit, they can still attempt a future visit
        // through the same QR (and get re-decided on that scan).
        ...(existing.isFrequent ? {} : { expiresAt: decisionAt }),
      },
      include: {
        scanLogs: {
          include: { checkpoint: { select: { name: true, personName: true } } },
          orderBy: { scannedAt: 'desc' },
          take: 1,
        },
      },
    });

    // Mirror the rejection onto the sibling VisitorRequest (if this
    // Visitor came from a walk-in). Keeps the walk-in card in sync with
    // the table after a page refresh.
    await prisma.visitorRequest.updateMany({
      where: { visitorId: id, status: 'PENDING' },
      data: { status: 'REJECTED', ownerNote: approvalNote || null },
    });

    // Resolve the actor for the notification copy (owner or sub-admin).
    const rejActorOwner = await prisma.owner.findUnique({ where: { id: req.ownerId! }, select: { name: true, email: true } });
    const rejActorAdmin = req.adminId
      ? await prisma.admin.findUnique({ where: { id: req.adminId }, select: { name: true, email: true } })
      : null;
    const rejActorName = rejActorAdmin?.name || rejActorAdmin?.email || rejActorOwner?.name || rejActorOwner?.email || 'Owner';

    // Owner self-refresh + log the rejection in their notification history.
    emitToOwner(req.ownerId!, 'visitor.decided', { visitor });
    await recordNotification({
      recipientType: 'OWNER', recipientId: req.ownerId!,
      type: 'visitor.decided',
      title: `${visitor.name} rejected`,
      body: visitor.approvalNote
        ? `Rejected by ${rejActorName} — ${visitor.approvalNote} · #${visitor.shortId}`
        : `Rejected by ${rejActorName} — entry denied · #${visitor.shortId}`,
      link: '/visitors',
    });

    const rejectedAssignees = [visitor.assignedApproverId, visitor.assignedAdminId].filter((x): x is string => !!x);
    for (const id of rejectedAssignees) {
      emitToApprover(id, 'visitor.decided', { visitor });
      await recordNotification({
        recipientType: 'APPROVER', recipientId: id,
        type: 'visitor.decided',
        title: `${visitor.name} rejected`,
        body: visitor.approvalNote
          ? `${rejActorName} rejected — ${visitor.approvalNote} · #${visitor.shortId}`
          : `${rejActorName} rejected — entry denied · #${visitor.shortId}`,
        link: '/approver',
        push: { data: { visitorId: visitor.id, shortId: visitor.shortId, kind: 'visitor.decided' } },
      });
    }
    const lastScan = visitor.scanLogs?.[0];
    if (lastScan) {
      const cpId = (await prisma.visitorScanLog.findUnique({ where: { id: lastScan.id }, select: { checkpointId: true } }))?.checkpointId;
      if (cpId) {
        emitToCheckpoint(cpId, 'visitor.decided', { visitor });
        await recordNotification({
          recipientType: 'CHECKPOINT', recipientId: cpId,
          type: 'visitor.decided',
          title: `${visitor.name} rejected`,
          body: visitor.approvalNote
            ? `Host denied entry — ${visitor.approvalNote} · #${visitor.shortId}`
            : `Host denied entry · #${visitor.shortId}`,
          link: '/visitor-scanner',
          push: { data: { visitorId: visitor.id, shortId: visitor.shortId, kind: 'visitor.decided' } },
        });
      }
    }

    await resolveAwaitingNotifications(visitor.shortId);

    res.json(visitor);
  } catch (error) {
    console.error('rejectVisitorScan error:', error);
    res.status(500).json({ error: 'Failed to reject visitor' });
  }
}
