// every.channel web watcher
//
// This uses the upstream moq watch web component (WebTransport + WebCodecs).
// It is intentionally dependency-light: no framework, no bundler.

const DEFAULT_RELAY_URL = "https://cdn.moq.dev/anon";
const MOQ_WATCH_MODULE_URLS = [
  "https://esm.sh/@moq/watch@0.2.0/element",
  "https://cdn.jsdelivr.net/npm/@moq/watch@0.2.0/element/+esm",
  "https://unpkg.com/@moq/watch@0.2.0/element.js?module",
];
const HLS_MODULE_URLS = [
  "https://esm.sh/hls.js@1.6.2",
  "https://cdn.jsdelivr.net/npm/hls.js@1.6.2/+esm",
  "https://unpkg.com/hls.js@1.6.2/dist/hls.mjs",
];
const PUBLIC_STREAMS_PATH = "/api/public-streams";
const LIVE_JITTER_MS = 750;
let moqWatchModulePromise = null;
let hlsModulePromise = null;
let disposePlayerSignals = null;
let activeHlsPlayer = null;

function $(id) {
  const el = document.getElementById(id);
  if (!el) throw new Error(`missing element: ${id}`);
  return el;
}

function normalizeRelayUrl(s) {
  const trimmed = (s || "").trim();
  if (!trimmed) return DEFAULT_RELAY_URL;
  // Ensure trailing slash so relative fetches behave consistently.
  return trimmed.endsWith("/") ? trimmed : `${trimmed}/`;
}

function normalizeName(s) {
  return (s || "").trim();
}

function currentShareLink(relayUrl, name, mode) {
  const u = new URL(window.location.href);
  u.pathname = "/watch";
  u.searchParams.set("url", relayUrl);
  u.searchParams.set("name", name);
  if (mode === "archive") u.searchParams.set("mode", "archive");
  else u.searchParams.delete("mode");
  // Avoid leaking other params.
  for (const k of [...u.searchParams.keys()]) {
    if (k !== "url" && k !== "name" && k !== "mode") u.searchParams.delete(k);
  }
  return u.toString();
}

function setHint(text, kind) {
  const el = $("hint");
  el.textContent = text || "";
  el.dataset.kind = kind || "";
}

function setShareLink(text) {
  const el = $("shareLink");
  el.textContent = text || "";
}

function setListHint(text, kind) {
  const el = $("listHint");
  el.textContent = text || "";
  el.dataset.kind = kind || "";
}

function clearPlayerSignals() {
  if (typeof disposePlayerSignals === "function") {
    disposePlayerSignals();
  }
  disposePlayerSignals = null;
}

function destroyArchivePlayer() {
  if (activeHlsPlayer && typeof activeHlsPlayer.destroy === "function") {
    try {
      activeHlsPlayer.destroy();
    } catch (_) {
      // Ignore teardown errors.
    }
  }
  activeHlsPlayer = null;
}

function bindPlayerSignals(watch, name) {
  const cleanup = [];
  let offlineTimer = null;

  const clearOfflineTimer = () => {
    if (offlineTimer) {
      window.clearTimeout(offlineTimer);
      offlineTimer = null;
    }
  };

  const maybeWatch = (signal, onValue) => {
    if (!signal || typeof signal.watch !== "function") return;
    const dispose = signal.watch(onValue);
    if (typeof dispose === "function") cleanup.push(dispose);
  };

  let sawLoading = false;
  maybeWatch(watch?.broadcast?.status, (status) => {
    if (status === "loading") {
      clearOfflineTimer();
      sawLoading = true;
      setHint(`Connecting to relay and subscribing: ${name}`, "ok");
      return;
    }
    if (status === "live") {
      clearOfflineTimer();
      setHint(`Live: subscribed to ${name}`, "ok");
      return;
    }
    if (status === "offline" && sawLoading) {
      clearOfflineTimer();
      // Avoid flashing a false negative during short reconnect windows.
      offlineTimer = window.setTimeout(() => {
        setHint(`Connection interrupted, retrying: ${name}`, "warn");
        offlineTimer = null;
      }, 8000);
    }
  });

  maybeWatch(watch?.broadcast?.catalog, (catalog) => {
    if (!catalog) return;
    const hasVideo = Boolean(catalog.video && catalog.video.renditions);
    const hasAudio = Boolean(catalog.audio && catalog.audio.renditions);
    if (hasVideo || hasAudio) {
      setHint(`Live: subscribed to ${name}`, "ok");
    }
  });

  if (cleanup.length) {
    disposePlayerSignals = () => {
      clearOfflineTimer();
      for (const fn of cleanup) {
        try {
          fn();
        } catch (_) {
          // Best-effort cleanup only.
        }
      }
    };
  }
}

