// 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.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('/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('/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 }))
      .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>
  );
}

/* ===== 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, 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 = '/live/' + assetId + '/index.m3u8';
    const v = videoRef.current;

    // Safari can play HLS natively; everything else needs hls.js.
    if (v.canPlayType('application/vnd.apple.mpegurl')) {
      v.src = url;
      const onErr = () => setErr('playback failed');
      v.addEventListener('error', onErr);
      return () => v.removeEventListener('error', onErr);
    }
    if (!window.Hls) { setErr('hls.js missing'); return; }

    const 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) setErr(data.details || 'hls error');
    });
    return () => { 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>
  );
}

/* ===== Recorders ===== */
function _normRecorder(r) {
  let elapsed = '—';
  if (r.status === 'recording' && r.started_at) {
    const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
    elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
              String(Math.floor((s % 3600) / 60)).padStart(2, '00') + ':' +
              String(s % 60).padStart(2, '0');
  }
  const cfg = r.source_config || {};
  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 || '—',
    node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
    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(() => {});
  }, []);

  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 [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('');
  const isRec = recorder.status === 'recording';

  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]);

  const displayElapsed = React.useMemo(() => {
    if (liveStatus && liveStatus.duration != null) {
      const d = Math.max(0, liveStatus.duration);
      return String(Math.floor(d / 3600)).padStart(2, '0') + ':' +
             String(Math.floor((d % 3600) / 60)).padStart(2, '0') + ':' +
             String(d % 60).padStart(2, '0');
    }
    return recorder.elapsed;
  }, [liveStatus, recorder.elapsed]);

  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 on start; stop has no body.
    const body = (action === 'start' && clipName.trim())
      ? JSON.stringify({ clipName: clipName.trim() })
      : undefined;
    window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body })
      .then(() => {
        setPending(false);
        // Clear the input on a successful stop so the next take starts fresh.
        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 = () => {
    if (!window.confirm('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}>
      <div className="recorder-preview">
        {isRec && recorder.live_asset_id
          ? <HlsPreview assetId={recorder.live_asset_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>
        </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>
        {liveStatus?.currentFps != null && (
          <div className="recorder-stat">
            <div className="stat-label">FPS</div>
            <div className="stat-val mono">{Number(liveStatus.currentFps).toFixed(1)}</div>
          </div>
        )}
      </div>
      <div className="recorder-actions">
        {!isRec && (
          <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: 180, 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" 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 Capture({ navigate }) {
  const [devices, setDevices] = React.useState([]);
  const [activeIdx, setActiveIdx] = React.useState(0);

  const loadDevices = () => {
    window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
      .then(devs => setDevices(Array.isArray(devs) ? devs : []))
      .catch(() => setDevices([]));
  };

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

  if (devices.length === 0) {
    return (
      <div className="page">
        <div className="page-header">
          <h1>Capture</h1>
          <span className="subtitle">DeckLink SDI ingest</span>
          <div className="spacer" />
          <button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
        </div>
        <div className="page-body">
          <div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
            No DeckLink devices found in cluster.
          </div>
        </div>
      </div>
    );
  }

  const active = devices[activeIdx] || devices[0];

  return (
    <div className="page">
      <div className="page-header">
        <h1>Capture</h1>
        <span className="subtitle">DeckLink SDI ingest — {devices.length} device{devices.length > 1 ? 's' : ''} in cluster</span>
        <div className="spacer" />
        <button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
      </div>
      <div className="page-body">
        <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
          {devices.map((d, i) => (
            <button key={i} className={'btn ' + (activeIdx === i ? 'primary' : 'ghost') + ' sm'} onClick={() => setActiveIdx(i)}>
              {d.model || d.device || 'DeckLink'} — {d.hostname}
            </button>
          ))}
        </div>
        <div className="panel" style={{ padding: 20 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
            <StatusDot status={active.online ? 'online' : 'offline'} />
            <div>
              <div style={{ fontWeight: 600 }}>{active.model || active.device || 'DeckLink'}</div>
              <div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{active.hostname} · {active.ip_address}</div>
            </div>
          </div>
          <div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Connect a source and click Refresh to see port status.</div>
        </div>
      </div>
    </div>
  );
}

/* ===== Monitors ===== */
function Monitors({ navigate }) {
  const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
  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(() => {});
    };
    refresh();
    const id = setInterval(refresh, 5000);
    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 ? (
          <div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder to see live video here.</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>
        )}
      </div>
    </div>
  );
}

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

  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]);

  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>
    );
  }

  return (
    <div className="monitor-tile">
      {isLive && feed.live_asset_id
        ? <HlsPreview assetId={feed.live_asset_id} />
        : <FauxFrame />}
      {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 }}>
        {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>}
      </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>
        {feed.elapsed && feed.elapsed !== '—' && <span className="time mono">{feed.elapsed}</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.slice(0, 8)}</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.slice(0, 8)}</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>
  );
}

function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick }) {
  const s = new Date(event.start_at);
  const e = new Date(event.end_at);
  const startMin = _minutesIntoDay(s, dayStart);
  const endMin   = _minutesIntoDay(e, dayStart);
  const left  = (startMin / 60) * pph;
  const width = Math.max(40, ((endMin - startMin) / 60) * pph);

  const isLive   = event.status === 'running' || (event.status === 'pending' && s <= now && now < e);
  const isFailed = event.status === 'failed';
  const isPast   = (event.status === 'completed' || event.status === 'cancelled') || e < now;
  const color    = _projectColor(recorder?.project_id, projects);

  const classes = ['epg-block'];
  if (isLive)   classes.push('live');
  if (isFailed) classes.push('failed');
  else if (isPast) classes.push('past');

  return (
    <button
      className={classes.join(' ')}
      style={{ left, width, '--epg-block-color': color || 'var(--text-3)' }}
      onClick={(ev) => { ev.stopPropagation(); onClick(event); }}
      title={event.name + ' · ' + _fmtTime(s) + ' → ' + _fmtTime(e) + (event.error_message ? ' · ' + event.error_message : '')}>
      <span className="epg-block-bar" />
      <span className="epg-block-name">{event.name}</span>
      <span className="epg-block-time mono">{_fmtTime(s)}</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>}
    </button>
  );
}

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

  const handleRowClick = (e) => {
    // Translate clicked x to a Date in this row's day. Snap to 15-minute
    // increments so the resulting modal pre-fill looks intentional.
    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 / 15) * 15));
    const start = new Date(dayStart);
    start.setMinutes(minutes);
    onEmptyClick(recorder, start);
  };

  return (
    <div className="epg-row" style={{ width: 24 * pph }} onClick={handleRowClick}>
      {dayEvents.map(s => (
        <_EventBlock
          key={s.id}
          event={s}
          recorder={recorder}
          dayStart={dayStart}
          dayEnd={dayEnd}
          pph={pph}
          now={now}
          projects={projects}
          onClick={onEventClick} />
      ))}
    </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 [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 = (s) => {
    if (!confirm('Cancel scheduled recording "' + s.name + '"?')) return;
    window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' }).then(load).catch(e => alert('Cancel failed: ' + e.message));
  };
  const remove = (s) => {
    if (!confirm('Delete schedule "' + s.name + '"?')) return;
    window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' }).then(load).catch(e => alert('Delete failed: ' + e.message));
  };

  // 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' }}>
      <_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"><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"><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)}
                  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)}
                        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.slice(0, 8)}</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(); }} />}
    </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" 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" 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 });
