// downloads.jsx — offline episode downloads for the PWA.
//
// One feature, four pieces:
//   canDownload(user)   the single membership seam (ships ungated)
//   useDownloads(user)  registry + download engine + Cache Storage reconciliation
//   DownloadAction      the Spotlight button (download / progress / remove states)
//   DownloadsManager    the management list (sizes, total usage, re-download)
//
// Audio bytes live in the service worker's durable cache ("aot-audio-v1" —
// must match AUDIO_CACHE in sw.js), which serves them with 206 Range synthesis
// so iOS <audio> can play offline. The registry of what SHOULD be there lives
// in localStorage and is reconciled against the cache on every load, because
// the OS (especially iOS) may evict cached audio behind our back.

// Membership seam: EVERY download affordance must route through this one
// function. Ships ungated. A future membership build changes only this body
// (e.g. `return !!user && user.member`) plus server-side enforcement (signed
// audio_dl URLs minted by a Pages Function) — no other call site moves.
// Already-downloaded episodes keep playing after gating: the registry stores
// the URL captured at download time, and that URL is the cache key.
function canDownload(user) {
  return true;
}

const DOWNLOADS_KEY = "in-passing.downloads.v1";
const AUDIO_CACHE_NAME = "aot-audio-v1"; // must match sw.js AUDIO_CACHE

function readDownloadsRegistry() {
  try { return JSON.parse(localStorage.getItem(DOWNLOADS_KEY) || "{}"); }
  catch (e) { return {}; }
}
function writeDownloadsRegistry(reg) {
  try { localStorage.setItem(DOWNLOADS_KEY, JSON.stringify(reg)); } catch (e) {}
}
// The registry persists only durable facts; missing/stale are derived at
// runtime (reconciliation, HEAD checks) and must not be written back.
function stripRuntimeFlags(downloads) {
  const out = {};
  for (const [slug, e] of Object.entries(downloads)) {
    out[slug] = { url: e.url, bytes: e.bytes, ts: e.ts };
  }
  return out;
}

function fmtBytes(n) {
  if (!n && n !== 0) return "";
  const mb = n / (1024 * 1024);
  if (mb >= 1024) return (mb / 1024).toFixed(1) + " GB";
  return (mb >= 100 ? Math.round(mb) : mb.toFixed(1)) + " MB";
}

