/* global React, ReactDOM, window */
const { useState: uSA, useEffect: uEA, useReducer } = React;

const STORAGE_KEY = 'gg.pricing-cart.v1';

// Generate a UUID v4 for quote identification (spec §3.2.4, quote_uuid is
// the key that ties calculator state, Attio leads, Stripe customers, and
// post-checkout onboarding together). Prefers crypto.randomUUID() (all
// evergreen browsers since 2021); falls back to a Date+random hybrid in
// older contexts so we never end up with a null uuid.
function generateQuoteUuid() {
  try {
    if (window.crypto && typeof window.crypto.randomUUID === 'function') {
      return window.crypto.randomUUID();
    }
  } catch (e) { /* fall through */ }
  // Prefix-tagged so fallback uuids are identifiable in logs/analytics.
  return 'q-' + Date.now().toString(36) + '-'
       + Math.random().toString(36).slice(2, 10) + '-'
       + Math.random().toString(36).slice(2, 10);
}

// Public read API for external scripts (analytics, Stripe metadata, AE
// link generation, etc.) that need to tag events with the current quote.
// Reads from localStorage so it works outside the React tree, including
// before React mounts. Returns null only if storage is unreadable.
window.getQuoteUuid = function () {
  try {
    const raw = window.localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    return JSON.parse(raw).quote_uuid || null;
  } catch (e) { return null; }
};

// Spec §3.1: localStorage quote-in-progress lifetime is up to 14 days, after
// which the user sees a "refresh prices" prompt. Beyond this window, the
// agency-saved retail prices may no longer match current GoGorilla wholesale
// rates, so we surface a non-blocking notice. The prompt is dismissable.
const STALE_THRESHOLD_MS = 14 * 24 * 60 * 60 * 1000; // 14 days

// Spec §3.2: URL parameters override localStorage. Parsed once per page load.
// `prefill` (base64-encoded JSON state) and `ref=naz_call_*` (AE-generated
// sign-up link) BOTH trigger an override that ignores localStorage entirely.
// Other ref types (referral loops, win-back) just stash for downstream
// attribution (Attio/W15, win-back/W7) without overriding state.
//
// URL param contract:
//   ?prefill=<base64(JSON.stringify(state))> , full or partial state to load
//   ?ref=naz_call_<lead_id>                  , AE sign-up link (override)
//   ?ref=client_<hash>|agency_<hash>|investor_<id> , referral attribution
//   ?winback=<client_id>                     , win-back loop
//   ?client=<hint>                           , client-type pre-hint
function getUrlState() {
  try {
    const params = new URLSearchParams(window.location.search || '');
    const ref = params.get('ref');
    const winback = params.get('winback');
    const client = params.get('client');
    const prefillRaw = params.get('prefill');
    let prefilledState = null;
    if (prefillRaw) {
      try {
        // Expected encoding: btoa(JSON.stringify(state)). We try a URI-decoded
        // fallback for unicode-safe encodings the AE tool may use later.
        let decoded;
        try { decoded = atob(prefillRaw); }
        catch (_) { decoded = atob(decodeURIComponent(prefillRaw)); }
        let parsed;
        try { parsed = JSON.parse(decoded); }
        catch (_) { parsed = JSON.parse(decodeURIComponent(decoded)); }
        if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
          prefilledState = parsed;
        }
      } catch (e) {
        // Malformed prefill, silently fall through to localStorage path.
      }
    }
    const isAeLink = !!(ref && /^naz_call_/i.test(ref));
    return {
      prefilledState,
      ref: ref || null,
      winback: winback || null,
      client: client || null,
      isAeLink,
      hasOverride: !!(prefilledState || isAeLink),
    };
  } catch (e) {
    return { prefilledState: null, ref: null, winback: null, client: null, isAeLink: false, hasOverride: false };
  }
}
// Expose so external scripts (analytics, AE-link debug, etc.) can inspect.
window.getUrlState = getUrlState;

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "showMobileBar": true,
  "addonsDefault": "when-added",
  "showHomePreview": true
}/*EDITMODE-END*/;

// Build flow is now DYNAMIC by client type, see data.jsx → stepsForClient().
// Common shape:
//   step 0          = Client type
//   final step      = Checkout (YoureSetPage)
// Agencies: a dedicated White-Label step sits at index 1 (route-setting).
// Founders / investors: a Fundraising step sits second-to-last.
const initialBase = {
  step: 0,
  clientTypeId: null,
  intentId: null,
  selections: {},
  // Per-service commitment chosen via the tab group BEFORE the user has picked a
  // tier for that service. Stored separately so changing the commit toggle on a
  // not-yet-active service doesn't auto-select a tier. Flushed into the
  // selection on the first SET_TIER / SET_SERVICE-on for that service.
  pendingCommits: {},
  // Active promo / voucher code, shared across every Summary render. Lifted from
  // local useState so it survives the BuildPage → YoureSetPage step transition
  // (otherwise the Checkout page would always render an empty Summary mount and
  // lose the discount the user already applied). Shape: { code, pct, label } | null.
  promoApplied: null,
  // Per-quote UUID, generated on first load, persisted across sessions, and
  // used as the join key between calculator state, Attio leads, Stripe
  // customers, and the post-checkout onboarding form. Null here as a safety
  // default; loadState() always populates it (restore-or-generate) before
  // the App reducer consumes initial state.
  quote_uuid: null,
  // Spec §3.1: true when restored localStorage state is older than the stale
  // threshold (14 days). When true, the calculator surfaces a dismissable
  // "your saved quote may have outdated prices" prompt above the breadcrumb.
  // Reset to false when the user dismisses or makes any new state change
  // (the next persistence write refreshes saved_at to now).
  isStale: false,
  // Spec §3.2 / W3, URL-derived attribution fields. Set on page load from
  // ?ref=naz_call_<id> (AE sign-up link) or ?ref=client_*/agency_*/investor_*
  // (referral loop). Persisted to localStorage so they outlive a page refresh
  // even if the user no longer has the URL param. Used by W15 (Attio link
  // generation) and downstream Stripe metadata for AE commission attribution.
  ae_ref: null,
  // Spec §3.2, set when user arrives via ?winback=<client_id>. Surfaces in
  // analytics/Attio so we know they came from a re-engagement campaign.
  winback_ref: null,
  // Sprint 1, Q0 capture (white-label agencies only): client name + industry.
  // Industry pre-fills Q1 ICP via INDUSTRY_TO_Q1_ICP on intent change.
  q0: { clientName: '', industry: '' },
  // Sprint 1, qualifier answers Q1, Q4 + conditional Q1.5. Drives warmth score
  // + cohort tag (A/B/C/D) computed via computeWarmth/computeCohort. Cohort D
  // triggers the bypass screen (book-a-call) instead of the full configurator.
  qualifier: { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] },
  // Spec §17 (founders-step-1): once the qualifier first becomes ready
  // (isReadyToAdvance === true), the reducer auto-pre-selects the
  // recommended services from getGapRecommendations(qualifier) into
  // state.selections. This flag prevents the auto-apply from re-firing
  // on every subsequent state change. Cleared when q1 changes (user
  // picks a different business model → new gaps are relevant).
  // §5.2, How the founder wants to receive their proposal. Default 'schedule'
  // (recommended). Set via SET_CALL_PREFERENCE on the call-pref radio cards
  // on Step 6.
  callPreference: 'schedule',
  // 2026-05-29: ServicesInquiryModal on Step 6 (founders) / Step 4 (agency).
  // Soft-prompt for users who reach the final step with empty selections.
  // categories: array of service-need chips picked. notes: optional free
  // text. dismissed: true means user clicked Skip — modal stays closed
  // unless they re-open it via the chip below the title.
  servicesInquiry: { categories: [], notes: '', dismissed: false },
};

