// ─────────────────────────────────────────────────────────────────────────────
// schedule-volt-api.jsx — the single integration point between the YJ site and
// Volt's public booking API. Everything that talks to Volt lives here so the
// step components stay presentational. Loaded BEFORE schedule-shared.jsx in
// index.html (the availability fetch replaces the old mock SLOTS).
//
// Zero-build SPA conventions: no imports/exports; React comes off window; this
// file exposes one namespace `window.Volt` via Object.assign at the bottom.
//
// CONFIG (override via window globals in index.html; prod defaults baked in):
//   window.YJE_API_BASE            — Volt origin (default prod)
//   window.YJE_TURNSTILE_SITE_KEY  — Cloudflare Turnstile *site* key ('' = off)
//   window.YJE_FORMSPREE_ENDPOINT  — lead backstop if Volt is unreachable ('' = off)
//   window.YJE_GOOGLE_PLACES_KEY   — Places autocomplete key ('' = plain input)
// ─────────────────────────────────────────────────────────────────────────────

const VOLT_API_BASE =
  (typeof window !== 'undefined' && window.YJE_API_BASE) || 'https://app.buildwithvolt.com';
const VOLT_AVAILABILITY_URL = VOLT_API_BASE + '/api/public/availability';
const VOLT_SUBMIT_URL = VOLT_API_BASE + '/api/public/service-request';
const VOLT_UPLOAD_URL_URL = VOLT_API_BASE + '/api/public/booking-upload-url';

const VOLT_TURNSTILE_SITE_KEY =
  (typeof window !== 'undefined' && window.YJE_TURNSTILE_SITE_KEY) || '';
const VOLT_FORMSPREE_ENDPOINT =
  (typeof window !== 'undefined' && window.YJE_FORMSPREE_ENDPOINT) || '';

const VOLT_SUPPORT_PHONE = '214-888-8799';
const VOLT_SERVICE_STATE = 'TX';
const VOLT_FETCH_TIMEOUT_MS = 15000;   // availability GET — a quick, retriable read
// The booking POST awaits third-party sends (confirmation SMS + portal) server-
// side, so it can legitimately take longer than a read. A short client timeout
// used to abort a request the server was still committing → the customer retried
// and DOUBLE-booked. Give it real headroom (idempotency on submissionId is the
// belt-and-suspenders if a retry slips through anyway).
const VOLT_SUBMIT_TIMEOUT_MS = 30000;

// ── Small helpers ─────────────────────────────────────────────────────────────

const _trim = (v, n) => (v == null ? '' : String(v).trim()).slice(0, n);
const VOLT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function voltEmailValid(e) {
  return !e || !e.trim() || VOLT_EMAIL_RE.test(e.trim());
}

// Local civil "YYYY-MM-DD" from a Date. The picker Dates are built at LOCAL
// midnight from Volt's civil day keys, so the local components ARE the civil day
// — this round-trips a chosen slot back to the exact key Volt sent, no tz drift.
function voltYmdLocal(d) {
  if (!d) return null;
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}

// Friendly fallback copy per HTTP status (used when the server sent no message).
function voltMessageForStatus(status) {
  if (status === 403) return `Online scheduling isn’t available right now — please call us at ${VOLT_SUPPORT_PHONE}.`;
  if (status === 409) return 'That time was just taken — please pick another slot.';
  if (status === 429) return `We’ve already got a recent request from you. We’ll be in touch — or call ${VOLT_SUPPORT_PHONE}.`;
  if (status === 400) return 'Please double-check your details and try again.';
  return `Something went wrong on our end. Please try again, or call us at ${VOLT_SUPPORT_PHONE}.`;
}

// ── Field mapping ─────────────────────────────────────────────────────────────

// Site propertyType ('residential'|'multi'|'commercial') → Volt enum
// ('home'|'multi'|'rental'|'commercial'). A landlord booking a residential unit
// is a rental (preserves Volt's PROPERTY_OWNER classification — design §5).
function voltMapPropertyType(propertyType, customerType) {
  if (customerType === 'landlord' && (propertyType === 'residential' || !propertyType)) return 'rental';
  switch (propertyType) {
    case 'residential': return 'home';
    case 'multi': return 'multi';
    case 'commercial': return 'commercial';
    default: return undefined;
  }
}

