// Sandbox: simulate the operator's side (BOO-864).
//
// In production most booking.updated webhooks fire because an operator changed
// something in their table management system, and venue.updated fires when a
// venue closes. Sandbox has no operator, so these let a partner trigger those
// webhooks themselves and see exactly what their endpoint did with them:
//   - the booking drawer's Sandbox strip, and its simulate / message / result modes
//   - "Send test venue closure" on a results venue card
// Nothing here renders outside sandbox mode, and /api/sandbox refuses outside it.
//
// Where this differs from the design handoff, it's because the design showed
// something Bookable doesn't have (never invent it):
//   - Request headers are the two Bookable actually sends (Content-Type and the
//     X-API-Key signature), not the design's X-Bookable-* set.
//   - The operator message has a body only: the messages API takes no subject
//     or sender, and says nothing about whether the webhook was delivered.
//   - There is no Webhooks settings screen yet, so the no-callback state points
//     at POST /webhooks instead.

const PP_SANDBOX_AMBER = '#7a4a00';

const PP_TONES = {
  ok:      { fg: '#1a6b3a', bg: '#eaf6ee', border: '#c9e7d3', iconBg: '#d7f0e1' },
  error:   { fg: '#8a1f1a', bg: '#fdf2f2', border: '#f4d9d9', iconBg: '#fde2e0' },
  warn:    { fg: '#8c5a16', bg: '#fbf1e0', border: '#efd8ab', iconBg: '#fff2c4' },
  neutral: { fg: '#3d4866', bg: '#fbf9f4', border: '#ece6d8', iconBg: '#f4f0e6' },
};

const PP_HTTP_REASON = {
  200: 'OK', 201: 'Created', 202: 'Accepted', 204: 'No Content', 301: 'Moved Permanently', 302: 'Found',
  400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed',
  408: 'Request Timeout', 409: 'Conflict', 422: 'Unprocessable Entity', 429: 'Too Many Requests',
  500: 'Internal Server Error', 502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Timeout',
};

// Labels follow the Bookings API's own BookingStatus definitions. Only the
// statuses Bookable actually stores: `deleted` and `lost` are in the enum but
// never written (Collins' own lost/deleted arrive as cancelled).
const PP_SIM_STATUSES = [
  ['pending', 'Pending — received, not yet processed'],
  ['in_progress', 'In progress — enquiry assigned, not yet confirmed'],
  ['confirmed', 'Confirmed'],
  ['cancelled', 'Cancelled by the operator'],
];

// ── Pure helpers (tests/sandbox-sim.test.js) ─────────────────────────────────

// The booking's package pre-orders as editable rows.
function ppPreorderRows(b) {
  const packages = b && b.preorders && b.preorders.packages;
  return Array.isArray(packages)
    ? packages.map(p => ({ packageId: p.id == null ? '' : String(p.id), name: p.name || '', quantity: String(p.quantity == null ? 1 : p.quantity) }))
    : [];
}

// The simulate form, seeded from the booking.
function ppSimulateSeed(b) {
  return {
    actualGuests: b.actualGuests == null ? '' : String(b.actualGuests),
    actualSpend: b.actualSpend == null ? '' : String(b.actualSpend),
    noShow: false,
    status: '',
    date: b.date || '',
    time: String(b.time || '').slice(0, 5),
    partySize: String(b.guests),
    duration: b.duration == null ? '' : String(b.duration),
    compositeId: b.compositeId || '',
    reference: b.operatorBookingId || '',
    source: '',
    notes: b.notes || '',
    firstName: b.firstName || '',
    lastName: b.lastName || '',
    email: b.email || '',
    phone: b.phone || '',
    preorders: ppPreorderRows(b),
  };
}

// The request body: only what differs from the booking. A no-show forces
// actualGuests 0 + actualSpend 0; blank actuals are left out so they stay null.
function ppSimulateDiff(b, f) {
  const out = {};
  if (f.noShow) {
    out.actualGuests = 0;
    out.actualSpend = 0;
  } else {
    if (f.actualGuests !== '' && Number(f.actualGuests) !== b.actualGuests) out.actualGuests = Number(f.actualGuests);
    if (f.actualSpend !== '' && Number(f.actualSpend) !== b.actualSpend) out.actualSpend = Number(f.actualSpend);
  }
  if (f.status && f.status !== b.status) out.status = f.status;
  if (f.date && f.date !== b.date) out.date = f.date;
  if (f.time && f.time !== String(b.time || '').slice(0, 5)) out.time = f.time;
  if (f.partySize !== '' && Number(f.partySize) !== b.guests) out.partySize = Number(f.partySize);
  if (f.duration !== '' && Number(f.duration) !== b.duration) out.duration = Number(f.duration);
  if (f.compositeId && f.compositeId !== b.compositeId) out.compositeId = f.compositeId;
  const text = [
    ['reference', b.operatorBookingId], ['notes', b.notes], ['firstName', b.firstName],
    ['lastName', b.lastName], ['email', b.email], ['phone', b.phone],
  ];
  for (const [k, current] of text) if (f[k] !== (current || '')) out[k] = f[k];
  // The booking doesn't expose its source, so there is nothing to compare with.
  if (f.source) out.source = f.source;
  if (JSON.stringify(f.preorders) !== JSON.stringify(ppPreorderRows(b))) {
    out.preorders = f.preorders.map(p => ({ packageId: p.packageId.trim(), name: p.name.trim() || null, quantity: Number(p.quantity) }));
  }
  return out;
}