// ── Hydrate from URL params first (spec §3.2.1), then localStorage ──
function loadState() {
  const urlState = getUrlState();

  // 1. Spec §3.2.1, `prefill` overrides localStorage entirely. Build initial
  //    state directly from the decoded prefill payload. This handles the AE
  //    sign-up link case (?ref=naz_call_<lead>&prefill=<base64>) where the AE
  //    wants their recommendation surfaced exactly as configured.
  if (urlState.prefilledState) {
    const p = urlState.prefilledState;
    return {
      step: (typeof p.step === 'number' && p.step >= 0) ? p.step : 0,
      clientTypeId: p.clientTypeId || null,
      intentId: p.intentId || null,
      selections: (p.selections && typeof p.selections === 'object' && !Array.isArray(p.selections)) ? p.selections : {},
      pendingCommits: (p.pendingCommits && typeof p.pendingCommits === 'object' && !Array.isArray(p.pendingCommits)) ? p.pendingCommits : {},
      promoApplied: null, // promo doesn't carry through URL prefill
      quote_uuid: (typeof p.quote_uuid === 'string' && p.quote_uuid.length >= 8) ? p.quote_uuid : generateQuoteUuid(),
      isStale: false, // URL prefill is "fresh" by definition, AE just sent it
      ae_ref: urlState.ref || null,
      winback_ref: urlState.winback || null,
      q0: (p.q0 && typeof p.q0 === 'object') ? { clientName: p.q0.clientName || '', industry: p.q0.industry || '' } : { clientName: '', industry: '' },
      qualifier: (p.qualifier && typeof p.qualifier === 'object') ? { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], ...p.qualifier } : { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] },
      callPreference: p.callPreference || 'schedule',
      servicesInquiry: (p.servicesInquiry && typeof p.servicesInquiry === 'object')
        ? {
            categories: Array.isArray(p.servicesInquiry.categories) ? p.servicesInquiry.categories : [],
            notes:      typeof p.servicesInquiry.notes === 'string'      ? p.servicesInquiry.notes      : '',
            dismissed:  !!p.servicesInquiry.dismissed,
          }
        : { categories: [], notes: '', dismissed: false },
    };
  }

  // 2. Spec §3.2.1, `ref=naz_call_*` alone (no prefill) also overrides
  //    localStorage. Start fresh with AE attribution stamped in state.
  if (urlState.isAeLink) {
    return {
      ...initialBase,
      quote_uuid: generateQuoteUuid(),
      ae_ref: urlState.ref,
      winback_ref: urlState.winback || null,
    };
  }

  // 3. No URL override, fall back to existing localStorage flow.
  try {
    const raw = window.localStorage.getItem(STORAGE_KEY);
    if (!raw) {
      // First visit (or storage cleared), bootstrap a fresh quote_uuid so the
      // very first persistence cycle writes it to localStorage. Stash any
      // ref / winback URL params for attribution.
      return {
        ...initialBase,
        quote_uuid: generateQuoteUuid(),
        ae_ref: urlState.ref || null,
        winback_ref: urlState.winback || null,
      };
    }
    const saved = JSON.parse(raw);
    // Always restart at step 0 (Client type) on reload, saved selections persist,
    // but the user re-enters the flow from the beginning. This avoids landing on
    // a service page that no longer makes sense without re-confirming client type.
    let step = 0;
    // We always reset to step 0 on load (see comment above) but clamp anyway
    // against the dynamic step list for whatever client type was saved.
    const flow = window.stepsForClient ? window.stepsForClient(saved.clientTypeId, saved.intentId) : window.BUILD_STEPS;
    const maxStep = Math.max(0, (flow?.length || 6) - 1);
    if (typeof saved.step === 'number' && saved.step >= 0 && saved.step <= maxStep) {
      step = saved.step;
    }
    // If they had no client type yet, force back to 0
    if (!saved.clientTypeId) step = 0;
    // Restore the promo only if it still validates against PROMO_CODES so a
    // renamed/expired code isn't silently re-applied across sessions.
    let promoApplied = null;
    if (saved.promoApplied && typeof saved.promoApplied === 'object' && typeof saved.promoApplied.code === 'string') {
      const codes = window.PROMO_CODES || {};
      const match = codes[saved.promoApplied.code];
      if (match) promoApplied = { code: saved.promoApplied.code, ...match };
    }
    // Restore quote_uuid if present, else generate one. Older localStorage
    // payloads (pre-W4) won't have this field, so generating preserves
    // backward compatibility for returning users.
    const quote_uuid = (typeof saved.quote_uuid === 'string' && saved.quote_uuid.length >= 8)
      ? saved.quote_uuid
      : generateQuoteUuid();
    // Compute staleness from saved.saved_at (W5). Pre-W5 payloads have no
    // timestamp, so isStale defaults to false, they only see the prompt
    // after their next save (which writes saved_at = Date.now()) has aged
    // past the threshold. Acceptable trade-off for backward compatibility.
    const isStale = (typeof saved.saved_at === 'number'
                     && Number.isFinite(saved.saved_at)
                     && (Date.now() - saved.saved_at > STALE_THRESHOLD_MS));
    // Restore attribution fields. URL params still take priority (ref/winback
    // from the current visit override stored values), since the most recent
    // attribution is the most accurate. Spec §3.2, URL trumps storage.
    const ae_ref = urlState.ref || saved.ae_ref || null;
    const winback_ref = urlState.winback || saved.winback_ref || null;
    return {
      step,
      clientTypeId: saved.clientTypeId || null,
      intentId: saved.intentId || null,
      selections: saved.selections && typeof saved.selections === 'object' ? saved.selections : {},
      pendingCommits: saved.pendingCommits && typeof saved.pendingCommits === 'object' ? saved.pendingCommits : {},
      promoApplied,
      quote_uuid,
      isStale,
      ae_ref,
      winback_ref,
      q0: (saved.q0 && typeof saved.q0 === 'object') ? { clientName: saved.q0.clientName || '', industry: saved.q0.industry || '' } : { clientName: '', industry: '' },
      qualifier: (saved.qualifier && typeof saved.qualifier === 'object') ? { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], ...saved.qualifier } : { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] },
      callPreference: saved.callPreference || 'schedule',
      servicesInquiry: (saved.servicesInquiry && typeof saved.servicesInquiry === 'object')
        ? {
            categories: Array.isArray(saved.servicesInquiry.categories) ? saved.servicesInquiry.categories : [],
            notes:      typeof saved.servicesInquiry.notes === 'string'      ? saved.servicesInquiry.notes      : '',
            dismissed:  !!saved.servicesInquiry.dismissed,
          }
        : { categories: [], notes: '', dismissed: false },
    };
  } catch (e) {
    // On parse failure, still bootstrap a uuid so downstream code never sees null.
    return {
      ...initialBase,
      quote_uuid: generateQuoteUuid(),
      ae_ref: urlState.ref || null,
      winback_ref: urlState.winback || null,
    };
  }
}


