/* global React, ReactDOM */
const { useState, useEffect, useRef, useMemo, useCallback } = React;

const API_BASE = (window.__LECTERN_API_BASE__ || '').replace(/\/$/, '');
const apiUrl = (path) => `${API_BASE}${path}`;

const readEnvelope = async (response) => {
  const payload = await response.json().catch(() => null);
  if (!response.ok) {
    const message = payload?.error || `Request failed (${response.status})`;
    throw new Error(message);
  }
  return payload?.data ?? payload;
};

const mapApiMedia = (item) => ({
  id: item.id,
  title: item.title || item.filename,
  filename: item.filename,
  status: item.status || 'pending',
  duration_s: Math.round(item.duration_seconds || 0),
  org: item.org_id || ORGS[0],
  created_at: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10),
  job_id: item.job_id || '',
  error_message: item.error_message || '',
  transcript_s3_key: item.transcript_s3_key || '',
});

// ─────────────────────────────────────────────────────────────────────────────
// Icons (stroke 1.5, no flourish)
// ─────────────────────────────────────────────────────────────────────────────
const Svg = ({ children, size = 16, ...rest }) => (
  <svg width={size} height={size} viewBox="0 0 20 20" fill="none" stroke="currentColor"
       strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...rest}>{children}</svg>
);
const Icon = {
  Search: (p) => <Svg {...p}><circle cx="9" cy="9" r="6"/><path d="M13.5 13.5L17 17"/></Svg>,
  Upload: (p) => <Svg {...p}><path d="M10 13V3M6 7l4-4 4 4M3 16h14"/></Svg>,
  X:      (p) => <Svg {...p}><path d="M5 5l10 10M15 5L5 15"/></Svg>,
  Check:  (p) => <Svg {...p}><path d="M4 10.5l3.5 3.5L16 6"/></Svg>,
  Doc:    (p) => <Svg {...p}><path d="M5 2h6.5L15 5.5V18H5z"/><path d="M11.5 2v3.5H15"/></Svg>,
  ChevL:  (p) => <Svg {...p}><path d="M12 4l-5 6 5 6"/></Svg>,
  ChevR:  (p) => <Svg {...p}><path d="M8 4l5 6-5 6"/></Svg>,
  ChevD:  (p) => <Svg {...p}><path d="M5 8l5 5 5-5"/></Svg>,
  Filter: (p) => <Svg {...p}><path d="M3 5h14M5 10h10M8 15h4"/></Svg>,
  Refresh:(p) => <Svg {...p}><path d="M16 4v4h-4M4 16v-4h4"/><path d="M5 8a6 6 0 0110-2M15 12a6 6 0 01-10 2"/></Svg>,
  Trash:  (p) => <Svg {...p}><path d="M3 5h14M8 5V3h4v2M5 5l1 12h8l1-12"/></Svg>,
  Cross:  (p) => <Svg {...p}><path d="M10 3v14M5 8h10"/></Svg>,
};

// ─────────────────────────────────────────────────────────────────────────────
// Mock data
// ─────────────────────────────────────────────────────────────────────────────
const ORGS = [
  'Diocese of Trier',
  'Pontifical North American College',
  'Holy Cross Abbey',
  'St. Meinrad Archabbey',
  'Diocese of Charleston',
  'Mount Angel Seminary',
];

const UPLOAD_ACCEPT = 'audio/*,video/mp4,video/webm,.mp3,.wav,.m4a,.flac,.ogg,.opus,.amr,.mp4,.webm';
const UPLOAD_HINT = 'MP3 · WAV · M4A · FLAC · OGG/Opus · AMR · MP4 video · WebM video · up to 2 GB';

const MEDIA_SEED = [
  ['Homily — Solemnity of the Assumption',          'assumption-2026-08-15.mp3', 'ready',        2241, '2026-04-22'],
  ['Conference: The Spirituality of the Diaconate', 'deaconate-conf-pt2.wav',    'transcribing',  3540, '2026-05-08'],
  ['Vespers Sermon — Feast of St. Augustine',       'vespers-augustine.mp3',     'ready',        1420, '2026-04-19'],
  ['Catechesis on the Eucharist, Part III',         'eucharist-cat-03.mp3',      'indexing',     2810, '2026-05-07'],
  ['Lectio Divina in the Patristic Tradition',      'lectio-patristic.wav',      'ready',        4120, '2026-04-15'],
  ['Homily — Christ the King',                      'christ-king-2025.mp3',      'ready',        1380, '2026-04-12'],
  ['Retreat Address: Silence and the Soul',         'retreat-silence.m4a',       'ready',        2900, '2026-04-10'],
  ['Conference on Sacred Liturgy',                  'sacred-liturgy-01.mp3',     'failed',          0, '2026-05-06'],
  ['Homily — Good Friday Service',                  'good-friday-2026.mp3',      'ready',        1820, '2026-04-08'],
  ['Conference: The Theology of the Body',          'theology-body-04.mp3',      'ready',        3350, '2026-04-04'],
  ['Homily — Easter Vigil',                         'easter-vigil-2026.wav',     'ready',        2640, '2026-04-04'],
  ['Address to Seminarians on Vocation',            'vocation-address.mp3',      'pending',         0, '2026-05-09'],
  ['Conference: Marian Devotion in the East',       'marian-east.wav',           'ready',        2980, '2026-03-28'],
  ['Homily — Feast of Corpus Christi',              'corpus-christi.mp3',        'ready',        1560, '2026-03-22'],
  ['Catechesis on the Sacrament of Confession',     'confession-cat.mp3',        'uploading',       0, '2026-05-09'],
  ['Conference: Patristic Exegesis of Genesis',     'genesis-exegesis.wav',      'ready',        4250, '2026-03-15'],
  ['Holy Hour Reflection',                          'holy-hour-reflection.mp3',  'ready',        2050, '2026-03-10'],
  ['Address — Convocation of Priests',              'convocation-priests.m4a',   'ready',        2730, '2026-03-04'],
  ['Homily — Ash Wednesday',                        'ash-wednesday.mp3',         'ready',        1290, '2026-02-18'],
  ['Conference: The Catholic Imagination',          'catholic-imagination.wav',  'ready',        3680, '2026-02-12'],
];