// Why the body can't be sent, if it can't. The webhook requires these, so
// upstream would refuse them blank.
function ppSimulateProblem(body) {
  for (const [k, label] of [['reference', 'Reference'], ['firstName', 'First name'], ['lastName', 'Last name'], ['email', 'Email']]) {
    if (k in body && !String(body[k]).trim()) return label + ' can’t be blank';
  }
  if (body.preorders && body.preorders.some(p => !p.packageId)) return 'Every pre-order needs a package id';
  if (body.preorders && body.preorders.some(p => !(p.quantity >= 1))) return 'Pre-order quantities start at 1';
  return null;
}

function ppStatusText(code) {
  return code == null ? '—' : code + (PP_HTTP_REASON[code] ? ' ' + PP_HTTP_REASON[code] : '');
}

// What the result card says about a SimulatedWebhookDelivery.
function ppDeliveryView(d) {
  const venue = d.event === 'venue.updated';
  if (d.delivered) {
    return {
      tone: 'ok', ok: true, title: 'Delivered', chip: ppStatusText(d.statusCode),
      sub: venue
        ? 'Your endpoint accepted the webhook. In production this is what a venue closure would have sent.'
        : 'Your endpoint accepted the webhook. In production this is exactly what the operator’s change would have sent.',
    };
  }
  if (d.timedOut) {
    return {
      tone: 'warn', title: 'Endpoint timed out', chip: 'Timeout · 10 s',
      sub: 'No response within 10 seconds. Check that the URL is reachable from the internet and responds before doing slow work.',
    };
  }
  if (d.statusCode == null) {
    return {
      tone: 'error', title: 'Couldn’t reach your endpoint', chip: 'No response',
      sub: 'Bookable couldn’t connect to the URL' + (d.error ? ': ' + d.error : '.') ,
    };
  }
  return {
    tone: 'error', title: 'Endpoint returned an error', chip: ppStatusText(d.statusCode),
    sub: 'Bookable sent the webhook but your endpoint failed to process it. Fix the receiver and resend — the payload below is what it saw.',
  };
}

// The upstream problem details behind a failed /api/sandbox call, if any.
function ppSandboxProblem(e) {
  const detail = e && e.body && e.body.detail;
  return detail && typeof detail === 'object' ? detail : null;
}

// A refusal the strip should show as a state rather than an error.
function ppSandboxBlocked(e) {
  const p = ppSandboxProblem(e);
  if (p && p.code === 'SBOX-N-003') return 'no-callback';
  if (p && p.code === 'PMAP-N-003') return 'not-owned';
  return null;
}

function ppSandboxError(e) {
  const p = ppSandboxProblem(e);
  if (p) {
    const fieldErrors = p.errors ? Object.values(p.errors).flat() : [];
    return [p.detail || p.title, ...fieldErrors].filter(Boolean).join(' ');
  }
  if (e && e.status === 401) return 'The sandbox API rejected your session (401).';
  if (e && e.status === 403) return 'Bookable refused this for your sandbox client (403).';
  return 'Couldn’t reach Bookable. Try again.';
}

function ppPrettyJson(text) {
  try { return JSON.stringify(JSON.parse(text), null, 2); } catch { return text; }
}

function ppNowTime() {
  return new Date().toLocaleTimeString('en-GB');
}

// ── Hook ─────────────────────────────────────────────────────────────────────

// The registered sandbox callback URL. status: loading | ready | error.
function usePpSandboxWebhook(enabled) {
  const [state, setState] = React.useState({ status: 'loading', callbackUrl: null });
  React.useEffect(() => {
    if (!enabled) return;
    let live = true;
    ppLoadSandboxWebhook()
      .then(r => live && setState({ status: 'ready', callbackUrl: (r && r.callbackUrl) || null }))
      .catch(() => live && setState({ status: 'error', callbackUrl: null }));
    return () => { live = false; };
  }, [enabled]);
  return state;
}

// ── Small pieces ─────────────────────────────────────────────────────────────

function SandboxPill() {
  return <span className="pp-today-eyebrow">Sandbox</span>;
}

function SandboxNote({ children }) {
  return (
    <div style={{ background: '#fff8e8', border: '1px solid #f4d8a7', borderRadius: 10, padding: '10px 14px', display: 'flex', gap: 10, alignItems: 'flex-start', flexShrink: 0 }}>
      <SandboxPill/>
      <span style={{ fontSize: 12.5, lineHeight: 1.5, color: '#8a5612' }}>{children}</span>
    </div>
  );
}

function SandboxCode({ children, navy, maxHeight }) {
  const look = navy
    ? { background: '#0b1530', color: '#dfe6ff' }
    : { background: '#fbf9f4', border: '1px solid #ece6d8', color: '#0b1530' };
  return (
    <pre className="pp-mono" style={{ margin: 0, borderRadius: 10, padding: '12px 14px', fontSize: 11.5, lineHeight: 1.55, whiteSpace: 'pre-wrap', wordBreak: 'break-word', textAlign: 'left', ...(maxHeight ? { maxHeight, overflow: 'auto' } : {}), ...look }}>
      {children}
    </pre>
  );
}