// Compose the ≤120-char serviceType headline from the picked services (the full
// list still rides in `services[]` + description, so nothing is lost). data.services
// holds service KEYS — resolve each to its human label via V2_SERVICES so the
// dispatcher sees "Troubleshooting & Repair", not "trouble".
function voltComposeServiceType(data) {
  const labels = (data.services || [])
    .map((s) => {
      if (s && typeof s === 'object') return s.label || '';
      const meta = (window.V2_SERVICES || []).find((x) => x.key === s);
      return (meta && meta.label) || s || ''; // fall back to the raw key if unknown
    })
    .filter(Boolean);
  // V2_WORK is keyed by customerType (each value an array) — index it like every
  // other consumer does; calling .find() on the object throws and breaks EVERY submit.
  const work = data.workType && window.V2_WORK
    ? (((window.V2_WORK[data.customerType] || []).find((w) => w.key === data.workType)) || {}).label
    : null;
  const headline = labels.join(', ') || work || 'Service request';
  return _trim(headline, 120);
}

// Build the requestServiceSchema payload from the collected `data` + captcha.
// `data` is the v2 flow state (schedule-flow-v2.jsx). Unknown/empty fields are
// omitted so Volt's zod (email(), enums) never 400s on a blank.
function voltBuildPayload(data, captchaToken) {
  const svcHours = (window.v2BookingInfo ? window.v2BookingInfo(data).totalHours : null);
  const services = (data.services || [])
    .map((key) => {
      const meta = (window.V2_SERVICES || []).find((s) => s.key === key) || {};
      return { key: _trim(key, 40), label: _trim(meta.label || key, 80), hours: meta.hours || undefined };
    })
    .slice(0, 12);

  const payload = {
    serviceType: voltComposeServiceType(data),
    zip: _trim(data.zip, 20),
    firstName: _trim(data.firstName, 80),
    phone: _trim(data.phone, 40),
    textOptIn: !!data.textOptIn,
    // Preserve a Places-resolved / typed state (e.g. an OK address across the Red
    // River); default to TX only when empty.
    state: _trim(data.state, 20) || VOLT_SERVICE_STATE,
  };

  const propertyType = voltMapPropertyType(data.propertyType, data.customerType);
  if (propertyType) payload.propertyType = propertyType;
  if (_trim(data.lastName, 80)) payload.lastName = _trim(data.lastName, 80);
  if (data.email && data.email.trim() && voltEmailValid(data.email)) payload.email = _trim(data.email, 200);
  if (_trim(data.address, 300)) payload.streetAddress = _trim(data.address, 300);
  if (_trim(data.city, 120)) payload.city = _trim(data.city, 120);
  if (_trim(data.description, 4000)) payload.description = _trim(data.description, 4000);
  if (_trim(data.projectDetails, 4000)) payload.projectDetails = _trim(data.projectDetails, 4000);
  if (data.contactPreference) payload.contactPreference = data.contactPreference;

  // v2 persona/booking fields (all optional; Volt stores them in webIntake).
  if (data.customerType) payload.customerType = data.customerType;
  if (data.workType) payload.workType = _trim(data.workType, 40);
  if (services.length) payload.services = services;
  if (svcHours != null) payload.estimatedHours = Math.max(0, Math.min(24, svcHours));

  // Booking intent. path='book' + a picked slot → Volt attempts an auto-book;
  // anything else is a quote/request. The site already tracks slotStart/slotEnd
  // as the org-tz wall-clock hours, which ARE Volt's requestedWindow contract.
  payload.path = data.path === 'book' ? 'book' : 'quote';
  if (payload.path === 'book' && data.date && data.slot && data.slotStart != null && data.slotEnd != null) {
    payload.requestedDate = voltYmdLocal(data.date);
    payload.requestedWindow = {
      label: _trim(data.slot, 40),
      startHour: data.slotStart,
      endHour: data.slotEnd,
    };
  }

  // ── Marketing attribution & consent (lead-capture) ──────────────────────────
  // First-touch attribution captured on the session's first page load (§1 of the
  // field spec); Volt stores it only when a NEW customer is created, so re-sending
  // for a returning customer is harmless. Omit empties (never send "").
  const attr = voltGetAttribution();
  const ATTR_CAP = { utmSource: 200, utmMedium: 200, utmCampaign: 200, gclid: 300, referrerUrl: 500, landingPage: 500 };
  for (const k in ATTR_CAP) {
    if (attr[k]) payload[k] = _trim(attr[k], ATTR_CAP[k]);
  }
  if (_trim(data.howHeard, 120)) payload.howHeard = _trim(data.howHeard, 120);
  if (_trim(data.referredByName, 120)) payload.referredByName = _trim(data.referredByName, 120);
  // Single "marketing" checkbox → both channel flags (Volt keeps them separate so
  // they can be split later). Sent explicitly (true/false) — false = documented
  // no-consent, distinct from the transactional textOptIn.
  payload.emailMarketingConsent = !!data.marketingConsent;
  payload.smsMarketingConsent = !!data.marketingConsent;

  if (captchaToken) payload.captchaToken = captchaToken;
  // Idempotency key (one per booking-form session; the shell mints it at mount and
  // keeps it across retries). A timed-out/retried POST carrying the same key
  // returns the ORIGINAL booking instead of creating a duplicate ServiceCall.
  if (data.submissionId) payload.submissionId = _trim(data.submissionId, 64);
  return payload;
}