// ── §17 auto-pre-select gap recommendations ───────────────────────
// Returns the next selections object with any recommended services that
// aren't already present added at their default tier. Pure function, no
// side effects. Only the founders flow consumes this (gated upstream).
function applyGapRecommendations(prevSelections, qualifier, intentId) {
  const gaps = window.getGapRecommendations ? window.getGapRecommendations(qualifier) : [];
  if (!Array.isArray(gaps) || gaps.length === 0) return prevSelections;
  const SERVICES = window.SERVICES || [];
  const tiersFor = window.tiersFor;
  const defaultTierFor = window.defaultTierForService;
  const canon = window._canonicalServiceId || ((x) => x);
  const next = { ...prevSelections };
  for (const rawId of gaps) {
    const svcId = canon(rawId);
    if (next[svcId]) continue; // user already has it; don't overwrite
    const svc = SERVICES.find(x => x.id === svcId);
    if (!svc) continue;
    const svcTiers = tiersFor ? tiersFor(svc) : window.TIERS;
    let tier = defaultTierFor ? defaultTierFor(svcId, qualifier, intentId) : null;
    if (tier && !svcTiers?.some(t => t.id === tier)) tier = null;
    if (!tier) {
      const popularTier = svcTiers?.find(t => t.badge === 'popular');
      tier = popularTier?.id || svcTiers?.[1]?.id || svcTiers?.[0]?.id || 'grow';
    }
    next[svcId] = { tier, addons: [] };
  }
  return next;
}

