// screens-ingest.jsx - Upload, Recorders, Capture, Monitors

/* ===== Upload helpers ===== */
const _SIMPLE_MAX = 50 * 1024 * 1024;  // 50 MB → simple upload
const _PART_SIZE  = 10 * 1024 * 1024;  // 10 MB chunks for multipart

function _xhrPost(url, formData, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.withCredentials = true;
    xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded, e.total); };
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
      } else {
        let msg = xhr.status + ' ' + xhr.statusText;
        try { const j = JSON.parse(xhr.responseText); msg = j.error || j.message || msg; } catch {}
        reject(new Error(msg));
      }
    };
    xhr.onerror = () => reject(new Error('Network error'));
    xhr.open('POST', url);
    xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui');
    xhr.send(formData);
  });
}

async function _uploadFile(file, projectId, onProgress) {
  const mime = file.type || 'application/octet-stream';

  if (file.size <= _SIMPLE_MAX) {
    const fd = new FormData();
    fd.append('file', file, file.name);
    fd.append('filename', file.name);
    fd.append('projectId', projectId);
    fd.append('contentType', mime);
    return _xhrPost((window.ZAMPP_API_PREFIX || '/api/v1') + '/upload/simple', fd,
      (loaded, total) => onProgress(Math.round((loaded / total) * 100)));
  }

  // - Multipart -
  const init = await window.ZAMPP_API.fetch('/upload/init', {
    method: 'POST',
    body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }),
  });
  const { assetId, uploadId, key } = init;
  const totalParts = Math.ceil(file.size / _PART_SIZE);
  const parts = [];

  for (let i = 0; i < totalParts; i++) {
    const chunk = file.slice(i * _PART_SIZE, Math.min((i + 1) * _PART_SIZE, file.size));
    const fd = new FormData();
    fd.append('file', chunk, file.name);
    fd.append('uploadId', uploadId);
    fd.append('key', key);
    fd.append('partNumber', String(i + 1));
    const partRes = await _xhrPost((window.ZAMPP_API_PREFIX || '/api/v1') + '/upload/part', fd,
      (loaded, total) => onProgress(Math.round(((i + loaded / total) / totalParts) * 100)));
    parts.push({ PartNumber: i + 1, ETag: partRes.etag || partRes.ETag });
  }

  await window.ZAMPP_API.fetch('/upload/complete', {
    method: 'POST',
    body: JSON.stringify({ uploadId, key, assetId, parts }),
  });
  return { id: assetId };
}