function mountPlayer(relayUrl, name) {
  clearPlayerSignals();
  destroyArchivePlayer();

  const mount = $("playerMount");
  mount.textContent = "";

  const watch = document.createElement("moq-watch");
  watch.setAttribute("url", relayUrl);
  // Support both @moq/watch attribute variants across minor versions.
  watch.setAttribute("name", name);
  watch.setAttribute("path", name);
  watch.setAttribute("volume", "1");
  watch.setAttribute("muted", "");
  watch.setAttribute("jitter", String(LIVE_JITTER_MS));

  // Force WebTransport in-browser; websocket fallback has shown degraded
  // media behavior (especially audio) against public relay paths.
  if (watch.connection && typeof watch.connection === "object") {
    watch.connection.websocket = { enabled: false };
  }

  // Prefer a video element for native controls/audio routing.
  // Start muted to satisfy autoplay policy, then unlock audio on user gesture.
  const video = document.createElement("video");
  video.className = "archiveVideo";
  video.controls = true;
  video.autoplay = true;
  video.muted = true;
  video.volume = 1;
  video.playsInline = true;
  watch.appendChild(video);
  mount.appendChild(watch);
  const forceAudioOn = () => {
    try {
      watch.backend?.audio?.muted?.set?.(false);
      watch.backend?.audio?.volume?.set?.(1);
    } catch (_) {
      // Best effort only.
    }
  };
  const unlockAudio = () => {
    forceAudioOn();
    watch.backend?.paused?.set?.(true);
    watch.backend?.paused?.set?.(false);
    video.muted = false;
    video.volume = 1;
    void video.play().catch(() => {});
    setHint(`Live: subscribed to ${name} (audio unlocked)`, "ok");
  };
  document.addEventListener("pointerdown", unlockAudio, { once: true });
  video.addEventListener("pointerdown", unlockAudio, { once: true });
  setHint(`Live: subscribed to ${name} (tap video to unmute)`, "warn");
  void video.play().catch(() => {});
  bindPlayerSignals(watch, name);
}

async function ensureMoqWatchElement() {
  if (window.customElements && window.customElements.get("moq-watch")) return;
  if (!moqWatchModulePromise) {
    moqWatchModulePromise = (async () => {
      let lastErr = null;
      for (const moduleUrl of MOQ_WATCH_MODULE_URLS) {
        try {
          await import(moduleUrl);
        } catch (err) {
          lastErr = err;
          continue;
        }
        if (window.customElements && window.customElements.get("moq-watch")) {
          return;
        }
      }
      throw lastErr || new Error("moq-watch custom element is unavailable");
    })();
  }
  await moqWatchModulePromise;
  if (!(window.customElements && window.customElements.get("moq-watch"))) {
    throw new Error("moq-watch custom element is unavailable");
  }
}

async function ensureHlsPlayerCtor() {
  if (window.Hls) return window.Hls;
  if (!hlsModulePromise) {
    hlsModulePromise = (async () => {
      let lastErr = null;
      for (const moduleUrl of HLS_MODULE_URLS) {
        try {
          const mod = await import(moduleUrl);
          if (mod?.default) {
            window.Hls = mod.default;
          } else if (mod?.Hls) {
            window.Hls = mod.Hls;
          }
        } catch (err) {
          lastErr = err;
          continue;
        }
        if (window.Hls) return window.Hls;
      }
      throw lastErr || new Error("hls.js module is unavailable");
    })();
  }
  return hlsModulePromise;
}

function shouldUseNativeHls(video) {
  const ua = navigator.userAgent || "";
  const vendor = navigator.vendor || "";
  const isIos = /iPhone|iPad|iPod/i.test(ua);
  const isSafari =
    /Safari/i.test(ua) &&
    /Apple/i.test(vendor) &&
    !/CriOS|FxiOS|EdgiOS|OPiOS|Chrome|Chromium/i.test(ua);
  return (isIos || isSafari) && Boolean(video.canPlayType("application/vnd.apple.mpegurl"));
}