function reducer(s, a) {
  switch (a.type) {
    case 'SET_STEP': return { ...s, step: a.step };
    case 'SET_CLIENT': {
      // Reset intent when client type changes (or unchanged → keep).
      // Also clamp `step` against the new client's flow length so we don't
      // land on a step index that no longer exists (e.g. agency flow has no
      // 'fundraising' step, founder flow has no dedicated 'whitelabel' step).
      const sameClient = s.clientTypeId === a.id;
      const flow = window.stepsForClient ? window.stepsForClient(a.id, sameClient ? s.intentId : null) : window.BUILD_STEPS;
      const maxStep = Math.max(0, (flow?.length || 6) - 1);
      const nextStep = Math.min(s.step, maxStep);
      // Prune selections to only services that exist in the new flow. Without
      // this, services added under one persona (e.g. Sales & Demand Gen from
      // Founder flow) stay in state.selections after switching to Investor,
      // inflating the multi-service discount count and confusing the user.
      let nextSelections = s.selections;
      if (!sameClient && flow && Array.isArray(flow)) {
        const allowedIds = new Set();
        for (const step of flow) {
          if (Array.isArray(step.serviceIds)) {
            for (const id of step.serviceIds) allowedIds.add(id);
          }
        }
        const pruned = {};
        for (const [id, sel] of Object.entries(s.selections || {})) {
          if (allowedIds.has(id)) pruned[id] = sel;
        }
        if (Object.keys(pruned).length !== Object.keys(s.selections || {}).length) {
          nextSelections = pruned;
        }
      }
      // #55: When client type changes, qualifier answers are persona-specific so reset them.
      // CLEAR_FOR_CLIENT_SWITCH handles this when there are existing selections + confirmation;
      // this covers the no-selection path so stale answers never bleed into a new persona's flow.
      const _nextQualifier = sameClient ? s.qualifier : { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] };
      return { ...s, clientTypeId: a.id, intentId: sameClient ? s.intentId : null, step: nextStep, selections: nextSelections, qualifier: _nextQualifier };
    }
    case 'SET_INTENT': {
      // Sprint 1, when switching to agency-whitelabel and industry is already
      // captured, pre-fill Q1 ICP via INDUSTRY_TO_Q1_ICP.
      let qualifier = s.qualifier || { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] };
      const isWhitelabel = s.clientTypeId === 'agency' && a.id === 'agency-whitelabel';
      const industry = s.q0?.industry;
      if (isWhitelabel && industry && window.INDUSTRY_TO_Q1_ICP) {
        const mapped = window.INDUSTRY_TO_Q1_ICP[industry];
        if (mapped && !qualifier.q1) qualifier = { ...qualifier, q1: mapped };
      }
      // Whitelabel intent removes the talent step, clamp current step
      // against the new (potentially shorter) flow length.
      const _flowAfter = window.stepsForClient ? window.stepsForClient(s.clientTypeId, a.id) : window.BUILD_STEPS;
      const _maxStepAfter = Math.max(0, (_flowAfter?.length || 6) - 1);
      const _stepAfter = Math.min(s.step, _maxStepAfter);
      return { ...s, intentId: a.id, step: _stepAfter, qualifier };
    }
    case 'SET_SERVICE': {
      const next = { ...s.selections };
      const pending = { ...(s.pendingCommits || {}) };
      if (a.on) {
        if (!next[a.id]) {
          // Resolve default tier, services with custom tier overrides (e.g. Dedicated
          // Resources: parttime/fulltime) need a tier id that actually exists for them.
          const svc = window.SERVICES?.find?.(x => x.id === a.id);
          const svcTiers = window.tiersFor ? window.tiersFor(svc) : window.TIERS;
          // Sprint 2, qualifier-driven default. Falls back to popular/grow if
          // the qualifier is incomplete or returns a tier the service doesn't offer.
          let qualifierTier = window.defaultTierForService
            ? window.defaultTierForService(a.id, s.qualifier, s.intentId)
            : null;
          if (qualifierTier && !svcTiers?.some(t => t.id === qualifierTier)) qualifierTier = null;
          const popularTier = svcTiers?.find(t => t.badge === 'popular');
          const defaultTier = qualifierTier || popularTier?.id || svcTiers?.[1]?.id || svcTiers?.[0]?.id || 'grow';
          // Seed with any pending commit choice; otherwise default selection.
          // Channels intentionally NOT pre-selected. User must pick channels
          // themselves on the service card. (Removed Sprint 2 auto-rank pre-select.)
          const seed = { tier: defaultTier, addons: [] };
          if (pending[a.id]) seed.commitId = pending[a.id];
          // Talent FT (dedicated-ft), auto-seed default role mix based on
          // Step 1 q1/q1a (founders-talent-solutions-conditional-tree §5)
          // and pre-check the Buyout add-on if any seeded role triggers the
          // Series A+ SDR/CSM rule (§8). Only applies on first selection;
          // subsequent toggle-off + toggle-on hits this same branch (since
          // !next[a.id]), so the defaults re-apply, that's intentional UX.
          if (a.id === 'dedicated-ft' && defaultTier === 'fulltime') {
            const ftDefaults = window.getDrFtDefaultRoles ? window.getDrFtDefaultRoles(s) : [];
            const tierRoleIds = new Set((svc?.roles?.fulltime || []).map(r => r.id));
            const seededRoles = ftDefaults.filter(rid => tierRoleIds.has(rid));
            if (seededRoles.length > 0) {
              seed.roles = seededRoles;
              const shouldBuyout = seededRoles.some(rid =>
                window.shouldDefaultDrFtBuyout ? window.shouldDefaultDrFtBuyout(s, rid) : false
              );
              if (shouldBuyout) seed.addons = ['buyout'];
            }
            // §3.2, recommend a shorter commit for early-stage / urgent founders.
            // Honours user's prior pending choice (e.g. via the commit toggle)
            // if it exists; otherwise applies the qualifier-driven default.
            if (!pending[a.id] && window.getDrFtRecommendedCommit) {
              seed.commitId = window.getDrFtRecommendedCommit(s);
            }
          }
          // Talent PT (dedicated-pt), auto-seed default role mix based on
          // Step 1 q1 / q1a / q3 (talent-spec §4.6). Same gate as FT: only
          // fires on first activation; the click that activated the service
          // is handled separately in DedicatedFlow's handleToggleRole.
          if (a.id === 'dedicated-pt' && defaultTier === 'parttime') {
            const ptDefaults = window.getDrPtDefaultRoles ? window.getDrPtDefaultRoles(s) : [];
            const tierRoleIds = new Set((svc?.roles?.parttime || []).map(r => r.id));
            const seededRoles = ptDefaults.filter(rid => tierRoleIds.has(rid));
            if (seededRoles.length > 0) {
              seed.roles = seededRoles;
              // Seed each role with a default 5-day package config so the
              // PT card shows actual numbers (not "Custom") on first render.
              const cfgs = {};
              seededRoles.forEach(rid => { cfgs[rid] = { days: 5 }; });
              seed.roleConfigs = cfgs;
            }
          }
          next[a.id] = seed;
        }
        delete pending[a.id];
      } else {
        delete next[a.id];
        delete pending[a.id];
      }
      return { ...s, selections: next, pendingCommits: pending };
    }
    case 'SET_TIER': {
      const pending = { ...(s.pendingCommits || {}) };
      const seedCommit = pending[a.id];
      const cur = s.selections[a.id] || { tier: 'grow', addons: [], ...(seedCommit ? { commitId: seedCommit } : {}) };
      delete pending[a.id];
      // Prune channel selections that aren't available in the new tier's menu
      let nextChannels = Array.isArray(cur.channels) ? cur.channels : null;
      if (nextChannels && window.channelsForTier) {
        const allowed = new Set(window.channelsForTier(a.id, a.tier));
        nextChannels = nextChannels.filter(c => allowed.has(c));
      }
      // Prune roles that aren't available under the new tier (services with per-tier `roles`)
      let nextRoles = Array.isArray(cur.roles) ? cur.roles : null;
      if (nextRoles) {
        const svc = window.SERVICES?.find?.(x => x.id === a.id);
        const allowedRoles = svc?.roles?.[a.tier] ? new Set(svc.roles[a.tier].map(r => r.id)) : null;
        nextRoles = allowedRoles ? nextRoles.filter(rid => allowedRoles.has(rid)) : [];
      }
      const updated = { ...cur, tier: a.tier };
      if (nextChannels) updated.channels = nextChannels;
      if (nextRoles) updated.roles = nextRoles;
      // Special case: Dedicated Resources add-ons only apply to Full-Time tier.
      // Drop any selected add-ons (and their qty) when switching to Part-Time.
      if ((a.id === 'dedicated-pt' || a.id === 'dedicated-ft') && a.tier !== 'fulltime') {
        updated.addons = [];
        updated.addonQty = {};
      }
      // When tier changes on Dedicated, drop role configs (the schema differs
      // between Part-Time and Full-Time). Post-split each Dedicated service
      // is single-tier, so this is effectively dead code today, kept for
      // safety in case a future change reintroduces a tier toggle.
      if ((a.id === 'dedicated-pt' || a.id === 'dedicated-ft') && cur.tier !== a.tier) {
        updated.roleConfigs = {};
      }
      return { ...s, selections: { ...s.selections, [a.id]: updated }, pendingCommits: pending };
    }
    case 'SET_SERVICE_COMMIT': {
      // If the service has no selection yet, store the commit choice in
      // pendingCommits, do NOT auto-select a tier just because the user
      // tapped a commitment tab.
      if (!s.selections[a.id]) {
        return { ...s, pendingCommits: { ...(s.pendingCommits || {}), [a.id]: String(a.commitId) } };
      }
      const cur = s.selections[a.id];
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, commitId: String(a.commitId) } } };
    }
    case 'TOGGLE_PAY_UPFRONT': {
      // Per-service Pay Upfront toggle. When ON, subtotal for that service
      // (tier + addons) gets -10% applied at total time. Stored as a boolean
      // on the service selection. If the service has no selection yet, no-op
      // (the toggle is only meaningful once a tier is picked).
      if (!s.selections[a.id]) return s;
      const cur = s.selections[a.id];
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, payUpfront: !cur.payUpfront } } };
    }
    case 'TOGGLE_ADDON': {
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const has = cur.addons.includes(a.addonId);
      const next = has ? cur.addons.filter(x => x !== a.addonId) : [...cur.addons, a.addonId];
      // Initialise quantity to 1 when first selected (only if not already set)
      const qty = { ...(cur.addonQty || {}) };
      if (!has && qty[a.addonId] == null) qty[a.addonId] = 1;
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, addons: next, addonQty: qty } } };
    }
    case 'SET_ADDON_QTY': {
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const qty = { ...(cur.addonQty || {}) };
      const v = Math.max(1, Math.min(9999, Math.floor(Number(a.value) || 1)));
      qty[a.addonId] = v;
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, addonQty: qty } } };
    }
    case 'TOGGLE_ROLE': {
      // Toggle a role id within a service's `roles` array. Roles are scoped per tier
      // (the available role list differs between Part Time / Full Time on Dedicated Resources).
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const rs = Array.isArray(cur.roles) ? cur.roles : [];
      const next = rs.includes(a.roleId) ? rs.filter(x => x !== a.roleId) : [...rs, a.roleId];
      // When un-selecting, drop any per-role configuration too.
      const cfgs = { ...(cur.roleConfigs || {}) };
      if (rs.includes(a.roleId)) {
        delete cfgs[a.roleId];
      } else {
        // Initialise with sensible defaults based on tier.
        if (cur.tier === 'fulltime') {
          // Default to the role's recommended location (from DEDICATED_ROLE_RECS)
          // rather than always defaulting to UK.
          const _recs = window.DEDICATED_ROLE_RECS || {};
          const _recEntry = _recs[a.roleId];
          const _recLocId = _recEntry
            ? ((window.DEDICATED_FT_LOCATIONS || []).find(l => l.cc === _recEntry.cc) || {}).id || 'philippines'
            : 'philippines';
          cfgs[a.roleId] = { location: _recLocId, seniority: 'mid', tasks: '' };
        } else if (cur.tier === 'parttime') {
          cfgs[a.roleId] = { days: 5 };
        } else {
          cfgs[a.roleId] = {};
        }
      }
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, roles: next, roleConfigs: cfgs } } };
    }
    case 'SET_ROLE_CONFIG': {
      // Patch a single role's config. a = { id (serviceId), roleId, patch }
      const cur = s.selections[a.id];
      if (!cur) return s;
      const cfgs = { ...(cur.roleConfigs || {}) };
      cfgs[a.roleId] = { ...(cfgs[a.roleId] || {}), ...a.patch };
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, roleConfigs: cfgs } } };
    }
    case 'TOGGLE_CHANNEL': {
      // Toggle a channel id within a service's `channels` array (selection-mode services only).
      // Enforces a max if provided, if at max and adding, drops the oldest.
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const cs = Array.isArray(cur.channels) ? cur.channels : [];
      let next;
      if (cs.includes(a.channelId)) {
        next = cs.filter(x => x !== a.channelId);
      } else if (typeof a.max === 'number' && cs.length >= a.max) {
        // bump oldest selection to make room (keeps the toggle "swap" feel)
        next = [...cs.slice(1), a.channelId];
      } else {
        next = [...cs, a.channelId];
      }
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, channels: next } } };
    }
    case 'SET_AD_SPEND': {
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, dailyAdSpend: a.value } } };
    }
    case 'SET_LINKEDIN_PROFILES': {
      // 2026-05-22: per-service LinkedIn profile count (Sales only). Each
      // profile adds £399/mo to the monthly retainer (computed in
      // linesForRail). Clamped to 1..10.
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const n = Math.max(1, Math.min(10, Number(a.value) || 1));
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, linkedinProfiles: n } } };
    }
    case 'SET_OVERSEAS_COUNTRIES': {
      // 2026-05-22: list of country codes the user wants extended cold-call
      // coverage for, attached to the 'overseas-calling' addon on Sales.
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const list = Array.isArray(a.value) ? a.value.filter(Boolean) : [];
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, overseasCountries: list } } };
    }
    case 'SET_LEAD_SOURCE_MODE': {
      // Monthly Lead Volume widget (SDG only). When user picks 'byol', also
      // add the existing 'byol' add-on so the 15% retainer-discount math
      // fires. When switching back to 'we-source', remove it.
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const addons = Array.isArray(cur.addons) ? cur.addons.slice() : [];
      let byolListQuality = cur.byolListQuality;
      if (a.mode === 'byol' && !addons.includes('byol')) addons.push('byol');
      if (a.mode === 'byol' && !byolListQuality) byolListQuality = 'fully-enriched';
      if (a.mode === 'we-source') {
        const i = addons.indexOf('byol');
        if (i !== -1) addons.splice(i, 1);
        byolListQuality = null;
      }
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, leadSourceMode: a.mode, addons, byolListQuality } } };
    }
    case 'SET_BYOL_LIST_QUALITY': {
      // Captures the user's answer to "What's in your list?". The percentage
      // shown (25/10/5) is the marketing rate; final discount confirmed at
      // onboarding (see yellow banner copy in the widget).
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, byolListQuality: a.quality } } };
    }
    case 'SET_MONTHLY_LEADS': {
      // 2026-05-28: tier-aware floor. Starter 750, Grow 1,000, Scale 1,250.
      // Previously hardcoded 1,000 which stranded Starter users — slider
      // visible min was 750 but the reducer clamped any value < 1,000 back
      // up, leaving the thumb effectively stuck at ~1,050 on Starter.
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const floor = (typeof window !== 'undefined' && typeof window.leadIncludedForTier === 'function')
        ? window.leadIncludedForTier(cur.tier)
        : 750;
      const v = Math.max(floor, Math.min(5000, Math.round(Number(a.value) || floor)));
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, monthlyLeads: v } } };
    }
    case 'SET_PROMO': return { ...s, promoApplied: a.promo || null };

    case 'SET_Q0_FIELD': {
      // Sprint 1, white-label agency Q0 capture (clientName + industry).
      // When industry changes AND user is on agency-whitelabel intent, pre-fill
      // Q1 ICP via INDUSTRY_TO_Q1_ICP. User can override after seeing pre-fill.
      const q0 = { ...(s.q0 || { clientName: '', industry: '' }), [a.field]: a.value };
      let qualifier = s.qualifier || { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] };
      const isWhitelabel = s.clientTypeId === 'agency' && s.intentId === 'agency-whitelabel';
      if (a.field === 'industry' && isWhitelabel && window.INDUSTRY_TO_Q1_ICP) {
        const mapped = window.INDUSTRY_TO_Q1_ICP[a.value];
        if (mapped) qualifier = { ...qualifier, q1: mapped };
      }
      return { ...s, q0, qualifier };
    }
    // 2026-05-26: SET_QUALIFIER_SKIP + qualifierSkipped removed. The Skip-
    // questions floater that dispatched this is gone; the plan-summary-card
    // and CheckoutForm now always render regardless of qualifier completion.
    case 'SET_QUALIFIER': {
      // Sets a single qualifier answer. When the user changes `q1`, the
      // entire downstream branch is cleared (the JSX layer is responsible
      // for surfacing a confirmation modal when >=3 downstream answers exist
      //, by the time SET_QUALIFIER fires for q1, the user has already
      // confirmed). When the user changes `fundBacked` away from 'yes', the
      // funding sub-chain is cleared. Multi-select chips (priorActivities)
      // are handled via SET_QUALIFIER_MULTI below.
      const cur = s.qualifier || { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] };
      let next = { ...cur, [a.q]: a.value };
      if (a.q === 'q1') {
        // Clear every downstream field on a q1 change.
        next = { ...next,
          q1a: null,
          q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null,
          saasCycle: null,
          bservicesCat: null, bservicesCycle: null,
          bcservicesCat: null,
          q1b: null, q1c: null,
          q2: null, q2b: null, q3: null,
          tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null,
          q4: null, q5: null, q5b: null,
          urgency: null, priorActivities: [],
        };
      }
      if (a.q === 'q1a' && cur.q1 === 'dtc' && a.value !== cur.q1a) {
        next.q1aDtcAov = null; next.q1aDtcMargin = null; next.q1aDtcRepeat = null;
      }
      if (a.q === 'q1aDtcAov' && a.value !== cur.q1aDtcAov) { next.q1aDtcMargin = null; next.q1aDtcRepeat = null; }
      if (a.q === 'q1aDtcMargin' && a.value !== cur.q1aDtcMargin) { next.q1aDtcRepeat = null; }
      if (a.q === 'q1a' && cur.q1 === 'saas' && a.value !== cur.q1a) { next.saasCycle = null; }
      if (a.q === 'q1a' && cur.q1 === 'bservices' && a.value !== cur.q1a) { next.bservicesCat = null; next.bservicesCycle = null; }
      if (a.q === 'bservicesCat' && a.value !== cur.bservicesCat) { next.bservicesCycle = null; }
      if (a.q === 'q1a' && cur.q1 === 'bcservices' && a.value !== cur.q1a) { next.bcservicesCat = null; }
      if (a.q === 'fundBacked' && a.value !== 'yes') {
        next.fundRaised = null; next.fundInvestorCount = null;
      }
      if (a.q === 'fundRaised' && cur.fundBacked === 'yes' && a.value !== cur.fundRaised) {
        next.fundInvestorCount = null;
      }
      // §17 auto-pre-select: when the qualifier transitions !ready → ready
      // for the founders flow, splice in the recommended services. Only
      // fires once per qualifier session (gapsApplied flag prevents repeat).
      const _wasReady = window.qualifierComplete ? window.qualifierComplete(s.qualifier, s.clientTypeId) : false;
      const _nowReady = window.qualifierComplete ? window.qualifierComplete(next, s.clientTypeId) : false;
      // Pre-select / gap-recommendation auto-apply DISABLED, qualifier is now
      // optional and lives on the last page. Users explicitly choose services.
      // (applyGapRecommendations definition retained for future re-enablement.)
      return { ...s, qualifier: next, selections: s.selections };
    }
    case 'SET_QUALIFIER_MULTI': {
      // Toggles a value in a multi-select array (priorActivities). If the
      // toggled value matches `exclusive`, ALL other selections clear. If a
      // different value is added while `exclusive` is already in the array,
      // the exclusive value clears.
      const cur = s.qualifier || { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] };
      const arr = Array.isArray(cur[a.q]) ? [...cur[a.q]] : [];
      const exclusive = a.exclusive || null;
      const has = arr.includes(a.value);
      let nextArr;
      if (has) {
        nextArr = arr.filter(v => v !== a.value);
      } else if (a.value === exclusive) {
        nextArr = [exclusive];
      } else {
        nextArr = [...arr.filter(v => v !== exclusive), a.value];
      }
      let _nextMulti = { ...cur, [a.q]: nextArr };
      // Q6 spec §4, selecting 'none' wipes all cascade sub-fields. Toggling
      // off a primary chip clears just its cascade. Toggling on a chip leaves
      // sub-fields untouched (they re-mount empty anyway).
      if (a.q === 'priorActivities') {
        const CASCADE_MAP = {
          sales:       { priorOutboundChannels: [], priorSalesCycle: null },
          paid:        { priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null },
          email:       { emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null },
          smm:         { priorSocialFrequency: null, priorSocialPlatforms: [] },
          talent:      { hiringTime: null },
          fundraising: { fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] },
        };
        // 'none' was just toggled ON → wipe every cascade.
        if (a.value === 'none' && nextArr.includes('none')) {
          for (const slug of Object.keys(CASCADE_MAP)) {
            _nextMulti = { ..._nextMulti, ...CASCADE_MAP[slug] };
          }
        }
        // A primary chip was just toggled OFF → clear that cascade.
        if (has && a.value !== 'none' && CASCADE_MAP[a.value]) {
          _nextMulti = { ..._nextMulti, ...CASCADE_MAP[a.value] };
        }
      }
      // Pre-select / gap-recommendation auto-apply DISABLED (see SET_QUALIFIER).
      return { ...s, qualifier: _nextMulti, selections: s.selections };
    }
    case 'SET_PRIOR_SUB': {
      // Q6 cascade, single-select sub-field (e.g., priorSalesCycle,
      // priorPaidBudget, priorPaidRoas, emailSubscriberCount, etc.). Pass
      // a.field + a.value. Setting null clears.
      const cur = s.qualifier || {};
      const next = { ...cur, [a.field]: a.value };
      return { ...s, qualifier: next };
    }
    case 'TOGGLE_PRIOR_SUB': {
      // Q6 cascade, multi-select sub-field (priorOutboundChannels,
      // priorPaidPlatforms, priorSocialPlatforms, fundraisingInvestorTypes,
      // fundraisingFlags). Toggles a.value into a.field array.
      const cur = s.qualifier || {};
      const arr = Array.isArray(cur[a.field]) ? [...cur[a.field]] : [];
      const idx = arr.indexOf(a.value);
      const nextArr = idx >= 0 ? arr.filter(v => v !== a.value) : [...arr, a.value];
      return { ...s, qualifier: { ...cur, [a.field]: nextArr } };
    }
    case 'SET_SERVICES_INQUIRY': {
      // Partial-update. action.value is { categories?, notes?, dismissed? }.
      // Used by ServicesInquiryModal to save chips/notes or mark as dismissed.
      const cur = s.servicesInquiry || { categories: [], notes: '', dismissed: false };
      const upd = (a.value && typeof a.value === 'object') ? a.value : {};
      return {
        ...s,
        servicesInquiry: {
          categories: Array.isArray(upd.categories) ? upd.categories : cur.categories,
          notes:      typeof upd.notes === 'string' ? upd.notes      : cur.notes,
          dismissed:  typeof upd.dismissed === 'boolean' ? upd.dismissed : cur.dismissed,
        },
      };
    }
    case 'SET_CALL_PREFERENCE': {
      const v = a.value;
      const valid = ['schedule', 'email-first', 'already-had', 'contract-payment'];
      if (!valid.includes(v)) return s;
      return { ...s, callPreference: v };
    }
    case 'CLEAR_CART': return { ...initialBase, clientTypeId: s.clientTypeId, quote_uuid: s.quote_uuid || generateQuoteUuid(), ae_ref: s.ae_ref || null, winback_ref: s.winback_ref || null };
    case 'CLEAR_FOR_CLIENT_SWITCH':
      // Spec §6.5.1 (Calculator_Workflow_and_Account_Architecture): user is
      // switching to a different client type while having selections. Clear
      // selections, intent, pending commits, promo, and route them through
      // the new client-type's flow. Step stays at 0 (Client Type page).
      // quote_uuid is PRESERVED here, same user, same session. The spec's
      // "treat as new quote" edge case (post-final-page + saved email) will
      // be implemented when that flag exists; until then, keep continuity.
      // Attribution (ae_ref, winback_ref) also persists, same lead, just
      // exploring a different client-type configuration.
      // Reset q0 + qualifier, they were tailored to the previous client type.
      return { ...initialBase, clientTypeId: a.id, quote_uuid: s.quote_uuid || generateQuoteUuid(), ae_ref: s.ae_ref || null, winback_ref: s.winback_ref || null, q0: { clientName: '', industry: '' }, qualifier: { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] } };
    case 'DISMISS_STALE_PROMPT':
      // Spec §3.1 (W5): user acknowledged the "your saved quote is >14 days
      // old" notice. We just clear the flag, the next persistence write
      // updates saved_at to now anyway, so this dismissal also effectively
      // marks the data as freshly re-seen.
      return { ...s, isStale: false };
    case 'SET_QUOTE_SUBMITTED': {
      return { ...s, quoteSubmitted: !!a.value };
    }
    default: return s;
  }
}