/* ===== Upload ===== */
function Upload({ navigate }) {
  const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
  const [files, setFiles] = React.useState([]);
  const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');

  const updateFile = React.useCallback((id, patch) => {
    setFiles(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
  }, []);

  const startUpload = React.useCallback((entry, pid) => {
    _uploadFile(entry.file, pid, (pct) => updateFile(entry.id, { progress: pct }))
      .then(() => {
        updateFile(entry.id, { status: 'done', progress: 100 });
        window.dispatchEvent(new CustomEvent('df:assets-changed'));
      })
      .catch(e => updateFile(entry.id, { status: 'error', progress: 0, error: e.message }));
  }, [updateFile]);

  const handleDrop = React.useCallback((e) => {
    e.preventDefault();
    const dropped = Array.from(e.dataTransfer ? e.dataTransfer.files : e.target.files);
    const pid = projectId || PROJECTS[0]?.id || '';
    const newEntries = dropped.map((f, i) => ({
      id: Date.now() + i,
      name: f.name,
      size: window.ZAMPP_API.fmtSize(f.size),
      file: f,
      progress: 0,
      status: 'uploading',
      error: null,
    }));
    setFiles(prev => [...prev, ...newEntries]);
    newEntries.forEach(entry => startUpload(entry, pid));
  }, [projectId, startUpload]);

  return (
    <div className="page">
      <div className="page-header">
        <h1>Upload</h1>
        <span className="subtitle">Drop video, audio, or stills: we proxy and index automatically.</span>
      </div>
      <div className="page-body">
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
          <div>
            <label className="field-label">Project</label>
            <select className="field-input" value={projectId} onChange={e => setProjectId(e.target.value)}
              style={{ appearance: 'auto' }}>
              {PROJECTS.length === 0
                ? <option value="">No projects</option>
                : PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
            </select>
          </div>
        </div>

        <div className="dropzone"
          onDrop={handleDrop}
          onDragOver={e => e.preventDefault()}
          onClick={() => {
            const inp = document.createElement('input');
            inp.type = 'file'; inp.multiple = true;
            inp.onchange = handleDrop;
            inp.click();
          }}>
          <Icon name="upload" size={32} style={{ color: 'var(--text-3)' }} />
          <div style={{ fontSize: 15, fontWeight: 500 }}>Drop files here or click to browse</div>
          <div className="muted" style={{ fontSize: 12.5 }}>Video, audio, and image files</div>
          <div className="dropzone-formats">
            {['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f =>
              <span key={f} className="badge outline">{f}</span>)}
          </div>
        </div>

        {files.length > 0 && (
          <div style={{ marginTop: 24 }}>
            <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
              Queue <span className="badge neutral">{files.length}</span>
              <span style={{ flex: 1 }} />
              <button className="btn ghost sm" onClick={() => setFiles(f => f.filter(x => x.status === 'uploading'))}>Clear done</button>
            </div>
            <div className="panel">
              {files.map(f => (
                <div key={f.id} className="upload-row">
                  <Icon name={f.name.match(/\.(wav|aif|mp3|aiff)$/i) ? 'audio' : 'video'} size={16}
                    style={{ color: 'var(--text-3)' }} />
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
                      <span style={{ fontWeight: 500, fontSize: 12.5 }}>{f.name}</span>
                      <span className="muted" style={{ fontSize: 11, fontFamily: 'var(--font-mono)' }}>{f.size}</span>
                    </div>
                    <div style={{ marginTop: 6, height: 4, background: 'var(--bg-3)', borderRadius: 99, overflow: 'hidden' }}>
                      <div style={{
                        width: f.progress + '%', height: '100%',
                        background: f.status === 'done' ? 'var(--success)' : f.status === 'error' ? 'var(--danger)' : 'var(--accent)',
                        transition: 'width 200ms',
                      }} />
                    </div>
                    {f.status === 'error' && (
                      <div style={{ marginTop: 3, fontSize: 11, color: 'var(--danger)' }}>{f.error}</div>
                    )}
                  </div>
                  <span className="mono" style={{
                    fontSize: 11.5, minWidth: 60, textAlign: 'right',
                    color: f.status === 'done' ? 'var(--success)' : f.status === 'error' ? 'var(--danger)' : 'var(--text-3)',
                  }}>
                    {f.status === 'done' ? '✓ done'
                     : f.status === 'error' ? '✗ failed'
                     : f.progress + '%'}
                  </span>
                </div>
              ))}
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

/* ===== YouTube importer ===== */
// Accept the same three URL shapes the API validates against.
const _YT_PATTERNS = [
  /^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?[^ ]*v=[A-Za-z0-9_-]{11}/i,
  /^https?:\/\/youtu\.be\/[A-Za-z0-9_-]{11}/i,
  /^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[A-Za-z0-9_-]{11}/i,
];
function _looksLikeYouTube(s) {
  return typeof s === 'string' && _YT_PATTERNS.some(re => re.test(s.trim()));
}

function YouTubeImport({ navigate }) {
  const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
  const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
  const [url, setUrl] = React.useState('');
  const [queue, setQueue] = React.useState([]);     // { id, url, status, progress, title, error, assetId, jobId }
  const [submitting, setSubmitting] = React.useState(false);

  const valid = _looksLikeYouTube(url);

  const updateRow = React.useCallback((id, patch) => {
    setQueue(prev => prev.map(r => r.id === id ? { ...r, ...patch } : r));
  }, []);

  // Poll the asset row to pick up the title once yt-dlp resolves it, and the
  // proxy job's progress so the queue row reflects the full lifecycle, not
  // just the import step.
  const pollRow = React.useCallback((row) => {
    if (!row.assetId) return;
    let stopped = false;
    const tick = async () => {
      if (stopped) return;
      try {
        const asset = await window.ZAMPP_API.fetch('/assets/' + row.assetId);
        const patch = {};
        if (asset.display_name && asset.display_name !== row.url) patch.title = asset.display_name;
        if (asset.status === 'ready') {
          patch.status = 'done';
          patch.progress = 100;
          window.dispatchEvent(new CustomEvent('df:assets-changed'));
        } else if (asset.status === 'error') {
          patch.status = 'error';
          patch.error = patch.error || 'Import failed: check the Jobs screen for details.';
        } else if (asset.status === 'processing') {
          patch.status = 'processing';
        } else if (asset.status === 'ingesting') {
          patch.status = 'downloading';
        }

        // While the import is still running, pull the live percentage from its
        // BullMQ job so the bar reflects the actual yt-dlp download instead of
        // sitting at 0 until the asset flips to ready. The worker emits 2..100
        // across the download + S3 upload (job.updateProgress). If the job has
        // already completed and been evicted, the fetch throws and we just fall
        // back to the status-driven values above.
        if (row.jobId && asset.status !== 'ready' && asset.status !== 'error') {
          try {
            const job = await window.ZAMPP_API.fetch('/jobs/' + row.jobId);
            if (typeof job.progress === 'number') patch.progress = job.progress;
          } catch { /* job evicted after completion — fall back to status */ }
        }

        if (Object.keys(patch).length) updateRow(row.id, patch);
        if (asset.status === 'ready' || asset.status === 'error') return;
      } catch { /* ignore */ }
      // Poll fairly briskly: the download phase is where the user wants to see
      // the bar move, and a short clip can finish in a handful of seconds.
      setTimeout(tick, 2000);
    };
    tick();
    return () => { stopped = true; };
  }, [updateRow]);

  const submit = React.useCallback(async () => {
    if (!valid || !projectId || submitting) return;
    setSubmitting(true);
    const rowId = Date.now();
    const row = {
      id: rowId,
      url: url.trim(),
      status: 'queued',
      progress: 0,
      title: '',
      error: null,
      assetId: null,
      jobId: null,
    };
    setQueue(prev => [row, ...prev]);
    try {
      const res = await window.ZAMPP_API.fetch('/imports/youtube', {
        method: 'POST',
        body: JSON.stringify({ url: row.url, projectId }),
      });
      updateRow(rowId, { assetId: res.assetId, jobId: res.jobId, status: 'downloading' });
      pollRow({ ...row, assetId: res.assetId, jobId: res.jobId });
      setUrl('');
    } catch (e) {
      updateRow(rowId, { status: 'error', error: e.message || 'Failed to start import' });
    } finally {
      setSubmitting(false);
    }
  }, [valid, projectId, submitting, url, updateRow, pollRow]);

  return (
    <div className="page">
      <div className="page-header">
        <h1>YouTube</h1>
        <span className="subtitle">Paste a link: we download and import the best available MP4.</span>
      </div>
      <div className="page-body">
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
          <div>
            <label className="field-label">Project</label>
            <select className="field-input" value={projectId} onChange={e => setProjectId(e.target.value)}
              style={{ appearance: 'auto' }}>
              {PROJECTS.length === 0
                ? <option value="">No projects</option>
                : PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
            </select>
          </div>
        </div>

        <div className="field" style={{ marginBottom: 8 }}>
          <label className="field-label">YouTube URL</label>
          <div style={{ display: 'flex', gap: 8 }}>
            <input
              className="field-input mono"
              value={url}
              onChange={e => setUrl(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter' && valid) submit(); }}
              placeholder="https://www.youtube.com/watch?v=… or https://youtu.be/…"
              style={{ flex: 1 }}
              autoFocus
            />
            <button
              className="btn primary"
              onClick={submit}
              disabled={!valid || !projectId || submitting}
              title={!valid ? 'Paste a YouTube URL (youtube.com/watch, youtu.be, or shorts)' : ''}
            >
              <Icon name="download" />Import
            </button>
          </div>
          {url && !valid && (
            <div style={{ fontSize: 11.5, color: 'var(--danger)', marginTop: 4 }}>
              That doesn't look like a YouTube URL.
            </div>
          )}
          <div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 6 }}>
            Only import videos you have rights to use. Private, age-gated, and members-only videos are not supported.
          </div>
        </div>

        {queue.length > 0 && (
          <div style={{ marginTop: 20 }}>
            <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
              Queue <span className="badge neutral">{queue.length}</span>
              <span style={{ flex: 1 }} />
              <button className="btn ghost sm" onClick={() => setQueue(q => q.filter(r => r.status !== 'done' && r.status !== 'error'))}>
                Clear finished
              </button>
            </div>
            <div className="panel">
              {queue.map(r => {
                const statusColor =
                  r.status === 'done'  ? 'var(--success)' :
                  r.status === 'error' ? 'var(--danger)'  : 'var(--text-3)';
                return (
                  <div key={r.id} className="upload-row">
                    <Icon name="link" size={16} style={{ color: 'var(--text-3)' }} />
                    <div style={{ flex: 1, minWidth: 0 }}>
                      <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
                        <span style={{ fontWeight: 500, fontSize: 12.5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                          {r.title || r.url}
                        </span>
                      </div>
                      {r.title && (
                        <div className="muted mono" style={{ fontSize: 10.5, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={r.url}>
                          {r.url}
                        </div>
                      )}
                      <div style={{ marginTop: 6, height: 4, background: 'var(--bg-3)', borderRadius: 99, overflow: 'hidden' }}>
                        <div style={{
                          width: (r.status === 'done' ? 100 : r.progress) + '%',
                          height: '100%',
                          background: r.status === 'done' ? 'var(--success)' : r.status === 'error' ? 'var(--danger)' : 'var(--accent)',
                          transition: 'width 200ms',
                        }} />
                      </div>
                      {r.error && (
                        <div style={{ marginTop: 3, fontSize: 11, color: 'var(--danger)' }}>{r.error}</div>
                      )}
                    </div>
                    <span className="mono" style={{ fontSize: 11.5, minWidth: 88, textAlign: 'right', color: statusColor }}>
                      {r.status === 'done'        ? '✓ done'
                       : r.status === 'error'     ? '✗ failed'
                       : r.status === 'processing'? 'processing'
                       : r.status === 'downloading' ? 'downloading'
                       : 'queued'}
                    </span>
                  </div>
                );
              })}
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

/* ===== Live preview (HLS) ====================================
   Shared by RecorderRow + MonitorTile. The capture container writes
   HLS segments to /live/{assetId}/index.m3u8 (see capture-manager.js
   and nginx.conf); we attach hls.js to a <video> when a recorder is
   actively recording and has a live asset.
   ============================================================ */
function HlsPreview({ assetId, recorderId, muted = true, controls = false, className }) {
  const videoRef = React.useRef(null);
  const [err, setErr] = React.useState(null);

  React.useEffect(() => {
    if (!assetId || !videoRef.current) return;
    const url = recorderId
      ? '/api/v1/recorders/' + recorderId + '/live/' + assetId + '/index.m3u8'
      : '/live/' + assetId + '/index.m3u8';
    const v = videoRef.current;
    let destroyed = false;
    let retryTimer = 0;
    let retryCount = 0;
    const MAX_RETRIES = 8;

    const clearRetry = () => { if (retryTimer) { clearTimeout(retryTimer); retryTimer = 0; } };

    // Safari can play HLS natively; everything else needs hls.js.
    if (v.canPlayType('application/vnd.apple.mpegurl')) {
      const tryLoad = () => {
        if (destroyed) return;
        v.removeAttribute('src');
        v.load();
        v.src = url;
        v.play().catch(() => {});
      };
      const onErr = () => {
        if (destroyed || retryCount >= MAX_RETRIES) { setErr('playback failed'); return; }
        retryCount++;
        clearRetry();
        retryTimer = setTimeout(tryLoad, Math.min(500 * Math.pow(2, retryCount - 1), 8000));
        setErr('connecting…');
      };
      v.addEventListener('error', onErr);
      v.addEventListener('playing', () => { retryCount = 0; setErr(null); }, { once: false });
      tryLoad();
      return () => { destroyed = true; clearRetry(); v.removeEventListener('error', onErr); };
    }
    if (!window.Hls) { setErr('hls.js missing'); return; }

    let hls = null;

    const startHls = () => {
      if (destroyed) return;
      hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true });
      hls.loadSource(url);
      hls.attachMedia(v);
      hls.on(window.Hls.Events.ERROR, (_e, data) => {
        if (data.fatal) {
          if (retryCount >= MAX_RETRIES) { setErr(data.details || 'hls error'); return; }
          retryCount++;
          clearRetry();
          try { hls.destroy(); } catch (_) {}
          hls = null;
          setErr('connecting…');
          retryTimer = setTimeout(startHls, Math.min(500 * Math.pow(2, retryCount - 1), 8000));
        }
      });
      hls.on(window.Hls.Events.MANIFEST_PARSED, () => { retryCount = 0; setErr(null); });
    };

    startHls();
    v.play().catch(() => {});

    return () => { destroyed = true; clearRetry(); if (hls) { try { hls.destroy(); } catch (_) {} } };
  }, [assetId]);

  return (
    <div className={className} style={{ position: 'relative', width: '100%', height: '100%', background: '#000', overflow: 'hidden' }}>
      <video
        ref={videoRef}
        autoPlay
        playsInline
        muted={muted}
        controls={controls}
        style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
      />
      {err && (
        <div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
                      color: 'var(--text-3)', fontSize: 11, background: 'rgba(0,0,0,0.5)' }}>
          {err}
        </div>
      )}
    </div>
  );
}

/* ===== Idle signal preview (always-on HLS from sidecar) ===== */
function HlsPreviewUrl({ url }) {
  const videoRef = React.useRef(null);
  const [err, setErr] = React.useState(null);

  React.useEffect(() => {
    if (!url || !videoRef.current) return;
    const v = videoRef.current;
    let destroyed = false;
    let hls = null;
    let retryTimer = 0;
    let retryCount = 0;

    const clearRetry = () => { if (retryTimer) { clearTimeout(retryTimer); retryTimer = 0; } };

    if (v.canPlayType('application/vnd.apple.mpegurl')) {
      const tryLoad = () => {
        if (destroyed) return;
        v.src = url;
        v.play().catch(() => {});
      };
      v.addEventListener('error', () => {
        retryCount++;
        clearRetry();
        retryTimer = setTimeout(tryLoad, Math.min(2000 * retryCount, 15000));
        setErr('no signal');
      });
      v.addEventListener('playing', () => { retryCount = 0; setErr(null); }, { once: false });
      tryLoad();
      return () => { destroyed = true; clearRetry(); };
    }

    if (!window.Hls) { setErr('hls.js missing'); return; }

    const startHls = () => {
      if (destroyed) return;
      hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true });
      hls.loadSource(url);
      hls.attachMedia(v);
      hls.on(window.Hls.Events.ERROR, (_e, data) => {
        if (data.fatal) {
          try { hls.destroy(); } catch (_) {}
          hls = null;
          retryCount++;
          setErr('no signal');
          clearRetry();
          retryTimer = setTimeout(startHls, Math.min(2000 * retryCount, 15000));
        }
      });
      hls.on(window.Hls.Events.MANIFEST_PARSED, () => { retryCount = 0; setErr(null); });
    };

    startHls();
    v.play().catch(() => {});
    return () => { destroyed = true; clearRetry(); if (hls) { try { hls.destroy(); } catch (_) {} } };
  }, [url]);

  return (
    <div style={{ position: 'relative', width: '100%', height: '100%', background: '#000', overflow: 'hidden' }}>
      <video ref={videoRef} autoPlay playsInline muted
             style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
      {err && (
        <div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
                      color: 'var(--text-3)', fontSize: 11, background: 'rgba(0,0,0,0.6)' }}>
          {err}
        </div>
      )}
    </div>
  );
}

/* ===== Recorders ===== */
function _normRecorder(r) {
  const cfg = r.source_config || {};
  // Surface the capture port for SDI / Deltacast recorders so the recorder card
  // can show which physical input the recorder is bound to. For Deltacast,
  // cfg.port is the bridge port index (0-7). For Blackmagic SDI, cfg.device
  // is something like /dev/blackmagic/dv0 — we slice off the trailing index.
  let capturePort = null;
  if (r.source_type === 'deltacast') {
    capturePort = cfg.port != null ? `Port ${cfg.port}` : null;
  } else if (r.source_type === 'sdi') {
    const dev = cfg.device || '';
    const m = dev.match(/(\d+)$/);
    if (m) capturePort = `SDI ${m[1]}`;
  }
  return {
    ...r,
    source: r.source_type || '·',
    url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·',
    codec: r.recording_codec || '·',
    res: r.recording_resolution || '·',
    framerate: r.recording_framerate || 'native',
    node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
    capturePort,
    previewUrl: r.preview_url || null,
    elapsed: '·',
    bitrate: '·',
    health: 100,
    audio: false,
  };
}

function Recorders({ navigate, onNew }) {
  const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);

  const refresh = React.useCallback(() => {
    window.ZAMPP_API.fetch('/recorders')
      .then(raw => {
        const norm = (raw || []).map(_normRecorder);
        window.ZAMPP_DATA.RECORDERS = norm;
        setRecorders(norm);
      })
      .catch(err => {
        // apiFetch already redirects on 401 - don't log noise, interval
        // will be cleared automatically when the component unmounts on redirect (#55)
        if (err && err.message && err.message.includes('Unauthenticated')) return;
        window.DF_LOG.warn('[recorders] poll error:', err?.message);
      });
  }, []);

  React.useEffect(() => {
    refresh();
    const id = setInterval(refresh, 10000);
    // Any screen that creates/starts/stops/deletes a recorder dispatches
    // df:recorders-changed; refresh immediately instead of waiting for the tick.
    const onChange = () => refresh();
    window.addEventListener('df:recorders-changed', onChange);
    return () => {
      clearInterval(id);
      window.removeEventListener('df:recorders-changed', onChange);
    };
  }, [refresh]);

  const liveCount = recorders.filter(r => r.status === 'recording').length;
  const errCount  = recorders.filter(r => r.status === 'error').length;

  return (
    <div className="page">
      <div className="page-header">
        <h1>Recorders</h1>
        <span className="subtitle">Live ingest from SRT, RTMP, and SDI sources</span>
        <div className="spacer" />
        {(liveCount > 0 || errCount > 0) && (
          <div className="status-pip">
            <span className="dot" />
            <span>{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}</span>
          </div>
        )}
        <button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
        <button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>
      </div>
      <div className="page-body">
        {recorders.length === 0 ? (
          <div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
            No recorders configured.
            <div style={{ marginTop: 12 }}><button className="btn primary" onClick={onNew}><Icon name="plus" />Add recorder</button></div>
          </div>
        ) : (
          <div className="recorders-list">
            {recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}
          </div>
        )}
      </div>
    </div>
  );
}

function RecorderRow({ recorder: initialRecorder, onRefresh }) {
  const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
  const [recorder, setRecorder] = React.useState(initialRecorder);
  const [pending, setPending] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [liveStatus, setLiveStatus] = React.useState(null);
  const [clipName, setClipName] = React.useState('');
  // Project override for this take. Defaults to the recorder's configured project.
  const [takeProjectId, setTakeProjectId] = React.useState(initialRecorder.project_id || PROJECTS[0]?.id || '');
  const [confirm, confirmModal] = window.useConfirm();
  const isRec = recorder.status === 'recording';

  // Keep takeProjectId in sync if the recorder row changes (e.g. after a refresh).
  React.useEffect(() => {
    setTakeProjectId(initialRecorder.project_id || PROJECTS[0]?.id || '');
  }, [initialRecorder.id]);

  React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);

  // Poll the status endpoint every 3s while recording for live feedback.
  React.useEffect(() => {
    if (!isRec) { setLiveStatus(null); return; }
    const poll = () => {
      window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/status')
        .then(s => setLiveStatus(s))
        .catch(() => {});
    };
    poll();
    const id = setInterval(poll, 3000);
    return () => clearInterval(id);
  }, [isRec, recorder.id]);

  // Tick elapsed every second while recording. Seed from liveStatus.duration
  // (authoritative from the capture container) when available; fall back to
  // wall-clock diff from recorder.started_at so the counter never freezes.
  const [elapsedSecs, setElapsedSecs] = React.useState(0);
  React.useEffect(() => {
    if (!isRec) { setElapsedSecs(0); return; }
    const base = () => {
      if (liveStatus && liveStatus.duration != null) return liveStatus.duration;
      if (recorder.started_at) return Math.floor((Date.now() - new Date(recorder.started_at).getTime()) / 1000);
      return 0;
    };
    // Snap to latest authoritative value immediately, then tick from there.
    const anchor = { at: Date.now(), secs: base() };
    setElapsedSecs(anchor.secs);
    const id = setInterval(() => {
      setElapsedSecs(anchor.secs + Math.floor((Date.now() - anchor.at) / 1000));
    }, 1000);
    return () => clearInterval(id);
  // Re-anchor whenever liveStatus.duration arrives from the poll.
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isRec, liveStatus && liveStatus.duration, recorder.started_at]);

  const displayElapsed = React.useMemo(() => {
    if (!isRec) return '·';
    const d = Math.max(0, elapsedSecs);
    return String(Math.floor(d / 3600)).padStart(2, '0') + ':' +
           String(Math.floor((d % 3600) / 60)).padStart(2, '0') + ':' +
           String(d % 60).padStart(2, '0');
  }, [isRec, elapsedSecs]);

  // Show live fps when recording and signal is healthy; fall back to configured value.
  const displayFramerate = React.useMemo(() => {
    if (isRec && liveStatus && liveStatus.currentFps != null && liveStatus.currentFps > 0) {
      return Number(liveStatus.currentFps).toFixed(2) + ' fps';
    }
    return recorder.framerate || 'native';
  }, [isRec, liveStatus, recorder.framerate]);

  const displaySignal = liveStatus
    ? (liveStatus.signal || '·')
    : (isRec ? 'connecting…' : '·');

  const signalColor = displaySignal === 'receiving' ? 'var(--success)'
    : displaySignal === 'stopped' ? 'var(--danger)'
    : 'var(--text-3)';

  const toggle = () => {
    if (pending) return;
    const action = isRec ? 'stop' : 'start';
    setPending(true);
    setErr(null);
    setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
    // Ship the operator-typed clip name and project override on start; stop has no body.
    const body = action === 'start'
      ? JSON.stringify({
          ...(clipName.trim() ? { clipName: clipName.trim() } : {}),
          ...(takeProjectId ? { projectId: takeProjectId } : {}),
        })
      : undefined;
    window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body })
      .then(() => {
        setPending(false);
        // Clear the clip name on a successful stop so the next take starts fresh.
        // Leave takeProjectId as-is (operator likely wants the same project for the next take).
        if (action === 'stop') setClipName('');
        onRefresh();
        window.dispatchEvent(new CustomEvent('df:recorders-changed'));
        // Stopping a recorder flips its asset from 'live' to 'ready' on the
        // server side; tell the library/dashboard to re-pull.
        if (action === 'stop') {
          window.dispatchEvent(new CustomEvent('df:assets-changed'));
        }
      })
      .catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
  };

  const handleDelete = async () => {
    if (!(await confirm({ title: 'Delete recorder?', message: 'Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.' }))) return;
    window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
      .then(() => {
        onRefresh();
        window.dispatchEvent(new CustomEvent('df:recorders-changed'));
        window.dispatchEvent(new CustomEvent('df:assets-changed'));
      })
      .catch(e => setErr(e.message || 'Delete failed'));
  };

  return (
    <div className={'recorder-row ' + recorder.status}>
      {confirmModal}
      <div className="recorder-preview">
        {isRec && recorder.live_asset_id
          ? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.id} />
          : isRec
            ? <LiveStrip seed={recorder.id.length * 3} count={6} />
            : <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} /></div>}
      </div>
      <div className="recorder-info">
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <span style={{ fontWeight: 600, fontSize: 13 }}>{recorder.name}</span>
          <span className={'badge ' + badgeForStatus(recorder.status)}>
            <StatusDot status={recorder.status} /> {recorder.status.toUpperCase()}
          </span>
          <span className="badge outline">{recorder.source}</span>
          {recorder.capturePort && (
            <span className="badge outline" title="Capture port" style={{ background: 'rgba(74,158,255,0.12)', borderColor: 'rgba(74,158,255,0.4)', color: 'var(--accent)' }}>
              <Icon name="signal" size={10} style={{ marginRight: 4, verticalAlign: -1 }} />{recorder.capturePort}
            </span>
          )}
        </div>
        <div className="recorder-sub mono">{recorder.url}</div>
        <div className="recorder-sub">
          <span>{recorder.codec}</span><span>·</span>
          <span>{recorder.res}</span>
        </div>
        {err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
        {liveStatus?.lastError && isRec && (
          <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{liveStatus.lastError}</div>
        )}
      </div>
      <div className="recorder-stats">
        <div className="recorder-stat">
          <div className="stat-label">Elapsed</div>
          <div className="stat-val mono">{displayElapsed}</div>
        </div>
        <div className="recorder-stat">
          <div className="stat-label">Signal</div>
          <div className="stat-val signal-val" style={{ fontSize: 11, color: signalColor }}>
            <span className={'signal-dot ' + displaySignal} style={{ background: signalColor }} />
            {displaySignal}
          </div>
        </div>
        <div className="recorder-stat">
          <div className="stat-label">Framerate</div>
          <div className="stat-val mono">{displayFramerate}</div>
        </div>
      </div>
      <div className="recorder-actions">
        {!isRec && (
          <>
            {PROJECTS.length > 0 && (
              <select
                className="field-input"
                value={takeProjectId}
                onChange={e => setTakeProjectId(e.target.value)}
                disabled={pending}
                style={{ width: 160, padding: '5px 8px', fontSize: 12, appearance: 'auto' }}
                title="Project clips go to"
              >
                {PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
              </select>
            )}
            <input
              className="field-input"
              value={clipName}
              onChange={e => setClipName(e.target.value)}
              placeholder="Clip name (optional)"
              disabled={pending}
              maxLength={80}
              onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
              style={{ width: 160, padding: '5px 8px', fontSize: 12 }}
              title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
            />
          </>
        )}
        {isRec
          ? <button className="btn danger sm" onClick={toggle} disabled={pending}>
              {pending ? '…' : <><span className="rec-dot" />Stop</>}
            </button>
          : <button className="btn subtle sm" onClick={toggle} disabled={pending}>
              {pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
            </button>}
        <button className="icon-btn" onClick={handleDelete} title="Delete recorder" aria-label="Delete recorder" style={{ color: 'var(--text-3)' }}>
          <Icon name="x" />
        </button>
      </div>
    </div>
  );
}

function badgeForStatus(s) {
  return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
}

/* ===== Capture ===== */

function _captureSignalChip(sig) {
  switch (sig) {
    case 'receiving':   return { label: 'RECEIVING',   color: 'var(--success)',  pulse: true  };
    case 'connecting':  return { label: 'CONNECTING',  color: 'var(--accent)',   pulse: true  };
    case 'lost':        return { label: 'LOST',        color: 'var(--danger)',   pulse: false };
    case 'error':       return { label: 'ERROR',       color: 'var(--danger)',   pulse: false };
    case 'idle':        return { label: 'IDLE',        color: 'var(--text-3)',   pulse: false };
    case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)',   pulse: false };
    default:            return { label: sig || '·',    color: 'var(--text-4)',   pulse: false };
  }
}

function CapturePortChip({ port, sigEntry }) {
  const sig = sigEntry ? sigEntry.signal : null;
  const { label, color, pulse } = _captureSignalChip(sig);
  const isReceiving = sig === 'receiving';
  const portLabel = port.device ? port.device.split('/').pop() : `port ${port.index}`;

  return (
    <div
      title={sigEntry ? `${sigEntry.recorder_name || 'recorder'}: ${label}` : label}
      style={{
        display: 'flex', alignItems: 'center', gap: 6,
        padding: '6px 12px', borderRadius: 5,
        background: isReceiving ? 'rgba(45,212,168,0.08)' : 'var(--bg-2)',
        border: `1px solid ${isReceiving ? 'rgba(45,212,168,0.35)' : 'var(--border)'}`,
        minWidth: 120,
      }}
    >
      <span style={{
        width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
        background: sig ? color : 'var(--text-4)',
        animation: pulse ? 'signalPulse 1.4s ease-in-out infinite' : 'none',
        boxShadow: isReceiving ? `0 0 6px ${color}` : 'none',
      }} />
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 11.5, fontFamily: 'var(--font-mono)', color: 'var(--text-2)', fontWeight: 600 }}>
          {portLabel}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 1 }}>
          <span style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '0.05em', color }}>
            {label}
          </span>
          {sigEntry && sigEntry.currentFps != null && (
            <span style={{ fontSize: 9.5, color: 'var(--text-4)', fontFamily: 'var(--font-mono)' }}>
              {Number(sigEntry.currentFps).toFixed(1)} fps
            </span>
          )}
        </div>
      </div>
    </div>
  );
}