let _id = 1000;
const makeMedia = () => MEDIA_SEED.map(([title, filename, status, dur, date], i) => ({
  id: `med_${++_id}`,
  title,
  filename,
  status,
  duration_s: dur,
  org: ORGS[i % ORGS.length],
  created_at: date,
}));

// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
const fmtDur = (s) => {
  if (!s) return '—';
  const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
  return h ? `${h}:${String(m).padStart(2, '0')}:${String(ss).padStart(2, '0')}`
           : `${m}:${String(ss).padStart(2, '0')}`;
};
const fmtDate = (iso) => new Date(iso + 'T12:00:00').toLocaleDateString('en-US',
  { year: 'numeric', month: 'short', day: 'numeric' });

const STATUS_LABELS = {
  pending: 'Pending', uploading: 'Uploading', transcribing: 'Transcribing',
  indexing: 'Indexing', ready: 'Ready', failed: 'Failed',
};
const STATUS_VARS = {
  pending:      { fg: 'var(--st-pending-fg)',    bg: 'var(--st-pending-bg)',    bd: 'var(--st-pending-bd)' },
  uploading:    { fg: 'var(--st-uploading-fg)',  bg: 'var(--st-uploading-bg)',  bd: 'var(--st-uploading-bd)' },
  transcribing: { fg: 'var(--st-progress-fg)',   bg: 'var(--st-progress-bg)',   bd: 'var(--st-progress-bd)' },
  indexing:     { fg: 'var(--st-progress-fg)',   bg: 'var(--st-progress-bg)',   bd: 'var(--st-progress-bd)' },
  ready:        { fg: 'var(--st-ready-fg)',      bg: 'var(--st-ready-bg)',      bd: 'var(--st-ready-bd)' },
  failed:       { fg: 'var(--st-failed-fg)',     bg: 'var(--st-failed-bg)',     bd: 'var(--st-failed-bd)' },
};

const StatusBadge = ({ status }) => {
  const v = STATUS_VARS[status] || STATUS_VARS.pending;
  return (
    <span className="badge" style={{ color: v.fg, background: v.bg, borderColor: v.bd }}>
      {STATUS_LABELS[status] || status}
    </span>
  );
};

// ─────────────────────────────────────────────────────────────────────────────
// Header
// ─────────────────────────────────────────────────────────────────────────────
const Header = ({ org, onOrgChange }) => (
  <header style={{ background: 'var(--primary)' }}>
    <div className="top-rule" />
    <div style={{ maxWidth: 1440, margin: '0 auto', padding: '18px 48px',
                  display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 32 }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 28 }}>
        <span className="wordmark" style={{ color: 'var(--bg)' }}>Lectern</span>
        <span className="eyebrow" style={{ color: 'rgba(247,240,230,0.45)' }}>Vox · Scriptum · Memoria</span>
      </div>
      <nav style={{ display: 'flex', gap: 28 }}>
        {[['Library', true], ['Search', false], ['Settings', false]].map(([l, active]) => (
          <a key={l} href={l === 'Search' ? './search.html' : '#'}
             className="eyebrow" style={{
               textDecoration: 'none',
               color: active ? 'var(--bg)' : 'rgba(247,240,230,0.45)',
               borderBottom: active ? '1px solid var(--gold)' : '1px solid transparent',
               paddingBottom: 4,
             }}>{l}</a>
        ))}
      </nav>
      <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
        <select value={org} onChange={(e) => onOrgChange(e.target.value)}
                style={{ background: 'transparent', border: '1px solid rgba(247,240,230,0.2)',
                         borderRadius: 2, padding: '7px 10px', fontSize: 13, color: 'var(--bg)' }}>
          {ORGS.map(o => <option key={o}>{o}</option>)}
        </select>
        <div style={{ width: 32, height: 32, borderRadius: 2, background: 'var(--primary)',
                      border: '1px solid var(--gold-15)',
                      color: 'var(--bg)', display: 'grid', placeItems: 'center',
                      fontFamily: "'Cormorant Garamond', serif", fontSize: 16 }}>R</div>
      </div>
    </div>
  </header>
);