// ── The hook ────────────────────────────────────────────────────────────────
// Owned by App, passed down as a single `dl` prop. Registry shape:
//   { "<slug>": { url, bytes, ts } }            (completed downloads only)
// Runtime state layers `missing: true` (evicted from Cache Storage) and
// `stale: true` (remote artifact changed since download) on top.
function useDownloads(user) {
  const supported = typeof window !== "undefined"
    && "caches" in window && !!window.ReadableStream;

  const [downloads, setDownloads] = React.useState(() => readDownloadsRegistry());
  const [inflight, setInflight] = React.useState({});   // { slug: { pct } }
  const [errors, setErrors] = React.useState({});       // { slug: message }
  const [estimate, setEstimate] = React.useState(null); // { usage, quota } | null
  const controllersRef = React.useRef({});              // { slug: AbortController }
  const downloadsRef = React.useRef(downloads);
  downloadsRef.current = downloads;

  const refreshEstimate = React.useCallback(() => {
    try {
      navigator.storage?.estimate?.().then(
        (est) => setEstimate(est || null),
        () => {}
      );
    } catch (e) {}
  }, []);

  // Mount-time reconciliation: trust the cache, not the registry. iOS can
  // evict even "persisted" storage; an evicted episode must show as
  // "Re-download", never as a fake "Downloaded".
  React.useEffect(() => {
    if (!supported) return;
    let cancelled = false;
    (async () => {
      const reg = readDownloadsRegistry();
      let cache;
      try { cache = await caches.open(AUDIO_CACHE_NAME); } catch (e) { return; }
      const next = {};
      for (const [slug, entry] of Object.entries(reg)) {
        let hit = null;
        try { hit = await cache.match(entry.url); } catch (e) {}
        next[slug] = hit ? entry : { ...entry, missing: true };
      }
      if (!cancelled) setDownloads(next);
    })();
    refreshEstimate();
    return () => { cancelled = true; };
  }, []);

  const setSlugError = (slug, message) => {
    setErrors(prev => ({ ...prev, [slug]: message }));
    setTimeout(() => setErrors(prev => {
      const next = { ...prev }; delete next[slug]; return next;
    }), 6000);
  };

  async function download(ep) {
    if (!supported || !canDownload(user) || !ep?.audio_dl) return;
    const slug = ep.slug;
    if (controllersRef.current[slug]) return; // already in flight
    const url = ep.audio_dl;
    const ctrl = new AbortController();
    controllersRef.current[slug] = ctrl;
    setInflight(prev => ({ ...prev, [slug]: { pct: 0 } }));
    try {
      // Ask the browser not to evict our storage. Best-effort and idempotent;
      // iOS grants it silently for installed PWAs, Chrome may prompt.
      try { await navigator.storage?.persist?.(); } catch (e) {}
      const res = await fetch(url, { mode: "cors", signal: ctrl.signal });
      // Must be a real CORS response: an opaque one can't be Range-sliced by
      // the service worker and Safari pads its quota cost enormously.
      if (!res.ok || res.type === "opaque") {
        throw new Error("download failed: " + (res.status || "opaque"));
      }
      const total = Number(res.headers.get("Content-Length")) || 0;
      const reader = res.body.getReader();
      const chunks = [];
      let received = 0;
      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        chunks.push(value);
        received += value.length;
        if (total) {
          const pct = Math.min(99, Math.round((received / total) * 100));
          setInflight(prev => ({ ...prev, [slug]: { pct } }));
        }
      }
      const type = res.headers.get("Content-Type") || "audio/mp4";
      // Transient RAM spike (~30-90 MB) while the blob assembles is the
      // deliberate cross-browser-safe choice; streaming cache.put bodies is
      // flaky on older Safari. The chunk array is released right after.
      const blob = new Blob(chunks, { type });
      chunks.length = 0;
      const cache = await caches.open(AUDIO_CACHE_NAME);
      await cache.put(url, new Response(blob, {
        status: 200,
        headers: {
          "Content-Type": type,
          "Content-Length": String(blob.size),
          "Accept-Ranges": "bytes",
        },
      }));
      setDownloads(prev => {
        const next = { ...prev, [slug]: { url, bytes: blob.size, ts: Date.now() } };
        writeDownloadsRegistry(stripRuntimeFlags(next));
        return next;
      });
    } catch (e) {
      if (e && e.name !== "AbortError") {
        const quota = e.name === "QuotaExceededError";
        setSlugError(slug, quota
          ? "Not enough storage — remove a download and retry."
          : "Download failed — try again.");
      }
    } finally {
      delete controllersRef.current[slug];
      setInflight(prev => {
        const next = { ...prev }; delete next[slug]; return next;
      });
      refreshEstimate();
    }
  }

  function cancel(slug) {
    controllersRef.current[slug]?.abort();
  }

  async function remove(ep) {
    const slug = ep.slug;
    const entry = downloadsRef.current[slug];
    if (entry) {
      try {
        const cache = await caches.open(AUDIO_CACHE_NAME);
        await cache.delete(entry.url);
      } catch (e) {}
    }
    setDownloads(prev => {
      const next = { ...prev }; delete next[slug];
      writeDownloadsRegistry(stripRuntimeFlags(next));
      return next;
    });
    refreshEstimate();
  }

  // Staleness pass (manager-open time, online only). The cache key is the URL,
  // so a re-published episode would silently keep serving old bytes; a size
  // mismatch flags it. 96k CBR makes size a reliable change detector.
  async function checkForUpdates() {
    if (!navigator.onLine) return;
    for (const [slug, entry] of Object.entries(downloadsRef.current)) {
      if (entry.missing) continue;
      try {
        const res = await fetch(entry.url, { method: "HEAD", mode: "cors" });
        if (res.ok) {
          const remote = Number(res.headers.get("Content-Length")) || 0;
          if (remote && remote !== entry.bytes) {
            setDownloads(prev => prev[slug]
              ? { ...prev, [slug]: { ...prev[slug], stale: true } }
              : prev);
          }
        }
      } catch (e) {}
    }
  }

  const isDownloaded = (slug) => {
    const e = downloads[slug];
    return !!(e && !e.missing);
  };
  const totalBytes = Object.values(downloads)
    .reduce((sum, e) => sum + (e.missing ? 0 : (e.bytes || 0)), 0);

  return { downloads, inflight, errors, supported, estimate,
           isDownloaded, download, cancel, remove, checkForUpdates, totalBytes };
}

