/* Scanify — fully wired SPA: auth, URL/file/IP scans, reports, account, settings.
   Same look as the design handoff, with real backend integration. */

const { useState, useEffect, useRef, useCallback } = React;

// Pull cross-script components/helpers off window (each <script type="text/babel">
// runs in its own scope under @babel/standalone, so bare names don't leak).
const { GlobeLoader, ScanWhirl } = window;
const {
  useTweaks, TweaksPanel, TweakSection, TweakRow,
  TweakSlider, TweakToggle, TweakRadio, TweakSelect,
  TweakText, TweakNumber, TweakColor, TweakButton,
} = window;

// GA4 event helper. Never throws: analytics must not break the app.
function track(event, params) {
  try { if (typeof window.gtag === 'function') window.gtag('event', event, params || {}); } catch (e) {}
}

function trackBlogScan(event) {
  // The site suppresses referrers. Only accept known article slugs from its CTA links.
  const guide = new URLSearchParams(location.search).get('guide');
  if (!['usps-redelivery-text-scam', 'parking-meter-qr-code-scam', 'paypal-invoice-scam'].includes(guide)) return;
  track(event, {
    article_path: '/blog/' + guide,
    page_location: location.origin + location.pathname,
  });
}

// ---------- inline icons ----------
const I = {
  check:    <svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2"><path d="m3 8.5 3.5 3.5L13 5" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  search:   <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="7" cy="7" r="4.5"/><path d="m13.5 13.5-3-3" strokeLinecap="round"/></svg>,
  url:      <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="7" cy="7" r="4.5"/><path d="m13.5 13.5-3-3" strokeLinecap="round"/></svg>,
  file:     <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 2h6l4 4v8H3z" strokeLinejoin="round"/><path d="M9 2v4h4"/></svg>,
  ip:       <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M2 6h12M5 9h2M9 9h2"/></svg>,
  reports:  <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 13V5l5-3 5 3v8" strokeLinejoin="round"/><path d="M3 13h10M6 13V8h4v5"/></svg>,
  user:     <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="8" cy="6" r="2.5"/><path d="M3 13c.5-2.5 2.5-4 5-4s4.5 1.5 5 4" strokeLinecap="round"/></svg>,
  settings: <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M8 2v2M8 12v2M2 8h2M12 8h2M3.8 3.8l1.4 1.4M10.8 10.8l1.4 1.4M3.8 12.2l1.4-1.4M10.8 5.2l1.4-1.4"/><circle cx="8" cy="8" r="2"/></svg>,
  bolt:     <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M9 1 3 9h4l-1 6 6-8H8z"/></svg>,
  globe:    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="8" cy="8" r="6"/><path d="M2 8h12M8 2c2 2 2 10 0 12M8 2c-2 2-2 10 0 12"/></svg>,
  shield:   <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M8 1.5 13 4v4c0 3-2 5-5 6.5-3-1.5-5-3.5-5-6.5V4z" strokeLinejoin="round"/></svg>,
  download: <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M8 2v8m-3-3 3 3 3-3M3 13h10" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  flag:     <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 14V2M3 3h8l-1.5 3L11 9H3" strokeLinejoin="round"/></svg>,
  back:     <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M9 3 4 8l5 5M4 8h9" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  copy:     <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M2 10V3a1 1 0 0 1 1-1h7"/></svg>,
  trash:    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 4h10M6 4V2.5h4V4M5 4l1 9h4l1-9"/></svg>,
  eye:      <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z"/><circle cx="8" cy="8" r="2"/></svg>,
};

// ---------- canvas background ----------
function BackgroundCanvas() {
  const ref = useRef(null);
  useEffect(() => {
    const cv = ref.current; if (!cv) return;
    const ctx = cv.getContext('2d');
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    function resize() {
      const r = cv.getBoundingClientRect();
      cv.width = r.width * dpr; cv.height = r.height * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    }
    resize();
    const ro = new ResizeObserver(resize); ro.observe(cv);

    const blobs = Array.from({length: 4}, () => ({
      x: Math.random(), y: Math.random(),
      vx: (Math.random()-0.5)*0.00006, vy: (Math.random()-0.5)*0.00006,
      r: 280 + Math.random()*220,
      hue: Math.random() < 0.5 ? 38 : 140,
      sat: 30, light: 80,
      a: 0.10 + Math.random()*0.06,
    }));
    const dots = Array.from({length: 38}, () => ({
      x: Math.random(), y: Math.random(),
      vx: (Math.random()-0.5)*0.00004, vy: (Math.random()-0.5)*0.00004,
      r: 1 + Math.random()*1.6,
      a: 0.08 + Math.random()*0.10,
    }));

    let t0 = performance.now();
    let raf;
    function draw(now) {
      const dt = Math.min(40, now - t0); t0 = now;
      const W = cv.clientWidth, H = cv.clientHeight;
      ctx.clearRect(0,0,W,H);
      for (const b of blobs) {
        b.x += b.vx*dt; b.y += b.vy*dt;
        if (b.x<-0.2) b.x=1.2; if (b.x>1.2) b.x=-0.2;
        if (b.y<-0.2) b.y=1.2; if (b.y>1.2) b.y=-0.2;
        const cx = b.x*W, cy = b.y*H;
        const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, b.r);
        g.addColorStop(0, `hsla(${b.hue}, ${b.sat}%, ${b.light}%, ${b.a})`);
        g.addColorStop(1, `hsla(${b.hue}, ${b.sat}%, ${b.light}%, 0)`);
        ctx.fillStyle = g;
        ctx.fillRect(cx-b.r, cy-b.r, b.r*2, b.r*2);
      }
      for (const d of dots) {
        d.x += d.vx*dt; d.y += d.vy*dt;
        if (d.x<0) d.x=1; if (d.x>1) d.x=0;
        if (d.y<0) d.y=1; if (d.y>1) d.y=0;
        ctx.fillStyle = `rgba(80, 100, 80, ${d.a})`;
        ctx.beginPath(); ctx.arc(d.x*W, d.y*H, d.r, 0, Math.PI*2); ctx.fill();
      }
      ctx.strokeStyle = 'rgba(60, 70, 60, 0.04)';
      ctx.lineWidth = 1;
      const gs = 48;
      for (let x = 0; x < W; x += gs) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
      for (let y = 0; y < H; y += gs) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }

      raf = requestAnimationFrame(draw);
    }
    raf = requestAnimationFrame(draw);
    return () => { cancelAnimationFrame(raf); ro.disconnect(); };
  }, []);
  return <canvas ref={ref} className="bg-canvas" />;
}

// ---------- helpers ----------
// Mirrors classifyRisk() in src/utils/riskLevel.js — the API's
// riskLevel uses the same bands, so keep the two in step.
function classifyLevel(score) {
  if (score >= 70) return { label: 'CRITICAL', cls: 'danger' };
  if (score >= 40) return { label: 'HIGH',     cls: 'danger' };
  if (score >= 20) return { label: 'MEDIUM',   cls: 'warn' };
  if (score >= 5)  return { label: 'LOW',      cls: 'warn' };
  return { label: 'SAFE', cls: 'safe' };
}
function formatTime(d) {
  const dt = d instanceof Date ? d : new Date(d);
  return `${dt.toLocaleDateString()} ${dt.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`;
}

// Where "Link Google" goes: sign in, then straight back to the app. The
// server sees the paid guest session on the way through and attaches that
// purchase to the Google account.
const LINK_GOOGLE_HREF = '/api/auth/google?returnTo=' + encodeURIComponent('/app/v2/');

// ---------- TopBar ----------
function TopBar({ user, onSignOut, onNav }) {
  const initial = (user?.name || user?.email || 'U').trim().charAt(0).toUpperCase();
  return (
    <div className="topbar">
      <div className="brand" onClick={() => onNav('url')} style={{cursor:'pointer'}}>
        <img src="/assets/scanify-mark-light.png" alt="" width="24" height="24"/><span className="brand-name">Scanify</span>
        <span className="brand-pill mono">THREAT INTELLIGENCE</span>
      </div>
      <div className="topbar-right">
        <span className="tb-chip"><span className="tb-dot"/>URL, file and IP checks</span>
        {user ? (
          <>
            <div className="tb-user">
              <div className="avatar">{initial}</div>
              <span className="user-name">{user.name?.split(' ')[0] || user.email || 'Pro member'}</span>
              {user.plan === 'pro' && <span className="pro-tag">PRO</span>}
            </div>
            {/* A real href, so it is reachable by following links. Only signed-in
                visitors get it; /account itself refuses everyone else with a 401. */}
            <a className="tb-link" href="/account">Account</a>
            {/* Someone who paid without an account has Pro on this browser only.
                Signing out would throw it away, so they are offered the opposite. */}
            {user.guest
              ? <a className="tb-link" href={LINK_GOOGLE_HREF}>Link Google</a>
              : <a className="tb-link" href="#" onClick={e => { e.preventDefault(); onSignOut(); }}>Sign out</a>}
          </>
        ) : (
          <a className="tb-link" href="/api/auth/google">Sign in</a>
        )}
        <a className="tb-about" href="#/about" onClick={e => { e.preventDefault(); onNav('about'); }}>About Us</a>
      </div>
    </div>
  );
}

// ---------- Sidebar ----------
function Sidebar({ active, onNav, apiEnabled }) {
  const item = (key, icon, label) => (
    <div className={`sb-item ${active === key ? 'active' : ''}`} onClick={() => onNav(key)}>
      <span className="sb-icon">{icon}</span> {label}
    </div>
  );
  return (
    <aside className="sidebar">
      <div className="sb-section">
        <div className="sb-label">Analysis</div>
        {item('url',  I.url,  'URL Scanner')}
        {item('file', I.file, 'File Scanner')}
        {item('ip',   I.ip,   'IP Lookup')}
      </div>
      <div className="sb-section">
        <div className="sb-label">Reports</div>
        {item('reports', I.reports, 'Export Reports')}
      </div>
      <div className="sb-section">
        <div className="sb-label">Account</div>
        {item('account',  I.user,     'My Account')}
        {apiEnabled && item('settings', I.settings, 'Settings')}
      </div>

      <div className="sb-bottom">
        <div className="sb-card">
          <div className="engines-status">
            <span className="dot"/>
            <div>
              <div className="sb-card-title">Engines connected</div>
              <div className="sb-card-body">95 AV Engines · Safe<br/>Browsing</div>
            </div>
          </div>
        </div>
        <div className="sb-foot-links">
          <a href="/pricing">Pricing</a>
          <a href="/vs">Compare</a>
          <a href="/how-we-score">How we score</a>
          <a href="/about">About</a>
          <a href="/privacy">Privacy</a>
          <a href="/terms">Terms</a>
        </div>
      </div>
    </aside>
  );
}

// ---------- live scan steps ----------
// A scan is a handful of sources asked at once. Each step below is one of
// them, and it only ticks when that source has really answered, with a real
// fact from its answer. Nothing here runs on a timer.
//
// useScanSteps() holds the list; `plan` sets which steps this scan runs and
// `done` marks one finished. A scan request sent with streamScan() feeds both
// from the server as the scan happens.
function useScanSteps() {
  const [steps, setSteps] = useState([]);
  const api = useRef({
    plan: (list) => setSteps(prev => (list || []).map(s => {
      const had = prev.find(p => p.key === s.key);
      return had && had.done ? { ...s, done: true, fact: had.fact, ok: had.ok } : { ...s, done: false };
    })),
    done: (key, fact, ok = true) => setSteps(prev => prev.map(s => (s.key === key ? { ...s, done: true, fact, ok } : s))),
  }).current;
  return [steps, api];
}

const sleep = ms => new Promise(r => setTimeout(r, ms));

// POSTs a scan and reads the answer as a live stream when the server offers
// one (a line per step, then the result). Resolves to { status, json } with
// the same `json` the plain endpoint returns, so callers handle both alike.
async function streamScan(url, body, { onPlan, onStep } = {}) {
  const res = await fetch(url, {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json', 'Accept': 'application/x-ndjson, application/json' },
    body: JSON.stringify(body),
  });
  const type = res.headers.get('content-type') || '';
  if (!type.includes('ndjson') || !res.body || !res.body.getReader) {
    if (type.includes('json')) return { status: res.status, json: await res.json() };
    // The rate limiter answers in plain text.
    return { status: res.status, json: { success: false, error: (await res.text()).slice(0, 200) || 'Scan failed' } };
  }
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let result = null;
  let streamed = 0;
  const handle = (line) => {
    if (!line.trim()) return;
    let msg;
    try { msg = JSON.parse(line); } catch { return; }
    if (msg.type === 'plan') onPlan?.(msg.steps);
    else if (msg.type === 'step') { streamed++; onStep?.(msg); }
    else if (msg.type === 'result') result = msg;
  };
  for (;;) {
    const { done, value } = await reader.read();
    if (value) {
      buffer += decoder.decode(value, { stream: true });
      let nl;
      while ((nl = buffer.indexOf('\n')) >= 0) { handle(buffer.slice(0, nl)); buffer = buffer.slice(nl + 1); }
    }
    if (done) break;
  }
  handle(buffer);
  if (!result) throw new Error('The scan stopped before it finished');
  // Let the last tick land on screen before the result replaces the steps.
  if (streamed) await sleep(550);
  return { status: result.success === false ? (result.code === 'LIMIT_REACHED' ? 402 : 500) : 200, json: result };
}

// Asks for the full result of a scan that was locked when it ran. Null when
// the server no longer holds it, and the caller runs the scan again.
async function fetchUnlocked(scanId) {
  if (!scanId) return null;
  try {
    const res = await fetch('/api/scan/unlock', {
      method: 'POST', credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ scanId }),
    });
    const j = await res.json();
    return j.success ? j.data : null;
  } catch { return null; }
}

