// 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, message.received when the
// operator writes to the partner, and venue.updated when a venue closes. Sandbox
// has no operator, so these let a partner trigger those webhooks themselves and
// see exactly what each of their endpoints 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.
//
// Every send is one POST /webhooks/test (WebhookApi 2.0.0): the event goes to
// each of the partner's active subscriptions, and the result reports each
// endpoint's answer separately. A subscription whose event filter excludes the
// event comes back skipped, not failed. Resend targets the one that failed.
//
// 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 ones Bookable actually sends (Content-Type, the
//     X-API-Key signature, X-Webhook-Id and X-Webhook-Test), not the design's
//     X-Bookable-* set.
//   - The operator message takes a subject and a body; the sender is always the
//     booking's venue. It is built for the webhook only: never stored, never emailed.
//   - There is no Webhooks settings screen yet, so the no-subscription state
//     points at POST /webhooks/subscriptions 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 a delivery card says about one WebhookTestDelivery.
function ppDeliveryView(d, eventType) {
  if (d.skipped) {
    return {
      tone: 'neutral', skipped: true, title: 'Not sent', chip: 'Filtered out',
      sub: 'This subscription’s event types don’t include ' + eventType + ', so Bookable didn’t send it — as in production.',
    };
  }
  if (d.delivered) {
    return {
      tone: 'ok', ok: true, title: 'Delivered', chip: ppStatusText(d.statusCode),
      sub: eventType === 'venue.updated'
        ? 'Your endpoint accepted the webhook. In production this is what a venue closure would have sent.'
        : eventType === 'message.received'
          ? 'Your endpoint accepted the webhook. In production this is what an operator’s message 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 headline over every delivery of one test event.
function ppResultSummary(result) {
  const all = result.deliveries || [];
  const sent = all.filter(d => !d.skipped);
  const delivered = sent.filter(d => d.delivered).length;
  if (sent.length === 0) {
    return { tone: 'neutral', title: 'Nothing sent', chip: all.length + ' skipped', sub: result.note || 'None of your subscriptions receive this event.' };
  }
  const chip = delivered + ' of ' + sent.length + ' delivered' + (all.length > sent.length ? ' · ' + (all.length - sent.length) + ' skipped' : '');
  if (delivered === sent.length) {
    return {
      tone: 'ok', ok: true, title: 'Delivered', chip,
      sub: sent.length === 1 ? 'Your endpoint accepted the webhook.' : 'Every endpoint accepted the webhook.',
    };
  }
  return {
    tone: 'error', title: delivered === 0 ? 'Not delivered' : 'Partly delivered', chip,
    sub: 'Bookable sent the webhook, but ' + (sent.length === 1 ? 'your endpoint' : (sent.length - delivered) + ' of your endpoints')
      + ' didn’t accept it. Each result below says why.',
  };
}

// A WebhookTestResult as the result screen keeps it: each delivery carries the
// payload and event id it was sent with, so a resend can replace one in place.
function ppTestOutcome(result, at) {
  const deliveries = (result.deliveries || []).map(d => ({ ...d, eventId: result.eventId, payload: result.payload || '', at }));
  return { eventType: result.eventType, eventId: result.eventId, note: result.message, payload: result.payload || '', deliveries, at };
}

// Swap the resent delivery into the outcome; the others are untouched.
function ppMergeResend(outcome, fresh, at) {
  const next = ppTestOutcome(fresh, at).deliveries[0];
  if (!next) return outcome;
  next.resent = true;
  return { ...outcome, deliveries: outcome.deliveries.map(d => (d.webhookId === next.webhookId ? next : d)) };
}

// The active subscriptions, which are the ones a test reaches.
function ppActiveSubscriptions(webhook) {
  return ((webhook && webhook.subscriptions) || []).filter(s => s.active);
}

// 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 === '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).';
  if (e && e.status === 429) return 'Too many test events — wait a minute and try again.';
  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 partner's sandbox webhook subscriptions. status: loading | ready | error.
function usePpSandboxWebhook(enabled) {
  const [state, setState] = React.useState({ status: 'loading', subscriptions: [] });
  React.useEffect(() => {
    if (!enabled) return;
    let live = true;
    ppLoadSandboxWebhook()
      .then(r => live && setState({ status: 'ready', subscriptions: (r && r.subscriptions) || [] }))
      .catch(() => live && setState({ status: 'error', subscriptions: [] }));
    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' };

function SandboxNoSubscriptionCard() {
  return (
    <SandboxBlockedCard icon={<AlertGlyph size={16}/>} title="No webhook endpoint registered">
      Test events go to your active sandbox webhook subscriptions, and you don’t have one yet. Create it
      with <span className="pp-mono" style={ppMono}>POST /webhooks/subscriptions</span> against the sandbox API, then reload this page.
    </SandboxBlockedCard>
  );
}

// Where a test goes: the one URL, or how many endpoints with the URLs listed.
function SandboxDestinations({ webhook, style }) {
  const active = ppActiveSubscriptions(webhook);
  if (webhook.status === 'loading') return <span style={style}>…</span>;
  if (active.length === 0) return <span style={style}>your active sandbox subscriptions</span>;
  if (active.length === 1) return <span className="pp-mono" style={{ wordBreak: 'break-all', ...style }}>{active[0].callbackUrl}</span>;
  return (
    <span style={style} title={active.map(s => (s.label ? s.label + ' · ' : '') + s.callbackUrl).join('\n')}>
      {active.length} endpoints
    </span>
  );
}

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

function SandboxStrip({ webhook, blocked, last, onSimulate, onMessage, onShowLast }) {
  const noCallback = blocked === 'no-callback' || (webhook.status === 'ready' && ppActiveSubscriptions(webhook).length === 0);
  const lastView = last ? ppResultSummary(last) : 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 ? (
        <SandboxNoSubscriptionCard/>
      ) : (
        <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>
            <SandboxDestinations webhook={webhook} style={{ fontSize: 11.5, color: '#7a4a00' }}/>
            {last && (
              <React.Fragment>
                <span>· Last delivery</span>
                <span className="pp-status-dot" style={{ background: PP_TONES[lastView.tone].fg }}/>
                <span style={{ fontWeight: 600, color: PP_TONES[lastView.tone].fg }}>
                  {last.eventType} · {lastView.chip} · {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 result = await ppSimulateBookingChange(booking.id, body);
      // Nobody to send to: upstream wrote nothing, so this is the strip's no-subscription state.
      if (result.deliveries.length === 0) onBlocked('no-callback');
      else onResult({ action: 'simulate', ...ppTestOutcome(result, 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 each of your endpoints — the same payload production sends.
          A product can only change to another at this venue.
        </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 /webhooks/test</span></React.Fragment>}>
            <SandboxCode navy>{JSON.stringify({ eventType: 'booking.updated', bookingId: booking.id, changes: 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 [subject, setSubject] = React.useState('');
  const [text, setText] = React.useState('');
  const [sending, setSending] = React.useState(false);
  const [error, setError] = React.useState(null);
  const message = { ...(subject.trim() ? { subject: subject.trim() } : {}), body: text };

  const send = async () => {
    setSending(true);
    setError(null);
    try {
      const result = await ppSimulateOperatorMessage(booking.id, message);
      if (result.deliveries.length === 0) onBlocked('no-callback');
      else onResult({ action: 'message', message, ...ppTestOutcome(result, 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 one <span className="pp-mono" style={ppMono}>message.received</span> to each of your endpoints, as if the venue
          had written to you about this booking. It’s built for the webhook only — nothing is stored and 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="Subject">
            <input className="pp-input" placeholder="Optional" value={subject} onChange={(e) => setSubject(e.target.value)}/>
          </Field>
          <div style={{ marginTop: 12 }}>
            <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>
          </div>
        </section>
        <section className="pp-form-section">
          <SandboxDetails title="Payload preview" hint={<span className="pp-mono">POST /webhooks/test</span>}>
            <SandboxCode navy>{JSON.stringify({ eventType: 'message.received', bookingId: booking.id, message }, 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) ─────────────────────────────────

// The headers Bookable sent with one delivery.
function ppRequestHeaders(d) {
  return [
    'Content-Type: application/json; charset=utf-8',
    d.signature ? 'X-API-Key: ' + d.signature : 'X-API-Key: (not sent — no webhook secret is registered)',
    'X-Webhook-Id: ' + d.webhookId,
    'X-Webhook-Test: true',
  ].join('\n');
}

function ppSubscriptionLabel(subscriptions, webhookId) {
  const s = (subscriptions || []).find(x => x.webhookId === webhookId);
  return (s && s.label) || null;
}

function ppPayloadHint(payload) {
  return new Blob([payload]).size.toLocaleString('en-GB') + ' bytes · show';
}

function SandboxToneIcon({ view, size }) {
  const tone = PP_TONES[view.tone];
  return (
    <span style={{ width: size * 2, height: size * 2, borderRadius: '50%', display: 'grid', placeItems: 'center', flexShrink: 0, background: tone.iconBg, color: tone.fg }}>
      {view.ok ? <IconCheck size={size}/> : <AlertGlyph size={size}/>}
    </span>
  );
}

function SandboxChip({ view, children }) {
  const tone = PP_TONES[view.tone];
  return (
    <span className="pp-mono" style={{ fontSize: 11.5, fontWeight: 600, padding: '2px 7px', borderRadius: 6, background: tone.iconBg, color: tone.fg }}>
      {children || view.chip}
    </span>
  );
}

function SandboxSummaryCard({ outcome }) {
  const view = ppResultSummary(outcome);
  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' }}>
        <SandboxToneIcon view={view} size={16}/>
        <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>
            <SandboxChip view={view}/>
          </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 }}>{outcome.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 }}>{outcome.eventType}</span>
        <span style={{ color: '#6a7088', fontWeight: 500 }}>Event id</span><span className="pp-mono" style={{ wordBreak: 'break-all' }}>{outcome.eventId}</span>
        <span style={{ color: '#6a7088', fontWeight: 500 }}>Attempts</span><span>1 per endpoint — sandbox does not retry</span>
      </div>
    </section>
  );
}

// One subscription's answer: its status in the header, the detail underneath.
function SandboxDeliveryRow({ delivery: d, eventType, label, open, onResend, resending }) {
  const view = ppDeliveryView(d, eventType);
  const tone = PP_TONES[view.tone];
  const failed = !d.skipped && !d.delivered;
  return (
    <section style={{ border: '1px solid ' + tone.border, borderRadius: 10, background: '#fff', overflow: 'hidden', flexShrink: 0 }}>
      <div style={{ padding: '10px 12px', display: 'flex', gap: 10, alignItems: 'flex-start', background: tone.bg }}>
        <SandboxToneIcon view={view} size={12}/>
        <div style={{ minWidth: 0, flex: 1 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
            <span style={{ fontSize: 13, fontWeight: 600, color: tone.fg }}>{view.title}</span>
            <SandboxChip view={view}/>
            {!d.skipped && d.durationMs != null && <span style={{ fontSize: 11.5, color: tone.fg }}>{Number(d.durationMs).toLocaleString('en-GB')} ms</span>}
            {d.resent && <span style={{ fontSize: 11.5, color: tone.fg }}>· resent {d.at}</span>}
          </div>
          {label && <div style={{ fontSize: 12, fontWeight: 600, color: '#0b1530', marginTop: 2 }}>{label}</div>}
          <div className="pp-mono" style={{ fontSize: 11.5, color: '#3d4866', wordBreak: 'break-all', marginTop: 2 }}>POST {d.callbackUrl}</div>
          <p style={{ margin: '4px 0 0', fontSize: 12, lineHeight: 1.5, color: tone.fg }}>{view.sub}</p>
        </div>
        {failed && onResend && (
          <button type="button" className="pp-btn pp-btn--ghost pp-btn--xs" onClick={() => onResend(d.webhookId)} disabled={resending}>
            {resending ? 'Resending…' : 'Resend'}
          </button>
        )}
      </div>
      {!d.skipped && (
        <div style={{ padding: '10px 12px', borderTop: '1px solid ' + tone.border }}>
          <SandboxDetails title="Response and headers" hint="show" open={open}>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
              <SandboxCode maxHeight={200}>{d.responseBody != null && d.responseBody !== ''
                ? d.responseBody
                : d.statusCode != null ? '— empty body —' : '— no response —'}</SandboxCode>
              <SandboxCode>{ppRequestHeaders(d)}</SandboxCode>
            </div>
          </SandboxDetails>
        </div>
      )}
    </section>
  );
}

// Every delivery of one test event. A lone failure opens straight to its detail.
function SandboxDeliveryList({ outcome, subscriptions, onResend, resendingId }) {
  const single = outcome.deliveries.length === 1;
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
      {outcome.deliveries.map(d => (
        <SandboxDeliveryRow key={d.webhookId} delivery={d} eventType={outcome.eventType}
                            label={ppSubscriptionLabel(subscriptions, d.webhookId)}
                            open={single && !d.delivered}
                            onResend={onResend} resending={resendingId === d.webhookId}/>
      ))}
    </div>
  );
}

function SandboxResultPanel({ result, subscriptions, onDone, onAgain, onResend, resendingId, resendError }) {
  const failed = result.deliveries.some(d => !d.skipped && !d.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>}
        <SandboxSummaryCard outcome={result}/>
        <section className="pp-form-section">
          <h3 className="pp-form-section-title">Your endpoints</h3>
          <SandboxDeliveryList outcome={result} subscriptions={subscriptions} onResend={onResend} resendingId={resendingId}/>
        </section>
        {result.payload && (
          <section className="pp-form-section">
            <SandboxDetails title="Webhook payload sent" open={failed} hint={ppPayloadHint(result.payload)}>
              <SandboxCode navy>{ppPrettyJson(result.payload)}</SandboxCode>
            </SandboxDetails>
          </section>
        )}
      </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">
          <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 [resendingId, setResendingId] = React.useState(null);
  const sending = stage === 'sending';
  const noCallback = webhook.status === 'ready' && ppActiveSubscriptions(webhook).length === 0;

  const send = async () => {
    setStage('sending');
    setError(null);
    try {
      const fresh = await ppSimulateVenueClosure(venue.upstreamId);
      if (fresh.deliveries.length === 0) {
        setError('No webhook endpoint is registered for your sandbox client. Create one with POST /webhooks/subscriptions, then reload this page.');
        setStage('confirm');
        return;
      }
      setResult(ppTestOutcome(fresh, ppNowTime()));
      setStage('done');
    } catch (e) {
      setError(ppSandboxError(e));
      setStage('confirm');
    }
  };

  const resend = async (webhookId) => {
    setResendingId(webhookId);
    setError(null);
    try {
      const at = ppNowTime();
      const fresh = await ppSimulateVenueClosure(venue.upstreamId, webhookId);
      setResult(r => ppMergeResend(r, fresh, at));
    } catch (e) {
      setError('The webhook wasn’t re-sent. ' + ppSandboxError(e));
    } finally {
      setResendingId(null);
    }
  };

  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 && ppResultSummary(result);

  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(540px, 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 endpoints 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 }}><SandboxNoSubscriptionCard/></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>
              <SandboxDestinations webhook={webhook}/>
            </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 }}>
              <SandboxToneIcon view={view} size={18}/>
              <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>
                  <SandboxChip view={view}/>
                </div>
                <p className="pp-modal-sub" style={{ margin: '4px 0 0' }}>{view.sub}</p>
              </div>
            </div>
            {error && <div style={{ marginBottom: 12 }}><SandboxErrorBanner>{error}</SandboxErrorBanner></div>}
            <div style={{ ...grid, marginBottom: 12 }}>
              <span style={{ color: '#6a7088' }}>Event</span><span className="pp-mono" style={{ fontWeight: 500 }}>{result.eventType}</span>
              <span style={{ color: '#6a7088' }}>Event id</span><span className="pp-mono" style={{ wordBreak: 'break-all' }}>{result.eventId}</span>
            </div>
            <div style={{ marginBottom: 12 }}>
              <SandboxDeliveryList outcome={result} subscriptions={webhook.subscriptions} onResend={resend} resendingId={resendingId}/>
            </div>
            {result.payload && (
              <div style={{ marginBottom: 18 }}>
                <SandboxDetails title="Webhook payload sent" hint={ppPayloadHint(result.payload)}>
                  <SandboxCode navy maxHeight={220}>{ppPrettyJson(result.payload)}</SandboxCode>
                </SandboxDetails>
              </div>
            )}
            <div className="pp-modal-actions" style={{ justifyContent: 'flex-end' }}>
              <button className="pp-btn pp-btn--primary" onClick={onClose}>Done</button>
            </div>
          </React.Fragment>
        )}
      </div>
    </React.Fragment>
  );
}

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