// ── First-touch attribution ─────────────────────────────────────────────────────
// Capture UTM/gclid/referrer/landing on the session's FIRST page load and stash in
// sessionStorage so it survives navigation to /schedule. First-touch wins (a later
// page load never overwrites). Called once at module load below.
function voltCaptureAttribution() {
  try {
    if (typeof window === 'undefined' || !window.sessionStorage) return;
    if (sessionStorage.getItem('yj_attr')) return; // first-touch already captured
    const q = new URLSearchParams(window.location.search);
    const attr = {
      utmSource: q.get('utm_source') || undefined,
      utmMedium: q.get('utm_medium') || undefined,
      utmCampaign: q.get('utm_campaign') || undefined,
      utmTerm: q.get('utm_term') || undefined,
      utmContent: q.get('utm_content') || undefined,
      gclid: q.get('gclid') || undefined,
      // Paid-ad click IDs beyond Google — Meta, Microsoft, and the newer
      // Google web-to-app params — so paid traffic isn't invisible in analytics.
      fbclid: q.get('fbclid') || undefined,
      msclkid: q.get('msclkid') || undefined,
      referrerUrl: document.referrer || undefined,
      // Include the hash route so an SPA landing isn't recorded as a bare '/'.
      landingPage: (window.location.pathname + (window.location.hash || '')) || undefined,
    };
    sessionStorage.setItem('yj_attr', JSON.stringify(attr));
  } catch (_e) { /* sessionStorage blocked (private mode) — attribution is best-effort */ }
}

// ── Funnel analytics (tool-agnostic) ─────────────────────────────────────────────
// Fire a booking-funnel event to window.dataLayer (GTM/GA4 dataLayer convention)
// AND directly to gtag if a GA4 tag is loaded (see index.html YJE_GA4_MEASUREMENT_ID).
// Never throws. With no tag configured the events still queue on dataLayer, so a
// GA4/GTM tag added later can pick up the whole funnel with no code change.
function voltTrack(event, params) {
  try {
    if (typeof window === 'undefined') return;
    const payload = Object.assign({ event }, params || {});
    (window.dataLayer = window.dataLayer || []).push(payload);
    if (typeof window.gtag === 'function') window.gtag('event', event, params || {});
  } catch (_e) { /* analytics is best-effort — never break the flow */ }
}

