/* ============================================================================
 * opfs.js — Origin Private File System video storage for PWA mode
 *
 * Exposes window.OPFS with methods that mirror the Tauri cache_* commands
 * so app.js can call them transparently in WEB mode.
 *
 * Videos are stored under the OPFS root at:
 *   videos/<videoId>.<ext>
 *
 * Object URLs created by getFileUrl() are tracked so they can be revoked
 * when no longer needed — call OPFS.revokeUrl(url) after the <video> unloads.
 *
 * OPFS is available in all modern browsers (Chrome 86+, Firefox 111+,
 * Safari 15.2+). Calls degrade gracefully if the API is absent.
 * ========================================================================== */

(function () {
  'use strict';

  // Root directory handle, lazily initialised
  let _rootPromise = null;

  async function getRoot() {
    if (!_rootPromise) {
      _rootPromise = (async () => {
        const root = await navigator.storage.getDirectory();
        return root.getDirectoryHandle('videos', { create: true });
      })();
    }
    return _rootPromise;
  }

  // Iterate the videos/ directory and find a file whose stem matches videoId
  // (ignoring the extension). Returns [FileSystemFileHandle, filename] or null.
  async function findHandle(videoId) {
    const dir = await getRoot();
    for await (const [name, handle] of dir.entries()) {
      if (handle.kind !== 'file') continue;
      const dot = name.lastIndexOf('.');
      const stem = dot > -1 ? name.slice(0, dot) : name;
      if (stem === videoId) return [handle, name];
    }
    return null;
  }

  // Extension → MIME type for Content-Type headers on playback
  function extToMime(ext) {
    const map = { mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
                  m4a: 'audio/mp4', ogg: 'audio/ogg', opus: 'audio/ogg' };
    return map[ext] || 'application/octet-stream';
  }

  const _blobUrls = new Set();

  window.OPFS = {
    // Does a file exist for this videoId?
    async hasVideo(videoId) {
      try {
        return (await findHandle(videoId)) !== null;
      } catch {
        return false;
      }
    },

    // Return a blob: URL usable as <video src>. Returns null if not cached.
    // The caller is responsible for calling OPFS.revokeUrl(url) when done.
    async getFileUrl(videoId) {
      try {
        const found = await findHandle(videoId);
        if (!found) return null;
        const [handle, name] = found;
        const file = await handle.getFile();
        const ext = name.slice(name.lastIndexOf('.') + 1);
        // Wrap the File (a lazy disk-backed Blob) instead of buffering it:
        // `await file.arrayBuffer()` here pulled the ENTIRE video into main-
        // thread memory just to retype it, which froze the UI and stuttered
        // audio on phones. Blob parts reference the File without reading it.
        const blob = new Blob([file], { type: extToMime(ext) });
        const url = URL.createObjectURL(blob);
        _blobUrls.add(url);
        return url;
      } catch {
        return null;
      }
    },

    // Revoke an object URL previously returned by getFileUrl().
    revokeUrl(url) {
      if (url && _blobUrls.has(url)) {
        URL.revokeObjectURL(url);
        _blobUrls.delete(url);
      }
    },

    // Download a video entirely inside a dedicated Web Worker — the fetch and
    // the OPFS writes both happen off the main thread, so saves can never
    // jank the UI. One worker per download; concurrent saves get concurrent
    // workers. Resolves { ok:true } on success, { ok:false, error } on a real
    // failure, or { ok:false, fallback:true } when the worker path is
    // unavailable and the caller should use writeFromResponse instead.
    downloadVideo(videoId, url) {
      return new Promise((resolve) => {
        let worker;
        try {
          worker = new Worker('/opfs-worker.js');
        } catch {
          resolve({ ok: false, fallback: true });
          return;
        }
        const finish = (result) => {
          worker.terminate();
          resolve(result);
        };
        worker.onmessage = (e) => {
          const m = e.data || {};
          if (m.type === 'done')             finish({ ok: true });
          else if (m.type === 'unsupported') finish({ ok: false, fallback: true });
          else if (m.type === 'error')       finish({ ok: false, error: m.error });
          // 'progress' messages are informational; ignored here
        };
        worker.onerror = () => finish({ ok: false, fallback: true });
        worker.postMessage({ videoId, url });
      });
    },

    // Stream a fetch Response body into OPFS. Uses a writable stream so only
    // a small chunk lives in memory at a time (no full-file buffering).
    // Falls back to ArrayBuffer if WritableStream is unavailable.
    // Main-thread fallback for downloadVideo — used when Workers or
    // createSyncAccessHandle are unavailable.
    async writeFromResponse(videoId, ext, response) {
      const dir = await getRoot();
      const filename = videoId + '.' + (ext || 'mp4');

      // Write to a temporary file first so a partial download doesn't leave
      // a corrupt permanent entry.
      const tmpName = filename + '.part';
      const tmpHandle = await dir.getFileHandle(tmpName, { create: true });

      try {
        if ('createWritable' in tmpHandle) {
          const writable = await tmpHandle.createWritable();
          try {
            if (response.body && typeof response.body.pipeTo === 'function') {
              await response.body.pipeTo(writable);
            } else {
              // Safari < 16.4 doesn't support pipeTo — buffer the whole response
              const buf = await response.arrayBuffer();
              await writable.write(buf);
              await writable.close();
            }
          } catch (err) {
            await writable.abort();
            throw err;
          }
        } else {
          // No createWritable on this browser — main-thread OPFS writes are
          // impossible (sync access handles are worker-only). Surface a real
          // error instead of the old branch that called the missing API.
          throw new Error('Offline saving is not supported in this browser');
        }

        // Rename tmp → final. Prefer the native rename; else stream-copy so
        // the whole file is never buffered in main-thread memory at once.
        // move() failures (WebKit's signature differs from Chrome's and
        // throws TypeError) fall back to the copy path too.
        let renamed = false;
        if (typeof tmpHandle.move === 'function') {
          try { await tmpHandle.move(filename); renamed = true; } catch { /* copy below */ }
        }
        if (!renamed) {
          const finalHandle = await dir.getFileHandle(filename, { create: true });
          const finalWritable = await finalHandle.createWritable();
          const tmpFile = await tmpHandle.getFile();
          const tmpStream = typeof tmpFile.stream === 'function' ? tmpFile.stream() : null;
          if (tmpStream && typeof tmpStream.pipeTo === 'function') {
            await tmpStream.pipeTo(finalWritable); // pipeTo closes the writable
          } else {
            await finalWritable.write(await tmpFile.arrayBuffer());
            await finalWritable.close();
          }
        }
      } finally {
        // Remove .part file regardless
        try { await dir.removeEntry(tmpName); } catch { /* already gone */ }
      }
    },

    // List all cached videos — returns [{ id, size, name }]
    async listVideos() {
      const dir = await getRoot();
      const items = [];
      try {
        for await (const [name, handle] of dir.entries()) {
          if (handle.kind !== 'file') continue;
          // Skip .part temporary files
          if (name.endsWith('.part')) continue;
          const dot = name.lastIndexOf('.');
          const id = dot > -1 ? name.slice(0, dot) : name;
          const file = await handle.getFile();
          items.push({ id, name, size: file.size });
        }
      } catch { /* OPFS not available */ }
      return items;
    },

    // Total bytes stored
    async totalSize() {
      const items = await this.listVideos();
      return items.reduce((s, i) => s + i.size, 0);
    },

    // Delete one video
    async deleteVideo(videoId) {
      try {
        const found = await findHandle(videoId);
        if (!found) return;
        const [, name] = found;
        const dir = await getRoot();
        await dir.removeEntry(name);
      } catch { /* already gone */ }
    },

    // Delete all cached videos
    async clearAll() {
      try {
        const dir = await getRoot();
        const names = [];
        for await (const [name] of dir.entries()) names.push(name);
        await Promise.all(names.map((n) => dir.removeEntry(n).catch(() => {})));
        // Revoke any outstanding blob URLs
        for (const url of _blobUrls) URL.revokeObjectURL(url);
        _blobUrls.clear();
      } catch { /* OPFS not available */ }
    },

    // Is the OPFS API supported in this browser?
    isSupported() {
      return typeof navigator !== 'undefined' &&
             typeof navigator.storage !== 'undefined' &&
             typeof navigator.storage.getDirectory === 'function';
    },
  };
}());