// ── Mobile sticky bar ──
// `onOpen` , left chevron opens the bottom sheet with the live breakdown.
// `onNext` , right "Next" CTA advances the user one step (same behaviour as
//             the inline Next button inside the desktop summary). Falls back
//             to `onOpen` if not supplied so the bar never has a dead button.
function MobileBar({ state, onOpen, onNext }) {
  const agyMult = window.getAgencyMultiplier(state);
  let total = 0;
  let custom = false;
  Object.entries(state.selections).forEach(([sid, sel]) => {
    const svc = window.SERVICES.find(x => x.id === sid);
    const tier = window.findTier(svc, sel.tier);
    if (!svc || !tier) return;
    if (tier.isEnterprise) { custom = true; return; }
    const opts = window.commitsFor(svc);
    const defaultCommitId = '12';
    const commitId = (opts && opts.some(o => o.id === sel.commitId)) ? sel.commitId : defaultCommitId;
    const p = window.priceFor(svc, tier.id, commitId);
    if (p.custom) { custom = true; return; }
    // Sprint 2, per-service multiplier so talent-on-whitelabel uses 0.85 not 0.60
    const _svcMult = window.getAgencyMultiplier(state, sid);
    // Per-service subtotal so we can apply the pay-upfront 10% discount
    // BEFORE bundling into the running total (mirrors Summary's math).
    let _svcSub = 0;
    if (!p.oneTime) _svcSub += p.value * _svcMult;
    const ctxAddons = window.getAddonsForContext
      ? window.getAddonsForContext(svc, sel.tier, commitId)
      : svc.addons;
    (sel.addons || []).forEach(aid => {
      const a = ctxAddons.find(x => x.id === aid);
      if (a && !a.free && !a.custom && !a.included) _svcSub += a.price;
    });
    // Pay-upfront -10% per opted-in service.
    if (sel.payUpfront) _svcSub *= 0.9;
    // Monthly Lead Volume, SDG-only. Tier-aware included floor:
    //   Starter 750, Grow 1,000, Scale 1,250 (matches tier feature pills).
    // Additional leads beyond the tier's included get £4/lead added on top.
    if (sid === 'sales' && sel.monthlyLeads && typeof window.computeAdditionalLeadCost === 'function') {
      const { cost } = window.computeAdditionalLeadCost(sel.monthlyLeads, sel.tier);
      if (cost > 0) _svcSub += cost * _svcMult;
    }
    total += _svcSub;
  });
  // Sprint 2, tiered multi-service discount (5/15/25/35/45). Combined-discount
  // cap applies for whitelabel agencies (whitelabel% + multi-service%).
  const _msPctRaw = window.multiServiceDiscountPct ? window.multiServiceDiscountPct(Object.keys(state.selections).length) : (Object.keys(state.selections).length >= 3 ? 0.10 : 0);
  // Whitelabel cap parity with Summary: multi-service portion is capped at 10%
  // on top of the headline 40% whitelabel discount (combined 50%).
  const _msPct = state.intentId === 'agency-whitelabel' ? Math.min(_msPctRaw, 0.10) : _msPctRaw;
  total *= (1 - _msPct);
  // Apply any active promo code on top of the bundle discount, mirroring the
  // Summary's order of operations (subtotal → multi-service → promo). Without
  // this the mobile bar shows the pre-promo total while the sheet/summary
  // shows the post-promo total, confusing for users.
  const _promoPct = (state.promoApplied?.pct || 0) / 100;
  if (_promoPct > 0) total *= (1 - _promoPct);
  total = Math.max(0, total);
  const count = Object.keys(state.selections).length;

  return (
    <div className="mobile-bar">
      <button className="mobile-bar__expand" onClick={onOpen} aria-label="Expand quote">
        <span className="mobile-bar__chev">⌃</span>
      </button>
      <div className="mobile-bar__info">
        <div className="mobile-bar__label">{count} service{count === 1 ? '' : 's'}</div>
        <div className="mobile-bar__total">
          {custom && total === 0 ? 'Custom' : window.fmt(Math.round(total)) + '/mo'}
        </div>
      </div>
      <button
        className="btn btn--primary btn--sm mobile-bar__cta"
        onClick={onNext || onOpen}
      >
        Next <span className="btn__arrow">›</span>
      </button>
    </div>
  );
}