function voltGetAttribution() {
  try {
    return JSON.parse((window.sessionStorage && sessionStorage.getItem('yj_attr')) || '{}') || {};
  } catch (_e) { return {}; }
}

// ── Availability ──────────────────────────────────────────────────────────────

// Fetch Volt's 28-day availability feed. Returns the parsed AvailabilityPayload,
// or {mode:'off'} when the lane is INTENTIONALLY dark (the route 403s when the
// flag/webBookingMode is off — a definitive "off", distinct from an outage), or
// null on a TRANSIENT failure (5xx/network/timeout). The caller treats those
// differently: off → present a "call us" path (booking really is disabled);
// transient → fall back to the mock grid (submit still re-checks).
async function voltFetchAvailability() {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), VOLT_FETCH_TIMEOUT_MS);
  try {
    const res = await fetch(VOLT_AVAILABILITY_URL, { method: 'GET', signal: controller.signal });
    if (res.status === 403) return { mode: 'off' }; // lane intentionally disabled
    if (!res.ok) return null;                        // transient → mock fallback
    return await res.json();
  } catch (_e) {
    return null;                                     // network/timeout → mock fallback
  } finally {
    clearTimeout(timer);
  }
}

// Transform Volt's payload.days into the site's SLOTS shape. CRITICAL (design §4):
// half/full-day availability is single-tech continuity carried in `aggregates` —
// the site MUST render those directly, NEVER derive them from base slots (which
// would show a window open when two techs each cover half). We attach the raw
// aggregates onto each day so v2WindowsForDay can read them by kind.
//   → Array<{ date: LocalMidnightDate, dateKey, mode, slots:[{label,avail}],
//             aggregates:[{label,kind,avail}] }>
function voltTransformDays(payload) {
  if (!payload || payload.mode === 'off' || !Array.isArray(payload.days)) return [];
  return payload.days.map((d) => ({
    date: new Date(`${d.date}T00:00:00`), // local midnight → local components == civil day
    dateKey: d.date,
    mode: d.mode, // 'instant' | 'confirm'
    slots: (d.slots || []).map((s) => ({ label: s.label, avail: s.avail })),
    aggregates: (d.aggregates || []).map((a) => ({ label: a.label, kind: a.kind, avail: a.avail })),
  }));
}

// ── Submit (with Formspree backstop) ────────────────────────────────────────────

