// TvDisplay.jsx — what the store TV shows (v21.00).
//
// The Roku channel in apps/roku-tv plays this list on a loop: an image for
// its duration, a video to the end. Order, duration and on/off are set
// here; the TV re-reads the list once a minute (through the `tv-playlist`
// Edge Function) and changes take effect at the next slide. The TV checks
// in through `tv-heartbeat`, which is what the "TVs" card reads.
//
// Files live in the public `tv-media` bucket under {restaurant}/{uuid}.ext.
// Images go through uploadImageCompressed (TinyPNG); videos upload as-is,
// because the Roku wants an H.264 MP4 and nothing here should re-encode.
//
// All top-level idents are tv/Tv/TV_-prefixed — Babel-standalone shares one
// global scope across every .jsx in the portal (HANDBOOK §9).

const TV_BUCKET = 'tv-media';
const TV_MAX_BYTES = 200 * 1024 * 1024;
const TV_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const TV_VIDEO_TYPES = ['video/mp4'];

const tvMediaFromRow = (r) => ({
  id: r.id,
  kind: r.kind,
  storageKey: r.storage_key,
  title: r.title || '',
  durationSeconds: Number(r.duration_seconds) || 10,
  sortOrder: Number(r.sort_order) || 0,
  active: r.active !== false,
  bytes: r.bytes == null ? null : Number(r.bytes),
  createdAt: r.created_at || null,
});
// NOTE: never send restaurant_id — useSupaList injects it on inserts.
const tvMediaToRow = (o) => ({
  id: o.id,
  kind: o.kind,
  storage_key: o.storageKey,
  title: o.title || '',
  duration_seconds: Math.max(1, Math.min(3600, Math.round(Number(o.durationSeconds) || 10))),
  sort_order: Number(o.sortOrder) || 0,
  active: o.active !== false,
  bytes: o.bytes == null ? null : Number(o.bytes),
  updated_at: new Date().toISOString(),
});
const tvDeviceFromRow = (r) => ({
  id: r.id,
  name: r.name || '',
  model: r.model || '',
  appVersion: r.app_version || '',
  playlistVersion: r.playlist_version || '',
  currentItemId: r.current_item_id || null,
  lastSeenAt: r.last_seen_at || null,
});
const tvDeviceToRow = (o) => ({ id: o.id, name: o.name || '' });

const tvPublicUrl = (key) => window.supa.storage.from(TV_BUCKET).getPublicUrl(key).data.publicUrl;
const tvFmtBytes = (n) => {
  if (!Number.isFinite(n) || n <= 0) return '';
  if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
  return (n / 1048576).toFixed(1) + ' MB';
};
const tvAgo = (iso) => {
  if (!iso) return 'never';
  const s = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
  if (s < 90) return s + 's ago';
  if (s < 5400) return Math.round(s / 60) + ' min ago';
  if (s < 172800) return Math.round(s / 3600) + ' h ago';
  return Math.round(s / 86400) + ' d ago';
};