async function mountArchivePlayer(name) {
  clearPlayerSignals();
  destroyArchivePlayer();

  const mount = $("playerMount");
  mount.textContent = "";

  const video = document.createElement("video");
  video.className = "archiveVideo";
  video.controls = true;
  video.autoplay = true;
  video.muted = false;
  video.playsInline = true;
  video.addEventListener("error", () => {
    setHint(
      "Archive replay bytes are not browser-HLS compatible yet (legacy container); timeline is available, live path is unaffected.",
      "warn",
    );
  });
  mount.appendChild(video);

  const archiveUrl = `/api/archive/${encodeURIComponent(name)}/master.m3u8`;
  if (shouldUseNativeHls(video)) {
    video.src = archiveUrl;
    void video.play().catch(() => {});
    return;
  }

  const HlsCtor = await ensureHlsPlayerCtor();
  if (!HlsCtor || typeof HlsCtor.isSupported !== "function" || !HlsCtor.isSupported()) {
    throw new Error("HLS playback is unsupported in this browser");
  }

  const hls = new HlsCtor({
    liveDurationInfinity: true,
    lowLatencyMode: false,
    backBufferLength: 120,
  });
  activeHlsPlayer = hls;
  hls.on(HlsCtor.Events.ERROR, (_event, data) => {
    if (!data?.fatal) return;
    if (data.type === HlsCtor.ErrorTypes.NETWORK_ERROR) {
      setHint("Archive network hiccup, retrying…", "warn");
      try {
        hls.startLoad();
      } catch (_) {
        // best-effort recovery
      }
      return;
    }
    if (data.type === HlsCtor.ErrorTypes.MEDIA_ERROR) {
      setHint("Archive media hiccup, recovering…", "warn");
      try {
        hls.recoverMediaError();
      } catch (_) {
        // best-effort recovery
      }
      return;
    }
    setHint(`Archive playback error: ${data.type || "fatal"}`, "warn");
  });
  hls.loadSource(archiveUrl);
  hls.attachMedia(video);
  void video.play().catch(() => {});
}

async function copyToClipboard(text) {
  if (!text) return;
  if (navigator.clipboard && navigator.clipboard.writeText) {
    await navigator.clipboard.writeText(text);
    return;
  }
  // Fallback: best-effort.
  const ta = document.createElement("textarea");
  ta.value = text;
  document.body.appendChild(ta);
  ta.select();
  document.execCommand("copy");
  document.body.removeChild(ta);
}

function readParams() {
  const u = new URL(window.location.href);
  // Accept legacy/share-link aliases for compatibility.
  const relay =
    u.searchParams.get("url") ||
    u.searchParams.get("relay") ||
    u.searchParams.get("relayUrl");
  const name =
    u.searchParams.get("name") ||
    u.searchParams.get("broadcast") ||
    u.searchParams.get("path");
  const mode = u.searchParams.get("mode") === "archive" ? "archive" : "live";
  return {
    relayUrl: normalizeRelayUrl(relay || DEFAULT_RELAY_URL),
    name: normalizeName(name || ""),
    mode,
  };
}

function writeParams(relayUrl, name, mode) {
  const u = new URL(window.location.href);
  u.pathname = "/watch";
  u.searchParams.set("url", relayUrl);
  u.searchParams.set("name", name);
  if (mode === "archive") u.searchParams.set("mode", "archive");
  else u.searchParams.delete("mode");
  // Canonicalize by dropping stale aliases/extra params.
  for (const k of [...u.searchParams.keys()]) {
    if (k !== "url" && k !== "name" && k !== "mode") u.searchParams.delete(k);
  }
  window.history.replaceState({}, "", u.toString());
}

function hasWebTransport() {
  return typeof window.WebTransport !== "undefined";
}

function renderLiveList(entries, onWatchLive, onWatchArchive) {
  const mount = $("liveList");
  mount.textContent = "";
  if (!entries.length) {
    setListHint("No public streams announced yet.", "");
    return;
  }
  setListHint(`${entries.length} live`, "ok");

  for (const entry of entries) {
    const row = document.createElement("div");
    row.className = "liveItem";

    const info = document.createElement("div");
    const title = document.createElement("div");
    title.className = "liveTitle";
    title.textContent = entry.title || entry.stream_id || entry.broadcast_name || "Live stream";
    info.appendChild(title);

    const meta = document.createElement("div");
    meta.className = "liveMeta";
    meta.textContent = `${entry.broadcast_name || ""} @ ${entry.relay_url || DEFAULT_RELAY_URL}`;
    info.appendChild(meta);

    const actions = document.createElement("div");
    actions.className = "liveActions";

    const watchBtn = document.createElement("button");
    watchBtn.className = "btn secondary";
    watchBtn.textContent = "Live";
    watchBtn.addEventListener("click", () => {
      onWatchLive(entry);
    });

    const archiveBtn = document.createElement("button");
    archiveBtn.className = "btn secondary";
    archiveBtn.textContent = "Archive";
    archiveBtn.addEventListener("click", () => {
      onWatchArchive(entry);
    });

    actions.appendChild(watchBtn);
    actions.appendChild(archiveBtn);

    row.appendChild(info);
    row.appendChild(actions);
    mount.appendChild(row);
  }
}