function CaptureNodeCard({ node, ports, portSignals }) {
  const svgRef = React.useRef(null);
  const nodeSignalMap = React.useMemo(() => {
    const map = new Map();
    ports.forEach(p => {
      const entry = portSignals[`${node.node_id}:${p.index}`];
      if (entry) map.set(p.index, entry.signal);
    });
    return map;
  }, [node.node_id, ports, portSignals]);

  React.useEffect(() => {
    if (!svgRef.current || !window.BMDCards || ports.length === 0) return;
    svgRef.current.innerHTML = '';
    const svg = window.BMDCards.render({
      model: ports[0].model || '',
      deviceCount: ports.length,
      compact: true,
      portSignals: nodeSignalMap,
    });
    if (svg) svgRef.current.appendChild(svg);
  }, [node.node_id, ports.length, nodeSignalMap]);

  const receivingCount = ports.filter(p => {
    const e = portSignals[`${node.node_id}:${p.index}`];
    return e && e.signal === 'receiving';
  }).length;

  return (
    <div className="panel" style={{ padding: 0, overflow: 'hidden' }}>
      {/* Node header */}
      <div style={{
        display: 'flex', alignItems: 'center', gap: 10,
        padding: '12px 16px',
        borderBottom: '1px solid var(--border)',
        background: 'var(--bg-2)',
      }}>
        <StatusDot status={node.online !== false ? 'online' : 'offline'} />
        <div style={{ flex: 1 }}>
          <div style={{ fontWeight: 600, fontSize: 13 }}>
            {ports[0].model || 'DeckLink'}
          </div>
          <div style={{ fontSize: 11, color: 'var(--text-3)', fontFamily: 'var(--font-mono)' }}>
            {node.hostname}{node.ip_address ? ` · ${node.ip_address}` : ''}
          </div>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
          {receivingCount > 0 && (
            <span style={{
              fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 3,
              background: 'rgba(45,212,168,0.15)', color: 'var(--success)',
              animation: 'signalPulse 1.4s ease-in-out infinite',
            }}>
              {receivingCount} LIVE
            </span>
          )}
          <span style={{
            fontSize: 10, fontWeight: 600, padding: '2px 7px', borderRadius: 3,
            background: 'rgba(91,124,250,0.12)', color: 'var(--accent)',
          }}>
            {ports.length} PORT{ports.length !== 1 ? 'S' : ''}
          </span>
        </div>
      </div>

      {/* Port chips */}
      <div style={{ padding: '14px 16px', display: 'flex', flexWrap: 'wrap', gap: 8 }}>
        {ports.map(p => (
          <CapturePortChip
            key={p.index}
            port={p}
            sigEntry={portSignals[`${node.node_id}:${p.index}`] || null}
          />
        ))}
      </div>

      {/* BMD card SVG diagram */}
      {window.BMDCards && (
        <div ref={svgRef} className="bmd-card-diagram" style={{ padding: '0 16px 14px' }} />
      )}
    </div>
  );
}