// POST the booking/quote to Volt. Resolves { ok:true, reference, outcome,
// booking, uploadToken, zipTier } on success, or throws Error(userMessage). On a
// network/timeout/5xx failure, falls back to Formspree (if configured) so a lead
// is NEVER lost to an outage — and resolves { ok:true, backstop:true } so the UI
// shows an honest "request received" confirmation.
async function voltSubmit(data, captchaToken, opts) {
  const body = voltBuildPayload(data, captchaToken);
  // Turnstile dead-end guard. When the anti-bot widget is REQUIRED but couldn't
  // produce a token (ad-blocker, privacy extension, CF outage, or a widget/domain
  // error), the server fails CLOSED — a token-less POST 400s, and we deliberately
  // never backstop a 4xx, so the lead would be silently lost at the final step.
  // Route straight to the Formspree backstop instead so the lead is captured; if
  // no backstop is configured, tell them to call rather than fire a doomed POST.
  const turnstileUnavailable = !!(opts && opts.turnstileUnavailable);
  if (window.Volt && window.Volt.TURNSTILE_ENABLED && !captchaToken && turnstileUnavailable) {
    if (VOLT_FORMSPREE_ENDPOINT) {
      const saved = await voltFormspreeBackstop(body).catch(() => false);
      if (saved) return { ok: true, backstop: true, reference: null, outcome: body.path === 'book' ? 'requested' : 'quote' };
    }
    throw new Error(`We couldn’t verify your browser (an ad-blocker may be interfering). Please call us at ${VOLT_SUPPORT_PHONE} and we’ll get you booked right away.`);
  }
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), VOLT_SUBMIT_TIMEOUT_MS);
  try {
    const res = await fetch(VOLT_SUBMIT_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
      signal: controller.signal,
    });
    let json = null;
    try { json = await res.json(); } catch (_e) { /* non-JSON body */ }

    if (res.ok && json && json.ok === true && json.reference) {
      return {
        ok: true,
        reference: json.reference,
        outcome: json.outcome || 'requested',
        booking: json.booking || null,
        uploadToken: json.uploadToken || null,
        zipTier: json.zipTier || null,
      };
    }

    // A 4xx is a definitive CLIENT rejection (bad input, slot taken, rate limit,
    // flag off) — surface it; do NOT backstop (Formspree can't fix it and a
    // double lead would confuse dispatch). TAG it so the catch can tell a real
    // client rejection apart from an outage — a raw fetch reject (DNS/refused/
    // TLS/CORS) throws a bare TypeError with no tag. 5xx/non-JSON fall through.
    if (res.status >= 400 && res.status < 500) {
      const e = new Error((json && json.error) || voltMessageForStatus(res.status));
      e.clientReject = true;
      if (res.status === 409) { e.slotConflict = true; e.slotReason = (json && json.reason) || null; }
      throw e;
    }
    throw new Error(voltMessageForStatus(res.status)); // 5xx → outage path below
  } catch (err) {
    // OUTAGE = anything that ISN'T a tagged 4xx: a raw fetch rejection
    // ('Failed to fetch'/'NetworkError'/'Load failed'), a 5xx, or a timeout —
    // exactly what the backstop exists to catch, so a lead is never lost.
    const isAbort = err && err.name === 'AbortError';
    const isOutage = !(err && err.clientReject);
    if (isOutage && VOLT_FORMSPREE_ENDPOINT) {
      const saved = await voltFormspreeBackstop(body).catch(() => false);
      if (saved) return { ok: true, backstop: true, reference: null, outcome: body.path === 'book' ? 'requested' : 'quote' };
    }
    if (isAbort) {
      throw new Error(`That took too long — check your connection and try again, or call us at ${VOLT_SUPPORT_PHONE}.`);
    }
    if (isOutage) {
      // Real outage, backstop unavailable/failed → friendly copy, never a raw
      // 'Failed to fetch'.
      throw new Error(voltMessageForStatus(0));
    }
    throw err; // tagged 4xx — re-throw verbatim (message + 409 slotConflict flags)
  } finally {
    clearTimeout(timer);
  }
}

async function voltFormspreeBackstop(payload) {
  if (!VOLT_FORMSPREE_ENDPOINT) return false;
  try {
    const res = await fetch(VOLT_FORMSPREE_ENDPOINT, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify({ _subject: 'YJ website booking (Volt backstop)', ...payload }),
    });
    return res.ok;
  } catch (_e) {
    return false;
  }
}

// ── Photo upload lane ───────────────────────────────────────────────────────────

// Volt's private bucket accepts these; skip anything else client-side so we don't
// waste a round-trip on a guaranteed 400. Mirrors BOOKING_UPLOAD_ALLOWED_TYPES.
const VOLT_UPLOAD_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif', 'application/pdf'];
const VOLT_EXT_MIME = {
  heic: 'image/heic', heif: 'image/heif', jpg: 'image/jpeg', jpeg: 'image/jpeg',
  png: 'image/png', webp: 'image/webp', pdf: 'application/pdf',
};

// Resolve a usable MIME type. iOS/Safari camera-roll HEIC files often arrive with
// an EMPTY file.type — without this they'd be silently dropped by the type filter
// (the primary customer device!). Fall back to the filename extension.
function voltMimeFor(file) {
  if (file.type && VOLT_UPLOAD_TYPES.indexOf(file.type) !== -1) return file.type;
  const ext = String(file.name || '').split('.').pop().toLowerCase();
  return VOLT_EXT_MIME[ext] || file.type || '';
}