// ── Spotlight action button ─────────────────────────────────────────────────
// One button, label/glyph swap per state. Tap-to-remove arms a 3s confirm so a
// stray tap can't silently delete a 100 MB download.
function DownloadAction({ episode, dl, online }) {
  const [confirming, setConfirming] = React.useState(false);
  const confirmTimer = React.useRef(null);
  React.useEffect(() => () => clearTimeout(confirmTimer.current), []);
  if (!episode?.audio_dl || !dl?.supported) return null;

  const slug = episode.slug;
  const entry = dl.downloads[slug];
  const busy = dl.inflight[slug];
  const error = dl.errors[slug];

  let label, sub = null, onClick, active = false, disabled = false, ariaLabel;
  if (busy) {
    label = "Downloading…"; sub = ` — ${busy.pct}%`;
    onClick = () => dl.cancel(slug);
    ariaLabel = `Cancel download, ${busy.pct} percent done`;
  } else if (entry && entry.missing) {
    label = "Re-download";
    onClick = () => dl.download(episode);
    ariaLabel = "This download was removed by the system — download again";
  } else if (entry && entry.stale) {
    label = "Update episode";
    onClick = () => dl.download(episode);
    ariaLabel = "A newer version of this episode exists — download again";
  } else if (entry) {
    active = true;
    if (confirming) {
      label = "Remove?";
      onClick = () => { clearTimeout(confirmTimer.current); setConfirming(false); dl.remove(episode); };
      ariaLabel = "Confirm removing this download";
    } else {
      label = "Downloaded"; sub = entry.bytes ? ` — ${fmtBytes(entry.bytes)}` : null;
      onClick = () => {
        setConfirming(true);
        clearTimeout(confirmTimer.current);
        confirmTimer.current = setTimeout(() => setConfirming(false), 3000);
      };
      ariaLabel = "Downloaded for offline listening — tap to remove";
    }
  } else {
    label = "Download";
    if (online === false) { disabled = true; sub = " — offline"; }
    onClick = () => dl.download(episode);
    ariaLabel = disabled ? "Download unavailable while offline" : "Download for offline listening";
  }

  return (
    <React.Fragment>
      <button type="button"
        className={"spot-action spot-action--download" + (active ? " is-active" : "")}
        onClick={disabled ? undefined : onClick}
        disabled={disabled}
        aria-label={ariaLabel}>
        <span className="spot-action-glyph" aria-hidden="true">
          {active && !confirming ? (
            <svg viewBox="0 0 14 14" width="11" height="11" fill="none" stroke="currentColor"
                 strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
              <path d="M3 7.3 L6 10 L11 4" />
            </svg>
          ) : (
            <svg viewBox="0 0 14 14" width="11" height="11" fill="none" stroke="currentColor"
                 strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
              <path d="M7 2 V9 M4 6.5 L7 9.5 L10 6.5" />
              <line x1="2.5" y1="12" x2="11.5" y2="12" />
            </svg>
          )}
        </span>
        {label}{sub && <span className="spot-action-sub">{sub}</span>}
      </button>
      {/* Completion/failure announcements for screen readers; the button label
          alone doesn't announce state changes. */}
      <span className="sr-only" role="status" aria-live="polite">
        {error ? error : (entry && !entry.missing && !busy ? "Episode downloaded for offline listening" : "")}
      </span>
      {error && <span className="spot-action-error" role="alert">{error}</span>}
    </React.Fragment>
  );
}