function Capture({ navigate }) {
  const [devices, setDevices] = React.useState([]);
  const [portSignals, setPortSignals] = React.useState({});
  const [lastPoll, setLastPoll] = React.useState(null);

  // Group devices by node
  const nodeGroups = React.useMemo(() => {
    const map = new Map();
    devices.forEach(d => {
      const key = d.node_id || d.hostname || 'unknown';
      if (!map.has(key)) map.set(key, { node_id: d.node_id, hostname: d.hostname, ip_address: d.ip_address, online: d.online, ports: [] });
      map.get(key).ports.push(d);
    });
    return Array.from(map.values());
  }, [devices]);

  // Load device list once (changes rarely)
  const loadDevices = React.useCallback(() => {
    window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
      .then(devs => setDevices(Array.isArray(devs) ? devs : []))
      .catch(() => setDevices([]));
  }, []);

  // Poll signal state every 3s
  React.useEffect(() => {
    const poll = () => {
      window.ZAMPP_API.fetch('/cluster/devices/blackmagic/signal')
        .then(entries => {
          const map = {};
          (entries || []).forEach(e => { map[`${e.node_id}:${e.index}`] = e; });
          setPortSignals(map);
          setLastPoll(new Date());
        })
        .catch(() => {});
    };
    poll();
    const id = setInterval(poll, 3000);
    return () => clearInterval(id);
  }, []);

  React.useEffect(() => { loadDevices(); }, []);

  const totalPorts = devices.length;
  const receivingPorts = Object.values(portSignals).filter(e => e.signal === 'receiving').length;

  return (
    <div className="page">
      <div className="page-header">
        <h1>Capture</h1>
        <span className="subtitle">DeckLink SDI ingest</span>
        <div className="spacer" />
        {totalPorts > 0 && (
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginRight: 8 }}>
            {receivingPorts > 0 && (
              <span style={{
                fontSize: 11, fontWeight: 700, padding: '3px 8px', borderRadius: 4,
                background: 'rgba(45,212,168,0.15)', color: 'var(--success)',
                animation: 'signalPulse 1.4s ease-in-out infinite',
              }}>
                {receivingPorts}/{totalPorts} LIVE
              </span>
            )}
            {lastPoll && (
              <span style={{ fontSize: 10.5, color: 'var(--text-4)', fontFamily: 'var(--font-mono)' }}>
                updated {lastPoll.toLocaleTimeString()}
              </span>
            )}
          </div>
        )}
        <button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
      </div>
      <div className="page-body">
        {totalPorts === 0 ? (
          <div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
            No DeckLink devices found in cluster.
          </div>
        ) : (
          <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
            {nodeGroups.map(node => (
              <CaptureNodeCard
                key={node.node_id || node.hostname}
                node={node}
                ports={node.ports}
                portSignals={portSignals}
              />
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

/* ===== Monitors ===== */
function Monitors({ navigate }) {
  const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
  const [channels, setChannels] = React.useState([]);
  const [grid, setGrid] = React.useState(4);

  React.useEffect(() => {
    const refresh = () => {
      window.ZAMPP_API.fetch('/recorders')
        .then(raw => {
          const norm = (raw || []).map(_normRecorder);
          window.ZAMPP_DATA.RECORDERS = norm;
          setRecorders(norm);
        })
        .catch(() => {});
      // Playout channels surface here too so an operator can watch on-air
      // output alongside ingest. Degrade silently if the endpoint is absent.
      window.ZAMPP_API.fetch('/playout/channels')
        .then(raw => setChannels(Array.isArray(raw) ? raw : []))
        .catch(() => setChannels([]));
    };
    refresh();
    const id = setInterval(refresh, 3000);
    return () => clearInterval(id);
  }, []);

  const videoFeeds = recorders.filter(r => !r.audio);
  const audioFeeds = recorders.filter(r => r.audio).map(r => ({ ...r, kind: 'audio' }));
  const allFeeds = [...videoFeeds.map(r => ({ ...r, kind: 'video' })), ...audioFeeds];
  const feeds = allFeeds.slice(0, grid * grid);

  return (
    <div className="page">
      <div className="page-header">
        <h1>Monitors</h1>
        <span className="subtitle">Multi-cam live monitoring</span>
        <div className="spacer" />
        <div className="tab-group">
          {[2, 3, 4].map(n => (
            <button key={n} className={grid === n ? 'active' : ''} onClick={() => setGrid(n)}>{n}×{n}</button>
          ))}
        </div>
      </div>
      <div className="page-body">
        {feeds.length === 0 && channels.length === 0 ? (
          <div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder or playout channel to see live video here.</div>
        ) : (
          <React.Fragment>
            {feeds.length > 0 && (
              <React.Fragment>
                <div className="monitor-section-head">Ingest</div>
                <div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}>
                  {feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
                </div>
              </React.Fragment>
            )}
            {channels.length > 0 && (
              <React.Fragment>
                <div className="monitor-section-head">Playout</div>
                <div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}>
                  {channels.slice(0, grid * grid).map(c => <PlayoutMonitorTile key={c.id} channel={c} />)}
                </div>
              </React.Fragment>
            )}
          </React.Fragment>
        )}
      </div>
    </div>
  );
}

function PlayoutMonitorTile({ channel }) {
  const videoRef = React.useRef(null);
  const hlsRef = React.useRef(null);
  const onAir = channel.status === 'running';
  const previewUrl = '/api/v1/playout/channels/' + channel.id + '/hls/index.m3u8';

  React.useEffect(() => {
    const vid = videoRef.current;
    if (!vid) return;
    if (hlsRef.current) { try { hlsRef.current.destroy(); } catch (_) {} hlsRef.current = null; }
    if (!onAir) { vid.src = ''; return; }

    if (window.Hls && window.Hls.isSupported()) {
      const hls = new window.Hls({
        liveSyncDurationCount: 3,
        liveMaxLatencyDurationCount: 6,
        xhrSetup: (xhr) => { xhr.withCredentials = true; },
      });
      hlsRef.current = hls;
      hls.loadSource(previewUrl);
      hls.attachMedia(vid);
      hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {}));
    } else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
      vid.src = previewUrl;
      vid.play().catch(() => {});
    }
    return () => {
      if (hlsRef.current) { try { hlsRef.current.destroy(); } catch (_) {} hlsRef.current = null; }
    };
  }, [onAir, channel.id]);

  return (
    <div className="monitor-tile">
      {onAir ? (
        <video ref={videoRef} muted playsInline autoPlay
               style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', background: '#000' }} />
      ) : (
        <FauxFrame />
      )}
      {onAir && <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />}
      {!onAir && (
        <div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', color: 'var(--text-3)', fontSize: 11 }}>channel idle</div>
      )}
      <div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
        {onAir ? <span className="badge live">ON AIR</span> : <span className="badge neutral">IDLE</span>}
      </div>
      <div className="monitor-tile-label">
        <span className="name">{channel.name}</span>
        {channel.output_type && <span className="time mono">{String(channel.output_type).toUpperCase()}</span>}
      </div>
    </div>
  );
}

function MonitorTile({ feed, seed }) {
  const [levels, setLevels] = React.useState([0.65, 0.78]);
  const isLive = feed.status === 'recording';

  // Persist the last known asset ID so we can show frozen HLS/thumbnail after
  // a recording stops. Cleared only when a new recording starts on this recorder.
  const [lastAssetId, setLastAssetId] = React.useState(feed.live_asset_id || null);
  const [lastRecorderId, setLastRecorderId] = React.useState(feed.id);
  const [frozenThumb, setFrozenThumb] = React.useState(null);

  React.useEffect(() => {
    if (feed.live_asset_id) {
      setLastAssetId(feed.live_asset_id);
      setLastRecorderId(feed.id);
      setFrozenThumb(null); // reset frozen thumb — live HLS takes over
    }
  }, [feed.live_asset_id]);

  // When recording stops, try to fetch a thumbnail for the last asset so we can
  // show a frozen frame instead of blank noise.
  React.useEffect(() => {
    if (isLive || !lastAssetId) return;
    if (frozenThumb) return; // already fetched
    let cancelled = false;
    window.ZAMPP_API.fetch('/assets/' + lastAssetId + '/thumbnail')
      .then(data => {
        if (!cancelled && data && data.url) setFrozenThumb(data.url);
      })
      .catch(() => {});
    return () => { cancelled = true; };
  }, [isLive, lastAssetId]);

  // Tick elapsed while recording. Seed from feed.started_at (populated by Docker
  // inspect on the mam-api side). Falls back to 0 if not yet available.
  const [elapsedSecs, setElapsedSecs] = React.useState(0);
  React.useEffect(() => {
    if (!isLive) { setElapsedSecs(0); return; }
    const tick = () => {
      if (feed.started_at) {
        setElapsedSecs(Math.max(0, Math.floor((Date.now() - new Date(feed.started_at).getTime()) / 1000)));
      }
    };
    tick();
    const id = setInterval(tick, 1000);
    return () => clearInterval(id);
  }, [isLive, feed.started_at]);

  React.useEffect(() => {
    if (!isLive) return;
    const id = setInterval(() => {
      setLevels([0.3 + Math.random() * 0.55, 0.3 + Math.random() * 0.55]);
    }, 180);
    return () => clearInterval(id);
  }, [isLive]);

  const displayElapsed = _fmtElapsed(elapsedSecs * 1000);

  if (feed.kind === 'audio') {
    return (
      <div className="monitor-tile audio">
        <div style={{ flex: 1, display: 'grid', placeItems: 'center', padding: 24 }}>
          <Waveform seed={seed * 7} color="var(--accent)" />
        </div>
        <div style={{ display: 'flex', gap: 4, padding: '0 16px 16px', justifyContent: 'center' }}>
          <AudioMeter level={levels[0]} vertical />
          <AudioMeter level={levels[1]} vertical />
        </div>
        <div className="monitor-tile-label">
          <span className="badge live">LIVE</span>
          <span className="name">{feed.name}</span>
        </div>
      </div>
    );
  }

  // Content priority: live HLS > frozen HLS briefly after stop > static thumbnail > noise
  let tileContent;
  if (isLive && feed.live_asset_id) {
    tileContent = <HlsPreview assetId={feed.live_asset_id} recorderId={feed.id} />;
  } else if (!isLive && lastAssetId) {
    // After stopping: HlsPreview will naturally degrade as segments expire and
    // show its own "connecting…" overlay. Once frozenThumb is loaded it replaces it.
    if (frozenThumb) {
      tileContent = (
        <div style={{ position: 'relative', width: '100%', height: '100%', background: '#000', overflow: 'hidden' }}>
          <img src={frozenThumb} alt=""
               style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', opacity: 0.65, filter: 'grayscale(0.3)' }} />
          <div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.25)' }} />
        </div>
      );
    } else {
      // HLS segments may still be hot right after stopping — keep player alive briefly
      tileContent = <HlsPreview assetId={lastAssetId} recorderId={lastRecorderId} />;
    }
  } else if (!isLive && feed.previewUrl) {
    tileContent = <HlsPreviewUrl url={feed.previewUrl} />;
  } else {
    tileContent = <FauxFrame />;
  }

  return (
    <div className="monitor-tile">
      {tileContent}
      {isLive && <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />}
      <div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6, flexWrap: 'wrap', maxWidth: '70%' }}>
        {isLive                    && <span className="badge live">REC</span>}
        {feed.status === 'stopped' && <span className="badge neutral">IDLE</span>}
        {feed.status === 'idle'    && <span className="badge neutral">IDLE</span>}
        {feed.status === 'error'   && <span className="badge danger">ERR</span>}
        {feed.capturePort && (
          <span className="badge outline" style={{ fontSize: 9, padding: '1px 5px', opacity: 0.8 }}>
            {feed.capturePort}
          </span>
        )}
      </div>
      {isLive && (
        <div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4 }}>
          <AudioMeter level={levels[0]} vertical />
          <AudioMeter level={levels[1]} vertical />
        </div>
      )}
      <div className="monitor-tile-label">
        <span className="name">{feed.name}</span>
        {isLive && <span className="time mono">{displayElapsed}</span>}
      </div>
    </div>
  );
}