function MobileSheet({ state, dispatch, onClose }) {
  return (
    <div className="mobile-sheet" role="dialog" aria-modal="true">
      <div className="mobile-sheet__backdrop" onClick={onClose} />
      <div className="mobile-sheet__panel">
        <button className="mobile-sheet__close" onClick={onClose} aria-label="Close">×</button>
        <div className="mobile-sheet__grab" />
        <window.Summary state={state} dispatch={dispatch} />
      </div>
    </div>
  );
}

function TweaksPanel({ tweaks, setTweaks, onClose, onClearCart, savedCount }) {
  const set = (k, v) => {
    const next = { ...tweaks, [k]: v };
    setTweaks(next);
    window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [k]: v } }, '*');
  };
  return (
    <div className="tweaks">
      <div className="tweaks__head">
        <strong>Tweaks</strong>
        <button className="tweaks__close" onClick={onClose}>×</button>
      </div>
      <div className="tweaks__row">
        <label>Add-ons default state</label>
        <div className="tweaks__seg">
          {[
            ['always','Always open'],
            ['when-added','When added'],
            ['collapsed','Collapsed']
          ].map(([k, lbl]) => (
            <button key={k} className={tweaks.addonsDefault === k ? 'active' : ''} onClick={() => set('addonsDefault', k)}>
              {lbl}
            </button>
          ))}
        </div>
      </div>
      <div className="tweaks__row">
        <label>Show mobile sticky bar</label>
        <button className={`tweaks__toggle ${tweaks.showMobileBar ? 'on' : ''}`} onClick={() => set('showMobileBar', !tweaks.showMobileBar)}>
          <span />
        </button>
      </div>
      <div className="tweaks__row">
        <label>Show home page cart preview</label>
        <button className={`tweaks__toggle ${tweaks.showHomePreview ? 'on' : ''}`} onClick={() => set('showHomePreview', !tweaks.showHomePreview)}>
          <span />
        </button>
      </div>
      <div className="tweaks__row" style={{flexDirection: 'column', alignItems: 'stretch', gap: '0.5rem'}}>
        <label>Saved cart ({savedCount} service{savedCount === 1 ? '' : 's'})</label>
        <button
          className="btn btn--ghost btn--sm"
          onClick={onClearCart}
          disabled={savedCount === 0}
          style={{opacity: savedCount === 0 ? 0.5 : 1}}
        >
          Clear saved cart
        </button>
      </div>
      <div className="tweaks__hint">Cart auto-saves to this browser. Reload to verify persistence.</div>
    </div>
  );
}