function ScanProgressCard({ steps, title = 'Scan in progress' }) {
  const finished = steps.filter(s => s.done).length;
  const latest = [...steps].reverse().find(s => s.done);
  const all = steps.length > 0 && finished === steps.length;
  return (
    <div className="card scan-live">
      <div className="scan-progress-head">
        <h3 className="card-title" style={{margin:0}}>{title}</h3>
        <div className="scan-progress-status" aria-live="polite">
          {all ? <span className="ok">Checks complete</span> : latest ? latest.fact : 'Asking every source at once…'}
        </div>
      </div>
      <div className="scan-tiles">
        {steps.map(s => (
          <div key={s.key} className={`scan-tile ${s.done ? (s.ok === false ? 'done missed' : 'done') : 'active'}`}>
            <div className="scan-tile-head">
              <span className="scan-tile-spinner"/>
              <span className="scan-tile-name">{s.label}</span>
            </div>
            <span className={`scan-tile-status${s.done ? ' fact' : ''}`}>{s.done ? s.fact : 'Checking…'}</span>
          </div>
        ))}
      </div>
      <div className="scan-progress-foot">
        <div className="scan-progress-bar">
          <div className="scan-progress-fill" style={{width: `${steps.length ? Math.max(6, (finished / steps.length) * 100) : 6}%`}}/>
        </div>
        <div className="scan-progress-count">{finished} / {steps.length || '…'} checks</div>
      </div>
    </div>
  );
}

// ---------- Updu AI Studio waitlist card (from the maker of Scanify), under every finished scan ----------
const UPDU_FONT = 'https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@1&display=swap';
function UpduCard({ kind }) {
  const ref = useRef(null);
  const track = (event) => { try { if (typeof window.gtag === 'function') window.gtag('event', event, { banner: 'scan-result' }); } catch (e) {} };
  useEffect(() => {
    if (!document.querySelector(`link[href="${UPDU_FONT}"]`)) {
      const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = UPDU_FONT; document.head.appendChild(link);
    }
    if (!('IntersectionObserver' in window) || !ref.current) return;
    const seen = new IntersectionObserver((entries) => {
      if (entries.some((en) => en.isIntersecting)) { track('updu_banner_view'); seen.disconnect(); }
    }, { threshold: 0.5 });
    seen.observe(ref.current);
    return () => seen.disconnect();
  }, [kind]);
  return (
    <div className="updu-bn-result" ref={ref} role="complementary" aria-label="Updu AI Studio, from the maker of Scanify">
      <span className="updu-bn-result__tile"><img src="https://www.myupdu.com/assets/updu-mark-96.png" width="30" height="30" alt="" decoding="async"/></span>
      <div className="updu-bn-result__copy">
        <p className="updu-bn-result__eyebrow">From the maker of <span className="notranslate" translate="no">Scanify</span></p>
        <p className="updu-bn-result__title">You check links before you click them. <em>Check your app before you ship it.</em></p>
        <p className="updu-bn-result__text"><b className="notranslate" translate="no">Updu AI Studio</b> tests websites and iOS and Android apps, and marks every bug it finds on a screenshot.</p>
      </div>
      <a className="updu-bn-result__cta" href="https://www.myupdu.com/?utm_source=scanify&utm_medium=banner&utm_campaign=waitlist&utm_content=scan-result#join" target="_blank" rel="noopener" onClick={() => track('updu_banner_click')}>Join the waitlist<span aria-hidden="true">&nbsp;→</span></a>
    </div>
  );
}

// ---------- shared verdict card ----------
function VerdictCard({ target, score, scannedAt, engines, protocol, headline, body, level }) {
  const lv = level || classifyLevel(score);
  return (
    <div className={`card verdict-card lvl-${lv.cls}`}>
      <div className="verdict-score-block">
        <div className="verdict-num">{score}</div>
        <div className="verdict-score-label">Risk Score</div>
        <div className={`verdict-pill verdict-pill-${lv.cls}`}>{lv.label}</div>
      </div>
      <div>
        <div className="verdict-target">{target}</div>
        <h2 className="verdict-headline">{headline}</h2>
        <p className="verdict-body">{body}</p>
      </div>
      <div className="verdict-meta">
        <div className="row"><span className="label">Scanned</span><span>{formatTime(scannedAt)}</span></div>
        {engines  && <div className="row"><span className="label">Engines</span><span>{engines}</span></div>}
        {protocol && <div className="row"><span className="label">Protocol</span><span>{protocol}</span></div>}
      </div>
    </div>
  );
}

// Pricing copy in one place. The amounts must match Paddle: price
// pri_01m6hzx9g4wbk0nftx9aemccqb ($20/mo) + discount dsc_01m6j00na5ebt97y2qrzyhmy1h
// ($19 off the first payment only).
const PRO_INTRO = '$1';
const PRO_PRICE = '$20/mo';
// Kept deliberately short, but always next to the button: it's a subscription,
// so the renewal price must be visible before the click, not only in Paddle.
const PRO_TERMS = `Then ${PRO_PRICE}. Cancel anytime.`;
// One-time alternative for people who won't start a subscription to check one
// link. Must match the $5 "Scanify 7-Day Pass" price in Paddle.
const PASS_PRICE = '$5';

function PassOption({ onUpgrade, resume, from }) {
  return (
    <button type="button" className="offer-alt" onClick={() => { track('upgrade_click', { plan: 'pass', from }); onUpgrade(resume, 'pass'); }}>
      Or pay {PASS_PRICE} once for 7 days. <span>Nothing renews.</span>
    </button>
  );
}

// Black Friday deal on the lifetime plan, shown at the paywall to everyone.
// The checkout sheet applies the BLACKFRIDAY code to the lifetime plan.
// Removes itself when the code expires in Paddle.
const LAUNCH_OFFER_ENDS = Date.UTC(2026, 11, 9, 23, 59); // BLACKFRIDAY code expires Dec 9
const launchOfferOn = () => Date.now() < LAUNCH_OFFER_ENDS;
const LIFETIME_PRICE = '$29';
const LIFETIME_DEAL_PRICE = '$14.50';
function LaunchOffer({ from, onUpgrade, resume }) {
  if (!launchOfferOn()) return null;
  return (
    <button type="button" className="offer-launch" onClick={() => { track('upgrade_click', { plan: 'lifetime', from: from + '_launch' }); onUpgrade(resume, 'lifetime'); }}>
      <b>Black Friday</b> Lifetime Pro for {LIFETIME_DEAL_PRICE} instead of {LIFETIME_PRICE}. One payment, ends Dec 9.
    </button>
  );
}

// Fires one GA event the first time a paywall card is shown.
function useViewEvent(event, params) {
  useEffect(() => { track(event, params); }, []);
}

// The preview and checkout share only the evidence returned by the locked API.
function ReportSummary({ teaser, type = 'url', target }) {
  const heading = teaser ? 'Your report is ready' : 'Full report access';
  return (
    <section className="report-summary">
      <div className="report-imprint"><img src="/assets/scanify-mark-light.png" width="24" height="24" alt=""/>Scanify <span>/</span> {type === 'ip' ? 'IP report' : type === 'file' ? 'File report' : 'Link report'}</div>
      <div className="report-identity">
        <h2>{heading}</h2>
        {target && <span className="report-target notranslate" dir="auto">{target}</span>}
      </div>
    </section>
  );
}

function LockedResultCard({ teaser, onUpgrade, resume }) {
  useEffect(() => { onUpgrade(resume); }, []);
  return <article className="report-preview"><ReportSummary teaser={teaser} target={teaser?.target}/><p className="report-unlock" role="status">Your report is ready. Checkout opens automatically to reveal the findings.</p></article>;
}

function LimitReachedCard({ onUpgrade, resume = null }) {
  useEffect(() => { onUpgrade(resume); }, []);
  return <article className="report-preview"><ReportSummary target={resume?.target || resume?.q}/><p className="report-unlock" role="status">Your one free scan across all tools has been used. Checkout opens automatically to continue.</p></article>;
}

function PreviewCard({ target, screenshotUrl, user, onUpgrade }) {
  const [attempt, setAttempt] = useState(0);
  const [failed, setFailed] = useState(false);

  // Reset retry state when a new screenshot URL arrives (new scan)
  useEffect(() => { setAttempt(0); setFailed(false); }, [screenshotUrl, target]);

  const chromeBar = (
    <div className="preview-chrome">
      <span className="ic" style={{display:'inline-flex', gap:4}}>
        <span style={{width:8,height:8,borderRadius:'50%',background:'#e35b50'}}/>
        <span style={{width:8,height:8,borderRadius:'50%',background:'#e8b13a'}}/>
        <span style={{width:8,height:8,borderRadius:'50%',background:'#3eb265'}}/>
      </span>
      <span className="mono" style={{fontSize:12, color:'#6b7269'}}>{target}</span>
    </div>
  );

  // NOTE: free users see the real live preview for all of their free scans.
  // The server hard-stops further scans once the limit is hit (handled by
  // LimitReachedCard before a scan ever runs), so reaching a completed scan
  // here always means the user is entitled to the real screenshot below.

  // Screenshots come from our own sandboxed capture worker via a same-origin
  // endpoint (no third-party screenshot services). The scan route pre-warms
  // this same URL when the scan starts, so it's usually a cache hit here.
  const cleanTarget = target ? (target.startsWith('http') ? target : 'https://' + target) : null;
  const ownShotSrc  = cleanTarget ? `/api/shot?url=${encodeURIComponent(cleanTarget)}&w=1280&h=800` : null;
  // urlscan's image is kept only as a last-resort fallback if our worker is down.
  const sources     = [ownShotSrc, screenshotUrl].filter(Boolean);
  const currentSrc  = sources[Math.min(attempt, sources.length - 1)];
  const urlscanResultUrl = screenshotUrl
    ? screenshotUrl.replace('/screenshots/', '/result/').replace('.png', '/')
    : null;

  function onSourceError() {
    if (attempt < sources.length - 1) {
      setAttempt(a => a + 1);
    } else {
      setFailed(true);
    }
  }

  return (
    <div className="card">
      <h3 className="card-title"><span className="ic">{I.globe}</span> Live Website Preview</h3>
      <div className="preview-frame">
        {chromeBar}
        {currentSrc && !failed ? (
          <img
            id="ss-preview-img"
            key={currentSrc}
            src={currentSrc}
            alt="Live page screenshot"
            onError={onSourceError}
            loading="lazy"
            style={{display:'block',width:'100%',maxHeight:480,objectFit:'cover',objectPosition:'top',background:'#f3efe8'}}
          />
        ) : (
          <div className="preview-body" style={{padding:'48px 24px', textAlign:'center', color:'#6b7269', fontSize:13}}>
            {!cleanTarget ? (
              '[ no screenshot available ]'
            ) : (
              <span>
                Screenshot could not be captured for this URL.
                {urlscanResultUrl && (
                  <>
                    {' '}
                    <a href={urlscanResultUrl} target="_blank" rel="noopener noreferrer" style={{color:'#3a8a55', textDecoration:'underline'}}>
                      View on URLScan ↗
                    </a>
                  </>
                )}
              </span>
            )}
          </div>
        )}
        <div className="preview-caption">Live capture taken at scan time — shows the actual page a visitor would see</div>
      </div>
    </div>
  );
}