/* ===== Schedule ===== */

const _STATUS_BADGE = {
  pending:   { cls: 'neutral',  label: 'pending' },
  running:   { cls: 'success',  label: 'recording' },
  completed: { cls: 'accent',   label: 'completed' },
  cancelled: { cls: 'warning',  label: 'cancelled' },
  failed:    { cls: 'danger',   label: 'failed' },
};

function _fmtWhen(iso) {
  if (!iso) return '·';
  const d = new Date(iso);
  // Local-time, short, human; e.g. "May 22 · 7:30 PM"
  return d.toLocaleString(undefined, {
    month: 'short', day: 'numeric',
    hour: 'numeric', minute: '2-digit',
  });
}

function _durationMin(startISO, endISO) {
  return Math.round((new Date(endISO) - new Date(startISO)) / 60000);
}

// ── EPG (timeline) helpers ───────────────────────────────────────────────────
//
// The Schedule screen is a broadcast-control-room timeline: recorders are
// rows, time is the horizontal axis. Helpers below convert between Date and
// "minutes into local day" so we can position absolute-positioned event
// blocks against a fixed --epg-pph (pixels-per-hour) CSS variable.
//
function _dayStart(d) {
  const x = new Date(d);
  x.setHours(0, 0, 0, 0);
  return x;
}
function _dayEnd(d) {
  const x = _dayStart(d);
  x.setDate(x.getDate() + 1);
  return x;
}
function _addDays(d, n) {
  const x = new Date(d);
  x.setDate(x.getDate() + n);
  return x;
}
function _minutesIntoDay(date, dayStart) {
  return Math.max(0, Math.min(24 * 60, (date - dayStart) / 60000));
}
function _eventOverlapsDay(ev, dayStart, dayEnd) {
  const s = new Date(ev.start_at);
  const e = new Date(ev.end_at);
  return s < dayEnd && e > dayStart;
}
function _sameDay(a, b) {
  return a.getFullYear() === b.getFullYear()
      && a.getMonth()    === b.getMonth()
      && a.getDate()     === b.getDate();
}
function _fmtDay(d) {
  return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}