function SandboxDetails({ title, hint, open, children }) {
  return (
    <details className="pp-sim-details" open={open}>
      <summary style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
        <h3 className="pp-form-section-title" style={{ margin: 0 }}>{title}</h3>
        <span style={{ fontSize: 11.5, color: '#6a7088' }}>{hint}</span>
      </summary>
      <div style={{ marginTop: 10 }}>{children}</div>
    </details>
  );
}

function SandboxSendButton({ disabled, onClick, children }) {
  return (
    <button className="pp-btn pp-btn--primary" onClick={onClick} disabled={disabled} style={{ background: PP_SANDBOX_AMBER }}>
      <IconSend size={14}/>
      <span>{children}</span>
    </button>
  );
}

function LockGlyph({ size = 16 }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
      <rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>
    </svg>
  );
}

function SandboxBlockedCard({ icon, title, children }) {
  return (
    <div style={{ background: '#fff', border: '1px solid #f4d8a7', borderRadius: 8, padding: '10px 12px', display: 'flex', gap: 10, alignItems: 'flex-start' }}>
      <span style={{ color: '#b9791f', flexShrink: 0, display: 'grid' }}>{icon}</span>
      <div>
        <div style={{ fontSize: 13, fontWeight: 600, color: '#7a4a00' }}>{title}</div>
        <div style={{ fontSize: 12.5, lineHeight: 1.5, color: '#8a5612', marginTop: 2 }}>{children}</div>
      </div>
    </div>
  );
}

function SandboxErrorBanner({ children }) {
  return (
    <div className="pp-banner-warn">
      <AlertGlyph size={14}/>
      <span>{children}</span>
    </div>
  );
}

const ppMono = { fontSize: 12 };
const ppDisabledInput = { background: '#f4f0e6', cursor: 'not-allowed' };

// ── Booking drawer: Sandbox strip (overview) ─────────────────────────────────