// Transcode an iPhone HEIC/HEIF photo to JPEG *on the device that can decode it*
// (iOS Safari has the system HEIC codec, so createImageBitmap succeeds there).
// The office dispatch grid renders uploads via <img>, and Chrome/Edge/Firefox
// can't decode HEIC → broken thumbnails for the primary customer device. Doing
// it here means the bytes that land in the bucket are always a browser-friendly
// JPEG. Best-effort: if decode isn't supported (e.g. a desktop Chrome customer),
// fall back to uploading the original — same as before, never blocks the upload.
// Returns { blob, mime, name }.
async function voltToUploadable(file, mime) {
  const isHeic = mime === 'image/heic' || mime === 'image/heif';
  if (!isHeic || typeof createImageBitmap !== 'function' || typeof document === 'undefined') {
    return { blob: file, mime, name: file.name };
  }
  try {
    const bitmap = await createImageBitmap(file);
    const canvas = document.createElement('canvas');
    canvas.width = bitmap.width;
    canvas.height = bitmap.height;
    const ctx = canvas.getContext('2d');
    if (!ctx) throw new Error('no 2d context');
    ctx.drawImage(bitmap, 0, 0);
    if (bitmap.close) { try { bitmap.close(); } catch (_e) { /* noop */ } }
    const jpeg = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.9));
    if (jpeg && jpeg.size > 0) {
      const base = String(file.name || 'photo').replace(/\.[^.]+$/, '');
      return { blob: jpeg, mime: 'image/jpeg', name: `${base}.jpg` };
    }
  } catch (_e) { /* decode unsupported → upload the original HEIC */ }
  return { blob: file, mime, name: file.name };
}

// After a successful booking that returned an uploadToken, push each attached
// File to Volt's private bucket via a per-file signed PUT (two steps: mint a
// signed URL, then PUT the bytes direct to Supabase — bypasses Vercel's body
// limit). Best-effort: a photo failure NEVER fails the booking (the appointment
// already landed). `files` are the drop-zone records; each must carry the raw
// File on `.file` (V2FileDrop retains it). Returns { uploaded, attempted } so the
// UI can surface a soft "some photos didn't attach" notice.
async function voltUploadPhotos(uploadToken, files) {
  if (!uploadToken || !files || !files.length) return { uploaded: 0, attempted: 0 };
  let uploaded = 0, attempted = 0;
  for (const rec of files.slice(0, 12)) {
    const file = rec && rec.file ? rec.file : rec; // {file} record or a raw File
    if (!file || typeof file.arrayBuffer !== 'function') continue;
    const mime = voltMimeFor(file);
    if (VOLT_UPLOAD_TYPES.indexOf(mime) === -1) continue; // unsupported type — not counted
    attempted += 1;
    try {
      // Transcode HEIC→JPEG on-device so the office grid can render it (no-op for
      // already-web-friendly types; falls back to the original if decode fails).
      const up = await voltToUploadable(file, mime);
      const putBlob = up.blob, putMime = up.mime, putName = up.name;
      // 1) Mint a signed upload URL (Volt verifies the token + per-call cap).
      const res = await fetch(VOLT_UPLOAD_URL_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ uploadToken, fileName: putName, fileType: putMime, fileSize: putBlob.size }),
      });
      if (!res.ok) continue;
      const j = await res.json().catch(() => null);
      if (!j || j.ok !== true || !j.signedUrl) continue;
      // 2) PUT the bytes straight to Supabase (the token rides in the signed URL).
      const put = await fetch(j.signedUrl, {
        method: 'PUT',
        headers: { 'Content-Type': putMime || 'application/octet-stream' },
        body: putBlob,
      });
      if (put.ok) uploaded += 1;
    } catch (_e) { /* skip this file */ }
  }
  return { uploaded, attempted };
}

// ── Cloudflare Turnstile (ported from the 2026-06-17 wired form) ─────────────────