function _fmtTime(d) {
  return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
function _fmtHour(h) {
  // 0 → "12 AM", 12 → "12 PM", 18 → "6 PM"
  const ampm = h < 12 ? 'AM' : 'PM';
  const hr = ((h + 11) % 12) + 1;
  return hr + ' ' + ampm;
}
function _fmtCountdown(ms) {
  if (ms <= 0) return 'now';
  const s = Math.floor(ms / 1000);
  if (s < 60) return 'in ' + s + 's';
  const m = Math.floor(s / 60);
  if (m < 60) return 'in ' + m + 'm';
  const h = Math.floor(m / 60);
  return 'in ' + h + 'h ' + (m % 60) + 'm';
}
function _fmtElapsed(ms) {
  if (ms < 0) ms = 0;
  const s = Math.floor(ms / 1000);
  return String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
         String(Math.floor((s % 3600) / 60)).padStart(2, '0') + ':' +
         String(s % 60).padStart(2, '0');
}

// Pick a stable color for a project_id given the global PROJECTS list.
function _projectColor(projectId, projects) {
  if (!projectId) return null;
  const p = (projects || []).find(p => p.id === projectId);
  return p?.color || null;
}

// ── EPG components ───────────────────────────────────────────────────────────

function _StatusStrip({ schedules, recorders, now, projects }) {
  // What's recording: any schedule whose window contains `now` AND whose
  // status is 'running'. (Manually-started recorders without a schedule
  // surface on the Recorders screen; the schedule strip stays focused on
  // planned events so the operator can trust it.)
  const active = (schedules || []).filter(s => {
    const start = new Date(s.start_at);
    const end   = new Date(s.end_at);
    return start <= now && now < end && (s.status === 'running' || s.status === 'pending');
  });

  // Next up: earliest pending schedule strictly in the future.
  const upcoming = (schedules || [])
    .filter(s => s.status === 'pending' && new Date(s.start_at) > now)
    .sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
  const next = upcoming[0];

  const recMap = {};
  (recorders || []).forEach(r => { recMap[r.id] = r; });

  return (
    <div className="epg-status">
      <div className="epg-status-row">
        {active.length === 0 ? (
          <>
            <span className="epg-status-dot idle" />
            <span className="epg-status-label">Nothing scheduled right now</span>
          </>
        ) : (
          <>
            <span className="epg-status-dot live" />
            <span className="epg-status-label">On air</span>
            <div className="epg-status-active">
              {active.map(s => {
                const rec = recMap[s.recorder_id];
                const elapsed = _fmtElapsed(now - new Date(s.start_at));
                const endsAt  = _fmtTime(new Date(s.end_at));
                const color   = _projectColor(rec?.project_id, projects);
                return (
                  <span key={s.id} className="epg-status-pill">
                    {color && <span className="epg-status-pill-bar" style={{ background: color }} />}
                    <span className="epg-status-pill-name">{s.name}</span>
                    <span className="epg-status-pill-rec mono">{rec?.name || (s.recorder_id ? s.recorder_id.slice(0, 8) : 'unassigned')}</span>
                    <span className="epg-status-pill-time mono">{elapsed} · ends {endsAt}</span>
                  </span>
                );
              })}
            </div>
          </>
        )}
      </div>
      <div className="epg-status-row sub">
        {next ? (
          <>
            <span className="epg-status-label muted">Next up</span>
            <span className="epg-status-next">{next.name}</span>
            <span className="epg-status-next-rec mono">{recMap[next.recorder_id]?.name || (next.recorder_id ? next.recorder_id.slice(0, 8) : 'unassigned')}</span>
            <span className="epg-status-next-time mono">{_fmtCountdown(new Date(next.start_at) - now)} · {_fmtTime(new Date(next.start_at))}</span>
          </>
        ) : (
          <span className="epg-status-label muted">No upcoming schedules</span>
        )}
      </div>
    </div>
  );
}

function _EpgRuler({ pph }) {
  // 25 ticks so the last one (24:00) labels the right edge. The 24h column
  // ends at 24*pph; the 25th tick is purely a label and has zero width.
  const hours = [];
  for (let h = 0; h <= 24; h++) hours.push(h);
  return (
    <div className="epg-ruler" style={{ width: 24 * pph }}>
      {hours.map(h => (
        <div key={h} className={'epg-ruler-tick ' + (h === 24 ? 'end' : '')}
             style={{ left: h * pph }}>
          <span>{h === 24 ? '' : _fmtHour(h)}</span>
        </div>
      ))}
    </div>
  );
}

// Minimum schedule length the UI permits while dragging. Anything shorter
// rarely reflects a real plan and would let the operator accidentally
// dismiss a block to zero width.
const _EPG_MIN_MS = 5 * 60 * 1000;
// Drag snap quantum. Mirrors the new-schedule click snap so a resized
// block lines up to the same grid an operator just placed a new one on.
const _EPG_SNAP_MIN = 15;
// Pointer travel (in px) before we treat the gesture as a drag rather
// than a click. Below this, pointerup fires the click handler.
const _EPG_DRAG_THRESHOLD = 4;

function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick, onContextMenu, onResize }) {
  // Drag state: null when idle, otherwise the in-flight resize/move
  // describing the original times and the current (snapped) preview times.
  // We render from this preview while dragging so the block follows the
  // cursor without round-tripping through props.
  const [drag, setDrag] = React.useState(null);
  const blockRef = React.useRef(null);

  const eventStartMs = new Date(event.start_at).getTime();
  const eventEndMs   = new Date(event.end_at).getTime();
  const isLive   = event.status === 'running' || (event.status === 'pending' && eventStartMs <= now.getTime() && now.getTime() < eventEndMs);
  const isFailed = event.status === 'failed';
  const isPast   = (event.status === 'completed' || event.status === 'cancelled') || eventEndMs < now.getTime();
  const color    = _projectColor(recorder?.project_id, projects);

  // Only pending schedules can be resized. The API rejects PUTs against
  // running schedules outright; cancelling them is what an operator
  // actually wants there. Terminal statuses are read-only.
  const canDrag = event.status === 'pending';

  const startDrag = (e, type) => {
    if (!canDrag) return;
    if (e.button !== 0) return;        // ignore right-click
    e.stopPropagation();
    try { blockRef.current.setPointerCapture(e.pointerId); } catch (_) {}
    setDrag({
      type, pointerId: e.pointerId,
      startX: e.clientX,
      origStart: eventStartMs, origEnd: eventEndMs,
      currStart: eventStartMs, currEnd: eventEndMs,
      moved: false,
    });
  };

  const onPointerMove = (ev) => {
    if (!drag) return;
    const dx = ev.clientX - drag.startX;
    if (!drag.moved && Math.abs(dx) < _EPG_DRAG_THRESHOLD) return;
    const snapMs = _EPG_SNAP_MIN * 60 * 1000;
    const dMs = Math.round((dx / pph) * 3600 * 1000 / snapMs) * snapMs;
    const dayStartMs = dayStart.getTime();
    const dayEndMs   = dayEnd.getTime();
    let cs = drag.origStart, ce = drag.origEnd;
    if (drag.type === 'left') {
      cs = Math.max(dayStartMs, Math.min(drag.origEnd - _EPG_MIN_MS, drag.origStart + dMs));
    } else if (drag.type === 'right') {
      ce = Math.min(dayEndMs, Math.max(drag.origStart + _EPG_MIN_MS, drag.origEnd + dMs));
    } else if (drag.type === 'body') {
      cs = drag.origStart + dMs;
      ce = drag.origEnd   + dMs;
      // Clamp to the day; preserve duration when bumping against an edge.
      if (cs < dayStartMs) { ce += (dayStartMs - cs); cs = dayStartMs; }
      if (ce > dayEndMs)   { cs -= (ce - dayEndMs);   ce = dayEndMs; }
    }
    setDrag({ ...drag, currStart: cs, currEnd: ce, moved: true });
  };

  const endDrag = (ev) => {
    if (!drag) return;
    try { blockRef.current.releasePointerCapture(drag.pointerId); } catch (_) {}
    const d = drag;
    setDrag(null);
    if (!d.moved) {
      // Treat as a click - open the edit modal.
      onClick(event);
      return;
    }
    if (d.currStart === d.origStart && d.currEnd === d.origEnd) return;
    onResize(event, new Date(d.currStart).toISOString(), new Date(d.currEnd).toISOString());
  };

  // Render from drag preview while a gesture is in flight so the block
  // tracks the pointer; otherwise from the canonical event prop.
  const dispStartMs = drag ? drag.currStart : eventStartMs;
  const dispEndMs   = drag ? drag.currEnd   : eventEndMs;
  const dispStart = new Date(dispStartMs);
  const dispEnd   = new Date(dispEndMs);
  const startMin  = _minutesIntoDay(dispStart, dayStart);
  const endMin    = _minutesIntoDay(dispEnd,   dayStart);
  const left  = (startMin / 60) * pph;
  const width = Math.max(40, ((endMin - startMin) / 60) * pph);

  // Schedules backed by a recorder configured to write a growing file get a
  // green accent so operators can tell at a glance which slots will produce
  // an edit-while-recording deliverable (vs. close-then-publish).
  const isGrowing = !!(recorder && recorder.growing_enabled);

  const classes = ['epg-block'];
  if (isLive)    classes.push('live');
  if (isFailed)  classes.push('failed');
  else if (isPast) classes.push('past');
  if (drag && drag.moved) classes.push('dragging');
  if (canDrag)   classes.push('resizable');
  if (isGrowing) classes.push('growing');

  const blockColor = isGrowing ? '#2ecc71' : (color || 'var(--text-3)');

  return (
    <div
      ref={blockRef}
      className={classes.join(' ')}
      style={{ left, width, '--epg-block-color': blockColor }}
      onContextMenu={(ev) => { ev.preventDefault(); ev.stopPropagation(); onContextMenu(event, ev); }}
      onPointerMove={onPointerMove}
      onPointerUp={endDrag}
      onPointerCancel={endDrag}
      title={event.name + (isGrowing ? ' · GROWING' : '') + ' · ' + _fmtTime(dispStart) + ' → ' + _fmtTime(dispEnd) + (event.error_message ? ' · ' + event.error_message : '')}>
      <span className="epg-block-bar" />
      {/* Body click → edit, body drag → move. We hang the click on pointerup
          so the threshold check above can demote a drag back to a click. */}
      <div className="epg-block-body" onPointerDown={(ev) => startDrag(ev, 'body')}>
        <span className="epg-block-name">{event.name}</span>
        <span className="epg-block-time mono">{_fmtTime(dispStart)}{drag && drag.moved ? ' → ' + _fmtTime(dispEnd) : ''}</span>
        {isGrowing && <span className="epg-block-glyph growing" title="growing file (edit-while-record)" style={{ color: '#2ecc71' }}>▶</span>}
        {isLive   && <span className="epg-block-glyph live" title="on air">●</span>}
        {isFailed && <span className="epg-block-glyph failed" title={event.error_message || 'failed'}>!</span>}
      </div>
      {canDrag && (
        <>
          <span className="epg-block-handle left"
                onPointerDown={(ev) => startDrag(ev, 'left')}
                title="Drag to change start time" />
          <span className="epg-block-handle right"
                onPointerDown={(ev) => startDrag(ev, 'right')}
                title="Drag to change end time" />
        </>
      )}
    </div>
  );
}

function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, onEventClick, onEmptyClick, onEventContextMenu, onEventResize }) {
  const dayEvents = (schedules || []).filter(s => s.recorder_id === recorder.id && _eventOverlapsDay(s, dayStart, dayEnd));

  const handleRowPointerUp = (e) => {
    // Open the new-schedule modal only on a real click in the empty
    // gutter. Clicks on event blocks stopPropagation themselves; we also
    // guard against the tail of a block-drag bubbling up here.
    if (e.target !== e.currentTarget) return;
    if (e.button !== 0) return;
    const rect = e.currentTarget.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const minutes = Math.max(0, Math.min(24 * 60 - 30, Math.round((x / pph) * 60 / _EPG_SNAP_MIN) * _EPG_SNAP_MIN));
    const start = new Date(dayStart);
    start.setMinutes(minutes);
    onEmptyClick(recorder, start);
  };

  return (
    <div className="epg-row" style={{ width: 24 * pph }} onPointerUp={handleRowPointerUp}>
      {dayEvents.map(s => (
        <_EventBlock
          key={s.id}
          event={s}
          recorder={recorder}
          dayStart={dayStart}
          dayEnd={dayEnd}
          pph={pph}
          now={now}
          projects={projects}
          onClick={onEventClick}
          onContextMenu={onEventContextMenu}
          onResize={onEventResize} />
      ))}
    </div>
  );
}

// ── Right-click menu for an EPG event block ─────────────────────────────────
// Same pattern as AssetContextMenu (screens-library.jsx): viewport-clamped,
// dismissed on outside click. Per-status action filtering mirrors the
// buttons rendered in the List view so the two surfaces stay consistent.
function _ScheduleContextMenu({ schedule, x, y, onClose, onEdit, onCancel, onDelete, onCopyId }) {
  const ref = React.useRef(null);
  const [pos, setPos] = React.useState({ left: x, top: y });
  React.useLayoutEffect(() => {
    if (!ref.current) return;
    const r = ref.current.getBoundingClientRect();
    const margin = 8;
    let nx = x, ny = y;
    if (x + r.width  + margin > window.innerWidth)  nx = window.innerWidth  - r.width  - margin;
    if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin;
    setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) });
  }, [x, y]);

  const canEdit   = schedule.status === 'pending' || schedule.status === 'failed';
  const canCancel = schedule.status === 'pending' || schedule.status === 'running';
  const canDelete = schedule.status !== 'running';

  return (
    <div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
         onClick={(e) => e.stopPropagation()}
         onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}>
      <div className="ctx-header">{schedule.name}</div>
      {canEdit   && <button onClick={onEdit}><Icon name="edit" size={11} />Edit…</button>}
      {canCancel && <button onClick={onCancel}><Icon name="x" size={11} />Cancel run</button>}
      <button onClick={onCopyId}><Icon name="library" size={11} />Copy schedule ID</button>
      {canDelete && <div className="ctx-divider" />}
      {canDelete && <button className="danger" onClick={onDelete}><Icon name="trash" size={11} />Delete schedule</button>}
    </div>
  );
}

function _NowLine({ now, dayStart, pph }) {
  if (!_sameDay(now, dayStart)) return null;
  const min = _minutesIntoDay(now, dayStart);
  const x = (min / 60) * pph;
  return (
    <div className="epg-now" style={{ left: x }}>
      <span className="epg-now-pip" />
    </div>
  );
}