function SandboxStrip({ webhook, blocked, last, onSimulate, onMessage, onShowLast }) {
  const noCallback = blocked === 'no-callback' || (webhook.status === 'ready' && !webhook.callbackUrl);
  const lastView = last && last.delivery ? ppDeliveryView(last.delivery) : null;
  return (
    <section style={{ background: '#fff8e8', border: '1px solid #f4d8a7', borderRadius: 10, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10, flexShrink: 0 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
        <SandboxPill/>
        <span style={{ fontSize: 13, fontWeight: 600, color: '#7a4a00' }}>Simulate the operator’s side</span>
      </div>
      {blocked === 'not-owned' ? (
        <SandboxBlockedCard icon={<LockGlyph/>} title="Not your booking">
          Bookable only sends a booking’s webhooks to the partner client that made it, and this one wasn’t made by
          yours. Pick a booking your client made to simulate operator changes.
        </SandboxBlockedCard>
      ) : noCallback ? (
        <SandboxBlockedCard icon={<AlertGlyph size={16}/>} title="No webhook endpoint registered">
          Simulated operator actions deliver to your sandbox callback URL, and there isn’t one yet. Register it
          with <span className="pp-mono" style={ppMono}>POST /webhooks</span> against the sandbox API, then reload this page.
        </SandboxBlockedCard>
      ) : (
        <React.Fragment>
          <p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.5, color: '#8a5612' }}>
            Trigger the webhooks an operator’s system would send for this test booking — actuals, status changes,
            amendments, messages. Nothing is sent to a venue.
          </p>
          <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
            <button type="button" className="pp-btn pp-btn--xs" style={{ borderColor: '#e9c98f', background: '#fff' }} onClick={onSimulate}>
              <IconPlus size={12}/> Simulate operator change
            </button>
            <button type="button" className="pp-btn pp-btn--xs" style={{ borderColor: '#e9c98f', background: '#fff' }} onClick={onMessage}>
              <IconMail size={12}/> Send operator message
            </button>
          </div>
          <div style={{ fontSize: 11.5, color: '#8a5612', display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
            <span>Delivers to</span>
            {webhook.callbackUrl
              ? <span className="pp-mono" style={{ fontSize: 11.5, color: '#7a4a00', wordBreak: 'break-all' }}>{webhook.callbackUrl}</span>
              : <span>your registered sandbox callback URL</span>}
            {last && (
              <React.Fragment>
                <span>· Last delivery</span>
                <span className="pp-status-dot" style={{ background: lastView ? PP_TONES[lastView.tone].fg : PP_TONES.neutral.fg }}/>
                <span style={{ fontWeight: 600, color: lastView ? PP_TONES[lastView.tone].fg : PP_TONES.neutral.fg }}>
                  {last.event} · {lastView ? lastView.chip : 'sent'} · {last.at}
                </span>
                <button type="button" onClick={onShowLast}
                        style={{ border: 0, background: 'none', padding: 0, fontSize: 11.5, fontWeight: 600, color: '#1c47ff', textDecoration: 'underline', cursor: 'pointer' }}>
                  View
                </button>
              </React.Fragment>
            )}
          </div>
        </React.Fragment>
      )}
    </section>
  );
}

// ── Booking drawer: Simulate operator change ─────────────────────────────────

function SandboxSimulatePanel({ booking, venue, onBack, onResult, onBlocked }) {
  const [form, setForm] = React.useState(() => ppSimulateSeed(booking));
  const [sending, setSending] = React.useState(false);
  const [error, setError] = React.useState(null);
  const set = (patch) => setForm(f => ({ ...f, ...patch }));
  const field = (k) => (e) => set({ [k]: e.target.value });

  const body = ppSimulateDiff(booking, form);
  const changes = Object.keys(body).length;
  const problem = ppSimulateProblem(body);
  const products = (venue && venue.rawProducts) || [];

  const send = async () => {
    setSending(true);
    setError(null);
    try {
      const delivery = await ppSimulateBookingChange(booking.id, body);
      onResult({ action: 'simulate', event: delivery.event, delivery, request: body, at: ppNowTime() });
    } catch (e) {
      const blocked = ppSandboxBlocked(e);
      if (blocked) onBlocked(blocked);
      else setError(ppSandboxError(e));
    } finally {
      setSending(false);
    }
  };

  const preorder = (i, patch) => set({ preorders: form.preorders.map((p, j) => (j === i ? { ...p, ...patch } : p)) });

  return (
    <React.Fragment>
      <div className="pp-drawer-body" style={{ gap: 22 }}>
        <button type="button" className="pp-ghost-btn" onClick={onBack} disabled={sending}>
          <IconArrowL size={13}/> Back to overview
        </button>
        <SandboxNote>
          Set only what the operator would have changed. Bookable writes it to this test booking and sends
          one <span className="pp-mono" style={ppMono}>booking.updated</span> to your endpoint — the same payload production sends.
        </SandboxNote>
        {error && <SandboxErrorBanner>{error}</SandboxErrorBanner>}

        <section className="pp-form-section">
          <div className="pp-form-section-header">
            <h3 className="pp-form-section-title">Actuals</h3>
            <span style={{ fontSize: 11.5, color: '#6a7088' }}>Reported after the visit</span>
          </div>
          <div className="pp-form-grid pp-form-grid--2">
            <Field label="Actual guests">
              <input className="pp-input" type="number" min="0" step="1" placeholder={form.noShow ? '0' : 'Not yet reported'}
                     style={form.noShow ? ppDisabledInput : undefined}
                     value={form.noShow ? '' : form.actualGuests} onChange={field('actualGuests')} disabled={form.noShow}/>
            </Field>
            <Field label="Actual spend">
              <input className="pp-input" type="number" min="0" step="0.01" placeholder={form.noShow ? '0' : 'Not yet reported'}
                     style={form.noShow ? ppDisabledInput : undefined}
                     value={form.noShow ? '' : form.actualSpend} onChange={field('actualSpend')} disabled={form.noShow}/>
            </Field>
          </div>
          <label className="pp-checkbox" style={{ marginTop: 10 }}>
            <input type="checkbox" checked={form.noShow} onChange={(e) => set({ noShow: e.target.checked })}/>
            <span className="pp-checkbox-box"><IconCheck size={11}/></span>
            <span>No-show — sends <span className="pp-mono" style={ppMono}>actualGuests: 0, actualSpend: 0</span></span>
          </label>
          <p className="pp-amend-hint">
            Leave blank for <span className="pp-mono" style={ppMono}>null</span> (not yet reported). <span className="pp-mono" style={ppMono}>0</span> means a no-show.
          </p>
        </section>

        <section className="pp-form-section">
          <h3 className="pp-form-section-title">Status</h3>
          <Select value={form.status} onChange={(v) => set({ status: v })}>
            <option value="">No change</option>
            {PP_SIM_STATUSES.map(([v, label]) => <option key={v} value={v}>{label}</option>)}
          </Select>
        </section>

        <section className="pp-form-section">
          <div className="pp-form-section-header">
            <h3 className="pp-form-section-title">Booking amendments</h3>
            <span style={{ fontSize: 11.5, color: '#6a7088' }}>As edited in the operator’s diary</span>
          </div>
          <div className="pp-form-grid pp-form-grid--2">
            <Field label="Date"><input className="pp-input" type="date" value={form.date} onChange={field('date')}/></Field>
            <Field label="Time"><input className="pp-input" type="time" value={form.time} onChange={field('time')}/></Field>
            <Field label="Party size"><input className="pp-input" type="number" min="1" value={form.partySize} onChange={field('partySize')}/></Field>
            <Field label="Duration (minutes)"><input className="pp-input" type="number" min="1" step="15" placeholder="Not set" value={form.duration} onChange={field('duration')}/></Field>
          </div>
          {products.length > 0 && (
            <div style={{ marginTop: 12 }}>
              <Field label="Product">
                <Select value={form.compositeId} onChange={(v) => set({ compositeId: v })}>
                  {!products.some(p => p.compositeId === form.compositeId) && <option value={form.compositeId}>{booking.productName || form.compositeId}</option>}
                  {products.map(p => <option key={p.compositeId} value={p.compositeId}>{p.name} · {p.compositeId}</option>)}
                </Select>
              </Field>
            </div>
          )}
          <div className="pp-form-grid pp-form-grid--2" style={{ marginTop: 12 }}>
            <Field label="Reference"><input className="pp-input pp-mono" value={form.reference} onChange={field('reference')}/></Field>
            <Field label="Source"><input className="pp-input" placeholder="Unchanged" value={form.source} onChange={field('source')}/></Field>
          </div>
          <div style={{ marginTop: 12 }}>
            <Field label="Notes"><textarea className="pp-input pp-input--area" value={form.notes} onChange={field('notes')}/></Field>
          </div>
        </section>

        <section className="pp-form-section">
          <div className="pp-form-section-header">
            <h3 className="pp-form-section-title">Guest details</h3>
            <span style={{ fontSize: 11.5, color: '#6a7088' }}>PII edited in the operator’s system</span>
          </div>
          <div className="pp-form-grid pp-form-grid--2">
            <Field label="First name"><input className="pp-input" value={form.firstName} onChange={field('firstName')}/></Field>
            <Field label="Last name"><input className="pp-input" value={form.lastName} onChange={field('lastName')}/></Field>
            <Field label="Email"><input className="pp-input" type="email" value={form.email} onChange={field('email')}/></Field>
            <Field label="Phone"><input className="pp-input" type="tel" value={form.phone} onChange={field('phone')}/></Field>
          </div>
        </section>

        <section className="pp-form-section">
          <div className="pp-form-section-header">
            <h3 className="pp-form-section-title">Pre-orders</h3>
            <button type="button" className="pp-btn pp-btn--ghost pp-btn--xs"
                    onClick={() => set({ preorders: [...form.preorders, { packageId: '', name: '', quantity: '1' }] })}>
              <IconPlus size={12}/> Add item
            </button>
          </div>
          {form.preorders.length === 0 ? (
            <p style={{ margin: 0, fontSize: 12.5, fontStyle: 'italic', color: '#6a7088' }}>
              No pre-orders on this booking. Add an item to simulate a guest or operator adding one.
            </p>
          ) : (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
              {form.preorders.map((p, i) => (
                <div key={i} style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1.4fr) minmax(0,1fr) 64px 32px', gap: 8, alignItems: 'center' }}>
                  <input className="pp-input" placeholder="Item name" aria-label="Item name" value={p.name} onChange={(e) => preorder(i, { name: e.target.value })}/>
                  <input className="pp-input pp-mono" placeholder="Package id" aria-label="Package id" value={p.packageId} onChange={(e) => preorder(i, { packageId: e.target.value })}/>
                  <input className="pp-input" type="number" min="1" aria-label="Quantity" value={p.quantity} onChange={(e) => preorder(i, { quantity: e.target.value })}/>
                  <button type="button" className="pp-icon-btn" aria-label="Remove item"
                          onClick={() => set({ preorders: form.preorders.filter((_, j) => j !== i) })}>
                    <IconClose size={14}/>
                  </button>
                </div>
              ))}
            </div>
          )}
        </section>

        <section className="pp-form-section">
          <SandboxDetails title="Payload preview"
                          hint={<React.Fragment>{changes === 0 ? 'No changes yet' : changes + (changes === 1 ? ' field' : ' fields')} · <span className="pp-mono">POST /bookings/{'{id}'}/simulate</span></React.Fragment>}>
            <SandboxCode navy>{JSON.stringify(body, null, 2)}</SandboxCode>
          </SandboxDetails>
        </section>
      </div>

      <footer className="pp-drawer-foot pp-drawer-foot--edit">
        <button className="pp-btn pp-btn--ghost" onClick={onBack} disabled={sending}>Discard</button>
        <div className="pp-drawer-foot-right">
          <SandboxSendButton onClick={send} disabled={sending || changes === 0 || !!problem}>
            {sending ? 'Sending webhook…'
              : changes === 0 ? 'Nothing to send'
              : problem ? problem
              : 'Send booking.updated · ' + changes + (changes === 1 ? ' change' : ' changes')}
          </SandboxSendButton>
        </div>
      </footer>
    </React.Fragment>
  );
}

// ── Booking drawer: Send operator message ────────────────────────────────────

function SandboxMessagePanel({ booking, onBack, onResult, onBlocked }) {
  const [text, setText] = React.useState('');
  const [sending, setSending] = React.useState(false);
  const [error, setError] = React.useState(null);

  const send = async () => {
    setSending(true);
    setError(null);
    try {
      const message = await ppSimulateOperatorMessage(booking.id, text);
      onResult({ action: 'message', event: 'message.received', message, at: ppNowTime() });
    } catch (e) {
      const blocked = ppSandboxBlocked(e);
      if (blocked) onBlocked(blocked);
      else setError(ppSandboxError(e));
    } finally {
      setSending(false);
    }
  };

  return (
    <React.Fragment>
      <div className="pp-drawer-body" style={{ gap: 22 }}>
        <button type="button" className="pp-ghost-btn" onClick={onBack} disabled={sending}>
          <IconArrowL size={13}/> Back to overview
        </button>
        <SandboxNote>
          Sends a message as the operator, <span className="pp-mono" style={ppMono}>direction: operator_to_partner</span>. You
          receive one <span className="pp-mono" style={ppMono}>message.received</span> webhook. Sandbox is webhook-only — no email is sent.
        </SandboxNote>
        {error && <SandboxErrorBanner>{error}</SandboxErrorBanner>}
        <section className="pp-form-section">
          <h3 className="pp-form-section-title">Message from the operator</h3>
          <Field label="Message">
            <textarea className="pp-input pp-input--area" style={{ minHeight: 120 }} placeholder="What the operator writes to you"
                      value={text} onChange={(e) => setText(e.target.value)}/>
          </Field>
        </section>
        <section className="pp-form-section">
          <SandboxDetails title="Payload preview" hint={<span className="pp-mono">POST /bookings/{'{id}'}/messages</span>}>
            <SandboxCode navy>{JSON.stringify({ body: text, direction: 'operator_to_partner' }, null, 2)}</SandboxCode>
          </SandboxDetails>
        </section>
      </div>
      <footer className="pp-drawer-foot pp-drawer-foot--edit">
        <button className="pp-btn pp-btn--ghost" onClick={onBack} disabled={sending}>Discard</button>
        <div className="pp-drawer-foot-right">
          <SandboxSendButton onClick={send} disabled={sending || !text.trim()}>
            {sending ? 'Sending webhook…' : 'Send message.received'}
          </SandboxSendButton>
        </div>
      </footer>
    </React.Fragment>
  );
}

// ── Delivery result (drawer + closure modal) ─────────────────────────────────

function SandboxDeliveryCard({ delivery, at }) {
  const view = ppDeliveryView(delivery);
  const tone = PP_TONES[view.tone];
  return (
    <section style={{ border: '1px solid ' + tone.border, background: tone.bg, borderRadius: 12, overflow: 'hidden', flexShrink: 0 }}>
      <div style={{ padding: '14px 16px', display: 'flex', gap: 12, alignItems: 'flex-start' }}>
        <span style={{ width: 32, height: 32, borderRadius: '50%', display: 'grid', placeItems: 'center', flexShrink: 0, background: tone.iconBg, color: tone.fg }}>
          {view.ok ? <IconCheck size={16}/> : <AlertGlyph size={16}/>}
        </span>
        <div style={{ minWidth: 0, flex: 1 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
            <span style={{ fontSize: 15, fontWeight: 700, letterSpacing: '-0.01em', color: tone.fg }}>{view.title}</span>
            <span className="pp-mono" style={{ fontSize: 11.5, fontWeight: 600, padding: '2px 7px', borderRadius: 6, background: tone.iconBg, color: tone.fg }}>{view.chip}</span>
          </div>
          <p style={{ margin: '4px 0 0', fontSize: 12.5, lineHeight: 1.5, color: tone.fg }}>{view.sub}</p>
        </div>
        <span style={{ fontSize: 11.5, fontVariantNumeric: 'tabular-nums', color: tone.fg }}>{at}</span>
      </div>
      <div style={{ background: '#fff', borderTop: '1px solid ' + tone.border, padding: '12px 16px', display: 'grid', gridTemplateColumns: '110px 1fr', gap: '6px 12px', fontSize: 12.5 }}>
        <span style={{ color: '#6a7088', fontWeight: 500 }}>Event</span><span className="pp-mono" style={{ fontWeight: 500 }}>{delivery.event}</span>
        <span style={{ color: '#6a7088', fontWeight: 500 }}>Event id</span><span className="pp-mono" style={{ wordBreak: 'break-all' }}>{delivery.eventId}</span>
        <span style={{ color: '#6a7088', fontWeight: 500 }}>Endpoint</span><span className="pp-mono" style={{ wordBreak: 'break-all' }}>POST {delivery.callbackUrl}</span>
        <span style={{ color: '#6a7088', fontWeight: 500 }}>Response time</span><span style={{ fontWeight: 600 }}>{delivery.durationMs.toLocaleString('en-GB')} ms</span>
        <span style={{ color: '#6a7088', fontWeight: 500 }}>Attempts</span><span>1 of 1 — sandbox does not retry</span>
      </div>
    </section>
  );
}

function SandboxResultPanel({ result, onDone, onAgain, onResend, resending, resendError }) {
  const failed = result.delivery && !result.delivery.delivered;
  return (
    <React.Fragment>
      <div className="pp-drawer-body" style={{ gap: 22 }}>
        <button type="button" className="pp-ghost-btn" onClick={onDone}>
          <IconArrowL size={13}/> Back to overview
        </button>
        {resendError && <SandboxErrorBanner>The webhook wasn’t re-sent. {resendError}</SandboxErrorBanner>}
        {result.delivery ? (
          <React.Fragment>
            <SandboxDeliveryCard delivery={result.delivery} at={result.at}/>
            <section className="pp-form-section">
              <h3 className="pp-form-section-title">Your endpoint returned</h3>
              <SandboxCode maxHeight={240}>{result.delivery.responseBody != null && result.delivery.responseBody !== ''
                ? result.delivery.responseBody
                : result.delivery.statusCode != null ? '— empty body —' : '— no response —'}</SandboxCode>
            </section>
            <section className="pp-form-section">
              <SandboxDetails title="Webhook payload sent" open={failed}
                              hint={new Blob([result.delivery.payload]).size.toLocaleString('en-GB') + ' bytes · show'}>
                <SandboxCode navy>{ppPrettyJson(result.delivery.payload)}</SandboxCode>
              </SandboxDetails>
            </section>
            <section className="pp-form-section">
              <h3 className="pp-form-section-title">Request headers</h3>
              <SandboxCode>
                {'Content-Type: application/json; charset=utf-8\n'
                  + (result.delivery.signature
                    ? 'X-API-Key: ' + result.delivery.signature
                    : 'X-API-Key: (not sent — no webhook secret is registered)')}
              </SandboxCode>
            </section>
          </React.Fragment>
        ) : (
          <React.Fragment>
            <section style={{ border: '1px solid ' + PP_TONES.neutral.border, background: PP_TONES.neutral.bg, borderRadius: 12, padding: '14px 16px', flexShrink: 0 }}>
              <div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
                <span style={{ fontSize: 15, fontWeight: 700, letterSpacing: '-0.01em', color: '#0b1530' }}>Message sent</span>
                <span style={{ fontSize: 11.5, fontVariantNumeric: 'tabular-nums', color: '#6a7088' }}>{result.at}</span>
              </div>
              <p style={{ margin: '4px 0 0', fontSize: 12.5, lineHeight: 1.5, color: '#3d4866' }}>
                Bookable stored the message and attempted <span className="pp-mono" style={ppMono}>message.received</span> to your
                endpoint. The messages API doesn’t report whether it was delivered, so check your endpoint’s logs (BOO-869).
              </p>
            </section>
            <section className="pp-form-section">
              <h3 className="pp-form-section-title">Message stored</h3>
              <SandboxCode navy>{JSON.stringify(result.message, null, 2)}</SandboxCode>
            </section>
          </React.Fragment>
        )}
      </div>
      <footer className="pp-drawer-foot pp-drawer-foot--edit">
        <button className="pp-btn pp-btn--ghost" onClick={onDone}>Done</button>
        <div className="pp-drawer-foot-right">
          {failed && <button className="pp-btn pp-btn--ghost" onClick={onResend} disabled={resending}>{resending ? 'Resending…' : 'Resend'}</button>}
          <button className="pp-btn pp-btn--primary" onClick={onAgain} style={{ background: PP_SANDBOX_AMBER }}>
            {result.action === 'message' ? 'Send another message' : 'Simulate another change'}
          </button>
        </div>
      </footer>
    </React.Fragment>
  );
}

// ── Results: test venue closure ──────────────────────────────────────────────

function SandboxVenueClosureRow({ venue }) {
  const [open, setOpen] = React.useState(false);
  if (!venue.upstreamId) return null;
  return (
    <React.Fragment>
      <div style={{ marginTop: 8, paddingTop: 10, borderTop: '1px dashed #f1ecdf', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
        <SandboxPill/>
        <button type="button" className="pp-btn pp-btn--ghost pp-btn--xs" style={{ borderColor: '#f4d8a7', color: '#8a5612' }} onClick={() => setOpen(true)}>
          <IconSend size={12}/> Send test venue closure
        </button>
      </div>
      {/* Portalled to the page root: the card lifts on hover with a transform, and a transformed ancestor
          traps position:fixed, so rendered in place the dialog was clipped inside the card. */}
      {open && ReactDOM.createPortal(<SandboxVenueClosureModal venue={venue} onClose={() => setOpen(false)}/>, document.body)}
    </React.Fragment>
  );
}

function SandboxVenueClosureModal({ venue, onClose }) {
  const webhook = usePpSandboxWebhook(true);
  const [stage, setStage] = React.useState('confirm'); // confirm | sending | done
  const [result, setResult] = React.useState(null);
  const [error, setError] = React.useState(null);
  const sending = stage === 'sending';
  const noCallback = webhook.status === 'ready' && !webhook.callbackUrl;

  const send = async () => {
    setStage('sending');
    setError(null);
    try {
      const delivery = await ppSimulateVenueClosure(venue.upstreamId);
      setResult({ delivery, at: ppNowTime() });
      setStage('done');
    } catch (e) {
      setError(ppSandboxBlocked(e) === 'no-callback'
        ? 'No webhook endpoint is registered for your sandbox client. Register one with POST /webhooks, then reload this page.'
        : ppSandboxError(e));
      setStage('confirm');
    }
  };

  const grid = { display: 'grid', gridTemplateColumns: '110px 1fr', gap: '6px 12px', fontSize: 12.5, background: '#fbf9f4', border: '1px solid #f1ecdf', borderRadius: 10, padding: '12px 14px' };
  const view = result && ppDeliveryView(result.delivery);
  const tone = view && PP_TONES[view.tone];

  return (
    <React.Fragment>
      <div className="pp-modal-scrim" onClick={() => !sending && onClose()}/>
      <div className="pp-modal" role="dialog" aria-label="Send test venue closure" style={{ textAlign: 'left', width: 'min(480px, 92vw)', maxHeight: '90vh', overflowY: 'auto' }}>
        {stage !== 'done' ? (
          <React.Fragment>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
              <SandboxPill/><span style={{ fontSize: 11.5, color: '#8a5612', fontWeight: 600 }}>Simulated venue event</span>
            </div>
            <h3 className="pp-modal-title">Send test venue closure?</h3>
            <p className="pp-modal-sub" style={{ marginBottom: 14 }}>
              Sends a synthetic <span className="pp-mono" style={ppMono}>venue.updated</span> for <strong style={{ color: '#0b1530' }}>{venue.name}</strong> with{' '}
              <span className="pp-mono" style={ppMono}>active: false</span> — to your sandbox endpoint only. The venue itself isn’t
              changed: it stays open and bookable.
            </p>
            {error && <div style={{ marginBottom: 12 }}><SandboxErrorBanner>{error}</SandboxErrorBanner></div>}
            {noCallback && !error && (
              <div style={{ marginBottom: 12 }}>
                <SandboxBlockedCard icon={<AlertGlyph size={16}/>} title="No webhook endpoint registered">
                  Register your sandbox callback URL with <span className="pp-mono" style={ppMono}>POST /webhooks</span>, then reload this page.
                </SandboxBlockedCard>
              </div>
            )}
            <div style={{ ...grid, marginBottom: 18 }}>
              <span style={{ color: '#6a7088' }}>Venue</span><span style={{ fontWeight: 600 }}>{venue.name}{venue.city ? ' · ' + venue.city : ''}</span>
              <span style={{ color: '#6a7088' }}>Venue id</span><span className="pp-mono" style={{ wordBreak: 'break-all' }}>{venue.upstreamId}</span>
              <span style={{ color: '#6a7088' }}>Delivers to</span>
              <span className="pp-mono" style={{ wordBreak: 'break-all' }}>{webhook.callbackUrl || (webhook.status === 'loading' ? '…' : '—')}</span>
            </div>
            <div className="pp-modal-actions" style={{ justifyContent: 'flex-end' }}>
              <button className="pp-btn pp-btn--ghost" onClick={onClose} disabled={sending}>Cancel</button>
              <SandboxSendButton onClick={send} disabled={sending || noCallback}>
                {sending ? 'Sending webhook…' : 'Send venue.updated'}
              </SandboxSendButton>
            </div>
          </React.Fragment>
        ) : (
          <React.Fragment>
            <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginBottom: 14 }}>
              <span style={{ width: 36, height: 36, borderRadius: '50%', display: 'grid', placeItems: 'center', flexShrink: 0, background: tone.iconBg, color: tone.fg }}>
                {view.ok ? <IconCheck size={18}/> : <AlertGlyph size={18}/>}
              </span>
              <div style={{ minWidth: 0, flex: 1 }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
                  <h3 className="pp-modal-title" style={{ margin: 0 }}>{view.title}</h3>
                  <span className="pp-mono" style={{ fontSize: 11.5, fontWeight: 600, padding: '2px 7px', borderRadius: 6, background: tone.iconBg, color: tone.fg }}>{view.chip}</span>
                </div>
                <p className="pp-modal-sub" style={{ margin: '4px 0 0' }}>{view.sub}</p>
              </div>
            </div>
            <div style={{ ...grid, marginBottom: 12 }}>
              <span style={{ color: '#6a7088' }}>Event</span><span className="pp-mono" style={{ fontWeight: 500 }}>{result.delivery.event}</span>
              <span style={{ color: '#6a7088' }}>Event id</span><span className="pp-mono" style={{ wordBreak: 'break-all' }}>{result.delivery.eventId}</span>
              <span style={{ color: '#6a7088' }}>Response time</span><span style={{ fontWeight: 600 }}>{result.delivery.durationMs.toLocaleString('en-GB')} ms</span>
              <span style={{ color: '#6a7088' }}>Returned</span>
              <span className="pp-mono" style={{ wordBreak: 'break-all', display: 'block', maxHeight: 120, overflowY: 'auto' }}>{result.delivery.responseBody || (result.delivery.statusCode != null ? '— empty body —' : '— no response —')}</span>
            </div>
            <div style={{ marginBottom: 18 }}>
              <SandboxDetails title="Webhook payload sent" hint={new Blob([result.delivery.payload]).size.toLocaleString('en-GB') + ' bytes · show'}>
                <SandboxCode navy maxHeight={220}>{ppPrettyJson(result.delivery.payload)}</SandboxCode>
              </SandboxDetails>
            </div>
            <div className="pp-modal-actions" style={{ justifyContent: 'flex-end' }}>
              {!result.delivery.delivered && <button className="pp-btn pp-btn--ghost" onClick={send}>Resend</button>}
              <button className="pp-btn pp-btn--primary" onClick={onClose}>Done</button>
            </div>
          </React.Fragment>
        )}
      </div>
    </React.Fragment>
  );
}

Object.assign(window, {
  ppSimulateSeed, ppSimulateDiff, ppSimulateProblem, ppDeliveryView, ppSandboxBlocked, ppSandboxError,
  usePpSandboxWebhook, SandboxStrip, SandboxSimulatePanel, SandboxMessagePanel, SandboxResultPanel,
  SandboxVenueClosureRow,
});