function DetailsCard({ checks }) {
  return (
    <div className="card">
      <h3 className="card-title">Detailed Check Results</h3>
      <div className="checks">
        <div className="checks-head">
          <span>Check</span><span>Engine / Source</span><span>Finding</span><span>Status</span>
        </div>
        {checks.map((c, i) => (
          <div key={i} className="checks-row">
            <span className="check-name">{c.name}</span>
            <span className="check-source">{c.source}</span>
            <span className="check-finding">{c.finding}</span>
            <span className="check-status">{c.status === 'flag' ? '⚠ Flagged' : c.status === 'warn' ? '⊘ Unavailable' : '✓ Clean'}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

function AICard({ text }) {
  return (
    <div className="card">
      <h3 className="card-title">AI Threat Assessment</h3>
      <div className="ai-block">{text}</div>
    </div>
  );
}

// Build a "checks" list and AI summary from the backend scan result.
// IMPORTANT: field names here must match what the backend actually returns
// (src/services/*.js) — vt.flagged/vt.total, gsb.isMalicious/threats,
// ssl.valid/domainMatch, whois.ageDays/registrationDate, urlscan.score/malicious.
// Statuses mirror the same thresholds the backend scorer uses, so the rows
// can never all read "clean" while the risk score says otherwise.
function detailsFromScan(data) {
  const vt   = data?.details?.virustotal;
  const gsb  = data?.details?.safebrowsing;
  const ssl  = data?.details?.ssl;
  const wh   = data?.details?.whois;
  const us   = data?.details?.urlscan;
  const failedSources = new Set((data?.errors || []).map(e => e.provider));
  const checks = [];
  if (vt) {
    const flagged = vt.flagged ?? ((vt.malicious || 0) + (vt.suspicious || 0));
    const total   = vt.total || 0;
    const vtDown  = vt.unavailable || vt.noData || failedSources.has('virustotal') || total === 0;
    checks.push({
      name: '95 AV Engines', source: 'VirusTotal',
      finding: vtDown ? 'Engine data unavailable — not counted as clean' : `${flagged}/${total} engines flagged`,
      status: vtDown ? 'warn' : flagged > 0 ? 'flag' : 'ok',
    });
  }
  if (gsb) {
    const gsbDown = gsb.unavailable || failedSources.has('safebrowsing');
    checks.push({
      name: 'Phishing & Malware Check', source: 'Google Safe Browsing',
      finding: gsbDown ? 'Check unavailable — not counted as clean'
        : gsb.isMalicious ? `Flagged: ${(gsb.threats || []).join(', ') || 'threat detected'}` : 'No matches in threat database',
      status: gsbDown ? 'warn' : gsb.isMalicious ? 'flag' : 'ok',
    });
  }
  if (ssl) {
    const sslBad = !ssl.valid || ssl.domainMatch === false;
    checks.push({
      name: 'SSL Certificate', source: 'TLS Inspector',
      finding: !ssl.valid ? (ssl.error || 'Invalid or expired certificate')
        : ssl.domainMatch === false ? 'Certificate does not match domain — suspicious'
        : `Valid · issued by ${ssl.issuer || 'CA'}${ssl.daysOld != null ? ` · ${ssl.daysOld}d old` : ''}`,
      status: sslBad ? 'flag' : 'ok',
    });
  }
  if (us) {
    const usBad = !!us.malicious || (Number(us.score) || 0) >= 40;
    checks.push({
      name: 'Sandbox Analysis', source: 'urlscan.io',
      finding: us.pending ? 'Scan pending' : `Score: ${Math.round(us.score || 0)}/100${us.malicious ? ' · flagged malicious' : ''}`,
      status: usBad ? 'flag' : 'ok',
    });
  }
  if (wh) {
    checks.push({
      name: 'Domain Age & Registration', source: 'RDAP / WHOIS',
      finding: `${wh.ageLabel ? wh.ageLabel + ' old' : ''}${wh.registrationDate ? ' · Registered ' + new Date(wh.registrationDate).toLocaleDateString() : ''}${wh.registrar ? ' · ' + wh.registrar : ''}`.replace(/^ · /, '').trim() || 'Lookup completed',
      status: wh.ageDays != null && wh.ageDays < 30 ? 'flag' : 'ok',
    });
  }
  // Sources that failed outright never produce a details object — surface
  // them as "unavailable" rows instead of silently omitting them.
  if (!us && failedSources.has('urlscan')) {
    checks.push({ name: 'Sandbox Analysis', source: 'urlscan.io', finding: 'Check unavailable — not counted as clean', status: 'warn' });
  }
  if (!wh && failedSources.has('whois')) {
    checks.push({ name: 'Domain Age & Registration', source: 'RDAP / WHOIS', finding: 'Lookup unavailable', status: 'warn' });
  }
  // Heuristic signals (brand impersonation, cross-domain redirects) only
  // exist in the score breakdown — surface them as rows too so the table
  // always explains the score.
  (data?.breakdown || []).forEach(b => {
    if (/impersonat|redirect/i.test(b.signal)) {
      checks.push({
        name: b.signal, source: 'Scanify heuristics',
        finding: `+${b.contribution} risk points`,
        status: 'flag',
      });
    }
  });

  const flagged = vt?.flagged ?? ((vt?.malicious || 0) + (vt?.suspicious || 0));
  const total   = vt?.total || 0;
  const score   = data?.riskScore ?? 0;
  const level   = classifyLevel(score).label;
  const reasons = (data?.breakdown || []).map(b => b.signal);
  const incomplete = !!data?.incomplete || failedSources.size > 0 || checks.some(c => c.status === 'warn');
  const aiText  = (level === 'SAFE' && reasons.length === 0)
    ? (incomplete
        ? `Partial scan: one or more scanning engines were unavailable, so this result is inconclusive — it is NOT a clean bill of health. The sources that did respond found no threats. Re-run the scan in a minute for a complete verdict.`
        : `Malware scan: ${flagged}/${total || 95} engines flagged. Phishing & Malware Check: clean. No indicators of compromise found. Confidence: High.`)
    : `Risk score ${score}/100 — ${level}. Contributing signals: ${reasons.join('; ') || `${flagged}/${total || 95} engines flagged`}. ${
        level === 'HIGH' || level === 'CRITICAL'
          ? 'Avoid visiting this URL, do not enter credentials, and do not download files.'
          : level === 'MEDIUM'
          ? 'Mixed signals — review the findings below before trusting this site.'
          : 'Minor signals detected — generally safe, but review the findings below.'
      }${incomplete && reasons.length ? ' Note: some engines were unavailable, so the true risk may be higher.' : ''}`;
  return { checks, aiText, incomplete };
}

// Google's official four-colour mark — their branding guidelines require the
// real logo on a sign-in button, not a recoloured or monotone substitute.
function GoogleMark() {
  return (
    <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" focusable="false">
      <path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"/>
      <path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/>
      <path fill="#FBBC05" d="M3.97 10.72a5.41 5.41 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33z"/>
      <path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/>
    </svg>
  );
}

// ====================================================================
// PAGE: URL SCANNER
// ====================================================================
// The steps a link scan runs, shown until the server sends its own plan (it
// drops the ones that do not apply, such as the certificate for an http link).
const URL_STEPS = [
  { key: 'engines',      label: 'Antivirus engines' },
  { key: 'safebrowsing', label: 'Google Safe Browsing' },
  { key: 'sandbox',      label: 'Sandbox analysis' },
  { key: 'domain',       label: 'Domain registration' },
  { key: 'ssl',          label: 'SSL certificate' },
  { key: 'redirects',    label: 'Redirect trace' },
];

function PageURL({ user, authReady, initialQuery, onScanConsumed, onUpgrade }) {
  const [url, setUrl] = useState('');
  const [phase, setPhase] = useState('idle'); // idle | scanning | locked | done | error | limit
  const [error, setError] = useState(null);
  const [data, setData]   = useState(null);
  const [recents, setRecents] = useState([]);
  const [steps, stepsApi] = useScanSteps();
  const [lockedId, setLockedId] = useState(null);   // unlocks this exact result after paying
  const [revealed, setRevealed] = useState(false);  // the result just came out from behind the lock
  const deepLinkRef = useRef(false);
  const resultRef = useRef(null);

  // Scroll the result/limit/error block into view on mobile after a scan finishes
  useEffect(() => {
    if (phase === 'idle') return;
    if (!resultRef.current) return;
    requestAnimationFrame(() => {
      try { resultRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch {}
    });
  }, [phase]);

  // Prefill from deep link, then auto-scan once auth resolves
  useEffect(() => {
    if (!initialQuery) return;
    setUrl(initialQuery.replace(/^\s*https?:\/\//i, ''));
  }, [initialQuery]);

  useEffect(() => {
    if (deepLinkRef.current) return;
    if (!initialQuery) return;
    if (!authReady) return; // wait until /api/auth/me has resolved
    deepLinkRef.current = true;
    onScanConsumed?.();
    // No account needed: a signed-out visitor scans as a guest.
    setTimeout(() => startScan(initialQuery), 150);
  }, [authReady, user, initialQuery]);

  // Pro arrived while a result was waiting (they just paid in the checkout
  // sheet): bring out the result that was already scanned, or run the scan
  // the free limit stopped.
  useEffect(() => {
    if (user?.plan !== 'pro') return;
    if (phase === 'limit') { startScan(); return; }
    if (phase !== 'locked') return;
    (async () => {
      const full = await fetchUnlocked(lockedId);
      if (full) { setRevealed(true); showResult(full); }
      else startScan();
    })();
  }, [user?.plan]);

  function showResult(d, extra = {}) {
    setData({ ...d, preview: extra.preview, scansUsed: extra.scansUsed, scansLimit: extra.scansLimit });
    setPhase('done');
    setRecents(prev => [{ target: d.url, score: d.riskScore, finishedAt: Date.now() }, ...prev].slice(0, 6));
  }

  async function startScan(overrideUrl) {
    const target = (overrideUrl ?? url).trim();
    if (!target) return;
    // Mobile: close the keyboard immediately so the scan progress / result is visible
    try { document.activeElement && document.activeElement.blur(); } catch {}
    setError(null);
    setRevealed(false);
    setLockedId(null);
    stepsApi.plan(URL_STEPS);
    setPhase('scanning');
    // Scroll to the result area NOW — don't wait for React effect, and use two strategies
    setTimeout(() => {
      try {
        if (resultRef.current) {
          resultRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
        } else {
          window.scrollTo({ top: document.body.scrollHeight * 0.5, behavior: 'smooth' });
        }
      } catch {}
    }, 50);
    try {
      trackBlogScan('blog_url_scan_start');
      const { status, json: j } = await streamScan('/api/scan', { url: target }, {
        onPlan: stepsApi.plan,
        onStep: s => stepsApi.done(s.key, s.fact, s.ok),
      });
      if (!j.success) {
        if (status === 402 || j.code === 'LIMIT_REACHED') {
          setError({ code: 'LIMIT_REACHED', message: j.error, scansUsed: j.scansUsed, scansLimit: j.scansLimit, receipt: j.receipt });
          setPhase('limit');
          return;
        }
        setError(j.error || 'Scan failed');
        setPhase('error');
        return;
      }
      trackBlogScan('blog_url_scan_complete');
      if (j.locked) {
        setLockedId(j.scanId || null);
        setData({ ...j.data });
        setPhase('locked');
        return;
      }
      showResult(j.data, j);
    } catch (e) {
      setError('Network error — try again');
      setPhase('error');
    }
  }
  function newScan() { setPhase('idle'); setData(null); setError(null); setRevealed(false); setLockedId(null); }

  const target = data?.url || (url.startsWith('http') ? url : 'https://' + url);
  const screenshotUrl = data?.details?.urlscan?.screenshotUrl || null;
  const { checks, aiText, incomplete } = data ? detailsFromScan(data) : { checks: [], aiText: '', incomplete: false };
  const verdictHeadline = data && data.riskScore < 5
    ? (incomplete ? 'Scan incomplete — inconclusive' : 'No threats detected')
    : data?.riskScore < 20 ? 'Low risk indicators'
    : data?.riskScore < 40 ? 'Medium-risk signals found'
    : 'High-risk indicators detected';
  const verdictBody = data && data.riskScore < 5
    ? (incomplete
        ? 'Some scanning engines were unavailable, so this URL could not be fully verified. Do not treat this as a clean result — re-run the scan in a minute.'
        : 'This URL passed all checks. The domain has a clean history and no malicious patterns were found across any scanning engine.')
    : 'Review the detailed checks below. One or more sources flagged this target — proceed with caution.';

  return (
    <>
      <div className="page-kicker">Website analysis</div>
      <h1 className="page-title">Check a <em>link.</em></h1>
      <p className="page-sub">Run one scan free. Pro reveals the verdict, risk score and source findings.</p>

      <div className="card">
        <h3 className="card-title"><span className="ic">{I.search}</span> Submit Website URL for Analysis</h3>
        <p className="card-sub">Supports HTTP and HTTPS</p>
        <div className="url-row">
          <span className="url-prefix-box">TARGET WEBSITE</span>
          <label className="url-input-box">
            <span className="pfx">https://</span>
            <input
              type="text"
              value={url}
              onChange={e => setUrl(e.target.value.replace(/^\s*https?:\/\//i, ''))}
              onKeyDown={e => e.key === 'Enter' && phase !== 'scanning' && url.trim() && startScan()}
              placeholder="example.com"
              spellCheck={false}
            />
          </label>
          <button
            className="run-btn"
            type="button"
            onClick={(e) => {
              e.preventDefault();
              e.stopPropagation();
              try { startScan(); }
              catch (err) {
                setError('Click failed: ' + (err?.message || err));
                setPhase('error');
              }
            }}
            disabled={phase === 'scanning' || !url.trim()}
          >
            {phase === 'scanning' ? 'Scanning…' : 'Run Scan'} →
          </button>
        </div>
        <p className="privacy-note">
          No account needed. Links are checked by VirusTotal, Google Safe Browsing and urlscan.io, and the urlscan.io report is public,
          so remove passwords, tokens or personal details from a link before you paste it. <a href="/privacy">Privacy policy</a>
        </p>
      </div>

      <div ref={resultRef}>
        {phase === 'error' && (
          <div className="card" style={{borderColor:'#b8453d'}}>
            <h3 className="card-title" style={{color:'#b8453d'}}>{typeof error === 'string' ? error : (error?.message || 'Scan failed')}</h3>
          </div>
        )}

        {phase === 'limit' && <LimitReachedCard error={error} onUpgrade={onUpgrade} kind="url" resume={{ type: 'url', q: url.trim(), target: url.trim() }}/>}
        {phase === 'locked' && data && <LockedResultCard teaser={data} kind="url" onUpgrade={onUpgrade} resume={{ type: 'url', q: url.trim(), teaser: data }}/>}

        {phase === 'scanning' && <ScanProgressCard steps={steps}/>}
      </div>

      {phase === 'done' && data && (
        <div className={revealed ? 'unlock-reveal' : undefined}>
          {revealed && <UnlockedNote/>}
          {data.preview && (
            <div className="card" style={{borderColor:'#d4ccba'}}>
              <h3 className="card-title">You've used your free scan</h3>
              <p className="card-sub">Showing a preview only. Pro gives you unlimited scans for {PRO_INTRO}. {PRO_TERMS}</p>
              <button className="run-btn offer-btn" onClick={onUpgrade}>Get result {PRO_INTRO}</button>
            </div>
          )}
          <VerdictCard
            target={target}
            score={data.riskScore}
            scannedAt={data.scannedAt}
            engines={data.details?.virustotal?.totalEngines ? `${data.details.virustotal.totalEngines} engines` : '—'}
            protocol={target.startsWith('https') ? 'HTTPS / TLS' : 'HTTP'}
            headline={verdictHeadline}
            body={verdictBody}
          />
          <PreviewCard target={target} screenshotUrl={screenshotUrl} user={user} onUpgrade={onUpgrade}/>
          <DetailsCard checks={checks}/>
          <AICard text={aiText}/>
          <UpduCard kind="url"/>
          <div className="actions">
            <button className="btn" onClick={newScan}><span className="ic">{I.back}</span> New Scan</button>
            {user?.plan === 'pro'
              ? <a className="btn" href={`/api/report/url?url=${encodeURIComponent(target)}`}><span className="ic">{I.download}</span> Download PDF Report</a>
              : <button className="btn" onClick={onUpgrade}><span className="ic">{I.download}</span> Download PDF (Pro)</button>}
          </div>
        </div>
      )}

      {recents.length > 0 && (
        <div className="card">
          <h3 className="card-title" style={{marginBottom: 8}}>Recent Scans</h3>
          <div className="recents">
            {recents.map((s, i) => (
              <div key={i} className="recents-row">
                <span className="recent-target">
                  <span className="recent-dot"/>
                  <span className="recent-url">{s.target}</span>
                </span>
                <span className="recent-score">{s.score}/100</span>
                <span className="recent-time">{formatTime(s.finishedAt)}</span>
              </div>
            ))}
          </div>
        </div>
      )}
    </>
  );
}

// Shown once, above a result that has just come out from behind the lock.
function UnlockedNote() {
  return (
    <div className="unlocked-note" role="status">
      <span className="ic">{I.check}</span>
      <span><strong>Report unlocked.</strong> Your full findings are below.</span>
    </div>
  );
}

// ====================================================================
// PAGE: FILE SCANNER
// ====================================================================
// Uploads are capped at 1 GB. Bigger files (installers, ISOs, archives of
// several GB) are fingerprinted in the browser and only the SHA-256 is sent,
// so nothing is uploaded and there is no size limit.
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
const HASH_WASM_SRC = 'https://cdn.jsdelivr.net/npm/hash-wasm@4.12.0/dist/sha256.umd.min.js';

function fmtBytes(n) {
  if (!n && n !== 0) return 'unknown size';
  if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(2) + ' GB';
  if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
  return (n / 1024).toFixed(1) + ' KB';
}

let hashWasmPromise = null;
function loadHashWasm() {
  if (window.hashwasm?.createSHA256) return Promise.resolve(window.hashwasm);
  if (!hashWasmPromise) {
    hashWasmPromise = new Promise((resolve, reject) => {
      const s = document.createElement('script');
      s.src = HASH_WASM_SRC;
      s.async = true;
      s.onload = () => (window.hashwasm?.createSHA256 ? resolve(window.hashwasm) : reject(new Error('hash library missing')));
      s.onerror = () => { hashWasmPromise = null; reject(new Error('Could not load the hashing library')); };
      document.head.appendChild(s);
    });
  }
  return hashWasmPromise;
}

// Streams the file in 8MB slices so a multi-GB file never sits in memory.
async function sha256OfFile(file, onProgress) {
  const hw = await loadHashWasm();
  const hasher = await hw.createSHA256();
  hasher.init();
  const CHUNK = 8 * 1024 * 1024;
  for (let off = 0; off < file.size; off += CHUNK) {
    const buf = new Uint8Array(await file.slice(off, off + CHUNK).arrayBuffer());
    hasher.update(buf);
    onProgress?.(Math.min(100, Math.round(((off + buf.length) / file.size) * 100)));
  }
  return hasher.digest('hex');
}

// A file scan's steps. They tick from what the server really reports: the
// fingerprint it computed, whether VirusTotal already knew the file, and how
// many engines answered.
const FILE_STEPS = [
  { key: 'hash',    label: 'File fingerprint' },
  { key: 'lookup',  label: 'VirusTotal lookup' },
  { key: 'engines', label: 'Antivirus engines' },
];
const shortHash = sha => 'SHA-256 ' + String(sha).slice(0, 12) + '…';

function PageFile({ user, authReady, onUpgrade, resumeFile, onResumeConsumed }) {
  const [file, setFile] = useState(null);
  const [phase, setPhase] = useState('idle');
  const [error, setError] = useState(null);
  const [data, setData] = useState(null);
  const [steps, stepsApi] = useScanSteps();
  const [lockedId, setLockedId] = useState(null);   // unlocks this exact result after paying
  const [revealed, setRevealed] = useState(false);  // the result just came out from behind the lock
  const [dragOver, setDragOver] = useState(false);
  const fileInputRef = useRef(null);
  const resultRef = useRef(null);
  const [lockedHash, setLockedHash] = useState(null);
  const [hashPct, setHashPct] = useState(null); // fingerprint progress for big files
  const resumedRef = useRef(false);

  // Back from checkout with a paid-for file result: fetch it by hash. No
  // re-upload, and it does not count as a new scan.
  useEffect(() => {
    if (resumedRef.current || !resumeFile?.sha256 || !authReady || !user) return;
    resumedRef.current = true;
    onResumeConsumed?.();
    stepsApi.plan(FILE_STEPS);
    stepsApi.done('hash', shortHash(resumeFile.sha256));
    setPhase('scanning');
    pollForResult('', resumeFile.sha256, resumeFile.filename, resumeFile.size, 0);
  }, [authReady, user, resumeFile]);

  // Pro arrived while a result was waiting (they just paid in the checkout
  // sheet): bring out the result that was already scanned. If the server no
  // longer holds it, ask again by fingerprint, which needs no second upload.
  useEffect(() => {
    if (user?.plan !== 'pro') return;
    if (phase === 'limit') { startScan(); return; }
    if (phase !== 'locked') return;
    (async () => {
      const full = await fetchUnlocked(lockedId);
      if (full) { setRevealed(true); setData({ ...full }); setPhase('done'); return; }
      if (lockedHash) {
        stepsApi.plan(FILE_STEPS);
        stepsApi.done('hash', shortHash(lockedHash));
        setPhase('scanning');
        pollForResult('', lockedHash, data?.target || file?.name || '', data?.fileSize || file?.size || 0, 0);
      } else startScan();
    })();
  }, [user?.plan]);

  function pickFile() {
    fileInputRef.current?.click();
  }
  function onDrop(e) {
    e.preventDefault();
    setDragOver(false);
    const f = e.dataTransfer?.files?.[0];
    if (f) setFile(f);
  }

  // The verdict is in. Tick the last step with what it says, leave the
  // finished steps on screen for a moment, then show the result.
  async function showFileResult(j, sha, lookupFact = 'Report found') {
    const vt = j.data?.details?.virustotal;
    const engines = j.locked ? j.data?.enginesChecked : vt?.total;
    stepsApi.done('lookup', lookupFact);
    stepsApi.done('engines',
      !engines ? 'No engine data came back'
        : j.locked ? `${engines} engines answered`
        : `${engines} engines answered, ${vt.flagged ?? ((vt.malicious || 0) + (vt.suspicious || 0))} flagged it`,
      !!engines);
    await sleep(650);
    if (j.locked) {
      setLockedId(j.scanId || null);
      setLockedHash(j.sha256 || sha || null);
      setData({ ...j.data });
      setPhase('locked');
    } else {
      setData({ ...j.data });
      setPhase('done');
    }
  }
  useEffect(() => {
    if (phase === 'idle' || !resultRef.current) return;
    requestAnimationFrame(() => { try { resultRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch {} });
  }, [phase]);

  async function startScan() {
    if (!file) return;
    // Over the upload cap: check by fingerprint instead of rejecting the file.
    if (file.size > MAX_UPLOAD_BYTES) { await startHashLookup(); return; }
    try { document.activeElement && document.activeElement.blur(); } catch {}
    setError(null);
    setRevealed(false);
    setLockedId(null);
    stepsApi.plan(FILE_STEPS);
    setPhase('scanning');
    setTimeout(() => { try { resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch {} }, 50);
    try {
      const fd = new FormData();
      fd.append('file', file);
      const res = await fetch('/api/scan/file', { method: 'POST', credentials: 'include', body: fd });
      const j = await res.json();
      if (!j.success) {
        if (res.status === 402 || j.code === 'LIMIT_REACHED') {
          setError({ code: 'LIMIT_REACHED', message: j.error, scansUsed: j.scansUsed, scansLimit: j.scansLimit, receipt: j.receipt });
          setPhase('limit'); return;
        }
        setError(j.error || 'Scan failed');
        setPhase('error'); return;
      }
      if (j.sha256) stepsApi.done('hash', shortHash(j.sha256));
      if (j.notFound) { setData({ filename: file.name, fileSize: file.size, sha256: j.sha256 }); setPhase('notfound'); return; }
      if (j.pending) {
        // Fresh file — VirusTotal queued the analysis. Poll for the verdict
        // instead of holding one long request open (which timed out before).
        stepsApi.done('lookup', 'New file, fresh analysis queued');
        await pollForResult(j.analysisId, j.sha256, file.name, file.size);
        return;
      }
      await showFileResult(j, j.sha256, 'Known file, report found');
    } catch { setError('Network error — try again'); setPhase('error'); }
  }

  async function startHashLookup() {
    try { document.activeElement && document.activeElement.blur(); } catch {}
    setError(null);
    setRevealed(false);
    setLockedId(null);
    stepsApi.plan(FILE_STEPS);
    setPhase('scanning');
    setHashPct(0);
    track('large_file_check', { size_mb: Math.round(file.size / 1048576) });
    setTimeout(() => { try { resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch {} }, 50);
    let sha256;
    try {
      sha256 = await sha256OfFile(file, setHashPct);
    } catch (e) {
      setHashPct(null);
      setError('Could not read this file in your browser. Try again, or try a different browser.');
      setPhase('error');
      return;
    }
    setHashPct(null);
    stepsApi.done('hash', shortHash(sha256));
    try {
      const res = await fetch('/api/scan/file/hash', {
        method: 'POST',
        credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sha256, filename: file.name, size: file.size }),
      });
      const j = await res.json();
      if (!j.success) {
        if (res.status === 402 || j.code === 'LIMIT_REACHED') {
          setError({ code: 'LIMIT_REACHED', message: j.error, scansUsed: j.scansUsed, scansLimit: j.scansLimit, receipt: j.receipt });
          setPhase('limit'); return;
        }
        setError(j.error || 'Scan failed');
        setPhase('error'); return;
      }
      if (j.notFound) { setData({ filename: file.name, fileSize: file.size, sha256 }); setPhase('notfound'); return; }
      await showFileResult(j, sha256, 'Known file, report found');
    } catch { setError('Network error. Please try again.'); setPhase('error'); }
  }

  async function pollForResult(analysisId, sha256, filename, size, firstDelay = 5000) {
    const deadline = Date.now() + 5 * 60 * 1000; // give VT up to 5 minutes
    const qs = new URLSearchParams({
      analysisId: analysisId || '',
      sha256: sha256 || '',
      filename: filename || '',
      size: String(size || 0),
    }).toString();
    let delay = firstDelay;
    while (Date.now() < deadline) {
      await new Promise(r => setTimeout(r, delay));
      delay = 5000;
      try {
        const res = await fetch('/api/scan/file/result?' + qs, { credentials: 'include' });
        const j = await res.json();
        if (j.success && (j.locked || j.data)) { await showFileResult(j, sha256, analysisId ? 'Fresh analysis finished' : 'Report found'); return; }
        if (!j.success && !j.pending) { setError(j.error || 'Scan failed'); setPhase('error'); return; }
        // still pending → keep polling
      } catch { /* transient — keep polling */ }
    }
    setError('Scan is taking longer than usual. Please try again in a few minutes.');
    setPhase('error');
  }
  function newScan() { setPhase('idle'); setData(null); setError(null); setFile(null); setRevealed(false); setLockedId(null); }

  return (
    <>
      <div className="page-kicker">Malware Analysis</div>
      <h1 className="page-title">File <em>Scanner</em></h1>
      <p className="page-sub">Upload a file up to 1 GB. Try one scan free; Pro reveals the verdict and source findings.</p>

      <div className="card">
        <h3 className="card-title"><span className="ic">{I.file}</span> Upload File for Malware Scan</h3>
        <p className="card-sub">VirusTotal accepts content uploads up to 650 MB. For larger files, we look up an existing report by fingerprint. An unknown fingerprint has no verdict.</p>
        <input
          ref={fileInputRef}
          type="file"
          style={{display:'none'}}
          onChange={e => setFile(e.target.files?.[0] || null)}
        />
        <div
          className={`file-drop${dragOver ? ' is-drag' : ''}${file ? ' has-file' : ''}`}
          role="button"
          tabIndex={0}
          onClick={pickFile}
          onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pickFile(); } }}
          onDragOver={e => { e.preventDefault(); setDragOver(true); }}
          onDragLeave={() => setDragOver(false)}
          onDrop={onDrop}
        >
          <span className="file-drop-icon">{I.file}</span>
          {file ? (
            <>
              <span className="file-drop-name">{file.name}</span>
              <span className="file-drop-hint">{fmtBytes(file.size)}{file.size > MAX_UPLOAD_BYTES ? ' · checked by fingerprint, not uploaded' : ''} · click to choose a different file</span>
            </>
          ) : (
            <>
              <span className="file-drop-name">Drop a file here or click to browse</span>
              <span className="file-drop-hint">Executables, PDFs, documents, scripts, installers, any size</span>
            </>
          )}
        </div>
        <button
          className="run-btn file-run"
          onClick={startScan}
          disabled={phase === 'scanning' || !file}
        >
          {phase === 'scanning' ? 'Scanning…' : 'Run Scan'} →
        </button>
        <p className="privacy-note">
          Files up to 1 GB are uploaded temporarily to Scanify. Files up to 650 MB may be sent to VirusTotal and shared with its security community. Larger uploads use a fingerprint lookup. Avoid confidential files. <a href="/privacy">Privacy policy</a>
        </p>
      </div>

      <div ref={resultRef}>
        {phase === 'error' && (
          <div className="card" style={{borderColor:'#b8453d'}}>
            <h3 className="card-title" style={{color:'#b8453d'}}>{typeof error === 'string' ? error : (error?.message || 'Scan failed')}</h3>
          </div>
        )}

        {phase === 'limit' && <LimitReachedCard error={error} onUpgrade={onUpgrade} kind="file" resume={{ type: 'file', target: file?.name || '' }}/>}
        {phase === 'locked' && data && <LockedResultCard teaser={data} kind="file" onUpgrade={onUpgrade}
          resume={lockedHash ? { type: 'file', sha256: lockedHash, filename: data.target || file?.name || '', size: data.fileSize || file?.size || 0, teaser: data } : { type: 'file', teaser: data }}/>}

        {phase === 'scanning' && hashPct !== null && (
          <div className="card">
            <h3 className="card-title">Fingerprinting {file?.name} in your browser… {hashPct}%</h3>
            <p className="card-sub">Nothing is uploaded. Only the SHA-256 fingerprint is sent to check against VirusTotal.</p>
          </div>
        )}
        {phase === 'scanning' && hashPct === null && <ScanProgressCard steps={steps}/>}

        {phase === 'notfound' && data && (
          <div className="card">
            <h3 className="card-title">VirusTotal hasn't seen this exact file before</h3>
            <p className="card-sub">
              We checked the fingerprint of <span className="mono">{data.filename}</span> ({fmtBytes(data.fileSize)}) and VirusTotal returned no existing report.
              That's normal for personal files and new builds, but it also means there is no verdict to rely on. This didn't use one of your scans.
            </p>
            <p className="card-sub">
              If it's an installer, download it again from the developer's official site and compare. If someone sent it to you unexpectedly, don't open it.
            </p>
            <p className="card-sub mono" style={{wordBreak:'break-all', fontSize:11}}>SHA-256: {data.sha256}</p>
            <div className="actions">
              <button className="btn" onClick={newScan}><span className="ic">{I.back}</span> Check another file</button>
            </div>
          </div>
        )}
      </div>

      {phase === 'done' && data && (
        <div className={revealed ? 'unlock-reveal' : undefined}>
          {revealed && <UnlockedNote/>}
          {data.preview && (
            <div className="card" style={{borderColor:'#d4ccba'}}>
              <h3 className="card-title">Your one free scan has been used</h3>
              <button className="run-btn offer-btn" onClick={onUpgrade}>Get result {PRO_INTRO}</button>
              <p className="offer-terms">{PRO_TERMS}</p>
            </div>
          )}
          <VerdictCard
            target={data.filename}
            score={data.riskScore}
            scannedAt={data.scannedAt}
            engines={data.details?.virustotal?.totalEngines ? `${data.details.virustotal.totalEngines} engines` : '—'}
            protocol={fmtBytes(data.fileSize)}
            headline={data.riskScore < 5 ? 'File is clean' : 'Threats detected'}
            body={data.riskScore < 5 ? 'No engines flagged this file as malicious.' : 'One or more antivirus engines flagged this file. Do not execute.'}
          />
          <DetailsCard checks={detailsFromScan(data).checks}/>
          <UpduCard kind="file"/>
          <div className="actions">
            <button className="btn" onClick={newScan}><span className="ic">{I.back}</span> New Scan</button>
          </div>
        </div>
      )}
    </>
  );
}

// ====================================================================
// PAGE: IP / DOMAIN SCANNER
// ====================================================================
const IP_STEPS = [
  { key: 'engines', label: 'Security vendors' },
  { key: 'network', label: 'Network owner' },
];

function PageIP({ user, authReady, initialQuery, onScanConsumed, onUpgrade }) {
  const [target, setTarget] = useState('');
  const [phase, setPhase] = useState('idle');
  const [error, setError] = useState(null);
  const [data, setData] = useState(null);
  const [steps, stepsApi] = useScanSteps();
  const [lockedId, setLockedId] = useState(null);   // unlocks this exact result after paying
  const [revealed, setRevealed] = useState(false);  // the result just came out from behind the lock
  const deepLinkRef = useRef(false);
  const resultRef = useRef(null);
  useEffect(() => {
    if (phase === 'idle' || !resultRef.current) return;
    requestAnimationFrame(() => { try { resultRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch {} });
  }, [phase]);

  useEffect(() => {
    if (!initialQuery) return;
    setTarget(initialQuery);
  }, [initialQuery]);

  useEffect(() => {
    if (deepLinkRef.current) return;
    if (!initialQuery) return;
    if (!authReady) return;
    deepLinkRef.current = true;
    onScanConsumed?.();
    // No account needed: a signed-out visitor scans as a guest.
    setTimeout(() => startScan(initialQuery), 150);
  }, [authReady, user, initialQuery]);

  // Pro arrived while a result was waiting (they just paid in the checkout
  // sheet): bring out the result that was already looked up, or run the
  // lookup the free limit stopped.
  useEffect(() => {
    if (user?.plan !== 'pro') return;
    if (phase === 'limit') { startScan(); return; }
    if (phase !== 'locked') return;
    (async () => {
      const full = await fetchUnlocked(lockedId);
      if (full) { setRevealed(true); setData({ ...full }); setPhase('done'); }
      else startScan();
    })();
  }, [user?.plan]);

  const IPV4_RE = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/;

  async function startScan(overrideTarget) {
    const t = (overrideTarget ?? target).trim();
    if (!t) return;
    // This page is IPv4-only; catch a domain client-side with a helpful
    // message rather than round-tripping to the server for the same 400.
    if (!IPV4_RE.test(t)) {
      setError('That looks like a domain, not an IP address. Use the URL Scanner to check a domain.');
      setPhase('error');
      return;
    }
    try { document.activeElement && document.activeElement.blur(); } catch {}
    setError(null);
    setRevealed(false);
    setLockedId(null);
    stepsApi.plan(IP_STEPS);
    setPhase('scanning');
    setTimeout(() => { try { resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch {} }, 50);
    try {
      const { status, json: j } = await streamScan('/api/scan/ip-domain', { target: t }, {
        onPlan: stepsApi.plan,
        onStep: s => stepsApi.done(s.key, s.fact, s.ok),
      });
      if (!j.success) {
        if (status === 402 || j.code === 'LIMIT_REACHED') {
          setError({ code: 'LIMIT_REACHED', message: j.error, scansUsed: j.scansUsed, scansLimit: j.scansLimit, receipt: j.receipt });
          setPhase('limit'); return;
        }
        setError(j.error || (j.errors && j.errors[0]) || 'Scan failed');
        setPhase('error'); return;
      }
      if (j.locked) { setLockedId(j.scanId || null); setData({ ...j.data }); setPhase('locked'); return; }
      setData({ ...j.data, scansUsed: j.scansUsed, scansLimit: j.scansLimit });
      setPhase('done');
    } catch { setError('Network error — try again'); setPhase('error'); }
  }
  function newScan() { setPhase('idle'); setData(null); setError(null); setRevealed(false); setLockedId(null); }

  return (
    <>
      <div className="page-kicker">Network Intelligence</div>
      <h1 className="page-title">IP <em>Lookup</em></h1>
      <p className="page-sub">Check an IPv4 address for free. Pro reveals the reputation score and source findings. Looking up a domain? Use the <a href="/app" style={{color:'inherit', textDecoration:'underline'}}>URL Scanner</a> instead.</p>

      <div className="card">
        <h3 className="card-title"><span className="ic">{I.ip}</span> IP Address</h3>
        <p className="card-sub">Examples: 8.8.8.8 · 1.1.1.1</p>
        <div className="url-row">
          <span className="url-prefix-box">TARGET</span>
          <label className="url-input-box">
            <input
              type="text"
              value={target}
              onChange={e => setTarget(e.target.value)}
              onKeyDown={e => e.key === 'Enter' && phase !== 'scanning' && target.trim() && startScan()}
              placeholder="8.8.8.8"
              spellCheck={false}
            />
          </label>
          <button className="run-btn" onClick={() => startScan()} disabled={phase === 'scanning' || !target.trim()}>
            {phase === 'scanning' ? 'Scanning…' : 'Run Scan'} →
          </button>
        </div>
      </div>

      <div ref={resultRef}>
        {phase === 'error' && (
          <div className="card" style={{borderColor:'#b8453d'}}>
            <h3 className="card-title" style={{color:'#b8453d'}}>{typeof error === 'string' ? error : (error?.message || 'Scan failed')}</h3>
          </div>
        )}

        {phase === 'limit' && <LimitReachedCard error={error} onUpgrade={onUpgrade} kind="ip" resume={{ type: 'ip', q: target.trim(), target: target.trim() }}/>}
        {phase === 'locked' && data && <LockedResultCard teaser={data} kind="ip" onUpgrade={onUpgrade} resume={{ type: 'ip', q: target.trim(), teaser: data }}/>}

        {phase === 'scanning' && <ScanProgressCard steps={steps} title="Lookup in progress"/>}
      </div>

      {phase === 'done' && data && (
        <div className={revealed ? 'unlock-reveal' : undefined}>
          {revealed && <UnlockedNote/>}
          {data.preview && (
            <div className="card" style={{borderColor:'#d4ccba'}}>
              <h3 className="card-title">Free scan limit reached</h3>
              <button className="run-btn" onClick={onUpgrade}>Upgrade to Pro →</button>
            </div>
          )}
          <VerdictCard
            target={data.target || target}
            score={data.riskScore}
            scannedAt={data.scannedAt}
            engines={data.details?.virustotal?.totalEngines ? `${data.details.virustotal.totalEngines} engines` : '—'}
            protocol={data.type === 'ip' ? 'IPv4' : 'Domain'}
            headline={data.riskScore < 5 ? (data.incomplete ? 'Scan incomplete — inconclusive' : 'Target appears clean') : 'Suspicious indicators found'}
            body={data.riskScore < 5
              ? (data.incomplete ? 'Some intelligence sources were unavailable — do not treat this as a clean result. Re-run the scan in a minute.' : 'No threat intelligence sources flagged this target.')
              : 'Review the detailed results — some sources reported risk indicators.'}
          />
          <DetailsCard checks={detailsFromScan(data).checks}/>
          <UpduCard kind="ip"/>
          <div className="actions">
            <button className="btn" onClick={newScan}><span className="ic">{I.back}</span> New Scan</button>
          </div>
        </div>
      )}
    </>
  );
}

// ====================================================================
// PAGE: REPORTS
// ====================================================================
function PageReports({ user }) {
  return (
    <>
      <div className="page-kicker">Reports</div>
      <h1 className="page-title">Export <em>Reports</em></h1>
      <p className="page-sub">Download branded PDF security reports for any URL, file, or IP scan. Pro tier required.</p>

      <div className="card">
        <h3 className="card-title">PDF Security Reports</h3>
        <p className="card-sub">Run a scan first, then click "Download PDF Report" on the result. Reports include full risk breakdown, SSL analysis, domain age, IP geolocation, and methodology — ready to share with clients.</p>
        {user?.plan !== 'pro' && (
          <a className="run-btn" href="/pricing" style={{textDecoration:'none'}}>Upgrade to Pro to enable PDF exports →</a>
        )}
      </div>

      <div className="card">
        <h3 className="card-title">What's in a report</h3>
        <ul style={{margin:0, paddingLeft:20, color:'#6b7269', fontSize:13, lineHeight:1.7}}>
          <li>Executive risk summary &amp; verdict</li>
          <li>VirusTotal 95+ engine breakdown</li>
          <li>Google Safe Browsing &amp; phishing-registry checks</li>
          <li>SSL/TLS certificate inspection</li>
          <li>Domain registration &amp; WHOIS history</li>
          <li>IP geolocation &amp; reputation</li>
          <li>Live page screenshot</li>
          <li>Methodology &amp; confidence rating</li>
        </ul>
      </div>
    </>
  );
}

// ====================================================================
// PAGE: ABOUT
// ====================================================================
function PageAbout() {
  const tools = [
    { name: 'URL Scanner', desc: 'Real-time analysis across 95+ antivirus engines, Google Safe Browsing, phishing registries, SSL inspection, redirect chain tracking, and live page screenshot capture.' },
    { name: 'File Scanner', desc: 'Upload any file — executables, PDFs, Office documents, scripts — and scan it instantly for malware against the world’s largest antivirus coalition powered by VirusTotal.' },
    { name: 'IP & Domain Intelligence', desc: 'Look up any IPv4 address or domain for reputation history, threat classifications, geolocation data, and registration age — all sourced from VirusTotal intelligence.' },
  ];
  const why = [
    ['01', 'Free to scan, pay for the result', 'Run a URL, file or IP scan on the web or with the Chrome extension at no cost. Pro shows you every result, with PDF reports and no ads: $1 for your first month, then $20/month.'],
    ['02', 'Every scan shows its working', 'You see which sources were queried, what each one said, and which were unavailable. A source that fails is reported as unavailable, never counted as clean, so an incomplete scan is never mistaken for a safe one.'],
    ['03', '95+ AV engines', 'Powered by VirusTotal and Google Safe Browsing, plus our own sandboxed browser for live screenshots and additional threat signals. We aggregate results from dozens of sources so you get a complete picture — not just one opinion.'],
    ['04', 'See before you click', 'Every URL scan captures a live screenshot of the target page in our own sandboxed browser. Spot fake login screens, brand impersonation, and suspicious layouts without ever visiting the site.'],
  ];
  return (
    <>
      <div className="page-kicker">About Us</div>
      <h1 className="page-title">Built to protect <em>everyone</em> online</h1>
      <p className="page-sub">Scanify is a threat intelligence platform that gives anyone — security professionals and everyday internet users alike — the power to verify links, files, and IP addresses in seconds. Use the web app, our Chrome extension, or the REST API. Free to get started, no credit card required.</p>

      <div className="card">
        <h3 className="card-title">What we scan</h3>
        <div className="about-grid">
          {tools.map((t, i) => (
            <div key={i} className="about-tile">
              <div className="about-tile-name">{t.name}</div>
              <div className="about-tile-desc">{t.desc}</div>
            </div>
          ))}
        </div>
      </div>

      <div className="card">
        <h3 className="card-title">Why Scanify</h3>
        <div className="about-grid">
          {why.map(([n, label, text]) => (
            <div key={n} className="about-tile">
              <div className="about-tile-num">{n}</div>
              <div className="about-tile-name">{label}</div>
              <div className="about-tile-desc">{text}</div>
            </div>
          ))}
        </div>
      </div>

      <div className="card">
        <h3 className="card-title">A message from the creator</h3>
        <div style={{color:'var(--text-dim)', fontSize:13, lineHeight:1.7}}>
          <p style={{marginTop:0}}>I built Scanify because I was tired of security being something only certain people could afford or understand. Every day, regular people get hit by phishing links, fake websites, and malicious files — and most of them don’t have a team or a tool to turn to.</p>
          <p>So I made one. Scanify is simple on purpose. You paste a link, you get an answer. No jargon, no fine print. Just the truth about whether something is safe or not.</p>
          <p>Anyone can run a scan for free. Pro shows the full result, with the verdict, the risk score and every engine's answer, and the revenue from Pro is what keeps the scanners running. A security tool asks you to trust it, so every result shows its working: which sources were checked, what each one said, and what could not be checked.</p>
          <p style={{marginBottom:0}}>Stay safe out there.</p>
        </div>
        <div style={{display:'flex', alignItems:'center', gap:14, marginTop:18, paddingTop:18, borderTop:'1px solid var(--border)'}}>
          <img src="/karim.jpg" alt="Karim Al Anani" style={{width:48, height:48, borderRadius:'50%', objectFit:'cover'}}/>
          <div style={{flex:1}}>
            <div style={{fontWeight:600, fontSize:14}}>Karim Al Anani</div>
            <div style={{fontSize:12, color:'var(--text-dim)'}}>Creator · Scanify</div>
          </div>
          <a className="btn" href="https://www.linkedin.com/in/karim-al-anani-a94b622ba/" target="_blank" rel="noopener noreferrer">LinkedIn</a>
          <a className="btn" href="https://github.com/techkar1m" target="_blank" rel="noopener noreferrer">GitHub</a>
        </div>
      </div>
    </>
  );
}

// ====================================================================
// CancelSubscriptionCard — survey → win-back offer → pause option → cancel
function CancelSubscriptionCard() {
  // 'idle' | 'survey' | 'offer' | 'pause' | 'confirm' | 'loading' | 'done' | 'discounted' | 'paused'
  const [step, setStep] = useState('idle');
  const [error, setError] = useState(null);
  const [reason, setReason] = useState('');
  const [note, setNote] = useState('');
  const [endsAt, setEndsAt] = useState(null);
  const [pausedUntil, setPausedUntil] = useState(null);
  const [pauseMonths, setPauseMonths] = useState(1);
  const [offerAvailable, setOfferAvailable] = useState(true); // disabled if backend says no discount configured

  async function submitSurvey() {
    // Fire and forget — survey is optional, never blocks
    try {
      await fetch('/api/billing/cancel-survey', {
        method: 'POST', credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ reason, note }),
      });
    } catch {}
    setStep('offer');
  }

  async function acceptDiscount() {
    setStep('loading');
    setError(null);
    try {
      const r = await fetch('/api/billing/discount', { method: 'POST', credentials: 'include' });
      const j = await r.json();
      if (!j.success) {
        if (/not configured/i.test(j.error || '')) {
          setOfferAvailable(false);
          setStep('pause');
          return;
        }
        setError(j.error || 'Could not apply discount.');
        setStep('offer');
        return;
      }
      setStep('discounted');
    } catch {
      setError('Network error. Please try again.');
      setStep('offer');
    }
  }

  async function acceptPause() {
    setStep('loading');
    setError(null);
    try {
      const r = await fetch('/api/billing/pause', {
        method: 'POST', credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ months: pauseMonths }),
      });
      const j = await r.json();
      if (!j.success) {
        setError(j.error || 'Could not pause.');
        setStep('pause');
        return;
      }
      setPausedUntil(j.pausedUntil);
      setStep('paused');
    } catch {
      setError('Network error. Please try again.');
      setStep('pause');
    }
  }

  async function doCancel() {
    setStep('loading');
    setError(null);
    try {
      const r = await fetch('/api/billing/cancel', { method: 'POST', credentials: 'include' });
      const j = await r.json();
      if (!j.success) {
        setError(j.error || 'Could not cancel.');
        setStep('confirm');
        return;
      }
      setEndsAt(j.endsAt);
      setStep('done');
    } catch {
      setError('Network error. Please try again.');
      setStep('confirm');
    }
  }

  // Final states
  if (step === 'done') {
    return (
      <div className="card">
        <h3 className="card-title">Subscription cancelled</h3>
        <p className="card-sub">
          Your Pro plan will end{endsAt ? ` on ${new Date(endsAt).toLocaleDateString()}` : ' at the close of your current billing period'}.
          You keep full access until then. No further charges will be made.
        </p>
      </div>
    );
  }

  if (step === 'discounted') {
    return (
      <div className="card">
        <h3 className="card-title">Discount applied</h3>
        <p className="card-sub">Your next billing period will be 50% off. You'll keep all Pro features and your subscription continues as normal.</p>
      </div>
    );
  }

  if (step === 'paused') {
    return (
      <div className="card">
        <h3 className="card-title">Subscription paused</h3>
        <p className="card-sub">
          Pro will pause at the end of your current billing period and automatically resume{pausedUntil ? ` on ${new Date(pausedUntil).toLocaleDateString()}` : ' after the pause window'}.
          You won't be billed during the pause.
        </p>
      </div>
    );
  }

  // Step 1 — Survey (skippable)
  if (step === 'survey') {
    const reasons = [
      ['too_expensive', 'Too expensive'],
      ['not_using', 'Not using it enough'],
      ['found_alternative', 'Found an alternative'],
      ['missing_feature', 'Missing a feature I need'],
      ['other', 'Other'],
    ];
    return (
      <div className="card">
        <h3 className="card-title">Quick question — why are you cancelling?</h3>
        <p className="card-sub">Optional. Helps me improve Scanify.</p>
        <div style={{display:'flex', flexDirection:'column', gap:6, margin:'10px 0'}}>
          {reasons.map(([val, label]) => (
            <label key={val} style={{display:'flex', alignItems:'center', gap:8, fontSize:13, cursor:'pointer'}}>
              <input type="radio" name="cancel-reason" value={val} checked={reason === val} onChange={() => setReason(val)} />
              <span>{label}</span>
            </label>
          ))}
        </div>
        {reason === 'other' && (
          <textarea
            value={note}
            onChange={e => setNote(e.target.value)}
            placeholder="Tell us more (optional)…"
            style={{width:'100%', minHeight:60, padding:8, border:'1px solid #d4ccba', borderRadius:6, fontFamily:'inherit', fontSize:12, marginBottom:10}}
            maxLength={500}
          />
        )}
        <div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
          <button className="run-btn" onClick={() => setStep('idle')}>Keep Pro</button>
          <button className="btn" onClick={() => setStep('offer')}>Skip</button>
          <button className="btn" onClick={submitSurvey}>Continue →</button>
        </div>
      </div>
    );
  }

  // Step 2 — Win-back offer (skippable, only shown if backend has discount configured)
  if (step === 'offer') {
    return (
      <div className="card">
        <style>{`
          @keyframes ssStrike { to { width: 100%; } }
          @keyframes ssFadeIn { from { opacity: 0; } to { opacity: 1; } }
          @keyframes ssFadeSlide { from { opacity: 0; transform: translateX(-10px) scale(0.92); } to { opacity: 1; transform: translateX(0) scale(1); } }
          @keyframes ssPulseGreen {
            0% { transform: scale(1); }
            40% { transform: scale(1.08); }
            100% { transform: scale(1); }
          }
        `}</style>
        <h3 className="card-title">Wait — try one month at 50% off?</h3>
        {offerAvailable && (
          <div style={{
            display:'flex', alignItems:'center', justifyContent:'center', gap:14,
            margin:'18px 0 22px', fontSize:26, fontWeight:700,
            fontFamily:'JetBrains Mono, monospace',
          }}>
            <span style={{position:'relative', color:'#6b7269', display:'inline-block'}}>
              Full price
              <span style={{
                position:'absolute', top:'52%', left:0, height:3,
                background:'#b8453d', borderRadius:2, width:0,
                animation: 'ssStrike 0.9s ease-out 0.4s forwards',
              }}/>
            </span>
            <span style={{
              fontSize:20, color:'#6b7269', opacity:0,
              animation: 'ssFadeIn 0.4s ease-out 1.3s forwards',
            }}>→</span>
            <span style={{
              color:'#3a8a55', display:'inline-block', opacity:0,
              animation: 'ssFadeSlide 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) 1.5s forwards, ssPulseGreen 0.6s ease-out 2.1s',
            }}>50% off</span>
          </div>
        )}
        <p className="card-sub">{offerAvailable
          ? 'One-time discount on your next billing period. Cancels nothing — your subscription continues as normal at the discounted rate, then back to your normal price.'
          : 'No win-back offer available right now. Continue to pause or cancel options.'}</p>
        {error && <div style={{color:'#b8453d', fontSize:12, marginBottom:10}}>{error}</div>}
        <div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
          {offerAvailable && <button className="run-btn" onClick={acceptDiscount}>Yes, give me 50% off</button>}
          <button className="btn" onClick={() => setStep('pause')}>No thanks</button>
        </div>
      </div>
    );
  }

  // Step 3 — Pause option
  if (step === 'pause') {
    return (
      <div className="card">
        <h3 className="card-title">Pause instead of cancel?</h3>
        <p className="card-sub">Pause your subscription for 1–3 months — no billing during the pause, automatic resume after. Your account, history, and API keys stay exactly as they are.</p>
        <div style={{display:'flex', gap:8, margin:'10px 0', flexWrap:'wrap'}}>
          {[1, 2, 3].map(m => (
            <button
              key={m}
              className={`btn ${pauseMonths === m ? '' : ''}`}
              style={{borderColor: pauseMonths === m ? '#3a8a55' : undefined, color: pauseMonths === m ? '#3a8a55' : undefined}}
              onClick={() => setPauseMonths(m)}
            >{m} month{m > 1 ? 's' : ''}</button>
          ))}
        </div>
        {error && <div style={{color:'#b8453d', fontSize:12, marginBottom:10}}>{error}</div>}
        <div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
          <button className="run-btn" onClick={acceptPause}>Pause for {pauseMonths} month{pauseMonths > 1 ? 's' : ''}</button>
          <button className="btn" onClick={() => setStep('confirm')}>No, cancel anyway</button>
        </div>
      </div>
    );
  }

  // Step 4 — Final confirm
  if (step === 'confirm' || step === 'loading') {
    const loading = step === 'loading';
    return (
      <div className="card">
        <h3 className="card-title">Cancel Pro?</h3>
        <p className="card-sub">You'll lose unlimited scans, PDF security reports, priority speed, and the higher API allowance. You'll keep Pro access until the end of your current billing period — then drop to the Free plan.</p>
        {error && <div style={{color:'#b8453d', fontSize:12, marginBottom:10}}>{error}</div>}
        <div style={{display:'flex', gap:8, flexWrap:'wrap'}}>
          <button className="run-btn" onClick={() => setStep('idle')} disabled={loading}>Keep Pro</button>
          <button className="btn warn" onClick={doCancel} disabled={loading}>
            {loading ? 'Cancelling…' : 'Yes, cancel'}
          </button>
        </div>
      </div>
    );
  }

  // Step 0 — idle entry point
  return (
    <div className="card">
      <h3 className="card-title">Manage subscription</h3>
      <p className="card-sub">Cancellation takes effect at the end of your current billing period. You keep Pro access until then — no partial refunds, no further charges.</p>
      {error && <div style={{color:'#b8453d', fontSize:12, marginBottom:10}}>{error}</div>}
      <button className="btn" onClick={() => setStep('survey')}>Cancel subscription →</button>
    </div>
  );
}

// PAGE: ACCOUNT
// ====================================================================
function PageAccount({ user, onUpgrade, onSignOut, onSignIn }) {
  if (!user) {
    return (
      <>
        <div className="page-kicker">Account</div>
        <h1 className="page-title">Sign <em>In</em></h1>
        <p className="page-sub">Sign in with Google to track your scans, download PDF reports, and manage API keys.</p>
        <div className="card">
          <a href="/api/auth/google" className="signin-provider"><GoogleMark/> <span>Continue with Google</span></a>
        </div>
      </>
    );
  }
  return (
    <>
      <div className="page-kicker">Account</div>
      <h1 className="page-title">My <em>Account</em></h1>
      <p className="page-sub">Manage your plan, view usage, and download invoices.</p>

      <div className="card">
        <h3 className="card-title">Profile</h3>
        <div style={{display:'grid', gridTemplateColumns:'140px 1fr', gap:'10px 16px', fontSize:13}}>
          <span style={{color:'#6b7269'}}>Name</span><span className="notranslate">{user.name || '—'}</span>
          <span style={{color:'#6b7269'}}>Email</span><span className="notranslate">{user.email || '—'}</span>
          <span style={{color:'#6b7269'}}>Plan</span>
          <span>{user.plan === 'pro' ? <span className="pro-tag">PRO</span> : 'Free'}</span>
        </div>
      </div>

      {user.plan !== 'pro' && (
        <div className="card">
          <h3 className="card-title">Upgrade to Pro</h3>
          <p className="card-sub">Every scan result, downloadable PDF reports and priority speed. {PRO_INTRO} to start. {PRO_TERMS}</p>
          <button className="run-btn" onClick={onUpgrade}>Upgrade to Pro →</button>
        </div>
      )}

      {user.guest && (
        <div className="card">
          <h3 className="card-title">Keep Pro on every device</h3>
          <p className="card-sub">You paid without an account, so Pro lives in this browser. Link Google once and it follows you to your phone, another browser, or a new computer.</p>
          <a href={LINK_GOOGLE_HREF} className="signin-provider"><GoogleMark/> <span>Link Google</span></a>
        </div>
      )}

      {user.plan === 'pro' && (!user.guest || user.paid?.kind === 'subscription') && <CancelSubscriptionCard />}

      {!user.guest && (
        <div className="card">
          <h3 className="card-title">Sign out</h3>
          <button className="btn" onClick={onSignOut}>Sign out of Scanify</button>
        </div>
      )}
    </>
  );
}

// ====================================================================
// PAGE: SETTINGS (API keys)
// ====================================================================
function PageSettings({ user, apiEnabled }) {
  const [keys, setKeys] = useState([]);
  const [loading, setLoading] = useState(false);
  const [newName, setNewName] = useState('');
  const [showKey, setShowKey] = useState(null);
  const [error, setError] = useState(null);
  const [account, setAccount] = useState(null);

  const reload = useCallback(async () => {
    if (!user) return;
    setLoading(true);
    try {
      const r = await fetch('/api/keys', { credentials:'include' });
      const j = await r.json();
      if (!j.success) throw new Error(j.error || 'Could not load API keys.');
      setAccount(j);
      setKeys(j.keys || []);
    } catch (err) {
      setError(err.message);
    } finally { setLoading(false); }
  }, [user]);

  useEffect(() => { if (apiEnabled) reload(); }, [reload, apiEnabled]);

  // Public API not launched yet — keep the keys UI hidden.
  if (!apiEnabled) {
    return (
      <>
        <div className="page-kicker">Settings</div>
        <h1 className="page-title"><em>Settings</em></h1>
        <p className="page-sub">The Scanify API is currently unavailable.</p>
      </>
    );
  }

  async function createKey() {
    setError(null);
    const r = await fetch('/api/keys', {
      method: 'POST', credentials:'include',
      headers: {'Content-Type':'application/json'},
      body: JSON.stringify({ name: newName.trim() || 'My Key' }),
    });
    const j = await r.json();
    if (!j.success) { setError(j.error || 'Could not create key'); return; }
    setShowKey({ id: j.keyId, key: j.key });
    setNewName('');
    reload();
  }
  async function revoke(id) {
    if (!confirm('Revoke this key? Apps using it will stop working.')) return;
    const r = await fetch(`/api/keys/${id}`, { method:'DELETE', credentials:'include' });
    const j = await r.json();
    if (j.success) {
      if (showKey?.id === id) setShowKey(null);
      reload();
    } else { setError(j.error || 'Could not revoke key.'); }
  }

  if (!user) {
    return (
      <>
        <div className="page-kicker">Settings</div>
        <h1 className="page-title"><em>Settings</em></h1>
        <p className="page-sub">Sign in to manage API keys.</p>
        <div className="card">
          <a href="/api/auth/google?returnTo=%2Fapp%23%2Fsettings" className="signin-provider"><GoogleMark/> <span>Continue with Google</span></a>
        </div>
      </>
    );
  }

  return (
    <>
      <div className="page-kicker">Settings</div>
      <h1 className="page-title">API <em>Keys</em></h1>
      <p className="page-sub">{account ? `${account.usage.used.toLocaleString()} of ${account.limits.monthly.toLocaleString()} monthly checks used across all your keys. ${account.limits.perMinute} requests per minute per key.` : 'Manage keys for the Scanify API.'}</p>

      <div className="card">
        <h3 className="card-title">Create new key</h3>
        <div className="url-row">
          <label className="url-input-box">
            <input type="text" value={newName} onChange={e => setNewName(e.target.value)} placeholder="Key name (e.g. Production)" />
          </label>
          <button className="run-btn" onClick={createKey} disabled={loading || !account || keys.length >= account.limits.keys}>Generate Key →</button>
        </div>
        {error && <div style={{color:'#b8453d', fontSize:12, marginTop:8}}>{error}</div>}
        {showKey && (
          <div style={{background:'#f3efe8', border:'1px solid #d4ccba', borderRadius:8, padding:12, marginTop:12, fontFamily:'JetBrains Mono, monospace', fontSize:11, wordBreak:'break-all'}}>
            <div style={{fontSize:11, color:'#6b7269', marginBottom:6}}>Save this key — it will not be shown again:</div>
            <div>{showKey.key}</div>
            <button className="btn" style={{marginTop:8}} onClick={() => { navigator.clipboard.writeText(showKey.key); }}><span className="ic">{I.copy}</span> Copy</button>
          </div>
        )}
      </div>

      <div className="card">
        <h3 className="card-title">Your keys</h3>
        {loading ? <div style={{fontSize:12, color:'#6b7269'}}>Loading…</div>
          : keys.length === 0 ? <div style={{fontSize:13, color:'#6b7269'}}>No keys yet. Create one above.</div>
          : (
            <div className="recents">
              {keys.map(k => (
                <div key={k.keyId} className="recents-row" style={{gridTemplateColumns:'1fr auto auto auto'}}>
                  <span>
                    <div style={{fontWeight:500, fontSize:13}}>{k.name}</div>
                    <div style={{fontFamily:'JetBrains Mono, monospace', fontSize:10, color:'#6b7269'}}>{k.hint || k.keyId}</div>
                  </span>
                  <span style={{fontSize:11, color:'#6b7269'}}>Created {new Date(k.createdAt).toLocaleDateString()}</span>
                  <span style={{fontSize:11, color:'#6b7269'}}>{k.lastUsedAt ? 'Used ' + new Date(k.lastUsedAt).toLocaleDateString() : 'Unused'}</span>
                  <button className="btn warn" onClick={() => revoke(k.keyId)}><span className="ic">{I.trash}</span> Revoke</button>
                </div>
              ))}
            </div>
          )}
      </div>

      <div className="card">
        <h3 className="card-title">API Documentation</h3>
        <p className="card-sub"><a href="/developers">Developer guide and examples</a> · <a href="/api/v1/openapi.json">OpenAPI specification</a></p>
      </div>
    </>
  );
}

// ====================================================================
// MAIN APP
// ====================================================================
// ====================================================================
// CHECKOUT SHEET
// ====================================================================
// Payment happens here, on top of the result it unlocks: a sheet that slides
// up on a phone and a two-column dialog on a wider screen, with Paddle's
// inline checkout inside it. No sign-in comes first. A signed-out visitor
// pays as a guest and the payment is claimed for this browser afterwards
// (POST /api/billing/claim).

// A payment made as a guest that has not been turned into Pro yet. Kept so a
// closed tab or a dropped connection cannot strand someone who has paid.
const PENDING_CLAIM_KEY = 'scanifyPendingClaim';

// 'ok' = Pro is active; 'no' = this payment cannot be claimed here;
// 'retry' = Paddle has not settled it yet, try again later.
async function claimPayment(txn, tries) {
  if (!txn) return 'no';
  for (let i = 0; i < tries; i++) {
    try {
      const r = await fetch('/api/billing/claim', {
        method: 'POST', credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ transactionId: txn }),
      });
      const j = await r.json().catch(() => ({}));
      if (j.success) return j.plan === 'pro' ? 'ok' : 'no';
      if ([400, 401, 403, 422].includes(r.status)) return 'no';
    } catch {}
    if (i < tries - 1) await sleep(1500);
  }
  return 'retry';
}

function money(amount, currency) {
  try { return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency || 'USD' }).format(Number(amount) || 0); }
  catch { return '$' + (Number(amount) || 0).toFixed(2); }
}

function CheckoutSheet({ open, kind, resume, user, guestId, cfg, payState, onKind, onGuestId, onPaid, onClose }) {
  const [loaded, setLoaded] = useState(false);
  const [totals, setTotals] = useState(null);
  const [problem, setProblem] = useState(false);
  const dialogRef = useRef(null);
  const frameRef = useRef(null);
  const paidRef = useRef(false);
  const busy = payState !== 'idle';
  // The Black Friday code only exists in the live Paddle account.
  const dealOn = launchOfferOn() && cfg?.env !== 'sandbox';

  React.useLayoutEffect(() => {
    if (!open) return;
    const dialog = dialogRef.current;
    const trigger = document.activeElement;
    const scrollX = window.scrollX;
    const scrollY = window.scrollY;
    const bodyStyle = document.body.style;
    const savedBody = { position: bodyStyle.position, top: bodyStyle.top, left: bodyStyle.left, width: bodyStyle.width, overflow: bodyStyle.overflow };
    const savedRootOverflow = document.documentElement.style.overflow;
    dialog.showModal();
    document.documentElement.style.overflow = 'hidden';
    Object.assign(bodyStyle, { position: 'fixed', top: -scrollY + 'px', left: -scrollX + 'px', width: '100%', overflow: 'hidden' });
    return () => {
      dialog.close();
      Object.assign(bodyStyle, savedBody);
      document.documentElement.style.overflow = savedRootOverflow;
      window.scrollTo({ left: scrollX, top: scrollY, behavior: 'instant' });
      if (trigger?.isConnected) trigger.focus({ preventScroll: true });
    };
  }, [open]);

  // Follow Paddle: when the form is on screen, what it will charge, when it is paid.
  useEffect(() => {
    if (!open) return;
    const onEvent = (e) => {
      const ev = e.detail || {};
      const d = ev.data || {};
      if (ev.name === 'checkout.loaded') setLoaded(true);
      if (d.totals) setTotals({ ...d.totals, currency: d.currency_code, recurring: d.recurring_totals || null });
      if (ev.name === 'checkout.error') setProblem(true);
      if (ev.name === 'checkout.completed' && !paidRef.current) { paidRef.current = true; onPaid(d); }
    };
    window.addEventListener('scanify:paddle', onEvent);
    return () => window.removeEventListener('scanify:paddle', onEvent);
  }, [open, onPaid]);

  // Put Paddle's checkout in the frame, again whenever the plan changes.
  useEffect(() => {
    if (!open) { try { window.Paddle?.Checkout?.close(); } catch {} return; }
    let cancelled = false;
    paidRef.current = false;
    setLoaded(false); setTotals(null); setProblem(false);
    (async () => {
      for (let i = 0; i < 50 && !window._paddleReady; i++) await sleep(100);
      if (cancelled) return;
      const priceId = cfg && (kind === 'lifetime' ? cfg.lifetimePriceId : kind === 'pass' ? cfg.passPriceId : cfg.priceId);
      if (!window._paddleReady || !priceId) { setProblem(true); return; }

      const signedIn = !!user && !user.guest;
      let gid = user?.guest ? user.id : guestId;
      if (!signedIn && !gid) {
        // Someone who opens the checkout before ever scanning has no guest id yet.
        try {
          const j = await (await fetch('/api/auth/guest', { method: 'POST', credentials: 'include' })).json();
          gid = j.guestId || null;
          if (gid) onGuestId(gid);
        } catch {}
      }
      if (cancelled) return;
      if (!signedIn && !gid) { setProblem(true); return; }

      try { window.Paddle.Checkout.close(); } catch {}
      if (frameRef.current) frameRef.current.innerHTML = '';
      // The $1 intro belongs to the monthly plan; the Black Friday code to lifetime.
      const discount = kind === 'monthly' ? (cfg.discountId ? { discountId: cfg.discountId } : {})
        : kind === 'lifetime' && dealOn ? { discountCode: 'BLACKFRIDAY' } : {};
      try {
        window.Paddle.Checkout.open({
          items: [{ priceId, quantity: 1 }],
          ...discount,
          ...(signedIn && user.email ? { customer: { email: user.email } } : {}),
          // This is what ties the payment to this browser (guest) or account.
          customData: signedIn ? { userId: user.id, email: user.email } : { guestId: gid },
          settings: {
            displayMode: 'inline',
            variant: 'one-page',
            frameTarget: 'cs-frame',
            frameInitialHeight: 450,
            frameStyle: 'width:100%;min-width:286px;background-color:transparent;border:none;',
            allowLogout: !signedIn,
          },
        });
      } catch { setProblem(true); }
    })();
    return () => { cancelled = true; };
    // `!!cfg`: opened before the price ids arrived, it starts again once they do.
  }, [open, kind, !!cfg]);

  if (!open) return null;

  const teaser = resume?.teaser || null;
  const target = teaser?.target || resume?.target || resume?.q || resume?.filename || '';
  const plans = [
    { kind: 'monthly', title: 'Monthly', price: PRO_INTRO, terms: `First month ${PRO_INTRO}, then ${PRO_PRICE}. Cancel anytime.` },
    { kind: 'pass', title: '7-day pass', price: PASS_PRICE, terms: 'One payment for 7 days. No renewal.' },
    { kind: 'lifetime', title: 'Lifetime', price: dealOn ? LIFETIME_DEAL_PRICE : LIFETIME_PRICE,
      terms: dealOn ? `One payment for life. Usually ${LIFETIME_PRICE}. Offer ends Dec 9.` : 'One payment for life. No renewal.' },
  ];
  const selected = plans.find(plan => plan.kind === kind) || plans[0];
  const due = totals?.total != null ? money(totals.total, totals.currency) : null;
  const renewal = kind === 'monthly' && totals?.recurring?.total != null
    ? `Then ${money(totals.recurring.total, totals.currency)} per month. Cancel anytime.`
    : selected.terms;

  return ReactDOM.createPortal(
    <dialog ref={dialogRef} className="cs-sheet" aria-labelledby={busy ? 'cs-status-title' : 'cs-title'}
      onCancel={e => { e.preventDefault(); onClose(); }}
      onClick={e => {
        if (e.target !== e.currentTarget) return;
        const box = e.currentTarget.getBoundingClientRect();
        if (e.clientX < box.left || e.clientX > box.right || e.clientY < box.top || e.clientY > box.bottom) onClose();
      }}>
      <div className="cs-bar">
        <span className="cs-brand"><img src="/assets/scanify-mark.png" width="24" height="24" alt=""/>Scanify</span>
        <span className="cs-bar-label">Checkout</span>
        <button type="button" className="cs-close" onClick={onClose} disabled={payState === 'activating' || payState === 'done'} aria-label="Close checkout">×</button>
      </div>
      <div className="cs-grid" hidden={busy}>
        <div className="cs-summary">
          <ReportSummary teaser={teaser} type={resume?.type} target={target}/>
          <div className="cs-included">
            <h3>Inside your report</h3>
            <ul>
              <li>{I.eye}<span>Verdict and risk score</span></li>
              <li>{I.search}<span>Findings from each source</span></li>
              <li>{I.download}<span>{resume?.type === 'file' || resume?.type === 'ip' ? 'PDF export for URL scans' : 'Downloadable PDF report'}</span></li>
            </ul>
          </div>
        </div>
        <section className="cs-pay" aria-label="Checkout details">
          <h2 id="cs-title">{target ? 'Unlock your report' : 'Get Scanify Pro'}</h2>
          <fieldset className="cs-plans" disabled={busy}>
            <legend>Choose your plan</legend>
            {plans.map(plan => (
              <label className="cs-plan" key={plan.kind}>
                <input type="radio" name="scanify-plan" checked={kind === plan.kind}
                  onChange={() => { track('checkout_plan_switch', { plan: plan.kind }); onKind(plan.kind); }}/>
                <span className="cs-plan-name">{plan.title}</span>
                <strong>{plan.price}</strong>
              </label>
            ))}
          </fieldset>
          <div className="cs-order" aria-live="polite">
            {totals && <div className="cs-breakdown">
              <span>Subtotal <b>{money(totals.subtotal, totals.currency)}</b></span>
              {Number(totals.discount) > 0 && <span>Discount <b>−{money(totals.discount, totals.currency)}</b></span>}
              <span>Tax <b>{money(totals.tax, totals.currency)}</b></span>
            </div>}
            <div className="cs-due"><span>Due today{totals?.currency && ` (${totals.currency})`}</span><strong>{due || 'Calculating…'}</strong></div>
            <p>{renewal}</p>
          </div>
          <div className="cs-payment-area">
            <div className="cs-frame" ref={frameRef}/>
            {!loaded && !problem && <div className="cs-state" role="status"><span className="cs-spinner"/>Loading secure checkout…</div>}
            {problem && <div className="cs-state" role="status">
              <span>We couldn’t load checkout here.</span><a className="btn" href={'/checkout?plan=' + kind}>Open the checkout page</a>
            </div>}
          </div>
          <p className="cs-trust">{I.shield} Secure checkout by Paddle · <a href="/refund" target="_blank" rel="noopener">Refund policy</a></p>
        </section>
      </div>
        {busy && (
          <div className={`cs-activating ${payState}`} role="status" aria-live="assertive">
            {payState === 'slow' ? (
              <>
                <h3 id="cs-status-title">Payment received</h3>
                <p>We’re still checking your payment. Reload in a minute to check your access.</p>
                <button type="button" className="btn" onClick={() => location.reload()}>Reload now</button>
              </>
            ) : (
              <>
                <span className={`cs-check${payState === 'done' ? ' done' : ''}`} aria-hidden="true">
                  <svg viewBox="0 0 52 52" width="64" height="64"><circle cx="26" cy="26" r="23" fill="none"/><path d="M15 27l8 8 15-17" fill="none"/></svg>
                </span>
                <h3 id="cs-status-title">{payState === 'done' ? 'You have Pro' : 'Payment received'}</h3>
                <p>{payState === 'done' ? 'Opening your result…' : 'Unlocking your result…'}</p>
              </>
            )}
          </div>
        )}
    </dialog>,
    document.body
  );
}

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "globeSpeed": 1.0,
  "whirlSpeed": 1.0,
  "showWhirl": true,
  "scansLive": 1284
}/*EDITMODE-END*/;

function App() {
  const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const toolEmbed = useRef(new URLSearchParams(location.search).get('toolCheckout') === '1').current;
  const toolCheckoutStarted = useRef(false);

  // Parse deep-link query params + sessionStorage (post-OAuth) on first mount.
  // Priority: URL ?q= overrides sessionStorage so a fresh landing-page submit
  // always wins over a stale stored value.
  const initialDeepLink = (() => {
    try {
      const params = new URLSearchParams(location.search);
      const q = params.get('q');
      const type = params.get('type');
      // File scans have no query string — open the upload tab directly.
      if (type === 'file') return { type: 'file', q: '' };
      if (q) return { type: type === 'ip' ? 'ip' : 'url', q };
      // Back from Paddle checkout: resume the scan the limit wall blocked.
      // Separate key from pendingScan so an abandoned checkout never auto-runs.
      if (params.get('payment') === 'success') {
        const paid = sessionStorage.getItem('paidResume');
        sessionStorage.removeItem('paidResume');
        const pr = paid ? JSON.parse(paid) : null;
        if (pr && (pr.q || pr.type === 'file')) {
          const t = pr.type === 'ip' ? 'ip' : pr.type === 'file' ? 'file' : 'url';
          return { type: t, q: pr.q || '', file: t === 'file' && pr.sha256 ? { sha256: pr.sha256, filename: pr.filename, size: pr.size } : null };
        }
      }
      return null;
    } catch { return null; }
  })();

  const [page, setPage] = useState(() => {
    const m = location.hash.match(/^#\/(\w+)/);
    if (m) return m[1];
    if (initialDeepLink) return initialDeepLink.type;
    return 'url';
  });
  const [pendingQuery, setPendingQuery] = useState(initialDeepLink?.q || '');
  const [resumeFile, setResumeFile] = useState(initialDeepLink?.file || null);
  const [user, setUser] = useState(null);
  const [guestId, setGuestId] = useState(null);       // this browser's id while nobody is signed in
  const [authReady, setAuthReady] = useState(false);
  const [apiEnabled, setApiEnabled] = useState(false); // public API + key UI gated until launch
  const [billingCfg, setBillingCfg] = useState(null);  // price ids from /api/billing/config
  const [checkout, setCheckout] = useState(null);      // { kind, resume } while the checkout sheet is open
  const [payState, setPayState] = useState('idle');    // idle | activating | done | slow
  const [linkNudge, setLinkNudge] = useState(false);   // "link Google" banner for someone who paid as a guest

  // Strip ?q= and ?type= from address bar after consuming them
  useEffect(() => {
    if (!initialDeepLink) return;
    const u = new URL(location.href);
    u.searchParams.delete('q');
    u.searchParams.delete('type');
    // Keep any other params (e.g. ?payment=success) for the effects that need them.
    window.history.replaceState({}, '', u.pathname + u.search + u.hash);
  }, []);

  // The session JWT carries `plan`, and nothing re-issued it after a payment,
  // so a new subscriber stayed "free" until they signed in again. On return
  // from checkout, poll /refresh until Paddle's webhook has granted Pro (it
  // usually lands within seconds), THEN load the user, so the resumed scan
  // runs as Pro instead of hitting the limit wall again.
  const [activating, setActivating] = useState(false);
  // Read once, during the first render, before any effect can rewrite the URL.
  const paidReturn = useRef(new URLSearchParams(location.search).get('payment') === 'success').current;
  useEffect(() => {
    (async () => {
      if (paidReturn) {
        setActivating(true);
        for (let i = 0; i < 20; i++) {
          try {
            const r = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
            const j = await r.json();
            if (j.plan === 'pro' || r.status === 401) break;
          } catch {}
          await new Promise(res => setTimeout(res, 1500));
        }
        try {
          const u = new URL(location.href);
          u.searchParams.delete('payment');
          history.replaceState(null, '', u.pathname + u.search + u.hash);
        } catch {}
        setActivating(false);
      }
      // A payment whose activation never finished (tab closed, network
      // dropped): finish it now, before deciding who this visitor is.
      try {
        const pending = localStorage.getItem(PENDING_CLAIM_KEY);
        if (pending && (await claimPayment(pending, 3)) !== 'retry') localStorage.removeItem(PENDING_CLAIM_KEY);
      } catch {}
      try {
        const d = await fetchMe();
        applyMe(d);
        // Pro is sold as tracker-free: only load GA for signed-out and free users.
        if (d.user?.plan !== 'pro') window.loadAnalytics?.();
      } catch { window.loadAnalytics?.(); }
      setAuthReady(true);
    })();
  }, []);

  const fetchMe = async () => (await fetch('/api/auth/me', { credentials: 'include' })).json();
  function applyMe(d) {
    setUser(d.user || null);
    setGuestId(d.guest?.id || null);
    setApiEnabled(!!d.apiEnabled);
    let dismissed = false;
    try { dismissed = localStorage.getItem('linkGoogleDismissed') === '1'; } catch {}
    setLinkNudge(!!d.user?.guest && d.user.plan === 'pro' && !dismissed);
  }

  useEffect(() => {
    location.hash = '#/' + page;
    window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
  }, [page]);

  async function signOut() {
    try { await fetch('/api/auth/logout', { method:'POST', credentials:'include' }); } catch {}
    setUser(null);
    setPage('url');
  }
  // Load the price ids, then start Paddle as soon as its SDK is on the page,
  // so the checkout sheet opens straight away on the first click. Every
  // Paddle event is re-broadcast as a window event for the sheet to follow.
  useEffect(() => {
    let stopped = false;
    let timer = null;
    (async () => {
      let cfg = null;
      try { cfg = await (await fetch('/api/billing/config')).json(); } catch {}
      if (stopped) return;
      if (cfg?.priceId) setBillingCfg(cfg);
      // A staging deploy runs Paddle in sandbox with its own test token. If
      // that token is missing, do not start Paddle at all: falling back to
      // the live token would take real money on a test site.
      const sandbox = cfg?.env === 'sandbox';
      if (sandbox && !cfg.clientToken) return;
      const tryInit = () => {
        if (window._paddleReady) return true;
        if (!window.Paddle) return false;
        try {
          if (sandbox) window.Paddle.Environment.set('sandbox');
          window.Paddle.Initialize({
            token: sandbox ? cfg.clientToken : 'live_31ff19e7924a12ddca9638a8994',
            eventCallback: (ev) => {
              const d = ev?.data || {};
              // Funnel visibility in GA4: checkout opened → purchase.
              const value = Number(d.totals?.total) || undefined;
              if (ev?.name === 'checkout.loaded') track('begin_checkout', { currency: d.currency_code, value });
              if (ev?.name === 'checkout.completed') track('purchase', { transaction_id: d.transaction_id, currency: d.currency_code, value });
              try { window.dispatchEvent(new CustomEvent('scanify:paddle', { detail: ev })); } catch {}
            },
          });
          window._paddleReady = true;
          return true;
        } catch { return false; }
      };
      if (tryInit()) return;
      timer = setInterval(() => { if (stopped || tryInit()) clearInterval(timer); }, 150);
      setTimeout(() => clearInterval(timer), 10000);
    })();
    return () => { stopped = true; if (timer) clearInterval(timer); };
  }, []);

  useEffect(() => {
    if (!toolEmbed || !authReady || toolCheckoutStarted.current) return;
    toolCheckoutStarted.current = true;
    if (user?.plan === 'pro') {
      window.parent.postMessage({ type: 'scanify-tool-paid' }, location.origin);
      return;
    }
    let teaser = null;
    try { teaser = JSON.parse(sessionStorage.getItem('toolCheckoutTeaser') || 'null'); } catch {}
    setCheckout({ kind: 'monthly', resume: teaser ? { type: 'url', target: teaser.target, teaser } : null });
  }, [toolEmbed, authReady, user?.plan]);

  // Opens the checkout sheet over whatever the visitor is looking at. Nobody
  // is sent to sign in first: a signed-out visitor pays as a guest.
  // kind: 'monthly' (default, $1 first month), 'pass' ($5 / 7 days) or 'lifetime' ($29).
  function startUpgrade(resume, kind) {
    if (!authReady) return;
    if (kind !== 'pass' && kind !== 'lifetime') kind = 'monthly';
    // A pass holder can still move up to a subscription or lifetime.
    if (user?.plan === 'pro' && !user.proUntil) return;
    // Called straight from onClick elsewhere, so `resume` may be a click event
    // (whose `type` is "click"): only a real scan reference is kept.
    const scan = resume && ['url', 'file', 'ip'].includes(resume.type) ? resume : null;
    setCheckout({ kind, resume: scan });
  }

  // Paddle says the payment went through. Turn it into Pro on this browser,
  // then let go of the sheet so the page underneath can bring out the result.
  async function completePurchase(d) {
    const txn = d?.transaction_id || '';
    const viaClaim = !user || user.guest; // no Google session: claim the payment itself
    setPayState('activating');
    if (viaClaim && txn) { try { localStorage.setItem(PENDING_CLAIM_KEY, txn); } catch {} }
    let ok = false;
    if (viaClaim) {
      const outcome = await claimPayment(txn, 24);
      ok = outcome === 'ok';
      if (outcome !== 'retry') { try { localStorage.removeItem(PENDING_CLAIM_KEY); } catch {} }
    } else {
      // A signed-in buyer gets Pro from Paddle's webhook, usually within seconds.
      for (let i = 0; i < 24 && !ok; i++) {
        try {
          const r = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
          const j = await r.json();
          if (j.plan === 'pro') { ok = true; break; }
          if (r.status === 401) break;
        } catch {}
        await sleep(1500);
      }
    }
    if (!ok) { setPayState('slow'); return; }
    let me = null;
    try { me = await fetchMe(); } catch {}
    setPayState('done');
    await sleep(1100);
    setCheckout(null);
    setPayState('idle');
    // Only now does the page learn about Pro, so the result is revealed in
    // view rather than behind the closing sheet.
    await sleep(120);
    if (me) applyMe(me);
    if (toolEmbed) window.parent.postMessage({ type: 'scanify-tool-paid' }, location.origin);
    else location.reload();
  }

  function closeCheckout() {
    if (payState === 'activating' || payState === 'done') return;
    setCheckout(null);
    setPayState('idle');
    if (toolEmbed) window.parent.postMessage({ type: 'scanify-tool-close' }, location.origin);
  }

  const Page = {
    url:      <PageURL      user={user} authReady={authReady} initialQuery={page === 'url' ? pendingQuery : ''} onScanConsumed={() => setPendingQuery('')} onUpgrade={startUpgrade}/>,
    file:     <PageFile     user={user} authReady={authReady} onUpgrade={startUpgrade} resumeFile={resumeFile} onResumeConsumed={() => setResumeFile(null)}/>,
    ip:       <PageIP       user={user} authReady={authReady} initialQuery={page === 'ip' ? pendingQuery : ''} onScanConsumed={() => setPendingQuery('')} onUpgrade={startUpgrade}/>,
    reports:  <PageReports  user={user}/>,
    account:  <PageAccount  user={user} onUpgrade={startUpgrade} onSignOut={signOut}/>,
    settings: <PageSettings user={user} apiEnabled={apiEnabled}/>,
    about:    <PageAbout/>,
  }[page] || <PageURL user={user} onUpgrade={startUpgrade}/>;

  return (
    <>
      <TopBar user={user} onSignOut={signOut} onNav={setPage}/>
      {activating && <div className="activating-banner">Payment received. Activating Pro and loading your result…</div>}
      {linkNudge && (
        <div className="link-banner" role="status">
          <span><strong>You're Pro on this browser.</strong> Link Google to keep it on every device.</span>
          <a className="link-banner-cta" href={LINK_GOOGLE_HREF}><GoogleMark/> Link Google</a>
          <button type="button" className="link-banner-x" aria-label="Dismiss"
            onClick={() => { setLinkNudge(false); try { localStorage.setItem('linkGoogleDismissed', '1'); } catch {} }}>×</button>
        </div>
      )}
      <CheckoutSheet
        open={!!checkout}
        kind={checkout?.kind || 'monthly'}
        resume={checkout?.resume || null}
        user={user}
        guestId={guestId}
        cfg={billingCfg}
        payState={payState}
        onKind={kind => setCheckout(c => (c ? { ...c, kind } : c))}
        onGuestId={setGuestId}
        onPaid={completePurchase}
        onClose={closeCheckout}
      />
      <div className="shell">
        <Sidebar active={page} onNav={setPage} apiEnabled={apiEnabled}/>
        <div className="content-wrap">
          <BackgroundCanvas/>
          <div className="content">
            <div className="content-main">{Page}</div>

            <aside className="right-rail">
              <div className="rail-globe-card">
                <div className="label">Network online</div>
                <GlobeLoader size={200} speed={tweaks.globeSpeed} paused={false}/>
                <div className="target">{user && (user.name || user.email) ? <>Hi <span className="notranslate">{(user.name || user.email).split(' ')[0]}</span></> : user ? 'Pro member' : 'No sign-in needed'}</div>
              </div>
              {tweaks.showWhirl && (
                <div className="rail-whirl">
                  <ScanWhirl size={240} speed={tweaks.whirlSpeed} paused={false}/>

                </div>
              )}
            </aside>
          </div>
        </div>
      </div>

      <TweaksPanel title="Tweaks" defaultPosition={{ right: 24, bottom: 24 }}>
        <TweakSection title="Right rail">
          <TweakSlider label="Globe spin speed" min={0.2} max={3} step={0.1}
            value={tweaks.globeSpeed} onChange={v => setTweak('globeSpeed', v)}/>
          <TweakSlider label="Whirl speed" min={0.2} max={3} step={0.1}
            value={tweaks.whirlSpeed} onChange={v => setTweak('whirlSpeed', v)}/>
          <TweakToggle label="Show scan whirl" value={tweaks.showWhirl}
            onChange={v => setTweak('showWhirl', v)}/>
          <TweakNumber label="Live scan counter" value={tweaks.scansLive}
            onChange={v => setTweak('scansLive', v)}/>
        </TweakSection>
      </TweaksPanel>
    </>
  );
}

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