function _RecorderGutter({ recorders, projects }) {
  return (
    <div className="epg-gutter-rows">
      {recorders.map(r => {
        const color = _projectColor(r.project_id, projects);
        const isLive = r.status === 'recording';
        const isErr  = r.status === 'error';
        return (
          <div key={r.id} className="epg-gutter-row">
            <span className={'epg-gutter-status ' + (isLive ? 'live' : isErr ? 'err' : 'idle')} />
            <div className="epg-gutter-meta">
              <div className="epg-gutter-name">{r.name}</div>
              <div className="epg-gutter-sub mono">{(r.source_type || '·').toUpperCase()}{color ? ' · ' : ''}{color && <span className="epg-gutter-dot" style={{ background: color }} />}</div>
            </div>
          </div>
        );
      })}
    </div>
  );
}

function Schedule({ navigate }) {
  const [schedules, setSchedules] = React.useState(null);
  const [recorders, setRecorders] = React.useState([]);
  const [showNew,   setShowNew]   = React.useState(false);
  const [newDefaults, setNewDefaults] = React.useState(null);
  const [editing,   setEditing]   = React.useState(null);
  const [ctxMenu,   setCtxMenu]   = React.useState(null); // { schedule, x, y }
  const [confirm, confirmModal] = window.useConfirm();
  const [view,      setView]      = React.useState('today'); // 'today' | 'week' | 'list'
  const [day,       setDay]       = React.useState(() => _dayStart(new Date()));
  const [listFilter, setListFilter] = React.useState('upcoming');
  const [now,       setNow]       = React.useState(() => new Date());

  // Tick the now-line every second. We only re-render the components that
  // consume `now`; the rest are React.memo or insensitive.
  React.useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 1000);
    return () => clearInterval(id);
  }, []);

  // Schedule data - pull everything once and filter client-side for the
  // active view. /schedules caps at 200 rows so this stays cheap.
  const apiFilter = view === 'list' ? listFilter : 'all';
  const load = React.useCallback(() => {
    window.ZAMPP_API.fetch('/schedules?status=' + apiFilter)
      .then(d => setSchedules(d.schedules || []))
      .catch(() => setSchedules([]));
  }, [apiFilter]);

  React.useEffect(() => { load(); }, [load]);
  React.useEffect(() => {
    window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([]));
  }, []);

  // Auto-refresh: 10s in normal view, 4s if anything is live so the now-state
  // catches transitions promptly.
  React.useEffect(() => {
    const anyLive = (schedules || []).some(s => s.status === 'running');
    const id = setInterval(load, anyLive ? 4000 : 10_000);
    return () => clearInterval(id);
  }, [load, schedules]);

  // The Recorders screen broadcasts these on create/delete; refresh the
  // gutter so renamed or new recorders show up immediately.
  React.useEffect(() => {
    const refresh = () => window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => {});
    window.addEventListener('df:recorders-changed', refresh);
    return () => window.removeEventListener('df:recorders-changed', refresh);
  }, []);

  const projects = window.ZAMPP_DATA?.PROJECTS || [];

  // Pixels per hour - wider on Today (high-res operations view), tighter
  // when the user is scanning Week-at-a-glance.
  const pph = view === 'week' ? 44 : 88;

  const dayStart = _dayStart(day);
  const dayEnd   = _dayEnd(day);

  // Scroll the canvas so "now" sits ~30% from the left edge on first paint
  // for Today view. Re-runs when the user jumps days via the Today button.
  const canvasRef = React.useRef(null);
  React.useLayoutEffect(() => {
    if (view !== 'today' || !canvasRef.current) return;
    if (!_sameDay(now, dayStart)) {
      canvasRef.current.scrollLeft = 0;
      return;
    }
    const min = _minutesIntoDay(now, dayStart);
    const x = (min / 60) * pph;
    const target = Math.max(0, x - canvasRef.current.clientWidth * 0.3);
    canvasRef.current.scrollLeft = target;
  // Deliberately only re-run on view/day change, not on `now` ticking.
  // Otherwise the canvas would re-scroll every second and trap the user.
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [view, day, pph, recorders.length]);

  const openNewAt = (recorder, start) => {
    const end = new Date(start.getTime() + 30 * 60 * 1000);
    setNewDefaults({ start, end, recorder_id: recorder.id });
    setShowNew(true);
  };
  const openNewBlank = () => { setNewDefaults(null); setShowNew(true); };

  const cancel = async (s) => {
    if (!(await confirm({ title: 'Cancel scheduled recording?', message: 'Cancel scheduled recording "' + s.name + '"?', confirmLabel: 'Cancel recording' }))) return;
    window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' }).then(load).catch(e => alert('Cancel failed: ' + e.message));
  };
  const remove = async (s) => {
    if (!(await confirm({ title: 'Delete schedule?', message: 'Delete schedule "' + s.name + '"?' }))) return;
    window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' }).then(load).catch(e => alert('Delete failed: ' + e.message));
  };

  // Drag-resize commit: optimistically patch the in-memory schedule so the
  // block stays put after the user lets go, then PUT the new times. The
  // refetch reconciles in case the server adjusted anything (or rejected).
  const handleResize = (s, newStart, newEnd) => {
    setSchedules(prev => prev ? prev.map(x => x.id === s.id ? { ...x, start_at: newStart, end_at: newEnd } : x) : prev);
    window.ZAMPP_API.fetch('/schedules/' + s.id, {
      method: 'PUT',
      body: JSON.stringify({ start_at: newStart, end_at: newEnd }),
    })
      .then(load)
      .catch(e => { alert('Resize failed: ' + e.message); load(); });
  };

  const openCtx = (s, ev) => setCtxMenu({ schedule: s, x: ev.clientX, y: ev.clientY });
  // Dismiss the context menu on any outside click - capture phase so a
  // click on a menu item still fires before the menu unmounts.
  React.useEffect(() => {
    if (!ctxMenu) return;
    const close = () => setCtxMenu(null);
    window.addEventListener('click', close);
    window.addEventListener('contextmenu', close);
    window.addEventListener('scroll', close, true);
    return () => {
      window.removeEventListener('click', close);
      window.removeEventListener('contextmenu', close);
      window.removeEventListener('scroll', close, true);
    };
  }, [ctxMenu]);

  const copyId = (id) => {
    if (navigator.clipboard) navigator.clipboard.writeText(id).catch(() => {});
  };

  // Days for Week view: the 7-day window starting at the Sunday of `day`.
  const weekDays = React.useMemo(() => {
    const sun = _addDays(dayStart, -dayStart.getDay());
    return Array.from({ length: 7 }, (_, i) => _addDays(sun, i));
  }, [dayStart]);

  // List view filters schedules by client-side time bucket too.
  const listSchedules = React.useMemo(() => {
    if (!schedules) return null;
    return [...schedules].sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
  }, [schedules]);

  return (
    <div className="epg-page" style={{ '--epg-pph': pph + 'px' }}>
      {confirmModal}
      <_StatusStrip schedules={schedules || []} recorders={recorders} now={now} projects={projects} />

      <div className="epg-toolbar">
        <div className="epg-date">
          <button className="icon-btn" onClick={() => setDay(_addDays(day, -1))} title="Previous day" aria-label="Previous day"><Icon name="chevron" style={{ transform: 'rotate(90deg)' }} /></button>
          <div className="epg-date-label">{_sameDay(day, new Date()) ? 'Today · ' + _fmtDay(day) : _fmtDay(day)}</div>
          <button className="icon-btn" onClick={() => setDay(_addDays(day, 1))} title="Next day" aria-label="Next day"><Icon name="chevron" style={{ transform: 'rotate(-90deg)' }} /></button>
          <button className="btn ghost sm" onClick={() => setDay(_dayStart(new Date()))} disabled={_sameDay(day, new Date())}>Today</button>
        </div>
        <div className="spacer" />
        <div className="tab-group">
          <button className={view === 'today' ? 'active' : ''} onClick={() => setView('today')}>Today</button>
          <button className={view === 'week' ? 'active' : ''}  onClick={() => setView('week')}>Week</button>
          <button className={view === 'list' ? 'active' : ''}  onClick={() => setView('list')}>List</button>
        </div>
        <button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
        <button className="btn primary" onClick={openNewBlank} disabled={recorders.length === 0}>
          <Icon name="plus" />New schedule
        </button>
      </div>

      {schedules === null && (
        <div className="epg-empty">Loading…</div>
      )}

      {schedules !== null && recorders.length === 0 && (
        <div className="epg-empty">
          <div className="epg-empty-title">No recorders configured</div>
          <div className="epg-empty-sub">Create a recorder before scheduling.</div>
          <button className="btn primary sm" onClick={() => navigate('recorders')}>Go to Recorders</button>
        </div>
      )}

      {schedules !== null && recorders.length > 0 && view === 'today' && (
        <div className="epg" ref={canvasRef}>
          <div className="epg-corner">
            <span className="mono">{_fmtDay(day)}</span>
          </div>
          <div className="epg-gutter">
            <_RecorderGutter recorders={recorders} projects={projects} />
          </div>
          <div className="epg-canvas-head">
            <_EpgRuler pph={pph} />
          </div>
          <div className="epg-canvas" style={{ width: 24 * pph }}>
            <div className="epg-rows">
              {recorders.map(r => (
                <_EpgRow key={r.id}
                  recorder={r}
                  schedules={schedules}
                  dayStart={dayStart}
                  dayEnd={dayEnd}
                  pph={pph}
                  now={now}
                  projects={projects}
                  onEventClick={(s) => setEditing(s)}
                  onEventContextMenu={openCtx}
                  onEventResize={handleResize}
                  onEmptyClick={openNewAt} />
              ))}
            </div>
            <_NowLine now={now} dayStart={dayStart} pph={pph} />
          </div>
        </div>
      )}

      {schedules !== null && recorders.length > 0 && view === 'week' && (
        <div className="epg-week">
          {weekDays.map(d => {
            const dEnd = _dayEnd(d);
            const isToday = _sameDay(d, new Date());
            return (
              <div key={d.toISOString()} className={'epg-week-day' + (isToday ? ' today' : '')}>
                <div className="epg-week-dayhead">
                  <span className="epg-week-dayname">{_fmtDay(d)}</span>
                  {isToday && <span className="epg-week-todaypip">today</span>}
                </div>
                <div className="epg-week-row-wrap" style={{ width: 24 * pph }}>
                  <_EpgRuler pph={pph} />
                  <div className="epg-rows">
                    {recorders.map(r => (
                      <_EpgRow key={r.id}
                        recorder={r}
                        schedules={schedules}
                        dayStart={d}
                        dayEnd={dEnd}
                        pph={pph}
                        now={now}
                        projects={projects}
                        onEventClick={(s) => setEditing(s)}
                        onEventContextMenu={openCtx}
                        onEventResize={handleResize}
                        onEmptyClick={openNewAt} />
                    ))}
                  </div>
                  {isToday && <_NowLine now={now} dayStart={d} pph={pph} />}
                </div>
              </div>
            );
          })}
        </div>
      )}

      {schedules !== null && recorders.length > 0 && view === 'list' && (
        <div className="epg-list">
          <div className="tab-group" style={{ marginBottom: 12 }}>
            <button className={listFilter === 'upcoming' ? 'active' : ''} onClick={() => setListFilter('upcoming')}>Upcoming</button>
            <button className={listFilter === 'past' ? 'active' : ''}     onClick={() => setListFilter('past')}>Past</button>
            <button className={listFilter === 'all' ? 'active' : ''}      onClick={() => setListFilter('all')}>All</button>
          </div>
          {(listSchedules || []).length === 0 ? (
            <div className="epg-empty"><div className="epg-empty-title">No {listFilter} schedules</div></div>
          ) : (
            <div className="panel">
              <div className="schedule-row head">
                <div>Name</div><div>Recorder</div><div>Starts</div><div>Duration</div><div>Recurrence</div><div>Status</div><div></div>
              </div>
              {listSchedules.map(s => {
                const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
                return (
                  <div key={s.id} className="schedule-row">
                    <div style={{ fontWeight: 500, fontSize: 13 }}>
                      {s.name}
                      {s.error_message && <div style={{ fontSize: 11, color: 'var(--danger)', marginTop: 3 }}>{s.error_message}</div>}
                    </div>
                    <div className="mono" style={{ fontSize: 11.5, color: 'var(--text-2)' }}>{s.recorder_name || (s.recorder_id ? s.recorder_id.slice(0, 8) : 'unassigned')}</div>
                    <div className="mono" style={{ fontSize: 11.5 }}>{_fmtWhen(s.start_at)}</div>
                    <div className="mono" style={{ fontSize: 11.5 }}>{_durationMin(s.start_at, s.end_at)} min</div>
                    <div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{s.recurrence === 'none' ? 'one-shot' : s.recurrence}</div>
                    <div><span className={'badge ' + badge.cls}>{badge.label}</span></div>
                    <div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
                      {s.status === 'pending' && <button className="btn ghost sm" onClick={() => setEditing(s)}>Edit</button>}
                      {(s.status === 'pending' || s.status === 'running') && <button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>}
                      {(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') &&
                        <button className="btn ghost sm" onClick={() => remove(s)}>Delete</button>}
                    </div>
                  </div>
                );
              })}
            </div>
          )}
        </div>
      )}

      {showNew && <NewScheduleModal
        recorders={recorders}
        defaultRecorderId={newDefaults?.recorder_id}
        defaultStart={newDefaults?.start}
        defaultEnd={newDefaults?.end}
        onClose={() => { setShowNew(false); setNewDefaults(null); }}
        onCreated={() => { setShowNew(false); setNewDefaults(null); load(); }} />}
      {editing && <EditScheduleModal schedule={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load(); }} />}
      {ctxMenu && (
        <_ScheduleContextMenu
          schedule={ctxMenu.schedule}
          x={ctxMenu.x}
          y={ctxMenu.y}
          onClose={() => setCtxMenu(null)}
          onEdit={() => { const s = ctxMenu.schedule; setCtxMenu(null); setEditing(s); }}
          onCancel={() => { const s = ctxMenu.schedule; setCtxMenu(null); cancel(s); }}
          onDelete={() => { const s = ctxMenu.schedule; setCtxMenu(null); remove(s); }}
          onCopyId={() => { const id = ctxMenu.schedule.id; setCtxMenu(null); copyId(id); }} />
      )}
    </div>
  );
}


