// ---- App shell: sidebar tabs, state, generation flow ----
const THEME = {
  uiFont: 'Space Grotesk',
  promptFont: 'Space Grotesk',
  monoFont: 'Space Grotesk',
  accent: '#1F6FEB',
  iconSet: 'line',
  radius: 24,
  density: 'comfy',
};

const SURFACES = {
  warm:    { bg: '#FAFAF8', card: '#FFFFFF', ink: '#1C1B18', muted: '#6E6B64', hairline: '#E8E6E0', sidebar: '#F4F3EF' },
  cool:    { bg: '#F7F9FC', card: '#FFFFFF', ink: '#0F1A2E', muted: '#5B6B82', hairline: '#E4EAF2', sidebar: '#EFF3F9' },
  neutral: { bg: '#FAFAFA', card: '#FFFFFF', ink: '#18181B', muted: '#71717A', hairline: '#E7E7EA', sidebar: '#F4F4F5' },
  dark:    { bg: '#161719', card: '#1F2024', ink: '#ECEDEF', muted: '#9A9CA3', hairline: '#2E3036', sidebar: '#1A1B1E', dark: true },
  midnight:{ bg: '#0E1421', card: '#172033', ink: '#E6ECF6', muted: '#8A99B5', hairline: '#23304A', sidebar: '#121A2A', dark: true },
};

const _tweakFonts = new Set(['Instrument Sans', 'Spline Sans Mono']);
const ALL_FONTS = [
  'Instrument Sans', 'Space Grotesk', 'Sora', 'Bricolage Grotesque', 'Plus Jakarta Sans', 'Hanken Grotesk', 'Figtree',
  'Instrument Serif', 'Newsreader', 'Source Serif 4', 'Fraunces', 'Lora',
  'Spline Sans Mono', 'JetBrains Mono', 'IBM Plex Mono', 'Geist Mono', 'Space Mono', 'Martian Mono',
];
function loadTweakFont(fam) {
  if (!fam || _tweakFonts.has(fam)) return;
  _tweakFonts.add(fam);
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = 'https://fonts.googleapis.com/css2?family=' + fam.replace(/ /g, '+') + ':wght@400;500;600;700&display=swap';
  document.head.appendChild(link);
}

const PLANS = {
  free: { id: 'free', name: 'Free', price: '$0', credits: 200, identity: false },
  pro:  { id: 'pro',  name: 'Pro',  price: '$15', credits: 8000, identity: true },
};

function _titleCase(w) { return w ? w.charAt(0).toUpperCase() + w.slice(1) : w; }
// Stable de-dupe key for a saved brand identity (domain + name).
function _identityKey(d) {
  if (!d) return '|';
  // brand.domain is an OBJECT ({ name, ext, display }); using it raw stringifies to
  // "[object Object]" and collapses every identity to one key, which makes the
  // persist-dedup guard suppress all-but-one identity. Key off real string fields.
  const dom = d.domain;
  const domStr = (dom && typeof dom === 'object' ? (dom.display || dom.name) : dom) || d.handle || '';
  const nm = d.businessName || d.name || '';
  return String(domStr + '|' + nm).toLowerCase();
}

// Shown while the Clerk SDK is still loading (before we know if there's a session).
function GateLoading() {
  return <div className="gate gate-loading"><div className="redirect-spinner"></div></div>;
}