function App() {
  const [state, dispatch] = useReducer(reducer, null, loadState);
  const [mobileOpen, setMobileOpen] = uSA(false);
  const [tweaks, setTweaks] = uSA(TWEAK_DEFAULTS);
  const [tweaksOpen, setTweaksOpen] = uSA(false);
  // Track the count loaded from storage on first paint, for "Welcome back"
  const [initialSavedCount] = uSA(() => Object.keys(state.selections).length);

  // Persist on every change
  uEA(() => {
    try {
      window.localStorage.setItem(STORAGE_KEY, JSON.stringify({
        step: state.step,
        clientTypeId: state.clientTypeId,
        intentId: state.intentId,
        selections: state.selections,
        promoApplied: state.promoApplied,
        quote_uuid: state.quote_uuid,
        // Timestamp on every write, spec §3.1 / W5. Read back in loadState
        // to determine if the saved quote has crossed the 14-day staleness
        // threshold and the "refresh prices" prompt should be shown.
        saved_at: Date.now(),
        // Spec §3.2 / W3, persist URL-derived attribution so it survives
        // page reload even after the user has clicked through to a URL
        // without the original ref/winback params.
        ae_ref: state.ae_ref || null,
        winback_ref: state.winback_ref || null,
        // Sprint 1, qualifier + Q0 persistence so cohort tag survives reload.
        q0: state.q0 || { clientName: '', industry: '' },
        qualifier: state.qualifier || { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] },
      }));
    } catch (e) { /* ignore */ }
  }, [state]);

  uEA(() => {
    const handler = (e) => {
      if (e.data?.type === '__activate_edit_mode') setTweaksOpen(true);
      if (e.data?.type === '__deactivate_edit_mode') setTweaksOpen(false);
    };
    window.addEventListener('message', handler);
    window.parent.postMessage({ type: '__edit_mode_available' }, '*');
    return () => window.removeEventListener('message', handler);
  }, []);

  uEA(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); }, [state.step]);

  const savedCount = Object.keys(state.selections).length;

  // ── Top-of-page progress bar ──────────────────────────────────────────
  // Computes overall wizard completion as (state.step / flow.length) plus
  // sub-progress on step 0 from the qualifier answers, so the bar moves as
  // the user fills in each question rather than only on step transitions.
  const _ggProgress = (() => {
    const flow = window.stepsForClient ? window.stepsForClient(state.clientTypeId, state.intentId) : (window.BUILD_STEPS || []);
    if (!flow.length) return 0;
    const total = flow.length;
    const step = Math.max(0, Math.min(state.step || 0, total - 1));
    let pct = step / total;
    if (step === 0) {
      // Per-question granularity inside step 0 (client type + qualifier)
      const qs = window.QUALIFIER_QUESTIONS || [];
      const visible = qs.filter(q => {
        if (!q.conditional) return true;
        const dep = state.qualifier?.[q.conditional.dependsOn];
        return q.conditional.show.includes(dep);
      });
      let answered = 0;
      if (state.clientTypeId) answered++;
      for (const q of visible) if (state.qualifier?.[q.id]) answered++;
      const totalSub = visible.length + 1;
      pct += (answered / totalSub) * (1 / total);
    } else if (step === total - 1) {
      pct = 1;
    } else {
      // For service steps, give a half-step bonus once user has picked at
      // least one service on the current step (feels more "alive").
      pct = (step + 0.5) / total;
    }
    return Math.max(0, Math.min(1, pct));
  })();

  return (
    <>
      {/* Thin blue progress bar pinned to the top of the viewport. */}
      <div className="gg-progress" role="progressbar" aria-label="Wizard progress"
        aria-valuenow={Math.round(_ggProgress * 100)} aria-valuemin={0} aria-valuemax={100}>
        <div className="gg-progress__fill" style={{ width: `${_ggProgress * 100}%` }} />
      </div>

      {/* Discount toast notifications, fires when any discount activates
          (multi-service, commitment, pay upfront, BYOL, promo code, agency).
          Hydration guard prevents toasts on page refresh. */}
      {window.DiscountToastManager && <window.DiscountToastManager state={state} />}

      {/* HomeCartPreview removed, the in-app "Welcome back" prompt below
          already surfaces a Resume CTA for returning visitors. */}
      

      {(() => {
        // Compute the active flow for the current client type.
        const flow = window.stepsForClient(state.clientTypeId, state.intentId);
        const lastIdx = flow.length - 1; // checkout
        const isCheckout = state.step === lastIdx;
        return (
          <>
            {!isCheckout && (
              <window.BuildPage
                state={state}
                dispatch={dispatch}
                step={state.step}
                flow={flow}
                onJumpStep={(s) => dispatch({ type: 'SET_STEP', step: s })}
                onNext={() => {
                  if (state.step === 0 && !state.clientTypeId) return;
                  dispatch({ type: 'SET_STEP', step: Math.min(state.step + 1, lastIdx) });
                }}
                addonsDefaultOpen={tweaks.addonsDefault}
                savedCount={savedCount}
              />
            )}
            {isCheckout && (
              <window.YoureSetPage
                state={state}
                dispatch={dispatch}
                flow={flow}
                onBack={() => dispatch({ type: 'SET_STEP', step: lastIdx - 1 })}
              />
            )}
            {tweaks.showMobileBar && !isCheckout && (
              /* Mobile floating nav bar, visible on every step except the
                 checkout page (YoureSetPage). Previously we hid it when no
                 services were selected, but the user wants easy step-by-step
                 navigation from the very first screen. The Next handler is a
                 no-op when step 0 has no clientType yet, so users can't slip
                 past picking a path. */
              <MobileBar
                state={state}
                onOpen={() => setMobileOpen(true)}
                onNext={() => {
                  if (state.step === 0 && !state.clientTypeId) return;
                  dispatch({ type: 'SET_STEP', step: Math.min(state.step + 1, lastIdx) });
                }}
              />
            )}
          </>
        );
      })()}
      {mobileOpen && <MobileSheet state={state} dispatch={dispatch} onClose={() => setMobileOpen(false)} />}
      {tweaksOpen && (
        <TweaksPanel
          tweaks={tweaks}
          setTweaks={setTweaks}
          onClose={() => setTweaksOpen(false)}
          onClearCart={() => {
            try { window.localStorage.removeItem(STORAGE_KEY); } catch (e) {}
            dispatch({ type: 'CLEAR_CART' });
          }}
          savedCount={savedCount}
        />
      )}
    </>
  );
}

Promise.resolve(window.__pricesFetched).then(function() {
  ReactDOM.createRoot(document.getElementById('root')).render(<App />);
});