function EditScheduleModal({ schedule, onClose, onSaved }) {
  const toLocalInput = (iso) => {
    const d = new Date(iso);
    const tz = d.getTimezoneOffset() * 60_000;
    return new Date(d.getTime() - tz).toISOString().slice(0, 16);
  };
  const [form, setForm] = React.useState({
    name:       schedule.name,
    start_at:   toLocalInput(schedule.start_at),
    end_at:     toLocalInput(schedule.end_at),
    recurrence: schedule.recurrence || 'none',
  });
  const [saving, setSaving] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const set = (k, v) => setForm(p => ({ ...p, [k]: v }));

  const submit = () => {
    setErr(null);
    if (!form.name.trim()) return setErr('Name is required');
    const startD = new Date(form.start_at);
    const endD   = new Date(form.end_at);
    if (endD <= startD) return setErr('End must be after start');
    setSaving(true);
    window.ZAMPP_API.fetch('/schedules/' + schedule.id, {
      method: 'PUT',
      body: JSON.stringify({
        name: form.name.trim(),
        start_at: startD.toISOString(),
        end_at:   endD.toISOString(),
        recurrence: form.recurrence,
      }),
    })
      .then(onSaved)
      .catch(e => { setSaving(false); setErr(e.message || 'Save failed'); });
  };

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
        <div className="modal-head">
          <div style={{ fontSize: 15, fontWeight: 600 }}>Edit scheduled recording</div>
          <button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
        </div>
        <div className="modal-body">
          <div className="field">
            <label className="field-label">Name</label>
            <input className="field-input" autoFocus value={form.name}
                   onChange={e => set('name', e.target.value)} />
          </div>
          <div className="field">
            <label className="field-label">Recorder</label>
            <input className="field-input mono" value={schedule.recorder_name || schedule.recorder_id} readOnly
                   style={{ color: 'var(--text-3)' }} />
            <div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>Recorder can't be reassigned: delete + recreate to change.</div>
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
            <div className="field">
              <label className="field-label">Start</label>
              <input className="field-input mono" type="datetime-local"
                     value={form.start_at} onChange={e => set('start_at', e.target.value)} />
            </div>
            <div className="field">
              <label className="field-label">End</label>
              <input className="field-input mono" type="datetime-local"
                     value={form.end_at} onChange={e => set('end_at', e.target.value)} />
            </div>
          </div>
          <div className="field">
            <label className="field-label">Recurrence</label>
            <select className="field-input" value={form.recurrence}
                    onChange={e => set('recurrence', e.target.value)}
                    style={{ appearance: 'auto' }}>
              <option value="none">One-shot (no repeat)</option>
              <option value="daily">Daily</option>
              <option value="weekly">Weekly</option>
            </select>
          </div>
          {err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
        </div>
        <div className="modal-foot">
          <button className="btn ghost sm" onClick={onClose}>Cancel</button>
          <button className="btn primary sm" onClick={submit} disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</button>
        </div>
      </div>
    </div>
  );
}

function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, defaultEnd, defaultRecorderId }) {
  // If the user clicked a day on the calendar we honour that; otherwise default
  // to "start in 5 minutes, run for 30 min" so the modal is immediately usable.
  const toLocalInput = (d) => {
    const tz = d.getTimezoneOffset() * 60_000;
    return new Date(d.getTime() - tz).toISOString().slice(0, 16);
  };
  const now = new Date();
  now.setSeconds(0, 0);
  const startDefault = defaultStart || new Date(now.getTime() + 5 * 60 * 1000);
  const endDefault   = defaultEnd   || new Date(startDefault.getTime() + 30 * 60 * 1000);

  const [form, setForm] = React.useState({
    name: '',
    recorder_id: defaultRecorderId || recorders[0]?.id || '',
    start_at: toLocalInput(startDefault),
    end_at:   toLocalInput(endDefault),
    recurrence: 'none',
  });
  const [saving, setSaving] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const set = (k, v) => setForm(p => ({ ...p, [k]: v }));

  const submit = () => {
    setErr(null);
    if (!form.name.trim())   return setErr('Name is required');
    if (!form.recorder_id)   return setErr('Pick a recorder');
    if (!form.start_at)      return setErr('Start time is required');
    if (!form.end_at)        return setErr('End time is required');
    const startD = new Date(form.start_at);
    const endD   = new Date(form.end_at);
    if (endD <= startD) return setErr('End must be after start');

    // Warn (but allow) start times in the past - the scheduler tick will fire
    // them immediately, which is occasionally what the operator wants
    // (e.g. "record the next 30 minutes starting now").
    if (startD < new Date(Date.now() - 60_000)) {
      if (!confirm('Start time is in the past: recorder will fire immediately when saved.\nContinue?')) return;
    }

    // Datetime-local inputs are in the browser's local zone; ship as ISO so
    // Postgres stores them as TIMESTAMPTZ properly.
    const body = {
      name:        form.name.trim(),
      recorder_id: form.recorder_id,
      start_at:    startD.toISOString(),
      end_at:      endD.toISOString(),
      recurrence:  form.recurrence,
    };

    setSaving(true);
    window.ZAMPP_API.fetch('/schedules', { method: 'POST', body: JSON.stringify(body) })
      .then(() => onCreated())
      .catch(e => { setSaving(false); setErr(e.message || 'Failed to schedule'); });
  };

  const onKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) submit(); };

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
        <div className="modal-head">
          <div style={{ fontSize: 15, fontWeight: 600 }}>New scheduled recording</div>
          <button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
        </div>
        <div className="modal-body">
          <div className="field">
            <label className="field-label">Name</label>
            <input className="field-input" autoFocus value={form.name}
                   onChange={e => set('name', e.target.value)}
                   onKeyDown={onKey} placeholder="Morning service stream" />
          </div>
          <div className="field">
            <label className="field-label">Recorder</label>
            <select className="field-input" value={form.recorder_id}
                    onChange={e => set('recorder_id', e.target.value)}
                    style={{ appearance: 'auto' }}>
              {recorders.length === 0 && <option value="">No recorders defined</option>}
              {recorders.map(r => (
                <option key={r.id} value={r.id}>
                  {r.name} · {r.source_type?.toUpperCase() || '?'}
                </option>
              ))}
            </select>
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
            <div className="field">
              <label className="field-label">Start</label>
              <input className="field-input mono" type="datetime-local"
                     value={form.start_at}
                     onChange={e => set('start_at', e.target.value)} />
            </div>
            <div className="field">
              <label className="field-label">End</label>
              <input className="field-input mono" type="datetime-local"
                     value={form.end_at}
                     onChange={e => set('end_at', e.target.value)} />
            </div>
          </div>
          <div className="field">
            <label className="field-label">Recurrence</label>
            <select className="field-input" value={form.recurrence}
                    onChange={e => set('recurrence', e.target.value)}
                    style={{ appearance: 'auto' }}>
              <option value="none">One-shot (no repeat)</option>
              <option value="daily">Daily</option>
              <option value="weekly">Weekly</option>
            </select>
            <div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
              Recurring schedules queue the next occurrence as soon as the current one completes.
            </div>
          </div>
          {err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
        </div>
        <div className="modal-foot">
          <button className="btn ghost sm" onClick={onClose}>Cancel</button>
          <button className="btn primary sm" onClick={submit} disabled={saving}>
            {saving ? 'Scheduling…' : 'Schedule recording'}
          </button>
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule, YouTubeImport });