const TvDisplay = () => {
  const [media, setMedia] = window.useSupaList('tv_media', {
    fromRow: tvMediaFromRow, toRow: tvMediaToRow, initial: () => [],
  });
  const [devices, setDevices] = window.useSupaList('tv_devices', {
    fromRow: tvDeviceFromRow, toRow: tvDeviceToRow, initial: () => [],
  });
  const [toast, setToast] = useState('');
  const [busy, setBusy] = useState(false);
  const [preview, setPreview] = useState(false);
  const [setupOpen, setSetupOpen] = useState(false);
  const fileRef = useRef(null);
  // Re-render every 20s so "seen 12s ago" keeps moving without a reload.
  const [, tick] = useState(0);
  useEffect(() => { const t = setInterval(() => tick(n => n + 1), 20000); return () => clearInterval(t); }, []);

  const ordered = [...media].sort((a, b) => (a.sortOrder - b.sortOrder) || String(a.createdAt).localeCompare(String(b.createdAt)));
  const active = ordered.filter(m => m.active);
  const loopSeconds = active.reduce((s, m) => s + (m.kind === 'image' ? m.durationSeconds : 0), 0);
  const videoCount = active.filter(m => m.kind === 'video').length;

  // ---- writes ------------------------------------------------------
  const patch = (id, p) => setMedia(list => list.map(m => m.id === id ? { ...m, ...p } : m));

  const upload = async (files) => {
    const list = Array.from(files || []);
    if (!list.length) return;
    setBusy(true);
    let maxSort = ordered.reduce((s, m) => Math.max(s, m.sortOrder), 0);
    let added = 0;
    for (const file of list) {
      const isImage = TV_IMAGE_TYPES.includes(file.type);
      const isVideo = TV_VIDEO_TYPES.includes(file.type);
      if (!isImage && !isVideo) { setToast(file.name + ': only JPG, PNG, WebP or MP4.'); continue; }
      if (file.size > TV_MAX_BYTES) { setToast(file.name + ' is over 200 MB.'); continue; }
      const ext = (file.name.split('.').pop() || (isVideo ? 'mp4' : 'jpg')).toLowerCase();
      const id = crypto.randomUUID();
      const key = `${window.RESTAURANT_ID}/${id}.${ext}`;
      try {
        let bytes = file.size;
        if (isImage) {
          const r = await window.uploadImageCompressed(TV_BUCKET, key, file, { contentType: file.type, upsert: false });
          if (r && r.outputBytes) bytes = r.outputBytes;
        } else {
          // UploadHUD is a script-global from portal-shared.jsx (top-level const,
          // shared scope), not a window property.
          if (typeof UploadHUD !== 'undefined') UploadHUD.working('Uploading ' + file.name + ' (' + tvFmtBytes(file.size) + ')…');
          const { error } = await window.supa.storage.from(TV_BUCKET).upload(key, file, { contentType: file.type, upsert: false });
          if (error) throw error;
          if (typeof UploadHUD !== 'undefined') UploadHUD.done('Uploaded ' + file.name + ' · ' + tvFmtBytes(file.size));
        }
        maxSort += 10;
        setMedia(prev => prev.concat([{
          id, kind: isVideo ? 'video' : 'image', storageKey: key,
          title: file.name.replace(/\.[^.]+$/, ''),
          durationSeconds: 10, sortOrder: maxSort, active: true, bytes,
          createdAt: new Date().toISOString(),
        }]));
        added += 1;
      } catch (e) {
        console.error('tv upload failed', e);
        setToast('Upload failed: ' + (e.message || String(e)));
        if (typeof UploadHUD !== 'undefined') UploadHUD.hide();
      }
    }
    if (added) setToast(added === 1 ? 'Added to the TV. Live within a minute.' : added + ' added to the TV. Live within a minute.');
    setBusy(false);
    if (fileRef.current) fileRef.current.value = '';
  };

  const move = (item, dir) => {
    const idx = ordered.findIndex(m => m.id === item.id);
    const j = idx + dir;
    if (idx < 0 || j < 0 || j >= ordered.length) return;
    const next = ordered.slice();
    [next[idx], next[j]] = [next[j], next[idx]];
    const orderById = new Map(next.map((m, i) => [m.id, (i + 1) * 10]));
    setMedia(list => list.map(m => orderById.has(m.id) && orderById.get(m.id) !== m.sortOrder ? { ...m, sortOrder: orderById.get(m.id) } : m));
  };

  const remove = (item) => {
    if (!window.confirm('Remove “' + (item.title || item.storageKey) + '” from the TV? The file is deleted too.')) return;
    setMedia(list => list.filter(m => m.id !== item.id));
    void window.supa.storage.from(TV_BUCKET).remove([item.storageKey]).then(({ error }) => {
      if (error) console.error('tv-media remove failed', error);
    });
    setToast('Removed. The TV drops it at the next slide.');
  };

  const renameDevice = (d) => {
    const name = window.prompt('Name this TV (e.g. Dining room)', d.name || '');
    if (name == null) return;
    setDevices(list => list.map(x => x.id === d.id ? { ...x, name: name.trim() } : x));
  };
  const forgetDevice = (d) => {
    if (!window.confirm('Forget this TV? It reappears the next time the channel checks in.')) return;
    setDevices(list => list.filter(x => x.id !== d.id));
  };

  // ---- render ------------------------------------------------------
  const cell = { padding: '10px 12px', verticalAlign: 'middle' };
  const titleFor = (id) => (media.find(m => m.id === id) || {}).title || '';

  return (
    <div className="portal-page" style={{ maxWidth: 1080 }}>
      <PPageHeader
        title="TV Display"
        subtitle="What the store TV plays, on a loop. Upload images and MP4 videos, put them in order, and the Roku picks the change up within a minute."
        right={
          <>
            <input ref={fileRef} type="file" multiple accept="image/jpeg,image/png,image/webp,video/mp4"
              style={{ display: 'none' }} onChange={e => upload(e.target.files)} />
            <PBtn variant="secondary" size="md" icon="ri-play-circle-line" disabled={active.length === 0} onClick={() => setPreview(true)}>Preview loop</PBtn>
            <PBtn variant="primary" size="md" icon="ri-upload-2-line" disabled={busy} onClick={() => fileRef.current && fileRef.current.click()}>
              {busy ? 'Uploading…' : 'Upload'}
            </PBtn>
          </>
        }
      />

      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
        <div className="portal-stat">
          <div className="portal-stat-label">On the loop</div>
          <div className="portal-stat-value">{active.length}</div>
          <div className="portal-stat-sub">{ordered.length - active.length ? (ordered.length - active.length) + ' switched off' : 'everything uploaded is on'}</div>
        </div>
        <div className="portal-stat">
          <div className="portal-stat-label">Image time per loop</div>
          <div className="portal-stat-value">{loopSeconds >= 60 ? Math.floor(loopSeconds / 60) + 'm ' + (loopSeconds % 60) + 's' : loopSeconds + 's'}</div>
          <div className="portal-stat-sub">{videoCount ? '+ ' + videoCount + ' video' + (videoCount === 1 ? '' : 's') + ' at full length' : 'no videos'}</div>
        </div>
        <div className="portal-stat">
          <div className="portal-stat-label">TVs</div>
          <div className="portal-stat-value">{devices.filter(d => d.lastSeenAt && Date.now() - new Date(d.lastSeenAt).getTime() < 3 * 60 * 1000).length}<span style={{ fontSize: 14, color: 'var(--fg-3)', fontWeight: 500 }}> / {devices.length} online</span></div>
          <div className="portal-stat-sub">{devices.length ? 'checked in within 3 minutes' : 'no TV has checked in yet'}</div>
        </div>
      </div>

      <div className="portal-card" style={{ marginBottom: 16 }}>
        <table className="portal-table">
          <thead>
            <tr>
              <th style={{ width: 36 }}></th>
              <th style={{ width: 120 }}>Preview</th>
              <th>Title</th>
              <th style={{ width: 90 }}>Type</th>
              <th style={{ width: 130, textAlign: 'right' }}>On screen</th>
              <th style={{ width: 80, textAlign: 'center' }}>On</th>
              <th style={{ width: 96 }}></th>
            </tr>
          </thead>
          <tbody>
            {ordered.length === 0 && (
              <tr><td colSpan={7} style={{ padding: 40, textAlign: 'center', color: 'var(--fg-3)', fontSize: 13 }}>
                Nothing yet. Upload a JPG, PNG or MP4 and it goes on the TV.
              </td></tr>
            )}
            {ordered.map((m, i) => {
              const url = tvPublicUrl(m.storageKey);
              return (
                <tr key={m.id} style={{ opacity: m.active ? 1 : 0.55 }}>
                  <td style={{ ...cell, padding: '10px 4px 10px 10px' }}>
                    <div className="rr-arrows" style={{ opacity: 1 }}>
                      <button title="Move up" disabled={i === 0} onClick={() => move(m, -1)} style={{ opacity: i === 0 ? 0.3 : 1, width: 18, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--fg-3)' }}>
                        <PIcon name="ri-arrow-up-s-line" size={14} />
                      </button>
                      <button title="Move down" disabled={i === ordered.length - 1} onClick={() => move(m, 1)} style={{ opacity: i === ordered.length - 1 ? 0.3 : 1, width: 18, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--fg-3)' }}>
                        <PIcon name="ri-arrow-down-s-line" size={14} />
                      </button>
                    </div>
                  </td>
                  <td style={cell}>
                    <a href={url} target="_blank" rel="noopener" title="Open the file" style={{ display: 'block', width: 96, height: 54, borderRadius: 6, overflow: 'hidden', background: '#111', border: '1px solid var(--border-2)' }}>
                      {m.kind === 'image'
                        ? <img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
                        : <video src={url + '#t=0.5'} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />}
                    </a>
                  </td>
                  <td style={cell}>
                    <input className="portal-input is-ghost" defaultValue={m.title} placeholder="Untitled"
                      onBlur={e => { const t = e.target.value.trim(); if (t !== m.title) patch(m.id, { title: t }); }}
                      onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }}
                      style={{ height: 30, fontWeight: 500 }} />
                    <div style={{ fontSize: 11, color: 'var(--fg-3)', marginTop: 2, paddingLeft: 8 }}>
                      {tvFmtBytes(m.bytes)}{m.createdAt ? ' · added ' + new Date(m.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : ''}
                    </div>
                  </td>
                  <td style={cell}>
                    <span className="pill" style={{ background: 'var(--bg-sunken)', color: 'var(--fg-2)' }}>
                      <PIcon name={m.kind === 'video' ? 'ri-film-line' : 'ri-image-line'} size={12} />
                      {m.kind === 'video' ? 'Video' : 'Image'}
                    </span>
                  </td>
                  <td style={{ ...cell, textAlign: 'right', fontFamily: 'var(--font-num)' }}>
                    {m.kind === 'image' ? (
                      <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                        <input type="number" min={1} max={3600} className="portal-input" defaultValue={m.durationSeconds}
                          onBlur={e => { const v = Math.max(1, Math.min(3600, Math.round(Number(e.target.value) || 10))); e.target.value = v; if (v !== m.durationSeconds) patch(m.id, { durationSeconds: v }); }}
                          onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }}
                          onWheel={e => e.currentTarget.blur()}
                          style={{ width: 64, height: 30, textAlign: 'right', fontFamily: 'var(--font-num)' }} />
                        <span style={{ color: 'var(--fg-3)', fontSize: 12 }}>sec</span>
                      </span>
                    ) : <span style={{ color: 'var(--fg-3)', fontSize: 12 }}>full length</span>}
                  </td>
                  <td style={{ ...cell, textAlign: 'center' }}>
                    <button type="button" role="switch" aria-checked={m.active} title={m.active ? 'On the loop — click to take it off' : 'Off — click to put it back on'}
                      onClick={() => patch(m.id, { active: !m.active })}
                      style={{ width: 38, height: 22, borderRadius: 999, padding: 2, background: m.active ? '#2D6A2A' : 'var(--border-1)', display: 'inline-flex', justifyContent: m.active ? 'flex-end' : 'flex-start', transition: 'background 120ms' }}>
                      <span style={{ width: 18, height: 18, borderRadius: 999, background: '#fff' }} />
                    </button>
                  </td>
                  <td style={{ ...cell, textAlign: 'right' }}>
                    <div className="row-actions" style={{ opacity: 1 }}>
                      <button title="Remove from the TV" onClick={() => remove(m)}><PIcon name="trash" size={14} /></button>
                    </div>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, alignItems: 'start' }}>
        <div className="portal-card">
          <div className="portal-card-header"><div className="portal-card-title">TVs</div></div>
          {devices.length === 0 ? (
            <div style={{ padding: 18, fontSize: 12.5, color: 'var(--fg-3)', lineHeight: 1.5 }}>
              No TV has checked in yet. Once the Snap TV channel runs on the Roku, it shows up here with what it's playing.
            </div>
          ) : devices.map(d => {
            const online = d.lastSeenAt && Date.now() - new Date(d.lastSeenAt).getTime() < 3 * 60 * 1000;
            return (
              <div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 18px', borderBottom: '1px solid var(--border-2)' }}>
                <span style={{ width: 8, height: 8, borderRadius: 999, background: online ? '#2D6A2A' : 'var(--fg-3)', flexShrink: 0 }} />
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ fontSize: 13, fontWeight: 600 }}>{d.name || d.model || 'Roku'}{d.name && d.model ? <span style={{ color: 'var(--fg-3)', fontWeight: 400 }}> · {d.model}</span> : null}</div>
                  <div style={{ fontSize: 11.5, color: 'var(--fg-3)', marginTop: 2 }}>
                    {online ? 'Online' : 'Last seen'} · {tvAgo(d.lastSeenAt)}
                    {d.currentItemId && titleFor(d.currentItemId) ? ' · playing ' + titleFor(d.currentItemId) : ''}
                    {d.appVersion ? ' · v' + d.appVersion : ''}
                  </div>
                </div>
                <div className="row-actions" style={{ opacity: 1 }}>
                  <button title="Name this TV" onClick={() => renameDevice(d)}><PIcon name="edit" size={13} /></button>
                  <button title="Forget this TV" onClick={() => forgetDevice(d)}><PIcon name="trash" size={13} /></button>
                </div>
              </div>
            );
          })}
        </div>

        <div className="portal-card">
          <button onClick={() => setSetupOpen(v => !v)} style={{ width: '100%', textAlign: 'left' }}>
            <div className="portal-card-header">
              <div className="portal-card-title">Setting up a TV</div>
              <PIcon name={setupOpen ? 'chevronU' : 'chevronD'} size={14} color="var(--fg-3)" />
            </div>
          </button>
          {setupOpen && (
            <div style={{ padding: '14px 18px', fontSize: 12.5, color: 'var(--fg-2)', lineHeight: 1.6 }}>
              <ol style={{ margin: 0, paddingLeft: 18 }}>
                <li>On the Roku, press <b>Home ×3, Up ×2, Right, Left, Right, Left, Right</b> to turn on Developer Mode, set a password, and note its IP address.</li>
                <li>On a Mac on the same Wi-Fi, in the <code>Snap Ops/apps/roku-tv</code> folder, run <code>ROKU_IP=&lt;ip&gt; ROKU_PASS=&lt;password&gt; ./build.sh</code>. The channel installs and launches as <b>Snap TV</b>.</li>
                <li>On the Roku: <b>Settings → Theme → Screensaver → Wait time → Disable</b>, and <b>Settings → Network → Bandwidth saver → Off</b>. Without these the screen saver or the "still watching?" prompt interrupts the loop.</li>
                <li>Within a minute the TV appears in the list on the left. Give it a name.</li>
              </ol>
              <div style={{ marginTop: 10, color: 'var(--fg-3)' }}>
                Files: JPG, PNG or WebP at 1920×1080 look best; videos must be MP4 (H.264 video, AAC audio), up to 200 MB. The full guide is the folder's README.
              </div>
            </div>
          )}
        </div>
      </div>

      {preview && <TvPreviewModal items={active} onClose={() => setPreview(false)} />}
      <PToast message={toast} open={!!toast} onClose={() => setToast('')} />
    </div>
  );
};

