// Upload / Excavation page — wired to real AWS APIs
// Design preserved exactly from design system output.
// Only data plumbing changed: mock timers → real XHR + API calls.

// ── API endpoint ──────────────────────────────────────────────────────────
// Update this to your actual API Gateway URL after Step 8 (CloudFront wiring).
// During development, use the raw API Gateway URL directly.
const API_BASE = 'https://r5vefiej3l.execute-api.eu-central-1.amazonaws.com/prod';

// ── Helpers ───────────────────────────────────────────────────────────────

function _fireScanNotifications(job_id, elapsed_s, data) {
  const prefs = (() => {
    try { return JSON.parse(localStorage.getItem('fossyl_notifs') || '{}'); } catch { return {}; }
  })();
  const token  = window.getIdToken ? window.getIdToken() : '';
  const post   = (path, body) => fetch(`${API_BASE}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
    body: JSON.stringify(body),
  }).catch(() => {});

  // ── Scan completed ──────────────────────────────────────────────────────
  if (prefs.scanApp !== false && window.__fossylToast) {
    window.__fossylToast('Excavation complete — report ready.', { tone: 'success', duration: 6000 });
  }
  if (prefs.scanEmail !== false) {
    post('/account/notify/scan', { job_id, status: 'done', duration_s: Math.round(elapsed_s) });
  }

  // ── High-risk artifacts (TTW count > 0) ────────────────────────────────
  const ttw = data?.ttw_count || 0;
  const div = data?.diverged_count || 0;
  if (ttw > 0 || div > 0) {
    if (prefs.riskApp !== false && window.__fossylToast) {
      window.__fossylToast(
        `${ttw} TTW artifact${ttw !== 1 ? 's' : ''} detected — review the risk map.`,
        { tone: 'error', duration: 8000 },
      );
    }
    if (prefs.riskEmail !== false) {
      post('/account/notify/risk', { job_id, ttw_count: ttw, diverged_count: div });
    }
  }
}

function fmt_bytes(bytes) {
  if (bytes === 0) return '0 B';
  const k = 1024;
  const units = ['B', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return (bytes / Math.pow(k, i)).toFixed(i >= 2 ? 1 : 0) + ' ' + units[i];
}

// Upload a single file to a presigned S3 PUT URL via XHR.
// Returns a Promise that resolves when done.
// onProgress(pct) is called with 0–100 as the upload progresses.
function upload_to_s3(presigned_url, file, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('PUT', presigned_url, true);
    xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
    };
    xhr.onload  = () => xhr.status === 200 ? resolve() : reject(new Error(`S3 upload failed: ${xhr.status}`));
    xhr.onerror = () => reject(new Error('Network error during S3 upload'));
    xhr.send(file);
  });
}

// ── Page shell ────────────────────────────────────────────────────────────

function UploadPage() {
  const { go } = useNav();
  const { toast } = useToast();

  // Shared job state threaded between states
  const [state, setState]         = useState('form');
  const [jobCtx, setJobCtx]       = useState(null);
  const [jobResult, setJobResult] = useState(null);

  const handleRun      = (ctx)    => { setJobCtx(ctx); setState('uploading'); };
  const handleUploaded = ()       => setState('analysis');
  const handleDone     = (result) => { setJobResult(result); setState('done'); };
  const handleError    = ()       => setState('error');
  const handleReset    = ()       => { setJobCtx(null); setJobResult(null); setState('form'); };

  return (
    <div style={{ minHeight: '100vh', display: 'grid', gridTemplateColumns: '240px 1fr', background: 'var(--bg)' }}>
      <AccountSidebar current="upload" go={go}/>
      <div style={{ overflow: 'auto' }}>
        <AccountTopBar crumb={['app', 'new excavation', state]}/>
        <div style={{ padding: '32px', maxWidth: 1100, margin: '0 auto' }}>
          <SectionHeader
            number="X1"
            eyebrow="New excavation"
            title="Upload specimens. Receive a map."
            kicker="The ingestion runs in an ephemeral container. Uploaded files are deleted immediately after your report is generated; no source is retained."
          />
          <div style={{ marginTop: 32 }}>
            {state === 'form'      && <UploadForm onRun={handleRun}/>}
            {state === 'uploading' && <UploadingState ctx={jobCtx} onDone={handleUploaded} onError={handleError}/>}
            {state === 'analysis'  && <AnalysisState ctx={jobCtx} onDone={handleDone} onError={handleError}/>}
            {state === 'done'      && <DoneState result={jobResult} onReset={handleReset} go={go}/>}
            {state === 'error'     && <ErrorState ctx={jobCtx} onRetry={handleReset}/>}
          </div>
        </div>
      </div>
    </div>
  );
}

// ── State 1: Form ─────────────────────────────────────────────────────────

function UploadForm({ onRun }) {
  const { paid, role } = useAuth();
  const { go }         = useNav();
  const { toast }      = useToast();
  const isViewer = role === 'Viewer';

  const [repoFile, setRepoFile] = useState(null);
  const [zexpFile, setZexpFile] = useState(null);
  const [sqlFile,  setSqlFile]  = useState(null);
  const [ai,           setAi]          = useState(true);
  const [fullSource,   setFullSource]  = useState(false);
  const [dpaEnabled,   setDpaEnabled]  = useState(false);
  const [intentTop,    setIntentTop]   = useState(10);
  const [dbPattern,    setDbPattern]   = useState('riscore_%');
  const [dbCount,      setDbCount]     = useState(1);
  const [loading,      setLoading]     = useState(false);
  const [error,        setError]       = useState('');

  // Check if account has DPA signed — only then show full-source toggle
  useEffect(() => {
    const token = window.getIdToken ? window.getIdToken() : '';
    if (!token) return;
    fetch(`${API_BASE}/account/profile`, { headers: { Authorization: 'Bearer ' + token } })
      .then(r => r.ok ? r.json() : null)
      .then(d => { if (d?.profile?.dpa_signed) setDpaEnabled(true); })
      .catch(() => {});
  }, []);

  const canRun = repoFile && zexpFile && !loading;

  const handleRun = async () => {
    if (!canRun) return;
    if (!paid) {
      toast('A subscription is required to run excavations.', { tone: 'error', duration: 4000 });
      go('billing');
      return;
    }
    if (isViewer) {
      toast('Viewer access — you can browse reports but cannot start new excavations.', { tone: 'error', duration: 4000 });
      return;
    }
    setLoading(true);
    setError('');

    try {
      // Step 1: Get presigned upload URLs from fossyl-presign Lambda
      const presign_body = {
        repo_filename: repoFile.name,
        zexp_filename: zexpFile.name,
        with_intent:   ai,
        intent_top:    intentTop,
        db_pattern:    dbPattern,
        db_count:      dbCount,
      };
      if (sqlFile) presign_body.sql_filename = sqlFile.name;

      const presign_resp = await fetch(`${API_BASE}/presign`, {
        method: 'POST',
        headers: {
          'Content-Type':  'application/json',
          'Authorization': 'Bearer ' + (window.getIdToken ? window.getIdToken() : ''),
        },
        body: JSON.stringify(presign_body),
      });

      if (!presign_resp.ok) {
        const err = await presign_resp.json().catch(() => ({}));
        throw new Error(err.error || `Presign failed (${presign_resp.status})`);
      }

      const presign_data = await presign_resp.json();

      // Pass context to uploading state — it handles the actual S3 uploads
      onRun({
        job_id:          presign_data.job_id,
        repo_upload_url: presign_data.repo_upload_url,
        zexp_upload_url: presign_data.zexp_upload_url,
        sql_upload_url:  presign_data.sql_upload_url || null,
        repo_s3_key:     presign_data.repo_s3_key,
        zexp_s3_key:     presign_data.zexp_s3_key,
        sql_s3_key:      presign_data.sql_s3_key || '',
        files: [
          { file: repoFile, url: presign_data.repo_upload_url, label: repoFile.name },
          { file: zexpFile, url: presign_data.zexp_upload_url, label: zexpFile.name },
          ...(sqlFile && presign_data.sql_upload_url
            ? [{ file: sqlFile, url: presign_data.sql_upload_url, label: sqlFile.name }]
            : []),
        ],
        options: {
          with_intent:   ai,
          intent_top:    intentTop,
          db_pattern:    dbPattern,
          db_count:      dbCount,
          analysis_mode: (ai && fullSource && dpaEnabled) ? 'full_source' : 'signatures',
        },
      });
    } catch (e) {
      setError(e.message || 'Failed to start excavation. Please try again.');
      setLoading(false);
    }
  };

  const total_bytes = (repoFile?.size || 0) + (zexpFile?.size || 0) + (sqlFile?.size || 0);

  return (
    <div className="col g-32">
      {/* Access banners */}
      {!paid && (
        <div style={{ padding: '14px 20px', border: '1px solid var(--bone-dim)', background: 'rgba(180,150,80,0.06)', display: 'flex', alignItems: 'center', gap: 16 }}>
          <span style={{ color: 'var(--bone)', fontFamily: 'var(--mono)', fontSize: 14 }}>◈</span>
          <span style={{ flex: 1, fontSize: 13, color: 'var(--ink-dim)' }}>You need an active subscription to run excavations.</span>
          <button className="btn btn-primary btn-sm" onClick={() => go('billing')}>Choose a plan →</button>
        </div>
      )}
      {paid && isViewer && (
        <div style={{ padding: '14px 20px', border: '1px solid var(--bone-dim)', background: 'rgba(180,150,80,0.06)', display: 'flex', alignItems: 'center', gap: 16 }}>
          <span style={{ color: 'var(--bone)', fontFamily: 'var(--mono)', fontSize: 14 }}>◈</span>
          <span style={{ flex: 1, fontSize: 13, color: 'var(--ink-dim)' }}>Viewer access — you can browse reports but cannot start new excavations.</span>
        </div>
      )}

      {/* Section: specimens */}
      <div>
        <div className="row jc-b ai-c" style={{ marginBottom: 14 }}>
          <span className="caps">01 — Specimens</span>
          <span className="mono" style={{ fontSize: 11, color: 'var(--muted)' }}>two required, one optional</span>
        </div>
        <div className="col g-12">
          <DropZone
            required
            label="Repository ZIP"
            accept=".zip"
            hint="Filesystem snapshot of the codebase. Up to 200 MB."
            icon="▢"
            file={repoFile}
            onFile={setRepoFile}
          />
          <DropZone
            required
            label="ZODB Export"
            accept=".zexp"
            hint="Exported from Zope ZMI. Contains the live object database."
            icon="◈"
            file={zexpFile}
            onFile={setZexpFile}
          />
          <DropZone
            label="Database schema"
            accept=".sql"
            hint="Optional. phpMyAdmin structure-only export. Enables §8 Schema map."
            icon="▤"
            file={sqlFile}
            onFile={setSqlFile}
          />
        </div>
      </div>

      {/* Section: options */}
      <div>
        <div className="row jc-b ai-c" style={{ marginBottom: 14 }}>
          <span className="caps">02 — Options</span>
          <span className="mono" style={{ fontSize: 11, color: 'var(--muted)' }}>affects runtime and cost</span>
        </div>
        <div className="panel">
          <div className="panel-b col g-0" style={{ padding: 0 }}>
            <ToggleRow
              label="AI plain-English explanations"
              sub={window.FOSSYL_AI_TOGGLE_COPY = "Uses Claude to generate dry, factual field notes for critical artifacts. Signatures only — " + (window.FOSSYL_SIGNATURES_PAYLOAD_DESC||"") + " — are sent to Anthropic's API. Implementation bodies are never transmitted. Fossyl retains nothing after report generation."}
              on={ai}
              setOn={setAi}
              right={<span className="mono" style={{ fontSize: 11, color: 'var(--muted)' }}>+ ~11s · + € 0.40</span>}
            />
            {ai && dpaEnabled && (
              <ToggleRow
                label="Full-source deep analysis"
                sub="Sends complete source code to Anthropic's API for richer notes. Requires signed DPA. Enables body-level variable name analysis."
                on={fullSource}
                setOn={setFullSource}
                right={<span className="mono" style={{ fontSize: 11, color: 'var(--rust)' }}>DPA required · active</span>}
              />
            )}
            {ai && (
              <div style={{ padding: '14px 20px', borderTop: '1px solid var(--line)', background: 'var(--bg-1)', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
                <div>
                  <span className="label">Artifacts to analyse</span>
                  <div className="row g-12 ai-c">
                    <input className="input mono" value={intentTop} type="number" min="1" max="20"
                      onChange={e => setIntentTop(Math.min(20, Math.max(1, +e.target.value)))}
                      style={{ width: 100, padding: '8px 12px' }}/>
                    <Ticks count={20} height={8}/>
                    <span className="mono" style={{ fontSize: 11, color: 'var(--muted)' }}>max 20</span>
                  </div>
                </div>
                <div className="col g-4">
                  <span className="label">Selection strategy</span>
                  <span className="mono" style={{ fontSize: 12, color: 'var(--ink-dim)' }}>Top-N by coupling × churn</span>
                  <span className="mono" style={{ fontSize: 11, color: 'var(--dim)' }}>auto — modify in scan settings after run</span>
                </div>
              </div>
            )}
            <ToggleRow
              label="Schema cross-linking"
              sub="If an SQL dump is attached above, cross-reference each table to Zope artifacts that read or write it."
              on={!!sqlFile}
              setOn={() => {}}
              right={<span className="mono" style={{ fontSize: 11, color: 'var(--muted)' }}>+ ~2s</span>}
            />
            {sqlFile && (
              <div style={{ padding: '14px 20px', borderTop: '1px solid var(--line)', background: 'var(--bg-1)', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24 }}>
                <div>
                  <span className="label">DB table pattern</span>
                  <input className="input mono" value={dbPattern}
                    onChange={e => setDbPattern(e.target.value)}
                    style={{ padding: '8px 12px' }}/>
                </div>
                <div>
                  <span className="label">Instance count</span>
                  <input className="input mono" value={dbCount} type="number" min="1"
                    onChange={e => setDbCount(Math.max(1, +e.target.value))}
                    style={{ padding: '8px 12px' }}/>
                </div>
              </div>
            )}
          </div>
        </div>
      </div>

      {/* Error message */}
      {error && (
        <div style={{ padding: '14px 18px', border: '1px solid var(--rust-dim)', background: 'rgba(180,60,40,0.06)', borderRadius: 2 }}>
          <span className="mono" style={{ fontSize: 12, color: 'var(--rust)' }}>{error}</span>
        </div>
      )}

      {/* Summary + CTA */}
      <div style={{ border: '1px solid var(--line)', padding: '20px 24px', background: 'var(--bg-1)' }}>
        <div className="row jc-b ai-s" style={{ gap: 32 }}>
          <div className="col g-12" style={{ flex: 1 }}>
            <div className="row g-32">
              <SummaryLine label="Specimens"   value={<><span className="num">{(repoFile ? 1 : 0) + (zexpFile ? 1 : 0) + (sqlFile ? 1 : 0)}</span> <span className="mono ink-dim" style={{ fontSize: 11 }}>files · {fmt_bytes(total_bytes)}</span></>}/>
              <SummaryLine label="Est. runtime" value={<><span className="num">~{ai ? '58' : '47'}</span> <span className="mono ink-dim" style={{ fontSize: 11 }}>seconds</span></>}/>
              <SummaryLine label="Est. cost"   value={<><span className="num">€ {ai ? '0.40' : '0.00'}</span> <span className="mono ink-dim" style={{ fontSize: 11 }}>in addition to plan</span></>}/>
            </div>
            <FieldNote index={8023}>
              Files are uploaded directly from your browser to ephemeral object storage via presigned URL. Once the excavation completes, the container and all uploaded artifacts are destroyed. Only the generated HTML report persists.
            </FieldNote>
          </div>
          <button
            className="btn btn-primary btn-lg"
            onClick={handleRun}
            disabled={!canRun && paid}
            style={{ whiteSpace: 'nowrap', opacity: (canRun || !paid) ? 1 : 0.4, cursor: 'pointer' }}
          >
            {loading ? 'Preparing…' : !paid ? 'Subscribe to excavate →' : isViewer ? 'Viewer access — read only' : 'Run excavation →'}
          </button>
        </div>
      </div>
    </div>
  );
}

// ── DropZone — real file input ─────────────────────────────────────────────

function DropZone({ required, label, accept, hint, icon, file, onFile }) {
  const inputRef = useRef(null);
  const [dragging, setDragging] = useState(false);

  const handleFiles = (files) => {
    if (files && files[0]) onFile(files[0]);
  };

  return (
    <div
      className="corners"
      style={{
        border: '1px solid ' + (file ? 'var(--line-2)' : dragging ? 'var(--bone-dim)' : 'var(--line)'),
        padding: '18px 20px',
        background: file ? 'var(--bg-1)' : dragging ? 'rgba(180,150,80,0.04)' : 'var(--bg)',
        display: 'grid', gridTemplateColumns: '40px 1fr 220px auto', gap: 20, alignItems: 'center',
        position: 'relative', transition: 'border-color 140ms, background 140ms',
      }}
      onDragOver={e => { e.preventDefault(); setDragging(true); }}
      onDragLeave={() => setDragging(false)}
      onDrop={e => { e.preventDefault(); setDragging(false); handleFiles(e.dataTransfer.files); }}
    >
      <span className="cbl"/><span className="cbr"/>

      {/* Hidden real file input */}
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        style={{ display: 'none' }}
        onChange={e => handleFiles(e.target.files)}
      />

      <div style={{
        width: 40, height: 40, display: 'grid', placeItems: 'center',
        border: '1px solid var(--line-2)',
        fontFamily: 'var(--serif)', fontSize: 20,
        color: file ? 'var(--bone)' : 'var(--muted)',
      }}>{icon}</div>

      <div className="col g-4">
        <div className="row g-8 ai-c">
          <span style={{ fontSize: 14, fontWeight: 500 }}>{label}</span>
          {required
            ? <span className="badge" style={{ color: 'var(--rust)', borderColor: 'var(--rust-dim)' }}>required</span>
            : <span className="badge">optional</span>}
          <span className="mono" style={{ fontSize: 10.5, color: 'var(--dim)' }}>accepts {accept}</span>
        </div>
        <div style={{ fontSize: 12.5, color: 'var(--ink-dim)' }}>{hint}</div>
      </div>

      <div className="col g-2">
        {file ? (
          <>
            <span className="mono" style={{ fontSize: 12, color: 'var(--ink)' }}>{file.name}</span>
            <span className="mono" style={{ fontSize: 10.5, color: 'var(--muted)' }}>{fmt_bytes(file.size)}</span>
          </>
        ) : (
          <>
            <span className="mono" style={{ fontSize: 12, color: 'var(--muted)' }}>no file staged</span>
            <span className="mono" style={{ fontSize: 10.5, color: 'var(--dim)' }}>drag & drop or browse</span>
          </>
        )}
      </div>

      <div className="row g-8">
        {file
          ? <>
              <button className="btn btn-sm" onClick={() => inputRef.current?.click()}>Replace</button>
              <button className="btn btn-sm btn-ghost" onClick={() => onFile(null)}>✕</button>
            </>
          : <button className="btn btn-sm btn-primary" onClick={() => inputRef.current?.click()}>Browse…</button>
        }
      </div>
    </div>
  );
}

function ToggleRow({ label, sub, on, setOn, right }) {
  return (
    <div style={{ padding: '16px 20px', borderBottom: '1px solid var(--line)', display: 'grid', gridTemplateColumns: '48px 1fr auto', gap: 16, alignItems: 'center' }}>
      <button onClick={() => setOn(!on)} style={{
        width: 36, height: 20, borderRadius: 10,
        border: '1px solid ' + (on ? 'var(--bone)' : 'var(--line-2)'),
        background: on ? 'var(--bone)' : 'var(--bg-2)',
        position: 'relative', cursor: 'pointer', padding: 0,
      }}>
        <span style={{
          position: 'absolute', top: 1, left: on ? 17 : 1,
          width: 16, height: 16, borderRadius: '50%',
          background: on ? 'var(--bg)' : 'var(--ink-dim)',
          transition: 'left 140ms',
        }}/>
      </button>
      <div>
        <div style={{ fontSize: 13.5, fontWeight: 500 }}>{label}</div>
        <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2, lineHeight: 1.5, maxWidth: 680 }}>{sub}</div>
      </div>
      <div>{right}</div>
    </div>
  );
}

function SummaryLine({ label, value }) {
  return (
    <div className="col g-4">
      <span className="caps">{label}</span>
      <div className="serif" style={{ fontSize: 20, letterSpacing: '-0.01em' }}>{value}</div>
    </div>
  );
}

// ── State 2: Uploading — real XHR to S3 ──────────────────────────────────

function UploadingState({ ctx, onDone, onError }) {
  const [progresses, setProgresses] = useState(
    () => (ctx?.files || []).map(() => 0)
  );
  const [elapsed, setElapsed] = useState(0);
  const [errMsg, setErrMsg] = useState('');
  const startedRef = useRef(false);

  // Elapsed timer
  useEffect(() => {
    const id = setInterval(() => setElapsed(e => +(e + 0.1).toFixed(1)), 100);
    return () => clearInterval(id);
  }, []);

  // Run uploads once
  useEffect(() => {
    if (startedRef.current || !ctx) return;
    startedRef.current = true;

    const run = async () => {
      try {
        const { files } = ctx;

        // Upload all files concurrently, tracking per-file progress
        await Promise.all(files.map((f, i) =>
          upload_to_s3(f.url, f.file, (pct) => {
            setProgresses(prev => {
              const next = [...prev];
              next[i] = pct;
              return next;
            });
          })
        ));

        // All uploaded — trigger the ECS job
        const trigger_resp = await fetch(`${API_BASE}/trigger`, {
          method: 'POST',
          headers: {
            'Content-Type':  'application/json',
            'Authorization': 'Bearer ' + (window.getIdToken ? window.getIdToken() : ''),
          },
          body: JSON.stringify({
            job_id:      ctx.job_id,
            repo_s3_key: ctx.repo_s3_key,
            zexp_s3_key: ctx.zexp_s3_key,
            sql_s3_key:  ctx.sql_s3_key,
            options:     ctx.options,
          }),
        });

        if (!trigger_resp.ok) {
          const err = await trigger_resp.json().catch(() => ({}));
          throw new Error(err.error || `Trigger failed (${trigger_resp.status})`);
        }

        // Small pause then move to analysis
        setTimeout(onDone, 400);

      } catch (e) {
        setErrMsg(e.message || 'Upload failed');
        setTimeout(onError, 1500);
      }
    };

    run();
  }, []);

  const files = ctx?.files || [];
  const all_done = progresses.every(p => p >= 100);

  return (
    <div className="panel">
      <div className="panel-h">
        <span className="caps">{errMsg ? 'Upload failed' : all_done ? 'Specimens transmitted ✓' : 'Transmitting specimens…'}</span>
        <span className="mono" style={{ fontSize: 11, color: 'var(--muted)' }}>
          elapsed {elapsed.toFixed(1)}s · direct-to-storage · presigned
        </span>
      </div>
      <div className="panel-b" style={{ padding: 0 }}>
        {files.map((f, i) => (
          <div key={f.label} style={{
            padding: '18px 20px', borderTop: i > 0 ? '1px solid var(--line)' : 'none',
            display: 'grid', gridTemplateColumns: '32px 1fr 140px 80px', gap: 16, alignItems: 'center',
          }}>
            <span className="mono" style={{ fontSize: 11, color: 'var(--dim)' }}>0{i + 1}</span>
            <div className="col g-6">
              <div className="row jc-b">
                <span className="mono" style={{ fontSize: 12, color: 'var(--ink)' }}>{f.label}</span>
                <span className="mono" style={{ fontSize: 11, color: progresses[i] >= 100 ? 'var(--lichen)' : 'var(--bone)' }}>
                  {progresses[i] >= 100 ? 'transmitted ✓' : 'transmitting…'}
                </span>
              </div>
              <div style={{ height: 3, background: 'var(--bg-2)', borderRadius: 1, overflow: 'hidden' }}>
                <div style={{ width: progresses[i] + '%', height: '100%', background: progresses[i] >= 100 ? 'var(--lichen)' : 'var(--bone)', transition: 'width 80ms linear' }}/>
              </div>
            </div>
            <div className="mono" style={{ fontSize: 11, color: 'var(--muted)' }}>{fmt_bytes(f.file.size)}</div>
            <div className="mono num text-right" style={{ fontSize: 13, color: progresses[i] >= 100 ? 'var(--lichen)' : 'var(--ink)' }}>
              {Math.floor(progresses[i])}%
            </div>
          </div>
        ))}
        {errMsg && (
          <div style={{ padding: '14px 20px', borderTop: '1px solid var(--line)' }}>
            <span className="mono" style={{ fontSize: 12, color: 'var(--rust)' }}>{errMsg}</span>
          </div>
        )}
      </div>
    </div>
  );
}

// ── State 3: Analysis — real polling ──────────────────────────────────────

const STEPS = [
  { k: 'extract',  n: '01', t: 'Extracting repository',     sub: 'unpacking ZIP, reading filesystem tree' },
  { k: 'fs',       n: '02', t: 'Ingesting filesystem',      sub: 'classifying artifacts, fingerprinting sources' },
  { k: 'zodb',     n: '03', t: 'Parsing ZODB export',       sub: 'walking persisted records' },
  { k: 'diverge',  n: '04', t: 'Detecting divergence',      sub: 'reconciling fs tree against live database' },
  { k: 'graph',    n: '05', t: 'Building dependency graph',  sub: 'resolving traversal edges' },
  { k: 'schema',   n: '06', t: 'Parsing database schema',   sub: 'cross-linking tables to Zope types' },
  { k: 'llm',      n: '07', t: 'LLM inference',             sub: 'generating plain-English field notes' },
  { k: 'emit',     n: '08', t: 'Generating HTML report',    sub: 'assembling self-contained artifact' },
];

function AnalysisState({ ctx, onDone, onError }) {
  const [statusData, setStatusData] = useState({ status: 'queued', progress: 'Queued…', steps_done: 0 });
  const [elapsed, setElapsed] = useState(0);
  const [currentStepIdx, setCurrentStepIdx] = useState(0);
  const pollRef = useRef(null);

  // Elapsed timer
  useEffect(() => {
    const id = setInterval(() => setElapsed(e => +(e + 0.1).toFixed(1)), 100);
    return () => clearInterval(id);
  }, []);

  // Poll /status every 1.5s
  useEffect(() => {
    if (!ctx?.job_id) return;

    const poll = async () => {
      try {
        const resp = await fetch(`${API_BASE}/status/${ctx.job_id}`, {
          headers: { 'Authorization': 'Bearer ' + (window.getIdToken ? window.getIdToken() : '') },
        });
        if (!resp.ok) return;
        const data = await resp.json();
        setStatusData(data);

        // Derive current step index from progress message
        const msg = (data.progress || '').toLowerCase();
        const idx = STEPS.findIndex(s => msg.includes(s.t.toLowerCase().split(' ')[0]));
        if (idx >= 0) setCurrentStepIdx(idx);

        if (data.status === 'done') {
          clearInterval(pollRef.current);
          _fireScanNotifications(ctx.job_id, elapsed, data);
          setTimeout(() => onDone(data), 400);
        } else if (data.status === 'error') {
          clearInterval(pollRef.current);
          setTimeout(onError, 400);
        }
      } catch (_) {}
    };

    poll(); // immediate first poll
    pollRef.current = setInterval(poll, 1500);
    return () => clearInterval(pollRef.current);
  }, [ctx?.job_id]);

  const steps_done = statusData.steps_done || 0;
  const steps_total = statusData.steps_total || STEPS.length;
  const pct = Math.min(100, Math.round((steps_done / steps_total) * 100));
  const currentStep = STEPS[Math.min(currentStepIdx, STEPS.length - 1)];

  return (
    <div className="col g-24">
      <div className="panel">
        <div className="panel-h">
          <span className="caps">Excavation in progress</span>
          <span className="mono" style={{ fontSize: 11, color: 'var(--muted)' }}>
            elapsed {elapsed.toFixed(1)}s · scan_id {ctx?.job_id?.slice(0, 8) || '—'} · container fargate
          </span>
        </div>
        <div className="panel-b">
          <div className="row jc-b ai-e" style={{ marginBottom: 14, gap: 32 }}>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div className="caps" style={{ marginBottom: 8 }}>Current stratum</div>
              <div className="row g-14 ai-b" style={{ flexWrap: 'nowrap' }}>
                <span className="mono" style={{ color: 'var(--dim)', fontSize: 13, flexShrink: 0, paddingBottom: 6 }}>§{currentStep.n}</span>
                <span className="serif" style={{ fontSize: 30, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.05, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                  {currentStep.t}
                </span>
                <span className="blink" style={{ color: 'var(--bone)', fontFamily: 'var(--mono)', fontSize: 14, paddingBottom: 6, flexShrink: 0 }}>▮</span>
              </div>
              <div className="mono" style={{ fontSize: 12, color: 'var(--muted)', marginTop: 10 }}>
                {statusData.progress || currentStep.sub}
              </div>
            </div>
            <div className="col g-4 ai-e" style={{ flexShrink: 0 }}>
              <span className="caps">Progress</span>
              <div className="num serif" style={{ fontSize: 44, letterSpacing: '-0.02em', lineHeight: 1 }}>
                {pct}<span className="mono" style={{ fontSize: 14, color: 'var(--muted)', marginLeft: 4 }}>%</span>
              </div>
            </div>
          </div>
          <Ticks count={60} height={10}/>
          <div style={{ height: 4, background: 'var(--bg-2)', borderRadius: 1, overflow: 'hidden', marginTop: 6 }}>
            <div style={{ width: pct + '%', height: '100%', background: 'var(--bone)', transition: 'width 300ms ease' }}/>
          </div>
        </div>
      </div>

      {/* Strata log */}
      <div className="panel">
        <div className="panel-h">
          <span className="caps">Fig. X.1 — Strata processed</span>
          <span className="mono" style={{ fontSize: 11, color: 'var(--muted)' }}>polling every 1.5s</span>
        </div>
        <div className="panel-b" style={{ padding: 0 }}>
          {STEPS.map((s, i) => {
            const finished = i < currentStepIdx;
            const active   = i === currentStepIdx;
            const pending  = i > currentStepIdx;
            return (
              <div key={s.k} style={{
                padding: '14px 20px', borderTop: i > 0 ? '1px solid var(--line)' : 'none',
                display: 'grid', gridTemplateColumns: '32px 24px 1fr 200px 80px', gap: 16, alignItems: 'center',
                opacity: pending ? 0.4 : 1,
              }}>
                <span className="mono" style={{ fontSize: 11, color: 'var(--dim)' }}>§{s.n}</span>
                <span style={{ color: finished ? 'var(--lichen)' : active ? 'var(--bone)' : 'var(--dim)', fontFamily: 'var(--mono)', fontSize: 14 }}>
                  {finished ? '✓' : active ? '◈' : '◌'}
                </span>
                <div className="col g-2">
                  <span style={{ fontSize: 13, fontWeight: 500, color: finished ? 'var(--ink)' : active ? 'var(--bone)' : 'var(--ink-dim)' }}>{s.t}</span>
                  <span className="mono" style={{ fontSize: 11, color: 'var(--muted)' }}>{s.sub}</span>
                </div>
                <div style={{ height: 3, background: 'var(--bg-2)', borderRadius: 1, overflow: 'hidden' }}>
                  <span style={{
                    display: 'block', height: '100%',
                    width: finished ? '100%' : active ? '60%' : '0%',
                    background: finished ? 'var(--lichen)' : 'var(--bone)',
                    transition: 'width 400ms ease',
                  }}/>
                </div>
                <span className="mono text-right" style={{ fontSize: 11, color: finished ? 'var(--lichen)' : 'var(--muted)' }}>
                  {finished ? '✓' : active ? 'running…' : 'queued'}
                </span>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

// ── State 4: Done — presigned download ───────────────────────────────────
function DoneState({ result, onReset, go }) {
  const { toast } = useToast();
  const [downloading, setDownloading] = useState(false);
  const job_id = result?.job_id || '';

  // Store in scan history on mount
  useEffect(() => {
    if (!job_id) return;
    // Save as active job
    localStorage.setItem('fossyl_active_job', job_id);
    // Append to history (newest first, max 50)
    try {
      const prev = JSON.parse(localStorage.getItem('fossyl_job_history') || '[]');
      const entry = {
        job_id,
        created_at:     result?.created_at     || new Date().toISOString(),
        status:         'done',
        report_size_kb: result?.report_size_kb || 0,
        ttw_count:      result?.ttw_count      || 0,
        artifact_count: result?.artifact_count || 0,
        edge_count:     result?.edge_count     || 0,
        diverged_count: result?.diverged_count || 0,
        synced_count:   result?.synced_count   || 0,
      };
      // Don't duplicate
      const filtered = prev.filter(j => j.job_id !== job_id);
      localStorage.setItem('fossyl_job_history', JSON.stringify([entry, ...filtered].slice(0, 50)));
    } catch {}
  }, [job_id]);

  const handleDownload = async () => {
    setDownloading(true);
    try {
      const resp = await fetch(`${API_BASE}/download/${job_id}`, {
        headers: { 'Authorization': 'Bearer ' + (window.getIdToken ? window.getIdToken() : '') },
      });
      if (!resp.ok) throw new Error('Download URL generation failed');
      const data = await resp.json();
      window.location.href = data.download_url;
    } catch (e) {
      toast('Download failed: ' + e.message, { tone: 'error' });
    } finally {
      setDownloading(false);
    }
  };

  const ttw_count  = result?.ttw_count      || '—';
  const artifacts  = result?.artifact_count || '—';
  const edges      = result?.edge_count     || '—';
  const diverged   = result?.diverged_count || '—';
  const synced     = result?.synced_count   || '—';
  const size_kb    = result?.report_size_kb || '—';
  const elapsed_s  = result?.elapsed_s      || '—';

  return (
    <div className="col g-24">
      <div style={{
        border: '1px solid var(--line-2)', background: 'var(--bg-1)',
        padding: '36px 40px', position: 'relative',
      }}>
        <div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: 'var(--bone)' }}/>
        <div className="row jc-b ai-s" style={{ marginBottom: 28 }}>
          <div>
            <span className="caps" style={{ color: 'var(--bone)' }}>Excavation complete</span>
            <h2 className="display" style={{ fontSize: 44, fontWeight: 400, letterSpacing: '-0.02em', margin: '12px 0 0', lineHeight: 1 }}>
              Map generated. {elapsed_s !== '—' ? `${elapsed_s}s.` : '—'}
            </h2>
            <p style={{ fontSize: 14, color: 'var(--ink-dim)', margin: '10px 0 0', maxWidth: 560 }}>
              Report for excavation {job_id.slice(0, 8)}. Self-contained HTML file. {size_kb} KB.
            </p>
          </div>
          <div className="col g-4 ai-e">
            <span className="mono" style={{ fontSize: 10.5, color: 'var(--muted)' }}>scan_id</span>
            <span className="mono" style={{ fontSize: 14, color: 'var(--ink)' }}>{job_id.slice(0, 8)}</span>
          </div>
        </div>
        <div className="row" style={{ border: '1px solid var(--line)', background: 'var(--bg)' }}>
          <Stat label="TTW-only scripts"    value={String(ttw_count)}  unit="unrecoverable" tone="rust"/>
          <Stat label="Artifacts classified" value={String(artifacts)}/>
          <Stat label="Dependencies mapped"  value={String(edges)}     unit="edges"/>
          <Stat label="Diverged files"       value={String(diverged)}/>
          <Stat label="Synchronized"         value={String(synced)}    tone="lichen"/>
        </div>
        <div className="row jc-b ai-c" style={{ marginTop: 28, gap: 16 }}>
          <div className="col g-4">
            <span className="mono" style={{ fontSize: 11, color: 'var(--muted)' }}>
              Report stored per your plan retention. Re-downloadable from the Reports tab.
            </span>
          </div>
          <div className="row g-12 ai-c">
            <button className="btn" onClick={() => go('report')}>View in reports →</button>
            <button className="btn btn-primary btn-lg" onClick={handleDownload} disabled={downloading}
              style={{ minWidth: 260, justifyContent: 'center', opacity: downloading ? 0.6 : 1 }}>
              {downloading ? 'Generating link…' : 'Download excavation report →'}
            </button>
          </div>
        </div>
      </div>

      <div className="row g-12">
        <a className="bone mono" style={{ fontSize: 12, cursor: 'pointer' }} onClick={onReset}>Run another excavation →</a>
      </div>
    </div>
  );
}
// ── State 5: Error ────────────────────────────────────────────────────────

function ErrorState({ ctx, onRetry }) {
  return (
    <div className="panel" style={{ borderColor: 'var(--rust-dim)' }}>
      <div className="panel-h" style={{ borderBottomColor: 'var(--rust-dim)' }}>
        <span className="caps" style={{ color: 'var(--rust)' }}>Excavation aborted</span>
        <span className="mono" style={{ fontSize: 11, color: 'var(--muted)' }}>
          scan_id {ctx?.job_id?.slice(0, 8) || '—'}
        </span>
      </div>
      <div className="panel-b col g-20">
        <div className="row g-24 ai-s">
          <div style={{
            width: 56, height: 56, flexShrink: 0, display: 'grid', placeItems: 'center',
            border: '1px solid var(--rust-dim)', color: 'var(--rust)',
            fontFamily: 'var(--serif)', fontSize: 30, fontStyle: 'italic',
          }}>!</div>
          <div className="col g-8" style={{ flex: 1 }}>
            <div className="display" style={{ fontSize: 30, fontWeight: 400, letterSpacing: '-0.02em' }}>
              Analysis failed.
            </div>
            <p style={{ margin: 0, fontSize: 14, color: 'var(--ink-dim)', maxWidth: 720, lineHeight: 1.6 }}>
              The excavation encountered an error. Your uploaded files have already been purged.
              Check that your ZEXP file is complete and not truncated, then try again.
            </p>
          </div>
        </div>
        <div>
          <div className="caps" style={{ marginBottom: 10 }}>Recommended</div>
          <ul style={{ margin: 0, paddingLeft: 16, fontSize: 13.5, color: 'var(--ink-dim)', lineHeight: 1.7 }}>
            <li>Re-export the ZODB from ZMI with <code className="mono" style={{ color: 'var(--bone)' }}>Export → "Download to local machine"</code> and verify the file size.</li>
            <li>Ensure the repository ZIP contains the full codebase, not a partial export.</li>
            <li>Your uploaded files have already been purged. Nothing to clean up on your side.</li>
          </ul>
        </div>
        <div className="row jc-b ai-c" style={{ paddingTop: 8, borderTop: '1px solid var(--line)' }}>
          <span className="mono" style={{ fontSize: 11, color: 'var(--muted)' }}>This attempt is not counted against your plan quota.</span>
          <div className="row g-8">
            <button className="btn btn-primary" onClick={onRetry}>Try again →</button>
          </div>
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { UploadPage });