let _voltTurnstilePromise = null;
function voltEnsureTurnstile() {
  if (typeof window === 'undefined') return Promise.resolve(null);
  if (window.turnstile) return Promise.resolve(window.turnstile);
  if (_voltTurnstilePromise) return _voltTurnstilePromise;
  _voltTurnstilePromise = new Promise((resolve) => {
    const SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
    const done = () => {
      const start = Date.now();
      (function check() {
        if (window.turnstile) return resolve(window.turnstile);
        if (Date.now() - start > 8000) return resolve(null);
        setTimeout(check, 60);
      })();
    };
    let tag = document.querySelector(`script[src="${SRC}"]`);
    if (!tag) {
      tag = document.createElement('script');
      tag.src = SRC; tag.async = true; tag.defer = true;
      tag.addEventListener('load', done);
      tag.addEventListener('error', () => resolve(null));
      document.head.appendChild(tag);
    } else { done(); }
  });
  return _voltTurnstilePromise;
}

// Explicit Turnstile widget. Remount via a changing `key` to force a fresh
// challenge after a failed submit (tokens are single-use).
function VoltTurnstile({ onToken, onUnavailable }) {
  const elRef = React.useRef(null);
  const widgetId = React.useRef(null);
  React.useEffect(() => {
    if (!VOLT_TURNSTILE_SITE_KEY) return undefined;
    let cancelled = false;
    voltEnsureTurnstile().then((ts) => {
      if (cancelled) return;
      // Script blocked (adblock/privacy ext / CF outage / timeout) → signal the
      // shell so a missing challenge doesn't permanently disable submit.
      if (!ts || !elRef.current) { if (onUnavailable) onUnavailable(); return; }
      try {
        elRef.current.innerHTML = '';
        widgetId.current = ts.render(elRef.current, {
          sitekey: VOLT_TURNSTILE_SITE_KEY,
          theme: 'light',
          callback: (token) => onToken(token),
          'expired-callback': () => onToken(null),
          // A render/challenge error (e.g. a site-key ↔ domain mismatch, or a CF
          // flake) leaves the widget on-screen but unable to mint a token — which
          // used to PERMANENTLY disable the submit button with no way out. Signal
          // unavailable too, so the shell drops the captcha gate and the submit
          // routes to the Formspree backstop instead of dead-ending.
          'error-callback': () => { onToken(null); if (onUnavailable) onUnavailable(); },
        });
      } catch (_e) { if (onUnavailable) onUnavailable(); }
    });
    return () => {
      cancelled = true;
      if (window.turnstile && widgetId.current != null) {
        try { window.turnstile.remove(widgetId.current); } catch (_e) { /* noop */ }
        widgetId.current = null;
      }
    };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  if (!VOLT_TURNSTILE_SITE_KEY) return null;
  return <div ref={elRef} style={{ marginTop: 12 }} />;
}

// ── Google Places (address autocomplete) ────────────────────────────────────────
// Uses the CURRENT Places API (AutocompleteSuggestion + Place.fetchFields), not
// the deprecated Autocomplete widget, so a freshly-issued key works. Powers the
// contact step's existing suggestion dropdown; with no key the mock list stands in.

const VOLT_GOOGLE_PLACES_KEY =
  (typeof window !== 'undefined' && window.YJE_GOOGLE_PLACES_KEY) || '';

let _voltPlacesPromise = null;
function voltEnsurePlaces() {
  if (!VOLT_GOOGLE_PLACES_KEY) return Promise.resolve(false);
  const loaded = () =>
    !!(window.google && window.google.maps && window.google.maps.places && window.google.maps.places.AutocompleteSuggestion);
  if (loaded()) return Promise.resolve(true);
  if (_voltPlacesPromise) return _voltPlacesPromise;
  _voltPlacesPromise = new Promise((resolve) => {
    const CB = '__voltPlacesReady';
    window[CB] = () => resolve(loaded());
    if (!document.querySelector('script[data-volt-places]')) {
      const tag = document.createElement('script');
      tag.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(VOLT_GOOGLE_PLACES_KEY)}&libraries=places&v=weekly&loading=async&callback=${CB}`;
      tag.async = true; tag.defer = true;
      tag.setAttribute('data-volt-places', '1');
      tag.addEventListener('error', () => resolve(false));
      document.head.appendChild(tag);
    }
    setTimeout(() => resolve(false), 8000); // resolve is idempotent — safety net
  });
  return _voltPlacesPromise;
}

function voltNewPlacesSession() {
  try {
    const places = window.google && window.google.maps && window.google.maps.places;
    return places && places.AutocompleteSessionToken ? new places.AutocompleteSessionToken() : null;
  } catch (_e) { return null; }
}

// Live address predictions → normalized dropdown items. `sessionToken` groups a
// type-then-pick into one billed session. Never throws (returns []).
async function voltAddressSuggestions(query, sessionToken) {
  try {
    const places = window.google && window.google.maps && window.google.maps.places;
    if (!places || !places.AutocompleteSuggestion) return [];
    const { suggestions } = await places.AutocompleteSuggestion.fetchAutocompleteSuggestions({
      input: query,
      sessionToken: sessionToken || undefined,
      includedRegionCodes: ['us'],
    });
    return (suggestions || [])
      .map((s) => {
        const p = s.placePrediction;
        if (!p) return null;
        return {
          id: p.placeId,
          line: (p.mainText && p.mainText.text) || (p.text && p.text.text) || '',
          sub: (p.secondaryText && p.secondaryText.text) || '',
          prediction: p,
        };
      })
      .filter((x) => x && x.line)
      .slice(0, 5);
  } catch (_e) { return []; }
}

// Pull street/city/state/zip out of a picked prediction's address components.
function voltParsePlaceFields(place) {
  const comp = place.addressComponents || place.address_components || [];
  const get = (type) => {
    const c = comp.find((x) => (x.types || []).indexOf(type) !== -1);
    return c ? { long: c.longText || c.long_name || '', short: c.shortText || c.short_name || '' } : null;
  };
  const num = get('street_number');
  const route = get('route');
  const city = get('locality') || get('postal_town') || get('sublocality_level_1') || get('sublocality') || get('administrative_area_level_2');
  const state = get('administrative_area_level_1');
  const zip = get('postal_code');
  const street = [num && num.long, route && route.long].filter(Boolean).join(' ');
  return {
    address: street || String(place.formattedAddress || '').split(',')[0] || '',
    city: city ? city.long : '',
    state: state ? state.short : '',
    zip: zip ? zip.long : '',
  };
}

async function voltPlaceDetails(prediction) {
  try {
    if (!prediction || !prediction.toPlace) return null;
    const place = prediction.toPlace();
    await place.fetchFields({ fields: ['addressComponents', 'formattedAddress'] });
    return voltParsePlaceFields(place);
  } catch (_e) { return null; }
}

// ── Public namespace ────────────────────────────────────────────────────────────

Object.assign(window, {
  Volt: {
    // config flags the UI reads
    TURNSTILE_ENABLED: !!VOLT_TURNSTILE_SITE_KEY,
    SUPPORT_PHONE: VOLT_SUPPORT_PHONE,
    // availability
    fetchAvailability: voltFetchAvailability,
    transformDays: voltTransformDays,
    // submit + uploads
    buildPayload: voltBuildPayload,
    submit: voltSubmit,
    uploadPhotos: voltUploadPhotos,
    // address autocomplete (Google Places)
    placesEnabled: () => !!VOLT_GOOGLE_PLACES_KEY,
    ensurePlaces: voltEnsurePlaces,
    newPlacesSession: voltNewPlacesSession,
    addressSuggestions: voltAddressSuggestions,
    placeDetails: voltPlaceDetails,
    // marketing attribution (first-touch) + funnel analytics
    getAttribution: voltGetAttribution,
    track: voltTrack,
    // mapping helpers (exposed for tests / reuse)
    mapPropertyType: voltMapPropertyType,
    composeServiceType: voltComposeServiceType,
    ymdLocal: voltYmdLocal,
    emailValid: voltEmailValid,
    messageForStatus: voltMessageForStatus,
  },
  VoltTurnstile,
});

// Capture first-touch attribution on the very first page load of the session.
voltCaptureAttribution();