// Plays the loop in the browser the way the TV will: images for their
// seconds, videos to the end, in order. Muted, because a preview in an
// office shouldn't blast the dining-room audio.
const TvPreviewModal = ({ items, onClose }) => {
  const [i, setI] = useState(0);
  const item = items[i % items.length];
  useEffect(() => {
    if (!item || item.kind !== 'image') return;
    const t = setTimeout(() => setI(n => n + 1), item.durationSeconds * 1000);
    return () => clearTimeout(t);
  }, [i, item && item.id]);
  if (!item) return null;
  const url = tvPublicUrl(item.storageKey);
  return (
    <PModal open onClose={onClose} width={960} title={'Preview · ' + (i % items.length + 1) + ' of ' + items.length + (item.title ? ' · ' + item.title : '')} footer={
      <>
        <PBtn variant="secondary" size="sm" icon="chevronL" onClick={() => setI(n => (n - 1 + items.length) % items.length)}>Previous</PBtn>
        <PBtn variant="secondary" size="sm" trailing="chevronR" onClick={() => setI(n => n + 1)}>Next</PBtn>
        <div style={{ flex: 1 }} />
        <PBtn variant="primary" size="sm" onClick={onClose}>Done</PBtn>
      </>
    }>
      <div style={{ background: '#000', aspectRatio: '16 / 9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
        {item.kind === 'image'
          ? <img key={item.id} src={url} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
          : <video key={item.id} src={url} autoPlay muted playsInline onEnded={() => setI(n => n + 1)} style={{ maxWidth: '100%', maxHeight: '100%' }} />}
      </div>
    </PModal>
  );
};

window.TvDisplay = TvDisplay;