// ── Downloads manager ───────────────────────────────────────────────────────
// A list section (matches EarlierList's shell) that only exists once there is
// something to manage. Collapsed by default; expanding runs the staleness pass.
function DownloadsManager({ episodes, dl, onPromote }) {
  const [open, setOpen] = React.useState(false);
  const checkedRef = React.useRef(false);
  React.useEffect(() => {
    if (open && !checkedRef.current) {
      checkedRef.current = true;
      dl.checkForUpdates();
    }
  }, [open]);

  if (!dl?.supported) return null;
  const slugs = Object.keys(dl.downloads);
  const inflightSlugs = Object.keys(dl.inflight).filter(s => !dl.downloads[s]);
  if (slugs.length === 0 && inflightSlugs.length === 0) return null;

  const bySlug = {};
  for (const ep of episodes) bySlug[ep.slug] = ep;
  const count = slugs.filter(s => !dl.downloads[s].missing).length;
  const free = dl.estimate && dl.estimate.quota
    ? Math.max(0, dl.estimate.quota - (dl.estimate.usage || 0)) : null;
  const summary = [
    `${count} episode${count === 1 ? "" : "s"}`,
    `${fmtBytes(dl.totalBytes)} on device`,
    free != null ? `${fmtBytes(free)} free` : null,
  ].filter(Boolean).join(" · ");

  const row = (slug, entry, busy) => {
    const ep = bySlug[slug];
    const title = ep ? ep.title : slug;
    let state, action;
    if (busy) {
      state = `Downloading ${busy.pct}%`;
      action = <button type="button" className="dl-row-btn" onClick={() => dl.cancel(slug)}>Cancel</button>;
    } else if (entry?.missing) {
      state = "Removed by system";
      action = ep?.audio_dl
        ? <button type="button" className="dl-row-btn" onClick={() => dl.download(ep)}>Re-download</button>
        : <button type="button" className="dl-row-btn" onClick={() => ep && dl.remove(ep)}>Forget</button>;
    } else if (entry?.stale) {
      state = "Update available";
      action = (
        <span className="dl-row-actions">
          {ep?.audio_dl && <button type="button" className="dl-row-btn" onClick={() => dl.download(ep)}>Update</button>}
          <button type="button" className="dl-row-btn" onClick={() => ep && dl.remove(ep)}>Remove</button>
        </span>
      );
    } else {
      state = "Downloaded";
      action = <button type="button" className="dl-row-btn" onClick={() => ep && dl.remove(ep)}>Remove</button>;
    }
    return (
      <div className="dl-manager-row" key={slug}>
        <button type="button" className="dl-row-title"
          onClick={() => ep && onPromote?.(slug)}
          title={title}>{title}</button>
        <span className="dl-row-size">{entry?.bytes ? fmtBytes(entry.bytes) : ""}</span>
        <span className={"dl-row-state" + (entry?.missing || entry?.stale ? " is-attention" : "")}>{state}</span>
        {action}
      </div>
    );
  };

  return (
    <section className="list-section dl-manager" aria-labelledby="downloads-label">
      <h2 className="section-label" id="downloads-label"><span>Downloads</span></h2>
      <button type="button" className="dl-manager-toggle"
        aria-expanded={open}
        onClick={() => setOpen(v => !v)}>
        <span className="dl-manager-summary">{summary}</span>
        <span aria-hidden="true">{open ? "▾" : "▸"}</span>
      </button>
      {open && (
        <div className="dl-manager-list">
          {slugs.map(s => row(s, dl.downloads[s], dl.inflight[s]))}
          {inflightSlugs.map(s => row(s, null, dl.inflight[s]))}
        </div>
      )}
    </section>
  );
}

Object.assign(window, {
  canDownload, useDownloads, DownloadAction, DownloadsManager, fmtBytes,
});