// ─────────────────────────────────────────────────────────────────────────────
// Upload panel (drag-drop + file picker + title + org + simulated PUT progress)
// ─────────────────────────────────────────────────────────────────────────────
const UploadPanel = ({ onUploadComplete, currentOrg }) => {
  const [file, setFile]       = useState(null);
  const [title, setTitle]     = useState('');
  const [org, setOrg]         = useState(currentOrg);
  const [phase, setPhase]     = useState('idle');     // idle | uploading | done
  const [pct, setPct]         = useState(0);
  const [drag, setDrag]       = useState(false);
  const [error, setError]     = useState('');
  const [durationSeconds, setDurationSeconds] = useState(0);
  const inputRef              = useRef(null);

  useEffect(() => setOrg(currentOrg), [currentOrg]);

  const onPick = (f) => {
    if (!f) return;
    setFile(f);
    setError('');
    setDurationSeconds(0);
    if (!title) setTitle(f.name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' '));
    const media = document.createElement(f.type.startsWith('video/') ? 'video' : 'audio');
    const src = URL.createObjectURL(f);
    media.preload = 'metadata';
    media.onloadedmetadata = () => {
      URL.revokeObjectURL(src);
      if (Number.isFinite(media.duration)) {
        setDurationSeconds(Math.round(media.duration));
      }
    };
    media.onerror = () => URL.revokeObjectURL(src);
    media.src = src;
  };

  const onDrop = (e) => {
    e.preventDefault(); setDrag(false);
    const f = e.dataTransfer.files?.[0]; onPick(f);
  };

  const startUpload = async () => {
    if (!file || !title) return;
    setPhase('uploading');
    setPct(8);
    setError('');
    let p = 8;
    const tick = setInterval(() => {
      p = Math.min(92, p + Math.random() * 10 + 4);
      setPct(p);
    }, 220);

    try {
      const initResp = await fetch(apiUrl('/api/uploads/init'), {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          filename: file.name,
          content_type: file.type || 'application/octet-stream',
          title,
          file_size_bytes: file.size,
          duration_seconds: durationSeconds,
          org_id: org,
        }),
      });
      const initData = await readEnvelope(initResp);

      const finalizeBody = {};
      if (initData.strategy === 'multipart') {
        const completedParts = [];
        let uploadedBytes = 0;
        for (const part of initData.parts || []) {
          const start = (part.part_number - 1) * initData.part_size_bytes;
          const end = Math.min(start + initData.part_size_bytes, file.size);
          const chunk = file.slice(start, end);
          const putResp = await uploadPartWithRetry(part.upload_url, chunk, file.type || 'application/octet-stream');
          const etag = putResp.headers.get('etag');
          if (!etag) {
            throw new Error(`Multipart upload missing ETag for part ${part.part_number}`);
          }
          completedParts.push({ part_number: part.part_number, etag });
          uploadedBytes += chunk.size;
          setPct(Math.max(8, Math.min(99, (uploadedBytes / file.size) * 100)));
        }
        finalizeBody.upload_id = initData.upload_id;
        finalizeBody.parts = completedParts;
      } else {
        const putResp = await fetch(initData.upload_url, {
          method: 'PUT',
          headers: { 'Content-Type': file.type || 'application/octet-stream' },
          body: file,
        });
        if (!putResp.ok) {
          throw new Error(`S3 upload failed (${putResp.status})`);
        }
      }

      setPct(100);
      const finalizeResp = await fetch(apiUrl(`/api/media/${initData.media_id}/finalize`), {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(finalizeBody),
      });
      const finalizeData = await readEnvelope(finalizeResp);
      const media = {
        id: finalizeData.media_id || initData.media_id,
        title,
        filename: file.name,
        status: 'transcribing',
        duration_s: durationSeconds,
        org,
        created_at: new Date().toISOString().slice(0, 10),
        job_id: finalizeData.job_id || '',
        error_message: '',
        transcript_s3_key: '',
      };
      onUploadComplete(media);
      setFile(null);
      setTitle('');
      setPhase('idle');
      setPct(0);
    } catch (err) {
      setError(err.message || 'Upload failed');
      setPhase('idle');
      setPct(0);
    } finally {
      clearInterval(tick);
    }
  };

  const uploadPartWithRetry = async (url, chunk, contentType) => {
    let lastErr = null;
    for (let attempt = 1; attempt <= 3; attempt += 1) {
      try {
        const resp = await fetch(url, {
          method: 'PUT',
          headers: { 'Content-Type': contentType },
          body: chunk,
        });
        if (!resp.ok) {
          throw new Error(`S3 upload failed (${resp.status})`);
        }
        return resp;
      } catch (err) {
        lastErr = err;
        if (attempt < 3) {
          await new Promise((resolve) => setTimeout(resolve, attempt * 750));
        }
      }
    }
    throw lastErr || new Error('Multipart upload failed');
  };

  return (
    <section style={{ background: 'var(--surface)', border: '1px solid var(--primary-08)',
                      borderRadius: 2, padding: '28px 28px 24px' }}>
      <div className="eyebrow-gold">Acquisition</div>
      <h3 style={{ margin: '6px 0 4px' }}>Upload Audio or Video</h3>
      <p style={{ fontSize: 13, color: 'var(--slate-soft)', margin: '0 0 22px' }}>
        Audio and supported video files are uploaded directly to S3, then queued for transcription and indexing.
      </p>

      <div className={`dropzone ${drag ? 'dropzone--active' : ''}`}
           onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
           onDragLeave={() => setDrag(false)}
           onDrop={onDrop}
           onClick={() => phase === 'idle' && inputRef.current?.click()}
           style={{ padding: '32px 20px', textAlign: 'center', cursor: phase === 'idle' ? 'pointer' : 'default' }}>
        <input ref={inputRef} type="file" hidden accept={UPLOAD_ACCEPT}
               onChange={(e) => onPick(e.target.files?.[0])} />
        {!file ? (
          <>
            <div style={{ color: 'var(--gold-strong)', marginBottom: 12 }}>
              <Icon.Upload size={22} />
            </div>
            <div style={{ color: 'var(--navy)', fontSize: 14, fontWeight: 500, marginBottom: 4 }}>
              Drag a file here, or <span style={{ borderBottom: '1px solid var(--gold)' }}>browse</span>
            </div>
            <div style={{ fontSize: 12, color: 'var(--slate-soft)' }}>{UPLOAD_HINT}</div>
          </>
        ) : (
          <div style={{ textAlign: 'left', display: 'flex', alignItems: 'center', gap: 14 }}>
            <div style={{ color: 'var(--navy-70)' }}><Icon.Doc size={26} /></div>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div className="mono" style={{ fontSize: 13, color: 'var(--navy)',
                   overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
              <div style={{ fontSize: 12, color: 'var(--slate-soft)' }}>
                {(file.size / 1024 / 1024).toFixed(1)} MB
              </div>
            </div>
            {phase === 'idle' && (
              <button onClick={(e) => { e.stopPropagation(); setFile(null); setTitle(''); }}
                      style={{ background: 'transparent', border: 'none', color: 'var(--slate-soft)', padding: 4 }}>
                <Icon.X size={16} />
              </button>
            )}
          </div>
        )}
      </div>

      <div style={{ marginTop: 22 }}>
        <label className="eyebrow" style={{ display: 'block', marginBottom: 8 }}>Title</label>
        <input className="input-bordered" placeholder="e.g., Homily — Feast of the Annunciation"
               value={title} onChange={(e) => setTitle(e.target.value)} disabled={phase !== 'idle'} />
      </div>

      <div style={{ marginTop: 16 }}>
        <label className="eyebrow" style={{ display: 'block', marginBottom: 8 }}>Organisation</label>
        <select className="input-bordered" value={org} onChange={(e) => setOrg(e.target.value)} disabled={phase !== 'idle'}>
          {ORGS.map(o => <option key={o}>{o}</option>)}
        </select>
      </div>

      {phase !== 'idle' && (
        <div style={{ marginTop: 22 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
            <span className="eyebrow">{phase === 'done' ? 'Complete' : 'Uploading to S3'}</span>
            <span className="mono" style={{ fontSize: 12, color: 'var(--navy)' }}>{Math.round(pct)}%</span>
          </div>
          <div style={{ height: 2, background: 'var(--navy-08)', borderRadius: 0, overflow: 'hidden' }}>
            <div style={{ width: `${pct}%`, height: '100%',
                          background: phase === 'done' ? 'var(--st-ready-fg)' : 'var(--gold)',
                          transition: 'width 0.2s ease' }} />
          </div>
        </div>
      )}

      <button className="btn-primary" disabled={!file || !title || phase !== 'idle'}
              onClick={startUpload} style={{ marginTop: 24, width: '100%' }}>
        {phase === 'uploading' ? 'Uploading…' : phase === 'done' ? 'Submitted' : 'Begin Upload'}
      </button>
      {error && (
        <div style={{ marginTop: 12, color: 'var(--st-failed-fg)', fontSize: 13 }}>{error}</div>
      )}
    </section>
  );
};

// ─────────────────────────────────────────────────────────────────────────────
// Job status panel — polls every 3s, shows progress steps
// ─────────────────────────────────────────────────────────────────────────────
const JOB_STEPS = ['Uploaded', 'Transcribing', 'Indexing', 'Ready'];

const JobPanel = ({ media, onClose, onRetry }) => {
  const [liveJob, setLiveJob] = useState(null);
  const [liveError, setLiveError] = useState('');
  // step index simulated based on status
  const [step, setStep] = useState(() => {
    if (media.status === 'pending' || media.status === 'uploading') return 0;
    if (media.status === 'transcribing') return 1;
    if (media.status === 'indexing') return 2;
    if (media.status === 'ready' || media.status === 'failed') return 3;
    return 0;
  });
  const [progress, setProgress] = useState(media.status === 'ready' ? 100 :
                                            media.status === 'failed' ? 100 : 32);
  const [log, setLog] = useState([
    [`+0s`, `media accepted: ${media.id}`],
    [`+0s`, `s3 PUT complete (${media.filename})`],
    [`+1s`, `enqueued for transcription`],
  ]);
  const [tick, setTick] = useState(0);
  const isLiveJob = Boolean(media.job_id);

  useEffect(() => {
    if (isLiveJob) return;
    if (media.status === 'ready' || media.status === 'failed') return;
    const id = setInterval(() => setTick((t) => t + 1), 3000);
    return () => clearInterval(id);
  }, [media.status, isLiveJob]);

  useEffect(() => {
    if (!isLiveJob) return;
    let active = true;
    const poll = async () => {
      try {
        const resp = await fetch(apiUrl(`/api/jobs/${media.job_id}`));
        const data = await readEnvelope(resp);
        if (!active) return;
        setLiveJob(data);
        setLiveError('');
      } catch (err) {
        if (!active) return;
        setLiveError(err.message || 'Unable to load job status');
      }
    };
    poll();
    const id = setInterval(poll, 3000);
    return () => {
      active = false;
      clearInterval(id);
    };
  }, [isLiveJob, media.job_id]);

  useEffect(() => {
    if (isLiveJob) return;
    if (tick === 0) return;
    if (media.status === 'ready' || media.status === 'failed') return;
    setProgress((p) => {
      const next = p + Math.random() * 14 + 6;
      if (next >= 100) {
        if (step < 3) {
          setStep(step + 1);
          setLog((l) => [...l, [`+${tick * 3}s`,
            step === 0 ? 'transcript ready · 4,182 segments' :
            step === 1 ? 'chunks indexed · MySQL FULLTEXT' :
                         'media marked ready']]);
          return 0;
        }
        return 100;
      }
      return next;
    });
  }, [tick]); // eslint-disable-line

  const liveStatus = liveJob?.status || 'queued';
  const currentStep = isLiveJob
    ? (liveStatus === 'completed' ? 3 : liveStatus === 'failed' ? 3 : 1)
    : step;
  const currentProgress = isLiveJob
    ? (liveStatus === 'completed' ? 100 : liveStatus === 'processing' ? 70 : 30)
    : progress;

  const stepStatus = (i) => {
    if (i < currentStep) return 'done';
    if (i === currentStep && currentStep < 3) return 'active';
    if (currentStep >= 3 && i === 3) return 'done';
    return 'pending';
  };

  return (
    <section style={{ background: 'var(--surface)', border: '1px solid var(--primary-08)',
                      borderRadius: 2 }}>
      <div style={{ padding: '20px 28px 16px', borderBottom: '1px solid var(--navy-08)',
                    display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16 }}>
        <div>
          <div className="eyebrow-gold">Processing Job</div>
          <h3 style={{ margin: '4px 0 2px', maxWidth: 320 }}>{media.title}</h3>
          <div className="mono" style={{ fontSize: 11, color: 'var(--slate-soft)' }}>
            {media.id} · {media.filename}
          </div>
        </div>
        <button onClick={onClose}
                style={{ background: 'transparent', border: 'none', color: 'var(--slate-soft)', padding: 4 }}>
          <Icon.X size={18} />
        </button>
      </div>

      <div style={{ padding: '20px 28px', borderBottom: '1px solid var(--navy-08)',
                    display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 18, fontSize: 13 }}>
          <div>
          <div className="eyebrow" style={{ marginBottom: 4 }}>Duration</div>
          <div style={{ color: 'var(--navy)' }} className="mono">{fmtDur(media.duration_s)}</div>
        </div>
        <div>
          <div className="eyebrow" style={{ marginBottom: 4 }}>Created</div>
          <div style={{ color: 'var(--navy)' }}>{fmtDate(media.created_at)}</div>
        </div>
        <div>
          <div className="eyebrow" style={{ marginBottom: 4 }}>Organisation</div>
          <div style={{ color: 'var(--navy)' }}>{media.org}</div>
        </div>
        <div>
          <div className="eyebrow" style={{ marginBottom: 4 }}>Status</div>
          <StatusBadge status={media.status} />
        </div>
      </div>

      <div style={{ padding: '24px 28px 8px' }}>
        <div className="eyebrow" style={{ marginBottom: 16 }}>Pipeline</div>
        <ol style={{ listStyle: 'none', padding: 0, margin: 0 }}>
          {JOB_STEPS.map((label, i) => {
            const s = stepStatus(i);
            const isLast = i === JOB_STEPS.length - 1;
            return (
              <li key={label} style={{ display: 'flex', gap: 14, paddingBottom: isLast ? 0 : 14, position: 'relative' }}>
                <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
                  <div style={{
                    width: 22, height: 22, borderRadius: 2,
                    border: `1px solid ${s === 'done' ? 'var(--st-ready-fg)'
                                       : s === 'active' ? 'var(--gold)'
                                       : 'var(--navy-15)'}`,
                    background: s === 'done' ? 'var(--st-ready-fg)'
                              : s === 'active' ? 'var(--surface)'
                              : 'var(--surface)',
                    color: s === 'done' ? 'white' : 'var(--gold-strong)',
                    display: 'grid', placeItems: 'center',
                  }}>
                    {s === 'done' ? <Icon.Check size={12} />
                     : s === 'active' ? <span style={{
                          width: 7, height: 7, borderRadius: '50%', background: 'var(--gold)',
                          animation: 'shimmer 1.4s ease-in-out infinite' }} />
                     : <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--navy-15)' }} />}
                  </div>
                  {!isLast && <div style={{ flex: 1, width: 1, background: 'var(--navy-08)', minHeight: 22, marginTop: 4 }} />}
                </div>
                <div style={{ flex: 1, paddingTop: 1 }}>
                  <div style={{ fontSize: 14, fontWeight: 500,
                                color: s === 'pending' ? 'var(--slate-soft)' : 'var(--navy)' }}>{label}</div>
                  {s === 'active' && (
                    <div style={{ marginTop: 8 }}>
                      <div style={{ height: 2, background: 'var(--navy-08)', overflow: 'hidden' }}>
                        <div style={{ width: `${currentProgress}%`, height: '100%', background: 'var(--gold)',
                                       transition: 'width 0.4s ease' }} />
                      </div>
                      <div className="mono" style={{ fontSize: 11, color: 'var(--slate-soft)', marginTop: 4 }}>
                        {Math.round(currentProgress)}% · polling every 3s
                      </div>
                    </div>
                  )}
                  {s === 'done' && i < 3 && (
                    <div className="mono" style={{ fontSize: 11, color: 'var(--slate-soft)', marginTop: 2 }}>
                      complete
                    </div>
                  )}
                </div>
              </li>
            );
          })}
        </ol>
      </div>

      <div style={{ padding: '16px 28px 24px', borderTop: '1px solid var(--navy-08)', marginTop: 16 }}>
        <div className="eyebrow" style={{ marginBottom: 10 }}>Event log</div>
        <div className="mono" style={{ fontSize: 11, lineHeight: 1.7, color: 'var(--slate-soft)' }}>
          {isLiveJob ? (
            <>
              <div><span style={{ color: 'var(--gold-strong)' }}>+0s</span> &nbsp; media accepted: {media.id}</div>
              <div><span style={{ color: 'var(--gold-strong)' }}>+0s</span> &nbsp; upload finalized, job {media.job_id}</div>
              <div><span style={{ color: 'var(--gold-strong)' }}>+3s</span> &nbsp; job status: {liveStatus}</div>
              {liveError && <div><span style={{ color: 'var(--st-failed-fg)' }}>! </span> {liveError}</div>}
              {media.error_message && <div><span style={{ color: 'var(--st-failed-fg)' }}>! </span> {media.error_message}</div>}
            </>
          ) : (
            log.map(([t, msg], i) => (
              <div key={i}><span style={{ color: 'var(--gold-strong)' }}>{t}</span> &nbsp; {msg}</div>
            ))
          )}
        </div>
        {(['failed', 'transcribing', 'indexing', 'uploading', 'pending'].includes(media.status)) && (
          <div style={{ marginTop: 16 }}>
            <button className="btn-ghost" onClick={() => onRetry(media.id)}>Retry / Reconcile</button>
          </div>
        )}
      </div>
    </section>
  );
};

// ─────────────────────────────────────────────────────────────────────────────
// Library table with status filter + pagination
// ─────────────────────────────────────────────────────────────────────────────
const STATUS_FILTERS = ['all', 'pending', 'uploading', 'transcribing', 'indexing', 'ready', 'failed'];
const PAGE_SIZE = 8;

const Library = ({ media, selectedId, onSelect }) => {
  const [filter, setFilter] = useState('all');
  const [query, setQuery]   = useState('');
  const [page, setPage]     = useState(1);

  const filtered = useMemo(() => {
    let m = media;
    if (filter !== 'all') m = m.filter(x => x.status === filter);
    if (query) {
      const q = query.toLowerCase();
      m = m.filter(x => x.title.toLowerCase().includes(q) || x.filename.toLowerCase().includes(q));
    }
    return m;
  }, [media, filter, query]);

  useEffect(() => { setPage(1); }, [filter, query]);

  const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
  const slice = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);

  return (
    <section>
      {/* Filter bar */}
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                    gap: 24, marginBottom: 18, flexWrap: 'wrap' }}>
        <div style={{ display: 'flex', gap: 0, border: '1px solid var(--primary-08)', borderRadius: 2,
                      background: 'var(--surface)', overflow: 'hidden' }}>
          {STATUS_FILTERS.map((s, i) => (
            <button key={s} onClick={() => setFilter(s)}
                    style={{
                      padding: '9px 16px', fontSize: 11, letterSpacing: '0.12em',
                      textTransform: 'uppercase', fontWeight: 500,
                      border: 'none',
                      borderLeft: i === 0 ? 'none' : '1px solid var(--navy-08)',
                      background: filter === s ? 'var(--navy)' : 'transparent',
                      color: filter === s ? 'var(--ivory)' : 'var(--slate)',
                      transition: 'background 0.12s',
                    }}>{s === 'all' ? 'All' : STATUS_LABELS[s]}</button>
          ))}
        </div>
        <div style={{ position: 'relative', width: 280 }}>
          <Icon.Search size={14} style={{ position: 'absolute', left: 10, top: '50%',
                                          transform: 'translateY(-50%)', color: 'var(--slate-soft)' }} />
          <input className="input-bordered" placeholder="Search titles…"
                 value={query} onChange={(e) => setQuery(e.target.value)}
                 style={{ paddingLeft: 32 }} />
        </div>
      </div>

      {/* Table */}
      <div style={{ background: 'var(--surface)', border: '1px solid var(--primary-08)', borderRadius: 2 }}>
        <div style={{
          display: 'grid',
          gridTemplateColumns: 'minmax(0, 1.7fr) minmax(0, 1fr) 110px 130px 130px 28px',
          gap: 20, padding: '14px 24px',
          borderBottom: '1px solid var(--navy-08)', background: 'var(--ivory)',
        }}>
          {['Title', 'Filename', 'Duration', 'Status', 'Created', ''].map((h, i) => (
            <div key={i} className="eyebrow">{h}</div>
          ))}
        </div>
        {slice.length === 0 ? (
          <div style={{ padding: 64, textAlign: 'center' }}>
            <div className="serif" style={{ fontSize: 22, fontStyle: 'italic', color: 'var(--slate-soft)' }}>
              No records match this filter.
            </div>
          </div>
        ) : slice.map((m) => (
          <div key={m.id} onClick={() => onSelect(m)}
               className={`row-hover ${selectedId === m.id ? 'active' : ''}`}
               style={{
                 display: 'grid',
                 gridTemplateColumns: 'minmax(0, 1.7fr) minmax(0, 1fr) 110px 130px 130px 28px',
                 gap: 20, padding: '16px 24px',
                 borderBottom: '1px solid var(--navy-04)',
                 alignItems: 'center',
                 borderLeft: selectedId === m.id ? '2px solid var(--gold)' : '2px solid transparent',
                 paddingLeft: selectedId === m.id ? 22 : 24,
               }}>
            <div style={{ minWidth: 0 }}>
              <div style={{ color: 'var(--navy)', fontWeight: 500, fontSize: 14,
                            overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                {m.title}
              </div>
              <div style={{ fontSize: 12, color: 'var(--slate-soft)', marginTop: 2 }}>{m.org}</div>
            </div>
            <div className="mono" style={{ fontSize: 12, color: 'var(--slate)',
                                            overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
              {m.filename}
            </div>
            <div className="mono" style={{ fontSize: 13, color: 'var(--navy)' }}>{fmtDur(m.duration_s)}</div>
            <div><StatusBadge status={m.status} /></div>
            <div style={{ fontSize: 13, color: 'var(--slate)' }}>{fmtDate(m.created_at)}</div>
            <div style={{ color: 'var(--slate-soft)' }}><Icon.ChevR size={14} /></div>
          </div>
        ))}
      </div>

      {/* Pagination */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
                    marginTop: 16, fontSize: 12, color: 'var(--slate-soft)' }}>
        <div className="eyebrow">
          Showing {filtered.length === 0 ? 0 : (page - 1) * PAGE_SIZE + 1}–
          {Math.min(page * PAGE_SIZE, filtered.length)} of {filtered.length}
        </div>
        <div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
          <button onClick={() => setPage(Math.max(1, page - 1))} disabled={page === 1}
                  style={{ background: 'transparent', border: '1px solid var(--navy-15)', borderRadius: 2,
                           padding: '6px 8px', color: page === 1 ? 'var(--navy-15)' : 'var(--navy)',
                           cursor: page === 1 ? 'not-allowed' : 'pointer' }}>
            <Icon.ChevL size={14} />
          </button>
          {Array.from({ length: pageCount }, (_, i) => i + 1).map((p) => (
            <button key={p} onClick={() => setPage(p)}
                    style={{ background: p === page ? 'var(--navy)' : 'transparent',
                             color: p === page ? 'var(--ivory)' : 'var(--navy)',
                             border: '1px solid var(--navy-15)', borderRadius: 2, minWidth: 28,
                             padding: '6px 10px', fontSize: 12, fontWeight: 500 }}>{p}</button>
          ))}
          <button onClick={() => setPage(Math.min(pageCount, page + 1))} disabled={page === pageCount}
                  style={{ background: 'transparent', border: '1px solid var(--navy-15)', borderRadius: 2,
                           padding: '6px 8px', color: page === pageCount ? 'var(--navy-15)' : 'var(--navy)',
                           cursor: page === pageCount ? 'not-allowed' : 'pointer' }}>
            <Icon.ChevR size={14} />
          </button>
        </div>
      </div>
    </section>
  );
};

// ─────────────────────────────────────────────────────────────────────────────
// App
// ─────────────────────────────────────────────────────────────────────────────
const App = () => {
  const [media, setMedia]     = useState(makeMedia);
  const [selected, setSel]    = useState(null);
  const [org, setOrg]         = useState(ORGS[0]);

  const loadMedia = useCallback(async (activeRef) => {
    try {
      const resp = await fetch(apiUrl('/api/media'));
      const data = await readEnvelope(resp);
      if (activeRef && !activeRef.current) return;
      const items = Array.isArray(data) ? data : [];
      if (items.length > 0) {
        setMedia(items.map(mapApiMedia));
      } else {
        setMedia(makeMedia());
      }
    } catch {
      if (!activeRef || activeRef.current) setMedia(makeMedia());
    }
  }, []);

  useEffect(() => {
    const active = { current: true };
    loadMedia(active);
    return () => { active.current = false; };
  }, [loadMedia]);

  useEffect(() => {
    const hasInflight = media.some((m) => ['transcribing', 'indexing', 'uploading', 'pending'].includes(m.status));
    if (!hasInflight) return;

    const active = { current: true };
    const id = setInterval(() => { loadMedia(active); }, 3000);
    return () => {
      active.current = false;
      clearInterval(id);
    };
  }, [loadMedia, media]);

  useEffect(() => {
    if (!selected) return;
    const next = media.find((m) => m.id === selected.id);
    if (!next || next === selected) return;
    const merged = next.job_id ? next : { ...next, job_id: selected.job_id };
    if (merged.status !== selected.status || merged.job_id !== selected.job_id || merged.title !== selected.title) {
      setSel(merged);
    }
  }, [media, selected]);

  const onUploadComplete = (m) => {
    setMedia((prev) => [m, ...prev]);
    setSel(m);
  };

  const onRetryMedia = async (mediaID) => {
    try {
      const resp = await fetch(apiUrl(`/api/media/${mediaID}/retry`), {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
      });
      const item = await readEnvelope(resp);
      const mapped = mapApiMedia(item);
      setMedia((prev) => prev.map((m) => (m.id === mediaID ? mapped : m)));
      setSel(mapped);
    } catch (err) {
      const message = err.message || 'Retry failed';
      setMedia((prev) => prev.map((m) => (m.id === mediaID ? { ...m, error_message: message } : m)));
      if (selected?.id === mediaID) {
        setSel((prev) => prev ? { ...prev, error_message: message } : prev);
      }
    }
  };

  const stats = useMemo(() => ({
    total: media.length,
    ready: media.filter(m => m.status === 'ready').length,
    inflight: media.filter(m => ['transcribing', 'indexing', 'uploading', 'pending'].includes(m.status)).length,
    failed: media.filter(m => m.status === 'failed').length,
  }), [media]);

  return (
    <div style={{ minHeight: '100vh' }}>
      <Header org={org} onOrgChange={setOrg} />

      <main style={{ maxWidth: 1440, margin: '0 auto', padding: '36px 48px 80px' }}>
        {/* Page header */}
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end',
                      marginBottom: 36, gap: 32 }}>
          <div>
            <div className="eyebrow-gold">Catalog</div>
            <h1 style={{ margin: '4px 0 0' }}>Media Library</h1>
          </div>
          <div style={{ display: 'flex', gap: 32, fontSize: 13, color: 'var(--slate)' }}>
            {[['Total', stats.total], ['Ready', stats.ready],
              ['In flight', stats.inflight], ['Failed', stats.failed]].map(([l, n]) => (
              <div key={l} style={{ textAlign: 'right', borderLeft: '1px solid var(--navy-08)', paddingLeft: 16 }}>
                <div className="eyebrow">{l}</div>
                <div className="serif" style={{ fontSize: 28, color: 'var(--navy)', fontWeight: 500, lineHeight: 1.1 }}>
                  {String(n).padStart(2, '0')}
                </div>
              </div>
            ))}
          </div>
        </div>

        {/* Two-column layout */}
        <div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 380px', gap: 36,
                      alignItems: 'start' }}>
          <Library media={media} selectedId={selected?.id} onSelect={setSel} />
          <aside style={{ position: 'sticky', top: 24 }}>
            {selected ? (
              <JobPanel media={selected} onClose={() => setSel(null)} onRetry={onRetryMedia} />
            ) : (
              <UploadPanel onUploadComplete={onUploadComplete} currentOrg={org} />
            )}
          </aside>
        </div>
      </main>

      <footer style={{ background: 'var(--primary)', padding: '20px 48px',
                       fontSize: 11, color: 'rgba(247,240,230,0.45)', textAlign: 'center',
                       letterSpacing: '0.12em', textTransform: 'uppercase' }}>
        Lectern · Gateway API <span className="mono" style={{ textTransform: 'none' }}>{API_BASE || window.location.origin}</span>
      </footer>
    </div>
  );
};

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