async function fetchLiveList() {
  const res = await fetch(PUBLIC_STREAMS_PATH, { cache: "no-store" });
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}`);
  }
  const body = await res.json();
  const entries = Array.isArray(body?.entries) ? body.entries : [];
  return entries;
}

function main() {
  const relayInput = $("relayUrl");
  const nameInput = $("broadcastName");
  const archiveModeInput = $("archiveMode");
  const watchBtn = $("watchBtn");
  const copyBtn = $("copyLinkBtn");
  const refreshListBtn = $("refreshListBtn");

  const initial = readParams();
  relayInput.value = initial.relayUrl;
  nameInput.value = initial.name;
  archiveModeInput.checked = initial.mode === "archive";

  function updateSharePreview() {
    const relayUrl = normalizeRelayUrl(relayInput.value);
    const name = normalizeName(nameInput.value);
    const mode = archiveModeInput.checked ? "archive" : "live";
    if (!name) {
      setShareLink("");
      return;
    }
    setShareLink(currentShareLink(relayUrl, name, mode));
  }

  async function start() {
    const relayUrl = normalizeRelayUrl(relayInput.value);
    const name = normalizeName(nameInput.value);
    const mode = archiveModeInput.checked ? "archive" : "live";

    updateSharePreview();

    if (!name) {
      setHint("Enter a broadcast name to watch.", "warn");
      return;
    }

    if (mode === "archive") {
      writeParams(relayUrl, name, mode);
      setHint(`Loading archive DVR: ${name}`, "ok");
      try {
        await mountArchivePlayer(name);
      } catch (e) {
        setHint(
          `Archive playback unavailable: ${String(e)}. Ensure /api/archive is configured.`,
          "warn",
        );
      }
      return;
    }

    if (!hasWebTransport()) {
      setHint(
        "WebTransport is not available in this browser. Try Chrome or Firefox Nightly. Safari support is still incomplete.",
        "warn",
      );
      return;
    }

    try {
      await ensureMoqWatchElement();
    } catch (e) {
      setHint(
        `Failed to load MoQ web player dependency: ${String(e)}. Disable script blockers for esm.sh/jsdelivr/unpkg and retry.`,
        "warn",
      );
      return;
    }

    writeParams(relayUrl, name, mode);
    setHint(`Connecting to relay and subscribing: ${name}`, "ok");
    mountPlayer(relayUrl, name);
  }

  relayInput.addEventListener("input", updateSharePreview);
  nameInput.addEventListener("input", updateSharePreview);
  archiveModeInput.addEventListener("input", updateSharePreview);

  watchBtn.addEventListener("click", () => {
    void start();
  });
  nameInput.addEventListener("keydown", (e) => {
    if (e.key === "Enter") void start();
  });

  copyBtn.addEventListener("click", async () => {
    const relayUrl = normalizeRelayUrl(relayInput.value);
    const name = normalizeName(nameInput.value);
    const mode = archiveModeInput.checked ? "archive" : "live";
    if (!name) {
      setHint("Enter a broadcast name first.", "warn");
      return;
    }
    const link = currentShareLink(relayUrl, name, mode);
    try {
      await copyToClipboard(link);
      setHint("Link copied.", "ok");
      setShareLink(link);
    } catch (e) {
      setHint(`Copy failed: ${String(e)}`, "warn");
      setShareLink(link);
    }
  });

  async function refreshLiveList() {
    setListHint("Loading live streams...", "");
    try {
      const entries = await fetchLiveList();
      renderLiveList(
        entries,
        (entry) => {
          archiveModeInput.checked = false;
          relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
          nameInput.value = normalizeName(entry.broadcast_name || "");
          updateSharePreview();
          void start();
        },
        (entry) => {
          archiveModeInput.checked = true;
          relayInput.value = normalizeRelayUrl(entry.relay_url || DEFAULT_RELAY_URL);
          nameInput.value = normalizeName(entry.broadcast_name || "");
          updateSharePreview();
          void start();
        },
      );
    } catch (e) {
      $("liveList").textContent = "";
      setListHint(`Live list error: ${String(e)}`, "warn");
    }
  }
  refreshListBtn.addEventListener("click", () => {
    void refreshLiveList();
  });

  updateSharePreview();
  void refreshLiveList();
  window.setInterval(() => {
    void refreshLiveList();
  }, 15000);

  // Auto-start if a name was provided.
  if (initial.name) void start();
}

main();