// Confirmation popup shown after the user returns from a successful Stripe
// Checkout. Replaces the lightweight inline banner as the primary, explicit
// "your payment went through" acknowledgement. Stays up until dismissed.
function PaidModal({ info, onClose }) {
  React.useEffect(() => {
    if (!info) return;
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [info, onClose]);
  if (!info) return null;
  const credits = info.credits || 0;
  let text;
  if (info.kind === 'subscription') {
    text = credits > 0
      ? "You're now on the Pro plan — " + credits.toLocaleString() + " credits have been added to your account."
      : "You're now on the Pro plan. Your account has been upgraded.";
  } else if (info.kind === 'credit_pack') {
    text = credits > 0
      ? credits.toLocaleString() + " credits have been added to your account."
      : "Your credits have been added to your account.";
  } else {
    text = "Your purchase is complete and your balance has been updated.";
  }
  return (
    <div className="modal-scrim" onClick={onClose} role="dialog" aria-modal="true" aria-label="Payment successful">
      <div className="modal-card modal-center" onClick={(e) => e.stopPropagation()}>
        <svg className="confirm-check" width="56" height="56" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <circle cx="12" cy="12" r="11" fill="currentColor" opacity="0.12" />
          <circle cx="12" cy="12" r="11" stroke="currentColor" strokeWidth="1.5" />
          <path d="M7.5 12.5l3 3 6-6.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
        <h2 className="modal-title">Payment successful</h2>
        <p className="modal-text">{text}</p>
        <div className="modal-actions" style={{ justifyContent: 'center', width: '100%' }}>
          <button className="btn-primary" onClick={onClose} style={{ minWidth: 150, justifyContent: 'center' }}>Continue</button>
        </div>
      </div>
    </div>
  );
}

// The SaaS requires an account. Signed-out visitors get this login wall instead
// of the app. We mount Clerk's real sign-up/sign-in form INLINE (not a modal
// popped over a separate screen) so it's a single surface: the form itself does
// the actual registration/login (email or Google) — powered by Clerk's HEADLESS
// API via window.namitAuth, so Clerk's own form is NEVER shown. Email-code
// verification (when the instance requires it) happens inline in this same card.
function GoogleMark() {
  return (
    <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" style={{ flex: '0 0 auto' }}>
      <path fill="#4285F4" d="M23 12.27c0-.79-.07-1.54-.2-2.27H12v4.3h6.18a5.3 5.3 0 0 1-2.29 3.48v2.9h3.7C21.45 18.6 23 15.74 23 12.27Z" />
      <path fill="#34A853" d="M12 23.5c3.1 0 5.7-1.03 7.6-2.8l-3.7-2.9c-1.03.69-2.35 1.1-3.9 1.1-3 0-5.54-2.03-6.45-4.76H1.7v2.99A11.5 11.5 0 0 0 12 23.5Z" />
      <path fill="#FBBC05" d="M5.55 14.14a6.9 6.9 0 0 1 0-4.28V6.87H1.7a11.5 11.5 0 0 0 0 10.26l3.85-2.99Z" />
      <path fill="#EA4335" d="M12 5.02c1.69 0 3.2.58 4.4 1.72l3.28-3.28C17.7 1.6 15.1.5 12 .5A11.5 11.5 0 0 0 1.7 6.87l3.85 2.99C6.46 7.05 9 5.02 12 5.02Z" />
    </svg>
  );
}

function LoginGate({ night, onToggleNight }) {
  const [mode, setMode] = React.useState('register'); // 'register' | 'login'
  const [step, setStep] = React.useState('creds');     // 'creds' | 'verify'
  const [name, setName] = React.useState('');
  const [email, setEmail] = React.useState('');
  const [pass, setPass] = React.useState('');
  const [code, setCode] = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState('');
  const isReg = mode === 'register';
  const waitRef = React.useRef(null);

  // Surface a failed Google (OAuth) return: clerk-boot stashes a readable
  // reason when handleRedirectCallback rejects, so the user sees why they're
  // back on this modal instead of being silently dropped here.
  React.useEffect(function () {
    try {
      var stashed = sessionStorage.getItem('namit_auth_error');
      if (stashed) { sessionStorage.removeItem('namit_auth_error'); setErr(stashed); }
    } catch (e) { /* ignore */ }
    function onAuthErr(ev) { if (ev && ev.detail) setErr(String(ev.detail)); }
    window.addEventListener('namit-auth-error', onAuthErr);
    return function () {
      window.removeEventListener('namit-auth-error', onAuthErr);
      // Clear any pending wait for the async Clerk scripts so a deferred auth
      // action can't run (or setState) after this gate unmounts.
      if (waitRef.current) { clearInterval(waitRef.current); waitRef.current = null; }
    };
  }, []);

  function go() { window.location.href = '/app'; }
  function fail(e) { setErr(window.namitAuth ? window.namitAuth.error(e) : 'Something went wrong. Please try again.'); setBusy(false); }
  // The Clerk scripts load async, so a fast click can land before
  // window.namitAuth exists. Instead of forcing a second click, hold the busy
  // state and wait for it to appear, then run `fn`. Times out honestly.
  function whenAuthReady(fn) {
    if (waitRef.current) { clearInterval(waitRef.current); waitRef.current = null; }
    if (window.namitAuth) { fn(); return; }
    let waited = 0;
    waitRef.current = setInterval(function () {
      if (window.namitAuth) { clearInterval(waitRef.current); waitRef.current = null; fn(); return; }
      waited += 50;
      if (waited >= 15000) { clearInterval(waitRef.current); waitRef.current = null; setErr('Sign-in is taking too long to load. Please refresh the page and try again.'); setBusy(false); }
    }, 50);
  }

  function onGoogle() {
    if (busy) return;
    setErr(''); setBusy(true);
    whenAuthReady(function () { window.namitAuth.google().catch(fail); });
  }
  function onSubmit(e) {
    e.preventDefault();
    if (busy) return;
    setErr(''); setBusy(true);
    const done = function (r) {
      if (r && r.done) { go(); return; }
      if (r && r.needsCode) { setStep('verify'); setBusy(false); return; }
      setErr(isReg ? 'Could not complete sign-up. Please try again.' : 'Could not log in. Please check your details.');
      setBusy(false);
    };
    whenAuthReady(function () {
      if (isReg) window.namitAuth.signUp({ name: name, email: email, password: pass }).then(done, fail);
      else window.namitAuth.signIn({ email: email, password: pass }).then(done, fail);
    });
  }
  function onVerify(e) {
    e.preventDefault();
    if (busy) return;
    setErr(''); setBusy(true);
    whenAuthReady(function () {
      window.namitAuth.verifyCode(code).then(function (r) {
        if (r && r.done) { go(); return; }
        setErr('That code did not match. Please try again.'); setBusy(false);
      }, fail);
    });
  }
  function switchMode() { setMode(isReg ? 'login' : 'register'); setStep('creds'); setErr(''); }

  return (
    <div className="gate">
      <button className="gate-theme" onClick={onToggleNight} aria-label="Toggle theme">{night ? '☀' : '☾'}</button>
      <div className="auth-card">
        <div className="auth-brand">namit<span className="gate-dot">.</span></div>
        {step === 'verify' ? (
          <form onSubmit={onVerify}>
            <h2 className="auth-title">Check your email</h2>
            <p className="auth-sub">Enter the 6-digit code we emailed to {email || 'your inbox'} to finish creating your account.</p>
            {err ? <p className="auth-error">{err}</p> : null}
            <div className="auth-field">
              <label htmlFor="vcode">Verification code</label>
              <input id="vcode" type="text" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" value={code} onChange={function (e) { setCode(e.target.value); }} />
            </div>
            <button type="submit" className="auth-submit" disabled={busy}>{busy ? 'Verifying…' : 'Verify & continue'}</button>
            <p className="auth-foot"><button type="button" className="auth-link" onClick={function () { setStep('creds'); setErr(''); }}>Use a different email</button></p>
          </form>
        ) : (
          <form onSubmit={onSubmit}>
            <h2 className="auth-title">{isReg ? 'Create your account' : 'Welcome back'}</h2>
            <p className="auth-sub">{isReg ? 'Start free — no credit card needed.' : 'Log in to pick up where you left off.'}</p>
            {isReg ? (
              <div className="auth-reward">
                <span className="auth-coin">+</span>
                <div><strong>Get +500 free credits</strong><span>Create your account now and they land in your balance instantly.</span></div>
              </div>
            ) : null}
            {err ? <p className="auth-error">{err}</p> : null}
            <button type="button" className="auth-google" onClick={onGoogle} disabled={busy}><GoogleMark />{isReg ? 'Sign up with Google' : 'Continue with Google'}</button>
            <div className="auth-divider">or</div>
            {isReg ? (
              <div className="auth-field">
                <label htmlFor="aname">Name</label>
                <input id="aname" type="text" autoComplete="name" placeholder="Alex Rivera" value={name} onChange={function (e) { setName(e.target.value); }} />
              </div>
            ) : null}
            <div className="auth-field">
              <label htmlFor="aemail">Email</label>
              <input id="aemail" type="email" autoComplete="email" placeholder="you@company.com" required value={email} onChange={function (e) { setEmail(e.target.value); }} />
            </div>
            <div className="auth-field">
              <label htmlFor="apass">Password</label>
              <input id="apass" type="password" autoComplete={isReg ? 'new-password' : 'current-password'} placeholder="••••••••" required value={pass} onChange={function (e) { setPass(e.target.value); }} />
            </div>
            <button type="submit" className="auth-submit" disabled={busy}>{busy ? (isReg ? 'Creating account…' : 'Logging in…') : (isReg ? 'Create account' : 'Log in')}</button>
            {isReg ? <p className="auth-fine">By continuing you agree to our <a href="/terms.html">Terms</a> and <a href="/privacy.html">Privacy Policy</a>.</p> : null}
            <p className="auth-foot">{isReg ? 'Already have an account?' : "Don't have an account?"} <button type="button" className="auth-link" onClick={switchMode}>{isReg ? 'Log in' : 'Sign up'}</button></p>
          </form>
        )}
      </div>
    </div>
  );
}

function App() {
  const t = THEME;
  const auth = useAuth();
  const me = authProfile(auth.user);
  const signedIn = !!auth.user;
  // True while a Google (OAuth) return is being completed by clerk-boot. The
  // SDK is loaded (auth.ready) but no user exists YET, so without this we'd
  // briefly render the sign-up gate full-page before the session lands. We show
  // the loading spinner for that window instead, then drop straight into the SaaS.
  const [authResolving, setAuthResolving] = React.useState(() => !!window.__namitAuthResolving);
  React.useEffect(() => {
    function onResolving() { setAuthResolving(true); }
    function onResolved() { setAuthResolving(false); }
    window.addEventListener('namit-auth-resolving', onResolving);
    window.addEventListener('namit-auth-resolved', onResolved);
    // Backstop in case the events fired before this listener was attached.
    if (window.__namitAuthResolving) setAuthResolving(true);
    return () => {
      window.removeEventListener('namit-auth-resolving', onResolving);
      window.removeEventListener('namit-auth-resolved', onResolved);
    };
  }, []);
  const [night, setNight] = React.useState(() => localStorage.getItem('domino-night') === '1');
  const toggleNight = () => setNight(n => { const v = !n; localStorage.setItem('domino-night', v ? '1' : '0'); return v; });
  const surface = night ? 'midnight' : 'cool';
  const [plan, setPlan] = React.useState(() => localStorage.getItem('domino-plan') === 'pro' ? 'pro' : 'free');
  // Two-bucket credit model (mirrors the server): a monthly `allotment` that
  // resets, plus a non-expiring `extra` balance bought via credit packs. The
  // client still tracks a single `creditsUsed` counter against the combined
  // total; the server reconciles it into the two buckets on /billing/consume.
  const [allotment, setAllotment] = React.useState(() => PLANS[localStorage.getItem('domino-plan') === 'pro' ? 'pro' : 'free'].credits);
  const [extra, setExtra] = React.useState(0);
  const creditsTotal = allotment + extra;
  const hasIdentity = PLANS[plan].identity;
  const choosePlan = (id) => {
    setPlan(id);
    if (PLANS[id]) setAllotment(PLANS[id].credits);
    localStorage.setItem('domino-plan', id);
  };
  // Load real Trustpilot ratings once on start into window.__tpRatings so the
  // buy panel's registrar list shows genuine, live-sourced scores (the backend
  // serves them instantly from a cache that refreshes itself from Trustpilot).
  const [tpReady, setTpReady] = React.useState(false);
  React.useEffect(() => {
    let alive = true;
    fetch('/api/domains/registrar-ratings')
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        if (!alive || !d || !d.ratings) return;
        window.__tpRatings = d.ratings;
        setTpReady(r => !r);
      })
      .catch(() => {});
    return () => { alive = false; };
  }, []);
  // One-time deep-link intent set by the marketing WHOIS tool before sign-up.
  const [postAuth] = React.useState(() => {
    try {
      const raw = sessionStorage.getItem('namit-postauth');
      if (raw) { sessionStorage.removeItem('namit-postauth'); return JSON.parse(raw); }
    } catch (e) { /* ignore */ }
    return null;
  });
  const _deepLink = (postAuth && (postAuth.tab === 'whois' || postAuth.tab === 'value') && postAuth.domain) ? postAuth : null;
  // Names deep-link: a signed-in visitor started a search on a public marketing
  // page. We land on the names tab with their prompt + options pre-filled and
  // auto-run the search once their real balance has hydrated (see effect below).
  const _namesLink = (postAuth && postAuth.tab === 'names' && postAuth.prompt) ? postAuth : null;
  const [whoisSeed, setWhoisSeed] = React.useState(_deepLink && _deepLink.tab === 'whois' ? _deepLink.domain : '');
  const [valueSeed, setValueSeed] = React.useState(_deepLink && _deepLink.tab === 'value' ? _deepLink.domain : '');
  const [tab, setTab] = React.useState(_deepLink ? _deepLink.tab : 'names');
  const [identityName, setIdentityName] = React.useState('');
  const [identitySeed, setIdentitySeed] = React.useState(null);
  const [navOpen, setNavOpen] = React.useState(false);
  const [prompt, setPrompt] = React.useState(_namesLink ? String(_namesLink.prompt) : '');
  const [selected, setSelected] = React.useState(_namesLink && Array.isArray(_namesLink.tlds) ? _namesLink.tlds : []);
  const [results, setResults] = React.useState(null);
  // Every domain the user has generated across searches (newest first). Drives
  // the History tab. Sourced from the server on hydration and kept fresh as new
  // batches are generated; results themselves are NOT restored on refresh.
  const [history, setHistory] = React.useState([]);
  const [loading, setLoading] = React.useState(false);
  const [panel, setPanel] = React.useState(null);
  const openBuy = (d) => {
    setPanel({ mode: 'buy', domain: d });
    // Buy-intent: user opened the buy panel for this name.
    if (window.namitRecordClick) {
      window.namitRecordClick({ kind: 'domain', clientId: d.id, name: d.name, ext: d.ext });
    }
  };
  const openHandles = (d) => setPanel({ mode: 'handles', domain: d });
  const openAvail = (d) => setPanel({ mode: 'avail', domain: d });
  const [lastQuery, setLastQuery] = React.useState('');
  const [revealed, setRevealed] = React.useState(0);
  const [onlyAvailable, setOnlyAvailable] = React.useState(_namesLink && typeof _namesLink.onlyAvailable === 'boolean' ? _namesLink.onlyAvailable : true);
  const [socialCheck, setSocialCheck] = React.useState(_namesLink && typeof _namesLink.socialCheck === 'boolean' ? _namesLink.socialCheck : false);
  const [checkedAvail, setCheckedAvail] = React.useState(true);
  const [genError, setGenError] = React.useState(null);
  // Tiered per-search cost: names only = 50; +50 to check availability; +50 to
  // check social handles. Availability is on by default, so the default is 100.
  const credits = 50 + (onlyAvailable ? 50 : 0) + (socialCheck ? 50 : 0);
  const saltRef = React.useRef(0);
  const seenRef = React.useRef({ q: '', names: [] });
  // Account-sync bookkeeping: snapshot of what the server already has so the
  // write-through effects only send genuine diffs, and a gate so we never push
  // back the very data we just hydrated.
  const serverFavIdsRef = React.useRef(new Set());
  const serverIdentityKeysRef = React.useRef(new Set());
  const hydratedRef = React.useRef(false);
  const prevSignedInRef = React.useRef(false);
  // Last `creditsUsed` value we persisted to the server, so the consume effect
  // can send only the delta since the previous sync.
  const lastSentUsedRef = React.useRef(0);
  // Flips true once the signed-in balance has hydrated from /api/me, so the
  // public-page names deep-link only auto-runs against the real credit balance.
  const [balanceReady, setBalanceReady] = React.useState(false);
  const autoRanNamesRef = React.useRef(false);

  const [creditsUsed, setCreditsUsed] = React.useState(() => {
    const v = parseInt(localStorage.getItem('domino-credits-used-v2'), 10);
    return Number.isFinite(v) ? v : 0;
  });
  const canAfford = (creditsTotal - creditsUsed) >= credits;
  const HANDLE_COST = 10;
  const canAffordHandles = (creditsTotal - creditsUsed) >= HANDLE_COST;
  const AVAIL_COST = 10;
  const canAffordAvail = (creditsTotal - creditsUsed) >= AVAIL_COST;
  const VALUE_COST = 100;
  const canAffordValue = (creditsTotal - creditsUsed) >= VALUE_COST;
  const IDENTITY_COST = 800;
  const canAffordIdentity = (creditsTotal - creditsUsed) >= IDENTITY_COST;
  const HUNT_COST = 500;
  const canAffordHunt = (creditsTotal - creditsUsed) >= HUNT_COST;

  const [handlesDone, setHandlesDone] = React.useState(() => {
    try { return JSON.parse(localStorage.getItem('domino-handles-v1')) || []; } catch (e) { return []; }
  });
  const markHandlesDone = (ids) => {
    setHandlesDone(prev => {
      const s = new Set(prev);
      (Array.isArray(ids) ? ids : [ids]).forEach(x => s.add(x));
      const next = [...s];
      localStorage.setItem('domino-handles-v1', JSON.stringify(next));
      return next;
    });
  };
  // Credits are RESERVED when an action starts and REFUNDED if that action
  // never actually completes (e.g. the network call fails). We never keep a
  // credit for work that didn't happen.
  const refundCredits = (n) => {
    if (!n) return;
    setCreditsUsed(u => {
      const v = Math.max(0, u - n);
      localStorage.setItem('domino-credits-used-v2', String(v));
      return v;
    });
  };
  // Best-effort analytics: record WHICH service a credit spend was for so the
  // admin panel can show a per-service breakdown. Fire-and-forget — it never
  // blocks the UI and only runs for signed-in users (anonymous spends have no
  // account to attribute to). Called only once a charge is confirmed to stand
  // (the success paths), mirroring the refund-on-failure logic so the ledger
  // tracks credits that were actually kept.
  const logUsage = (service, credits, label) => {
    if (!signedIn || !credits || credits < 1) return;
    api.post('/api/usage', { service, credits, label: label || undefined }).catch(() => {});
  };
  const unmarkHandlesDone = (id) => {
    setHandlesDone(prev => {
      const next = prev.filter(x => x !== id);
      localStorage.setItem('domino-handles-v1', JSON.stringify(next));
      return next;
    });
  };
  // Reserve the flat valuation cost for one "Domain value" evaluation. Returns
  // false (and charges nothing) if the balance can't cover it; the page refunds
  // via refundCredits if the API call fails.
  const reserveValue = () => {
    if ((creditsTotal - creditsUsed) < VALUE_COST) return false;
    setCreditsUsed(u => {
      const n = Math.min(creditsTotal, u + VALUE_COST);
      localStorage.setItem('domino-credits-used-v2', String(n));
      return n;
    });
    return true;
  };
  // Charge the flat identity cost. Unlike the fast on-demand checks this is
  // called only AFTER generation succeeds (generation runs for several seconds,
  // and the server-authoritative balance never accepts refunds), so there is
  // nothing to reverse. Returns false and charges nothing if the balance can't
  // cover it.
  const reserveIdentity = () => {
    if ((creditsTotal - creditsUsed) < IDENTITY_COST) return false;
    setCreditsUsed(u => {
      const n = Math.min(creditsTotal, u + IDENTITY_COST);
      localStorage.setItem('domino-credits-used-v2', String(n));
      return n;
    });
    return true;
  };
  // Domain Hunt runs for several seconds (sales lookup + AI + availability), so
  // like identity it charges only AFTER a successful hunt — nothing to refund.
  const reserveHunt = () => {
    if ((creditsTotal - creditsUsed) < HUNT_COST) return false;
    setCreditsUsed(u => {
      const n = Math.min(creditsTotal, u + HUNT_COST);
      localStorage.setItem('domino-credits-used-v2', String(n));
      return n;
    });
    return true;
  };
  // Tracks per-domain handle checks that were billed via the panel button and
  // are still awaiting a result, so we know whether to refund on failure.
  const handleChargeRef = React.useRef(new Set());
  const [handleErrors, setHandleErrors] = React.useState({});
  // Prevents a rapid double-submit of a search from charging twice before the
  // loading flag re-renders.
  const genInFlightRef = React.useRef(false);

  const generateHandles = (d) => {
    if (!canAffordHandles) return;
    // Re-entrancy guard: if a check for this domain is already reserved and
    // awaiting a result, ignore extra clicks so we never charge twice.
    if (handleChargeRef.current.has(d.id)) return;
    setCreditsUsed(u => {
      const n = Math.min(creditsTotal, u + HANDLE_COST);
      localStorage.setItem('domino-credits-used-v2', String(n));
      return n;
    });
    handleChargeRef.current.add(d.id);
    setHandleErrors(prev => { const n = { ...prev }; delete n[d.id]; return n; });
    markHandlesDone(d.id);
  };

  // Called by the handle panel once a check resolves. Only a check that was
  // billed here (via the button) is refunded/rolled back on failure.
  const onHandlesOutcome = (domainId, ok) => {
    if (!handleChargeRef.current.has(domainId)) return;
    handleChargeRef.current.delete(domainId);
    if (ok) { logUsage('social_handles', HANDLE_COST, domainId); return; }
    refundCredits(HANDLE_COST);
    unmarkHandlesDone(domainId);
    setHandleErrors(prev => ({ ...prev, [domainId]: true }));
  };

  // Per-domain availability checks run on demand from the favorites panel when
  // "Only available" was off during the search (so the saved domain's status is
  // unknown). Mirrors the social-handle flow: a credit is reserved when the
  // check starts and refunded only if the request itself fails.
  const [availDone, setAvailDone] = React.useState(() => {
    try { return JSON.parse(localStorage.getItem('domino-avail-v1')) || []; } catch (e) { return []; }
  });
  const markAvailDone = (id) => {
    setAvailDone(prev => {
      const s = new Set(prev);
      (Array.isArray(id) ? id : [id]).forEach(x => s.add(x));
      const next = [...s];
      localStorage.setItem('domino-avail-v1', JSON.stringify(next));
      return next;
    });
  };
  const unmarkAvailDone = (id) => {
    setAvailDone(prev => {
      const next = prev.filter(x => x !== id);
      localStorage.setItem('domino-avail-v1', JSON.stringify(next));
      return next;
    });
  };
  const availChargeRef = React.useRef(new Set());
  const [availErrors, setAvailErrors] = React.useState({});

  const checkAvail = (d) => {
    if (!canAffordAvail) return;
    if (availChargeRef.current.has(d.id)) return;
    setCreditsUsed(u => {
      const n = Math.min(creditsTotal, u + AVAIL_COST);
      localStorage.setItem('domino-credits-used-v2', String(n));
      return n;
    });
    availChargeRef.current.add(d.id);
    setAvailErrors(prev => { const n = { ...prev }; delete n[d.id]; return n; });
    markAvailDone(d.id);
  };

  // Called by the availability panel once a check resolves. A failed request
  // rolls back the charge; a genuine result is written onto the saved favorite
  // (and mirrored to the backend) so the card shows the real status.
  const onAvailOutcome = (domainId, ok, available) => {
    if (!availChargeRef.current.has(domainId)) return;
    availChargeRef.current.delete(domainId);
    if (!ok) {
      refundCredits(AVAIL_COST);
      unmarkAvailDone(domainId);
      setAvailErrors(prev => ({ ...prev, [domainId]: true }));
      return;
    }
    logUsage('availability_check', AVAIL_COST, domainId);
    const verdict = available === undefined ? null : available;
    setResults(prev => prev
      ? prev.map(r => r.id === domainId ? { ...r, available: verdict } : r)
      : prev);
    setHistory(prev => prev.map(h => h.id === domainId ? { ...h, available: verdict } : h));
    setFavs(prev => {
      const next = prev.map(f => f.id === domainId
        ? { ...f, available: verdict }
        : f);
      localStorage.setItem('domino-favs-v2', JSON.stringify(next));
      const hit = next.find(f => f.id === domainId);
      if (signedIn && hit) {
        api.post('/api/favorites', {
          clientId: hit.id, name: hit.name, ext: hit.ext,
          available: hit.available === undefined ? null : hit.available,
          price: hit.price === undefined ? null : hit.price,
          review: hit.review || null,
          prompt: hit.prompt || null,
        }).catch(() => {});
      }
      return next;
    });
  };

  const [favs, setFavs] = React.useState(() => {
    try { return JSON.parse(localStorage.getItem('domino-favs-v2')) || []; } catch (e) { return []; }
  });

  const toggleFav = (d) => {
    setFavs(prev => {
      // Stamp the save time so Favorites can show newest-first. Existing favs keep
      // their original addedAt; brand-new saves get "now".
      const next = prev.some(f => f.id === d.id)
        ? prev.filter(f => f.id !== d.id)
        : [...prev, { ...d, addedAt: d.addedAt || Date.now() }];
      localStorage.setItem('domino-favs-v2', JSON.stringify(next));
      return next;
    });
  };

  // Record a Domain Hunt's available names into History — both the live in-memory
  // list (so they show instantly) and the backend (so they survive a refresh and
  // appear in admin), mirroring how a normal name search is recorded. Hunt rows
  // carry no text prompt, so the brief prefill stays empty.
  const recordHunt = React.useCallback((rawItems, meta) => {
    if (!Array.isArray(rawItems) || !rawItems.length) return;
    const items = rawItems.map(it => ({
      id: it.name + it.ext,
      name: it.name,
      ext: it.ext,
      available: it.available === undefined ? true : it.available,
      price: it.price === undefined ? null : it.price,
      display: _titleCase(it.name) + it.ext,
      review: (typeof it.score === 'number')
        ? { score: it.score, verdict: it.verdict || '', benefits: Array.isArray(it.criteria) ? it.criteria : [] }
        : null,
      prompt: '',
    }));
    // Prepend to History immediately, de-duped against existing rows.
    setHistory(prev => {
      const ids = new Set(items.map(d => d.id));
      return [...items, ...prev.filter(h => !ids.has(h.id))];
    });
    // Persist the hunt as a search group (+ its rows) for History and admin.
    window.namitRecordSearch({
      surface: 'hunt',
      credits: (meta && Number.isFinite(meta.credits)) ? meta.credits : 0,
      items: items.slice(0, 50).map(d => ({
        clientId: d.id, name: d.name, ext: d.ext,
        available: d.available === undefined ? null : d.available,
        price: d.price === undefined ? null : d.price,
        review: d.review || null,
      })),
    });
  }, []);

  // Reload the guest/demo state from localStorage (used after sign-out).
  const loadGuestState = React.useCallback(() => {
    try { setFavs(JSON.parse(localStorage.getItem('domino-favs-v2')) || []); } catch (e) { setFavs([]); }
    const cu = parseInt(localStorage.getItem('domino-credits-used-v2'), 10);
    setCreditsUsed(Number.isFinite(cu) ? cu : 0);
    setPlan(localStorage.getItem('domino-plan') === 'pro' ? 'pro' : 'free');
  }, []);

  // ---- Account sync: hydrate from / persist to the backend when signed in ----
  React.useEffect(() => {
    if (!auth.ready) return;
    if (auth.user) {
      let alive = true;
      hydratedRef.current = false;
      (async () => {
        try {
          // Claim any searches this visitor ran BEFORE creating an account
          // (matched on the shared persistent session id), then hydrate — so
          // pre-account searches land in History and on this account immediately.
          try {
            await api.post('/api/searches/link', { sessionId: window.namitSession() });
          } catch (e) { /* best-effort; nothing to claim is fine */ }
          if (!alive) return;
          const [meRes, favRes, idRes, genRes] = await Promise.all([
            api.get('/api/me').catch(() => null),
            api.get('/api/favorites').catch(() => null),
            api.get('/api/identities').catch(() => null),
            api.get('/api/generated-domains').catch(() => null),
          ]);
          if (!alive) return;
          if (meRes) {
            setPlan(meRes.plan === 'pro' ? 'pro' : 'free');
            if (Number.isFinite(meRes.creditsAllotment)) setAllotment(meRes.creditsAllotment);
            if (Number.isFinite(meRes.extraCredits)) setExtra(meRes.extraCredits);
            if (Number.isFinite(meRes.creditsUsed)) {
              setCreditsUsed(meRes.creditsUsed);
              lastSentUsedRef.current = meRes.creditsUsed;
            }
          }
          if (favRes && Array.isArray(favRes.favorites)) {
            const mapped = favRes.favorites.map(f => ({
              id: f.clientId, name: f.name, ext: f.ext, available: f.available,
              price: f.price, display: _titleCase(f.name) + f.ext, review: f.review || null,
              prompt: f.prompt || '',
              addedAt: f.createdAt ? Date.parse(f.createdAt) || 0 : 0,
            }));
            serverFavIdsRef.current = new Set(mapped.map(f => f.id));
            localStorage.setItem('domino-favs-v2', JSON.stringify(mapped));
            setFavs(mapped);
          }
          if (idRes && Array.isArray(idRes.identities)) {
            const serverBrands = idRes.identities.map(r => r.data).filter(Boolean);
            const serverKeys = new Set(serverBrands.map(_identityKey));
            // Recover any locally-created identities the server doesn't have yet
            // (created before server-sync existed, or whose upload previously
            // failed): MERGE + re-upload them instead of overwriting localStorage
            // with the server list, which would silently destroy the local copy.
            let localBrands = [];
            try { localBrands = JSON.parse(localStorage.getItem('domino-identities')) || []; } catch (e) { localBrands = []; }
            const missing = Array.isArray(localBrands)
              ? localBrands.filter(b => b && !serverKeys.has(_identityKey(b)))
              : [];
            const merged = [...missing, ...serverBrands];
            localStorage.setItem('domino-identities', JSON.stringify(merged));
            serverIdentityKeysRef.current = serverKeys;
            window.dispatchEvent(new Event('namit-identities-updated'));
            // Push recovered local-only identities to the server so they appear in
            // the account (and admin) from now on. Same payload shape as the
            // create-time persist bridge so the server accepts them.
            missing.forEach(b => {
              const key = _identityKey(b);
              // Guard against duplicate local entries sharing a key (and against
              // racing the create-time bridge): only upload a key once.
              if (serverIdentityKeysRef.current.has(key)) return;
              serverIdentityKeysRef.current.add(key);
              const dom = b && b.domain;
              const domainStr = (dom && typeof dom === 'object' ? (dom.display || dom.name) : dom) || (b && b.handle) || (b && b.businessName) || 'identity';
              api.post('/api/identities', {
                domain: domainStr,
                name: (b && b.businessName) || (b && b.name) || undefined,
                data: b,
              }).catch(() => { serverIdentityKeysRef.current.delete(key); });
            });
          }
          // Load every generated domain into the History tab (newest first). The
          // live results grid is intentionally NOT restored here — a page refresh
          // clears the names view; past generations live on under History.
          if (genRes && Array.isArray(genRes.domains)) {
            // Rows are newest-first; the backend can hold duplicate clientIds
            // (the same name regenerated across searches), so keep only the first
            // (most recent) occurrence to avoid React key collisions.
            const seen = new Set();
            const hist = [];
            for (const r of genRes.domains) {
              if (seen.has(r.clientId)) continue;
              seen.add(r.clientId);
              hist.push({
                id: r.clientId, name: r.name, ext: r.ext, available: r.available,
                price: r.price, display: _titleCase(r.name) + r.ext, review: r.review || null,
                prompt: r.prompt || '',
              });
            }
            setHistory(hist);
          }
          hydratedRef.current = true;
          prevSignedInRef.current = true;
          setBalanceReady(true);
        } catch (e) { /* best-effort; demo state stays */ }
      })();
      return () => { alive = false; };
    }
    // Signed out: revert to the guest/demo state (only if we were signed in).
    if (prevSignedInRef.current) {
      hydratedRef.current = false;
      serverFavIdsRef.current = new Set();
      serverIdentityKeysRef.current = new Set();
      prevSignedInRef.current = false;
      setBalanceReady(false);
      loadGuestState();
    }
  }, [auth.ready, auth.user, loadGuestState]);

  // Persist credit spend to the server (debounced) once hydrated. The server is
  // authoritative for the balance. We only ever report POSITIVE spends: the
  // consume endpoint rejects refunds (a negative amount would let a client mint
  // credits). When the local counter drops (an optimistic charge was reversed
  // after a failed action) we simply re-baseline to the server-known value
  // without persisting a refund. Plan is owned by Stripe webhooks, never pushed.
  const reconcileBalance = React.useCallback((b) => {
    if (!b) return;
    if (Number.isFinite(b.creditsAllotment)) setAllotment(b.creditsAllotment);
    if (Number.isFinite(b.extraCredits)) setExtra(b.extraCredits);
    if (Number.isFinite(b.creditsUsed)) {
      lastSentUsedRef.current = b.creditsUsed;
      setCreditsUsed(b.creditsUsed);
      localStorage.setItem('domino-credits-used-v2', String(b.creditsUsed));
    }
  }, []);
  React.useEffect(() => {
    if (!signedIn || !hydratedRef.current) return;
    const delta = creditsUsed - lastSentUsedRef.current;
    // A non-positive delta means a local refund; re-baseline silently.
    if (delta <= 0) { lastSentUsedRef.current = creditsUsed; return; }
    const id = setTimeout(() => {
      // Advance the baseline only after the server confirms the spend, so a
      // failed/declined request is retried rather than silently dropped.
      api.post('/api/billing/consume', { amount: delta })
        .then(reconcileBalance)
        .catch(() => {
          // On insufficient-credits (402) or any error, snap to the server's
          // truth so the UI and baseline stay consistent.
          api.get('/api/me').then(reconcileBalance).catch(() => {});
        });
    }, 500);
    return () => clearTimeout(id);
  }, [creditsUsed, signedIn, reconcileBalance]);

  // Mirror favorite add/remove to the backend (diff vs server snapshot).
  React.useEffect(() => {
    if (!signedIn || !hydratedRef.current) return;
    const current = new Set(favs.map(f => f.id));
    const prev = serverFavIdsRef.current;
    favs.forEach(f => {
      if (prev.has(f.id)) return;
      api.post('/api/favorites', {
        clientId: f.id, name: f.name, ext: f.ext,
        available: f.available === undefined ? null : f.available,
        price: f.price === undefined ? null : f.price,
        review: f.review || null,
        prompt: f.prompt || null,
      }).catch(() => {});
    });
    prev.forEach(id => {
      if (!current.has(id)) api.del('/api/favorites/' + encodeURIComponent(id)).catch(() => {});
    });
    serverFavIdsRef.current = current;
  }, [favs, signedIn]);

  // Persist newly saved brand identities (only ones the server lacks).
  React.useEffect(() => {
    window.__onIdentityPersist = (list) => {
      if (!signedIn || !hydratedRef.current) return;
      (list || []).forEach(d => {
        const key = _identityKey(d);
        if (serverIdentityKeysRef.current.has(key)) return;
        serverIdentityKeysRef.current.add(key);
        // brand.domain is an OBJECT ({ name, ext, display }); send a readable
        // string so the server (which stores domain as text) accepts the save.
        const dom = d && d.domain;
        const domainStr = (dom && typeof dom === 'object' ? (dom.display || dom.name) : dom) || (d && d.handle) || (d && d.businessName) || 'identity';
        api.post('/api/identities', {
          domain: domainStr,
          name: (d && d.businessName) || (d && d.name) || undefined,
          data: d,
        }).catch(() => { serverIdentityKeysRef.current.delete(key); });
      });
    };
    return () => { try { delete window.__onIdentityPersist; } catch (e) { window.__onIdentityPersist = null; } };
  }, [signedIn]);

  React.useEffect(() => {
    const r = document.documentElement;
    loadTweakFont(t.uiFont); loadTweakFont(t.monoFont); loadTweakFont(t.promptFont);
    r.style.setProperty('--font-ui', "'" + t.uiFont + "', 'Helvetica Neue', sans-serif");
    r.style.setProperty('--font-mono', "'" + t.monoFont + "', ui-monospace, monospace");
    r.style.setProperty('--font-prompt', "'" + t.promptFont + "', Georgia, serif");
    r.style.setProperty('--accent', t.accent);
    r.style.setProperty('--radius', t.radius + 'px');
    r.style.setProperty('--row-pad', t.density === 'compact' ? '14px' : '20px');
    const s = SURFACES[surface] || SURFACES.warm;
    r.style.setProperty('--bg', s.bg);
    r.style.setProperty('--card', s.card);
    r.style.setProperty('--ink', s.ink);
    r.style.setProperty('--muted', s.muted);
    r.style.setProperty('--hairline', s.hairline);
    r.style.setProperty('--sidebar', s.sidebar);
    r.setAttribute('data-theme', s.dark ? 'dark' : 'light');
    const themeMeta = document.querySelector('meta[name="theme-color"]');
    if (themeMeta) themeMeta.setAttribute('content', s.bg);
  }, [surface]);

  // Names appear instantly; availability resolves per-row (staggered, like sequential API calls)
  React.useEffect(() => {
    if (!results) { setRevealed(0); return; }
    if (!checkedAvail) { setRevealed(results.length); return; }
    setRevealed(0);
    let i = 0; let timer;
    const tick = () => {
      i += 1;
      setRevealed(i);
      if (i < results.length) timer = setTimeout(tick, 240 + Math.random() * 320);
    };
    timer = setTimeout(tick, 300);
    return () => clearTimeout(timer);
  }, [results, checkedAvail]);

  const generate = () => {
    if (!prompt.trim() || loading || !canAfford || genInFlightRef.current) return;
    genInFlightRef.current = true;
    setLoading(true);
    setResults(null);
    setPanel(null);
    setGenError(null);
    const q = prompt.trim();
    // Track which names we've already shown for THIS prompt so "Regenerate"
    // asks the server for genuinely new ideas. Reset whenever the prompt changes.
    if (seenRef.current.q !== q) seenRef.current = { q, names: [] };
    const exclude = seenRef.current.names.slice(-300);
    setLastQuery(q);
    setCheckedAvail(onlyAvailable);
    // Reserve the credits for this search up front; refund below if it fails.
    const charged = credits;
    const socialPortion = socialCheck ? 50 : 0;
    setCreditsUsed(u => {
      const n = Math.min(creditsTotal, u + charged);
      localStorage.setItem('domino-credits-used-v2', String(n));
      return n;
    });
    const titleCase = (w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : w);
    fetch('/api/domains/suggest', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ prompt: q, tlds: selected, onlyAvailable, count: 10, exclude }),
    })
      .then(async (resp) => {
        const data = await resp.json().catch(() => ({}));
        if (!resp.ok) {
          refundCredits(charged);
          setGenError(data && data.message ? data.message : 'Something went wrong generating names.');
          setResults(null);
          setLoading(false);
          genInFlightRef.current = false;
          return;
        }
        const items = (data.items || []).map((it) => {
          // Keep the AI name review consistent across every flow: serve the
          // first real score we ever saw for this domain, and seed the shared
          // cache when this is the first time. Prevents the same domain scoring
          // 8.2 here but 7.5 later in the value-page buy panel.
          const full = (it.name + it.ext).toLowerCase();
          let review = it.review || null;
          const cached = window.getReviewCache && window.getReviewCache(full);
          if (cached) review = cached;
          else if (review && window.setReviewCache) window.setReviewCache(full, review);
          return {
            id: it.name + it.ext,
            name: it.name,
            ext: it.ext,
            available: it.available,
            price: it.price,
            display: titleCase(it.name) + it.ext,
            review,
            // Carry the search prompt on every live result so favoriting or
            // creating an identity straight from the grid prefills the brief
            // with the exact description this domain was generated from.
            prompt: q,
          };
        });
        if (seenRef.current.q === q) {
          for (const it of items) seenRef.current.names.push(it.name);
        }
        setResults(items);
        // The search itself succeeded, so the base+availability charge stands.
        // The social portion is logged separately below only if it delivers.
        logUsage('domain_search', charged - socialPortion, q);
        // Keep the History tab fresh without a refetch: prepend this batch
        // (newest first), de-duped against anything already there.
        if (items.length) {
          setHistory(prev => {
            const ids = new Set(items.map(d => d.id));
            return [...items, ...prev.filter(h => !ids.has(h.id))];
          });
        }
        // Record this search as a group (+ its domain rows) for History and the
        // admin. Cookies attach the signed-in user; the shared session id ties
        // the whole search together and supersedes the old generated-domains POST.
        if (items.length) {
          window.namitRecordSearch({
            prompt: q,
            onlyAvailable: onlyAvailable,
            socialCheck: socialCheck,
            credits: charged,
            surface: 'app',
            tlds: selected,
            items: items.slice(0, 50).map(d => ({
              clientId: d.id, name: d.name, ext: d.ext,
              available: d.available === undefined ? null : d.available,
              price: d.price === undefined ? null : d.price,
              review: d.review || null,
            })),
          });
        }
        if (socialCheck) {
          markHandlesDone(items.map(d => d.id));
          // The toggle is ON and the user paid the extra credit, so run a REAL
          // social-handle check for each result now (in the background) and cache
          // the genuine result. The side panel + favorites then show real data
          // immediately.
          const toCheck = items.filter(d => !(window.getHandleCache && window.getHandleCache(d.name)));
          if (toCheck.length === 0) {
            // Every result was already cached from a prior check — the social
            // portion delivered instantly, so the reserved credit stands.
            logUsage('social_handles', socialPortion, q);
          } else {
            Promise.allSettled(toCheck.map((d) =>
              fetch('/api/domains/handles', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ name: d.name }),
              }).then(async (r) => {
                if (!r.ok) throw new Error('handles_failed');
                const j = await r.json().catch(() => ({}));
                if (Array.isArray(j.handles) && j.handles.length && window.setHandleCache) {
                  window.setHandleCache(d.name, j.handles);
                  window.namitEnrich(d.id, { handles: j.handles });
                  return true;
                }
                throw new Error('handles_empty');
              })
            )).then((settled) => {
              // If NOT a single handle check succeeded, the social-check action
              // never happened — refund the extra credit it reserved.
              const anyOk = settled.some(s => s.status === 'fulfilled');
              if (!anyOk) {
                refundCredits(socialPortion);
                items.forEach(d => unmarkHandlesDone(d.id));
              } else {
                logUsage('social_handles', socialPortion, q);
              }
            });
          }
        }
        setLoading(false);
        genInFlightRef.current = false;
      })
      .catch(() => {
        refundCredits(charged);
        setGenError('Could not reach the name service. Please try again.');
        setResults(null);
        setLoading(false);
        genInFlightRef.current = false;
      });
  };

  // Names deep-link auto-run: when a signed-in visitor handed a prompt off from
  // a public marketing page, run that exact search once — but only after the
  // real balance has hydrated, so the search spends/guards against live credits.
  React.useEffect(() => {
    if (autoRanNamesRef.current) return;
    if (!_namesLink || !signedIn || !balanceReady) return;
    if (!prompt.trim()) return;
    autoRanNamesRef.current = true;
    generate();
  }, [signedIn, balanceReady, prompt]);

  const goTab = (id) => { setTab(id); setNavOpen(false); setPanel(null); };
  const topUp = () => {
    setTab('billing'); setNavOpen(false); setPanel(null);
    setTimeout(() => {
      const el = document.getElementById('extra-credits');
      if (el) {
        window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 24, behavior: 'smooth' });
        el.classList.add('flash');
        setTimeout(() => el.classList.remove('flash'), 1300);
      }
    }, 140);
  };
  const createIdentity = (d) => {
    setPanel(null);
    if (!hasIdentity) { setTab('billing'); setNavOpen(false); return; }
    setIdentitySeed(d); setTab('identity'); setNavOpen(false);
  };
  React.useEffect(() => { if (tab !== 'identity') { setIdentityName(''); setIdentitySeed(null); } }, [tab]);

  // Moving between tabs resets the Names search so returning to it shows a fresh
  // page (empty prompt + no results) instead of the previous, cached results.
  // The guard skips the initial mount so a public-site prompt hand-off — which
  // lands on Names with the prompt prefilled and auto-runs once — isn't wiped.
  const prevTabRef = React.useRef(tab);
  React.useEffect(() => {
    if (prevTabRef.current === tab) return;
    prevTabRef.current = tab;
    setResults(null);
    setPrompt('');
    setGenError(null);
    setPanel(null);
    seenRef.current = { q: '', names: [] };
  }, [tab]);

  // Real Stripe-hosted Checkout: post a priceId, then redirect the browser to
  // the returned session URL. Credits/plan are granted by the webhook on return.
  // Tracks which action is mid-redirect to Stripe so the clicked button alone
  // shows a spinner. Holds a priceId for a checkout, 'manage' for the billing
  // portal, or null when idle.
  const [billingPending, setBillingPending] = React.useState(null);
  const startCheckout = async (priceId) => {
    if (!priceId) return;
    if (!signedIn) { authActions.signIn(); return; }
    setBillingPending(priceId);
    try {
      const r = await api.post('/api/billing/checkout', { priceId });
      if (r && r.url) { window.location.href = r.url; return; }
    } catch (e) { /* fall through to reset */ }
    setBillingPending(null);
  };
  const manageBilling = async () => {
    if (!signedIn) { authActions.signIn(); return; }
    setBillingPending('manage');
    try {
      const r = await api.post('/api/billing/portal', {});
      if (r && r.url) { window.location.href = r.url; return; }
    } catch (e) { /* fall through to reset */ }
    setBillingPending(null);
  };

  // Live subscription status (renewal date, scheduled cancellation, retention
  // offer eligibility). Drives the in-app cancel / resume / stay-for-50%-off
  // flow for Pro users. Free users have no subscription, so it stays null.
  const [subscription, setSubscription] = React.useState(null);
  React.useEffect(() => {
    if (!signedIn || plan !== 'pro') { setSubscription(null); return; }
    let alive = true;
    api.get('/api/billing/subscription')
      .then(s => { if (alive) setSubscription(s || null); })
      .catch(() => { if (alive) setSubscription(null); });
    return () => { alive = false; };
  }, [signedIn, plan]);
  const cancelSubscription = async (reason, comment) => {
    const s = await api.post('/api/billing/cancel', { reason: reason, comment: comment || undefined });
    setSubscription(s || null);
    return s;
  };
  const resumeSubscription = async () => {
    const s = await api.post('/api/billing/resume', {});
    setSubscription(s || null);
    return s;
  };
  const claimRetentionOffer = async () => {
    const s = await api.post('/api/billing/retention-offer', {});
    setSubscription(s || null);
    return s;
  };

  // Live billing catalog (subscriptions + credit packs) sourced from Stripe.
  const [catalog, setCatalog] = React.useState(null);
  React.useEffect(() => {
    if (!signedIn) { setCatalog(null); return; }
    let alive = true;
    api.get('/api/billing/catalog')
      .then(c => { if (alive) setCatalog(c); })
      .catch(() => { if (alive) setCatalog(null); });
    return () => { alive = false; };
  }, [signedIn]);

  // Handle return from Stripe Checkout. On success the webhook has (or soon
  // will have) granted credits/plan, so refetch /me a couple of times and land
  // the user on the Billing tab. Strip the query param either way.
  const [billingNotice, setBillingNotice] = React.useState(null);
  // Holds purchase details for the post-payment confirmation popup; null hides it.
  const [paidInfo, setPaidInfo] = React.useState(null);
  React.useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const status = params.get('billing');
    if (!status) return;
    const kind = params.get('kind');
    const value = parseFloat(params.get('value') || '0') || 0;
    const currency = params.get('currency') || 'usd';
    const boughtCredits = parseInt(params.get('credits') || '0', 10) || 0;
    const sessionId = params.get('session_id');
    ['billing', 'kind', 'value', 'currency', 'credits', 'session_id'].forEach((k) => params.delete(k));
    const qs = params.toString();
    window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : ''));
    if (status === 'success') {
      if (window.namitTrack) {
        if (kind === 'subscription') {
          window.namitTrack('subscribe', { value: value, currency: currency, credits: boughtCredits });
        } else if (kind === 'credit_pack') {
          window.namitTrack('purchase_extra_credits', { value: value, currency: currency, credits: boughtCredits });
        }
      }
      // Google Ads conversion (exact per-purchase value, differentiated by kind).
      if (window.gtag && (kind === 'subscription' || kind === 'credit_pack')) {
        const adsLabel = kind === 'subscription'
          ? 'FatZCMzYh8IcEPLql_9D'   // "Subscribe" conversion
          : 't2XWCIGHo8IcEPLql_9D';  // "Extra Credits" conversion
        window.gtag('event', 'conversion', {
          send_to: 'AW-18251904370/' + adsLabel,
          value: value,
          currency: (currency || 'usd').toUpperCase(),
          transaction_id: '',
        });
      }
      setBillingNotice('success');
      setPaidInfo({ kind: kind, credits: boughtCredits, value: value, currency: currency });
      setTab('billing');
      const applyMe = (b) => {
        if (!b) return;
        setPlan(b.plan === 'pro' ? 'pro' : 'free');
        if (Number.isFinite(b.creditsAllotment)) setAllotment(b.creditsAllotment);
        if (Number.isFinite(b.extraCredits)) setExtra(b.extraCredits);
        if (Number.isFinite(b.creditsUsed)) {
          lastSentUsedRef.current = b.creditsUsed;
          setCreditsUsed(b.creditsUsed);
        }
      };
      const refresh = () => api.get('/api/me').then(applyMe).catch(() => {});
      // Webhook delivery is not guaranteed (especially in dev), so confirm the
      // session directly — this grants credits/plan idempotently if the webhook
      // hasn't already. Confirm is retried on the same cadence as the refreshes
      // so a transient failure doesn't strand a paid-for grant; once it succeeds
      // we just refresh /me. The per-session claim makes repeated calls safe.
      let confirmed = false;
      const tick = () => {
        if (!sessionId || confirmed) { refresh(); return; }
        api.post('/api/billing/confirm', { sessionId })
          .then((b) => { confirmed = true; applyMe(b); })
          .catch(() => { refresh(); });
      };
      tick();
      const t1 = setTimeout(tick, 1500);
      const t2 = setTimeout(tick, 4000);
      const t3 = setTimeout(() => setBillingNotice(null), 6000);
      return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); };
    }
    if (status === 'cancel') {
      setBillingNotice('cancel');
      setTab('billing');
      const t = setTimeout(() => setBillingNotice(null), 5000);
      return () => clearTimeout(t);
    }
  }, []);

  // Gate the whole SaaS behind auth: no account, no access.
  if (!auth.ready) return <GateLoading />;
  // While a Google (OAuth) return is being finalized, keep the spinner up rather
  // than flashing the sign-up gate — the session is about to land.
  if (!signedIn && authResolving) return <GateLoading />;
  if (!signedIn) return <LoginGate night={night} onToggleNight={toggleNight} />;

  return (
    <div className="app-shell">
      <Sidebar active={tab} onNav={goTab}
        creditsUsed={creditsUsed} creditsTotal={creditsTotal}
        favCount={favs.length} histCount={history.filter(h => !favs.some(f => f.id === h.id)).length}
        hasIdentity={hasIdentity} planName={PLANS[plan].name}
        iconSet={t.iconSet} night={night} onToggleNight={toggleNight} onTopUp={topUp}
        open={navOpen} onClose={() => setNavOpen(false)}
        user={me} onSignIn={authActions.signIn} />

      <div className="content">
        <div className="mobile-bar">
          <button className="menu-btn" onClick={() => setNavOpen(true)} aria-label="Open menu">☰</button>
          <span className="wordmark">namit<span className="wordmark-dot">.</span></span>
        </div>

        {tab === 'names' ? (
          <main className={'stage' + (results || loading ? ' stage-results' : '')}>
            <h1 className="hero-title">Find the perfect domain<br />for your next idea</h1>
            <p className="hero-sub">Describe what you're building. We'll suggest available names you can buy right away.</p>

            <PromptBox prompt={prompt} setPrompt={setPrompt} onGenerate={generate} loading={loading}
              onlyAvailable={onlyAvailable} setOnlyAvailable={setOnlyAvailable}
              socialCheck={socialCheck} setSocialCheck={setSocialCheck}
              credits={credits} canAfford={canAfford} onTopUp={() => goTab('billing')} />
            <TldChips selected={selected} setSelected={setSelected} />

            {loading ? <SkeletonRows /> : null}

            {genError ? (
              <div className="gen-error" role="alert">{genError}</div>
            ) : null}

            {results ? (
              results.length === 0 ? (
                <div className="gen-error" role="status">No domains to show for “{lastQuery}”.{checkedAvail ? ' Try turning off “Only available” or a different idea.' : ''}</div>
              ) : (
              <section className="results-wrap">
                <div className="results-meta">
                  <span>{results.length} domain ideas for “{lastQuery}”{checkedAvail && revealed < results.length ? ' · checking availability…' : ''}</span>
                  <button className="btn-link" onClick={generate} disabled={!canAfford}>
                    Regenerate · {credits} credit{credits > 1 ? 's' : ''}
                  </button>
                </div>
                <div className="results">
                  {results.map((d, i) => (
                    <ResultRow key={d.id} d={d} index={i}
                      status={!checkedAvail
                        ? (availDone.includes(d.id)
                            ? (d.available === false ? 'taken' : d.available === true ? 'available' : 'unknown')
                            : 'none')
                        : (i < revealed ? (d.available === false ? 'taken' : 'available') : 'checking')}
                      onBuy={openBuy} onHandles={openHandles} onAvail={openAvail}
                      isFav={favs.some(f => f.id === d.id)} onFav={toggleFav} />
                  ))}
                </div>
              </section>
              )
            ) : null}
          </main>
        ) : null}

        {tab === 'favorites' ? (
          <FavoritesView favs={favs} onBuy={openBuy} onHandles={openHandles} onFav={toggleFav}
            onIdentity={createIdentity} hasIdentity={hasIdentity} handlesDoneIds={handlesDone}
            onAvail={openAvail} availDoneIds={availDone} />
        ) : null}

        {tab === 'history' ? (
          <HistoryView history={history} favIds={favs.map(f => f.id)}
            onBuy={openBuy} onHandles={openHandles} onFav={toggleFav}
            onIdentity={createIdentity} hasIdentity={hasIdentity} handlesDoneIds={handlesDone}
            onAvail={openAvail} availDoneIds={availDone} onUpgrade={() => goTab('billing')} />
        ) : null}

        {tab === 'identity' ? (
          <IdentityView hasIdentity={hasIdentity} onUpgrade={() => goTab('billing')}
            favs={favs} seedDomain={identitySeed} onConsumeSeed={() => setIdentitySeed(null)}
            identityCost={IDENTITY_COST} canAffordIdentity={canAffordIdentity}
            creditsLeft={creditsTotal - creditsUsed}
            onReserveIdentity={reserveIdentity}
            onIdentityCharged={(label) => logUsage('brand_identity', IDENTITY_COST, label)} />
        ) : null}

        {tab === 'billing' ? (
          <BillingView creditsUsed={creditsUsed} creditsTotal={creditsTotal}
            extra={extra} allotment={allotment}
            plan={plan} plans={PLANS} catalog={catalog} signedIn={signedIn}
            busy={!!billingPending} pending={billingPending} notice={billingNotice}
            subscription={subscription} onCancel={cancelSubscription}
            onResume={resumeSubscription} onClaimOffer={claimRetentionOffer}
            onCheckout={startCheckout} onManage={manageBilling} onSignIn={authActions.signIn} />
        ) : null}

        {tab === 'whois' ? <WhoisView initialDomain={whoisSeed} autoRun={!!whoisSeed} onConsumed={() => setWhoisSeed('')} /> : null}
        {tab === 'value' ? <DomainValueView valueCost={VALUE_COST} canAfford={canAffordValue} onReserve={reserveValue} onRefund={refundCredits} onCharged={(label) => logUsage('domain_valuation', VALUE_COST, label)} onFindNames={() => goTab('names')} onBuy={openBuy} initialDomain={valueSeed} autoRun={!!valueSeed} onConsumed={() => setValueSeed('')} /> : null}

        {tab === 'hunt' ? <HuntView hasIdentity={hasIdentity} onUpgrade={() => goTab('billing')} huntCost={HUNT_COST} canAfford={canAffordHunt} onReserve={reserveHunt} onCharged={(label) => logUsage('domain_hunt', HUNT_COST, label)} onBuy={openBuy} favIds={favs.map(f => f.id)} onFav={toggleFav} onRecord={recordHunt} /> : null}

        {tab === 'profile' ? (
          <ProfileView user={me} isSignedIn={signedIn}
            onSignIn={authActions.signIn} onSignUp={authActions.signUp}
            onSignOut={authActions.signOut} onManage={authActions.manage} />
        ) : null}
      </div>

      {panel ? <SidePanel panel={panel} onClose={() => setPanel(null)}
        handlesGenerated={handlesDone.includes(panel.domain.id)}
        onGenerateHandles={generateHandles} handleCost={HANDLE_COST} canAffordHandles={canAffordHandles}
        onHandlesOutcome={onHandlesOutcome} handleErrored={!!handleErrors[panel.domain.id]}
        availChecked={availDone.includes(panel.domain.id)}
        onCheckAvail={checkAvail} availCost={AVAIL_COST} canAffordAvail={canAffordAvail}
        onAvailOutcome={onAvailOutcome} availErrored={!!availErrors[panel.domain.id]} /> : null}

      <PaidModal info={paidInfo} onClose={() => setPaidInfo(null)} />
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
