function json(data: unknown, init?: ResponseInit): Response {
  return new Response(JSON.stringify(data), {
    ...init,
    headers: {
      "content-type": "application/json; charset=utf-8",
      ...(init?.headers ?? {}),
    },
  });
}

function jsonNoStore(data: unknown, init?: ResponseInit): Response {
  return json(data, {
    ...init,
    headers: {
      "cache-control": "no-store",
      ...(init?.headers ?? {}),
    },
  });
}

async function hmacBase64(
  secret: string,
  msg: string,
  hash: "SHA-1" | "SHA-256",
): Promise<string> {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    enc.encode(secret),
    { name: "HMAC", hash: { name: hash } },
    false,
    ["sign"],
  );
  const sig = await crypto.subtle.sign("HMAC", key, enc.encode(msg));
  const bytes = new Uint8Array(sig);
  let s = "";
  for (const b of bytes) s += String.fromCharCode(b);
  return btoa(s);
}

async function handleTurn(env: Env): Promise<Response> {
  // Always provide STUN. TURN is optional and requires a shared secret.
  // Response shape is compatible with `just-webrtc` types.
  const ice_servers: Array<{
    urls: string[];
    username: string;
    credential: string;
    // `just-webrtc` uses serde defaults on enum variants (Password/Oauth).
    credential_type: "Password" | "Oauth";
  }> = [];

  ice_servers.push({
    urls: [
      "stun:stun.cloudflare.com:3478",
      "stun:stun.l.google.com:19302",
      "stun:stun1.l.google.com:19302",
    ],
    username: "",
    credential: "",
    credential_type: "Password",
  });

  const shared = env.EC_TURN_SHARED_SECRET?.trim();
  if (shared) {
    const ttlSec = Number(env.EC_TURN_TTL_SECS ?? "3600") || 3600;
    const exp = Math.floor(Date.now() / 1000) + Math.max(60, ttlSec);
    const prefix = (env.EC_TURN_USER_PREFIX ?? "every.channel").trim();
    const username = `${exp}:${prefix}`;
    const hash = (env.EC_TURN_HMAC ?? "sha1").toLowerCase() === "sha256" ? "SHA-256" : "SHA-1";
    const credential = await hmacBase64(shared, username, hash);

    const host = (env.EC_TURN_HOST ?? "turn.cloudflare.com").trim();
    ice_servers.push({
      urls: [
        `turn:${host}:3478?transport=udp`,
        `turn:${host}:3478?transport=tcp`,
        `turns:${host}:5349?transport=tcp`,
      ],
      username,
      credential,
      credential_type: "Password",
    });
  }

  return json({ ice_servers });
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // ICE bootstrap for WebRTC clients (browser + native).
    if (url.pathname === "/api/turn") {
      return handleTurn(env);
    }

    // Stream relay (one-to-many) for CMAF object frames transported via direct-wire chunks.
    // This is a bootstrap relay so browsers can watch live streams without direct P2P.
    if (url.pathname === "/api/stream/ws") {
      const stream_id = url.searchParams.get("stream_id") ?? "";
      if (!stream_id) {
        return jsonNoStore({ error: "missing stream_id" }, { status: 400 });
      }
      const id = env.EC_STREAM.idFromName(stream_id);
      const stub = env.EC_STREAM.get(id);
      return stub.fetch(request);
    }

    // Archive replay proxy. Forwards website requests to an archive replay origin
    // (typically forge running `ec-node wt-archive-serve`).
    if (url.pathname.startsWith("/api/archive/") || url.pathname.startsWith("/archive/")) {
      const origin = env.EC_ARCHIVE_ORIGIN?.trim();
      if (!origin) {
        return jsonNoStore({ error: "archive origin not configured" }, { status: 503 });
      }
      let base: URL;
      try {
        base = new URL(origin);
      } catch {
        return jsonNoStore({ error: "invalid archive origin" }, { status: 500 });
      }
      const target = new URL(base.toString());
      const suffix = url.pathname.startsWith("/api/archive/")
        ? url.pathname.slice("/api/archive".length)
        : url.pathname.slice("/archive".length);
      target.pathname = `${base.pathname.replace(/\/$/, "")}/archive${suffix}`;
      target.search = url.search;

      const upstream = await fetch(new Request(target.toString(), request), {
        cf: { cacheTtl: 0, cacheEverything: false },
      });
      const headers = new Headers(upstream.headers);
      headers.set("cache-control", "no-store");
      return new Response(upstream.body, {
        status: upstream.status,
        headers,
      });
    }

    // Minimal bootstrap API: proxy /api/* to a single durable object instance ("global").
    // This exists only to rendezvous WebRTC offers/answers and list "live" entries.
    if (url.pathname.startsWith("/api/")) {
      const id = env.EC_API.idFromName("global");
      const stub = env.EC_API.get(id);
      return stub.fetch(request);
    }

    // Serve static assets from the Worker Assets binding.
    // SPA routing: serve the app shell for "route-like" paths (e.g. `/watch`).
    //
    // Cloudflare's Assets binding may otherwise issue a redirect for unknown paths (e.g. `/watch` -> `/`),
    // which breaks stable share links.
    const assets = (env as unknown as { ASSETS?: Fetcher }).ASSETS;
    if (!assets || typeof (assets as any).fetch !== "function") {
      return new Response("Assets binding not configured", { status: 500 });
    }

    const method = request.method.toUpperCase();
    const is_nav = method === "GET" || method === "HEAD";
    const is_likely_asset = url.pathname.includes(".");

    // Use `/` as the app shell. Some asset handlers redirect `/index.html` -> `/`,
    // which turns SPA routes into 307s instead of serving the HTML.
    if (is_nav && !is_likely_asset && url.pathname !== "/") {
      const u = new URL(request.url);
      u.pathname = "/";
      return assets.fetch(new Request(u.toString(), request));
    }

    return assets.fetch(request);
  },
};

interface Env {
  ASSETS: Fetcher;
  EC_API: DurableObjectNamespace;
  EC_STREAM: DurableObjectNamespace;
  // Optional TURN REST shared secret (Worker secret). When set, `/api/turn` includes TURN URLs
  // with short-lived credentials derived from this shared secret.
  EC_TURN_SHARED_SECRET?: string;
  EC_TURN_TTL_SECS?: string;
  EC_TURN_USER_PREFIX?: string;
  EC_TURN_HOST?: string;
  EC_TURN_HMAC?: string;
  EC_STREAM_UPSERT_TOKEN?: string;
  EC_ARCHIVE_ORIGIN?: string;
}

type DirectoryEntry = {
  stream_id: string;
  title: string;
  offer: string;
  updated_ms: number;
  expires_ms: number;
};

type AnswerEntry = {
  stream_id: string;
  answer: string;
  updated_ms: number;
  expires_ms: number;
};

type DirectoryList = {
  now_ms: number;
  entries: DirectoryEntry[];
};

type PublicStreamEntry = {
  stream_id: string;
  title: string;
  relay_url: string;
  broadcast_name: string;
  track_name: string;
  relays: PublicStreamRelay[];
  updated_ms: number;
  expires_ms: number;
};

type PublicStreamRelay = {
  relay_url: string;
  broadcast_name: string;
  track_name: string;
};

type PublicStreamList = {
  now_ms: number;
  entries: PublicStreamEntry[];
};

function nowMs(): number {
  return Date.now();
}

function clampStr(s: string, maxLen: number): string {
  if (s.length <= maxLen) return s;
  return s.slice(0, maxLen);
}

function entryKey(streamId: string): string {
  return `e:${streamId}`;
}

function answerKey(streamId: string): string {
  return `a:${streamId}`;
}

function streamKey(streamId: string): string {
  return `s:${streamId}`;
}

async function listWithPrefix<T>(
  storage: DurableObjectStorage,
  prefix: string,
): Promise<Array<[string, T]>> {
  const out: Array<[string, T]> = [];
  // Pull in small pages; this is a bootstrap rendezvous, not a global index.
  let cursor: string | undefined = undefined;
  for (;;) {
    const page = await storage.list<T>({ prefix, cursor, limit: 256 });
    for (const [k, v] of page) out.push([k, v]);
    if (page.cursor) cursor = page.cursor;
    else break;
  }
  return out;
}

async function pruneAndCap(
  storage: DurableObjectStorage,
  now: number,
): Promise<void> {
  const entries = await listWithPrefix<DirectoryEntry>(storage, "e:");
  const answers = await listWithPrefix<AnswerEntry>(storage, "a:");
  const streams = await listWithPrefix<PublicStreamEntry>(storage, "s:");

  const toDelete: string[] = [];
  for (const [k, v] of entries) if (v.expires_ms <= now) toDelete.push(k);
  for (const [k, v] of answers) if (v.expires_ms <= now) toDelete.push(k);
  for (const [k, v] of streams) if (v.expires_ms <= now) toDelete.push(k);

  // Cap growth defensively. This is not spam-resistant; it's a bootstrap rendezvous.
  if (entries.length > 200) {
    const sorted = entries
      .map(([, v]) => v)
      .sort((a, b) => b.updated_ms - a.updated_ms)
      .slice(0, 200);
    const keep = new Set(sorted.map((e) => entryKey(e.stream_id)));
    for (const [k] of entries) if (!keep.has(k)) toDelete.push(k);
  }
  if (answers.length > 500) {
    const sorted = answers
      .map(([, v]) => v)
      .sort((a, b) => b.updated_ms - a.updated_ms)
      .slice(0, 500);
    const keep = new Set(sorted.map((a) => answerKey(a.stream_id)));
    for (const [k] of answers) if (!keep.has(k)) toDelete.push(k);
  }
  if (streams.length > 1000) {
    const sorted = streams
      .map(([, v]) => v)
      .sort((a, b) => b.updated_ms - a.updated_ms)
      .slice(0, 1000);
    const keep = new Set(sorted.map((s) => streamKey(s.stream_id)));
    for (const [k] of streams) if (!keep.has(k)) toDelete.push(k);
  }

  if (toDelete.length > 0) {
    // Delete in chunks to avoid oversized requests.
    for (let i = 0; i < toDelete.length; i += 128) {
      await storage.delete(toDelete.slice(i, i + 128));
    }
  }
}

type AnnounceReq = {
  stream_id: string;
  title: string;
  offer: string;
  expires_ms?: number;
};

type AnswerPostReq = {
  stream_id: string;
  answer: string;
};

type StreamUpsertReq = {
  stream_id: string;
  title: string;
  relay_url: string;
  broadcast_name: string;
  track_name?: string;
  relays?: PublicStreamRelay[];
  expires_ms?: number;
};

function publicStreamRelayKey(relay: PublicStreamRelay): string {
  return `${relay.relay_url}\n${relay.broadcast_name}\n${relay.track_name}`;
}

function normalizePublicStreamRelays(body: StreamUpsertReq): PublicStreamRelay[] {
  const relays: PublicStreamRelay[] = [];

  const addRelay = (candidate?: Partial<PublicStreamRelay>) => {
    if (!candidate?.relay_url || !candidate.broadcast_name) return;
    const relay: PublicStreamRelay = {
      relay_url: clampStr(candidate.relay_url, 512),
      broadcast_name: clampStr(candidate.broadcast_name, 256),
      track_name: clampStr(candidate.track_name || "video0.m4s", 256),
    };
    if (!relays.some((existing) => publicStreamRelayKey(existing) === publicStreamRelayKey(relay))) {
      relays.push(relay);
    }
  };

  addRelay({
    relay_url: body.relay_url,
    broadcast_name: body.broadcast_name,
    track_name: body.track_name,
  });
  if (Array.isArray(body.relays)) {
    for (const relay of body.relays) addRelay(relay);
  }

  return relays.slice(0, 16);
}

function authBearerToken(request: Request): string | null {
  const auth = request.headers.get("authorization");
  if (!auth) return null;
  const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
  if (!m) return null;
  return m[1];
}

// Minimal bootstrap API Durable Object. The binding name is historical; we keep it stable so
// existing migrations and wrangler config remain valid while removing Cloudflare Containers.
export class EcApiContainer implements DurableObject {
  private state: DurableObjectState;
  private env: Env;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    this.env = env;
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const now = nowMs();

    // Best-effort pruning on any request.
    await pruneAndCap(this.state.storage, now);

    if (url.pathname === "/api/health") {
      return jsonNoStore({ ok: true });
    }

    if (url.pathname === "/api/public-streams") {
      const items = await listWithPrefix<PublicStreamEntry>(this.state.storage, "s:");
      const entries = items
        .map(([, v]) => v)
        .filter((v) => v.expires_ms > now)
        .sort((a, b) => b.updated_ms - a.updated_ms);
      const resp: PublicStreamList = { now_ms: now, entries };
      return jsonNoStore(resp);
    }

    if (url.pathname === "/api/stream-upsert") {
      if (request.method !== "POST") {
        return jsonNoStore({ error: "method not allowed" }, { status: 405 });
      }

      const requiredToken = this.env.EC_STREAM_UPSERT_TOKEN?.trim();
      if (requiredToken) {
        const supplied = authBearerToken(request);
        if (!supplied || supplied !== requiredToken) {
          return jsonNoStore({ error: "unauthorized" }, { status: 401 });
        }
      }

      let body: StreamUpsertReq;
      try {
        body = (await request.json()) as StreamUpsertReq;
      } catch {
        return jsonNoStore({ error: "invalid json" }, { status: 400 });
      }

      if (!body.stream_id || !body.title || !body.relay_url || !body.broadcast_name) {
        return jsonNoStore(
          { error: "missing stream_id/title/relay_url/broadcast_name" },
          { status: 400 },
        );
      }
      const relays = normalizePublicStreamRelays(body);

      const requestedExpires = body.expires_ms ?? now + 20_000;
      const requestedTtl = Math.max(0, requestedExpires - now);
      const ttlMs = Math.min(60_000, Math.max(5_000, requestedTtl));

      const primaryRelay = relays[0];
      const entry: PublicStreamEntry = {
        stream_id: clampStr(body.stream_id, 256),
        title: clampStr(body.title, 128),
        relay_url: primaryRelay.relay_url,
        broadcast_name: primaryRelay.broadcast_name,
        track_name: primaryRelay.track_name,
        relays,
        updated_ms: now,
        expires_ms: now + ttlMs,
      };

      await this.state.storage.put(streamKey(entry.stream_id), entry);
      return jsonNoStore({ ok: true, ttl_ms: ttlMs, entry });
    }

    if (url.pathname === "/api/directory") {
      const items = await listWithPrefix<DirectoryEntry>(this.state.storage, "e:");
      const entries = items
        .map(([, v]) => v)
        .filter((v) => v.expires_ms > now)
        .sort((a, b) => b.updated_ms - a.updated_ms);
      const resp: DirectoryList = { now_ms: now, entries };
      return jsonNoStore(resp);
    }

    if (url.pathname === "/api/announce") {
      if (request.method !== "POST") {
        return jsonNoStore({ error: "method not allowed" }, { status: 405 });
      }
      let body: AnnounceReq;
      try {
        body = (await request.json()) as AnnounceReq;
      } catch {
        return jsonNoStore({ error: "invalid json" }, { status: 400 });
      }

      if (!body.stream_id || !body.title || !body.offer) {
        return jsonNoStore({ error: "missing stream_id/title/offer" }, { status: 400 });
      }
      if (body.offer.length > 64_000) {
        return jsonNoStore({ error: "offer too large" }, { status: 413 });
      }

      const requestedExpires = body.expires_ms ?? now + 20_000;
      const requestedTtl = Math.max(0, requestedExpires - now);
      const ttlMs = Math.min(60_000, Math.max(5_000, requestedTtl));

      const entry: DirectoryEntry = {
        stream_id: clampStr(body.stream_id, 256),
        title: clampStr(body.title, 128),
        offer: body.offer,
        updated_ms: now,
        expires_ms: now + ttlMs,
      };

      await this.state.storage.put(entryKey(entry.stream_id), entry);
      return jsonNoStore({ ok: true, ttl_ms: ttlMs, entry });
    }

    if (url.pathname === "/api/answer") {
      if (request.method === "POST") {
        let body: AnswerPostReq;
        try {
          body = (await request.json()) as AnswerPostReq;
        } catch {
          return jsonNoStore({ error: "invalid json" }, { status: 400 });
        }
        if (!body.stream_id || !body.answer) {
          return jsonNoStore({ error: "missing stream_id/answer" }, { status: 400 });
        }
        if (body.answer.length > 64_000) {
          return jsonNoStore({ error: "answer too large" }, { status: 413 });
        }
        const entry: AnswerEntry = {
          stream_id: clampStr(body.stream_id, 256),
          answer: body.answer,
          updated_ms: now,
          expires_ms: now + 2 * 60_000,
        };
        await this.state.storage.put(answerKey(entry.stream_id), entry);
        return jsonNoStore({ ok: true });
      }

      if (request.method === "GET") {
        const streamId = url.searchParams.get("stream_id") ?? "";
        if (!streamId) {
          return jsonNoStore({ error: "missing stream_id" }, { status: 400 });
        }
        const key = answerKey(streamId);
        const ans = await this.state.storage.get<AnswerEntry>(key);
        if (!ans || ans.expires_ms <= now) {
          await this.state.storage.delete(key);
          return jsonNoStore({ error: "not found" }, { status: 404 });
        }
        // One-shot: first reader consumes.
        await this.state.storage.delete(key);
        return jsonNoStore(ans);
      }

      return jsonNoStore({ error: "method not allowed" }, { status: 405 });
    }

    return jsonNoStore({ error: "not found" }, { status: 404 });
  }
}

// Historical class referenced by older migrations. It is not bound anymore,
// but exporting it keeps workerd/wrangler happy if migrations mention it.
export class DirectoryDO implements DurableObject {
  async fetch(): Promise<Response> {
    return new Response("gone", { status: 410 });
  }
}

const DIRECT_WIRE_TAG_FRAME = 0x00;
const DIRECT_WIRE_TAG_STREAM = 0x01;
const DIRECT_WIRE_TAG_PING = 0x02;
const DIRECT_WIRE_CHUNK_BYTES = 16 * 1024;

type TimingMeta = {
  chunk_index: number;
};

type ObjectMeta = {
  content_type: string;
  timing?: TimingMeta;
};

// One-to-many fanout for direct-wire message chunks. Publisher sends direct-wire messages,
// subscribers receive the same stream, plus a buffered init + recent segments upon join.
export class StreamRelayDO implements DurableObject {
  private publisher: WebSocket | null = null;
  private subs = new Set<WebSocket>();

  // Reassemble publisher STREAM chunks into full object frames for buffering.
  private buf = new Uint8Array(0);
  private want: number | null = null;

  private initFrame: Uint8Array | null = null;
  private segFrames = new Map<number, Uint8Array>();
  private maxSegments = 12;

  async fetch(request: Request): Promise<Response> {
    const upgrade = request.headers.get("Upgrade")?.toLowerCase();
    if (upgrade !== "websocket") {
      return json({ error: "expected websocket" }, { status: 400 });
    }

    const url = new URL(request.url);
    const role = (url.searchParams.get("role") ?? "sub").toLowerCase();

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const pair = new (WebSocketPair as any)();
    const client: WebSocket = pair[0];
    const server: WebSocket = pair[1];
    server.accept();

    if (role === "pub") {
      if (this.publisher) {
        try {
          this.publisher.close(1013, "publisher replaced");
        } catch {}
      }
      this.publisher = server;
    } else {
      this.subs.add(server);
      // Best-effort fast start: send init + last segments.
      try {
        if (this.initFrame) {
          this.sendFrame(server, this.initFrame);
        }
        const idxs = Array.from(this.segFrames.keys()).sort((a, b) => a - b);
        for (const idx of idxs) {
          const fr = this.segFrames.get(idx);
          if (fr) this.sendFrame(server, fr);
        }
      } catch {}
    }

    server.addEventListener("message", (evt: MessageEvent) => {
      const data = evt.data;
      if (!(data instanceof ArrayBuffer)) {
        return;
      }
      const msg = new Uint8Array(data);

      if (role === "pub") {
        // Fanout immediately (no buffering delay).
        for (const sub of this.subs) {
          try {
            sub.send(data);
          } catch {}
        }
        // Also decode for buffered join.
        this.handlePublisherMsg(msg);
      } else {
        // Subscribers can send pings; ignore.
        if (msg.length > 0 && msg[0] === DIRECT_WIRE_TAG_PING) {
          return;
        }
      }
    });

    server.addEventListener("close", () => {
      if (server === this.publisher) {
        this.publisher = null;
        // Keep buffered init/segments; publisher may reconnect soon.
      }
      this.subs.delete(server);
    });

    server.addEventListener("error", () => {
      if (server === this.publisher) {
        this.publisher = null;
      }
      this.subs.delete(server);
    });

    return new Response(null, { status: 101, webSocket: client });
  }

  private handlePublisherMsg(msg: Uint8Array) {
    if (msg.length === 0) return;
    const tag = msg[0];
    if (tag === DIRECT_WIRE_TAG_PING) return;
    if (tag === DIRECT_WIRE_TAG_FRAME) {
      const frame = msg.subarray(1);
      this.bufferFrame(frame);
      return;
    }
    if (tag !== DIRECT_WIRE_TAG_STREAM) {
      // Legacy: assume the whole thing is a frame.
      this.bufferFrame(msg);
      return;
    }

    // Append to reassembly buffer.
    const chunk = msg.subarray(1);
    this.buf = concatU8(this.buf, chunk);

    // Pull as many framed payloads as possible:
    // [u32be len][frame bytes...]
    // The `frame` bytes are `encode_object_frame(meta, data)`.
    while (true) {
      if (this.want === null) {
        if (this.buf.length < 4) return;
        this.want =
          (this.buf[0] << 24) | (this.buf[1] << 16) | (this.buf[2] << 8) | this.buf[3];
        this.buf = this.buf.subarray(4);
      }
      const want = this.want ?? 0;
      if (this.buf.length < want) return;
      const frame = this.buf.subarray(0, want);
      this.buf = this.buf.subarray(want);
      this.want = null;
      this.bufferFrame(frame);

      // Avoid unbounded growth if publisher sends junk.
      if (this.buf.length > 4 * 1024 * 1024) {
        this.buf = new Uint8Array(0);
        this.want = null;
        return;
      }
    }
  }

  private bufferFrame(frame: Uint8Array) {
    const meta = tryDecodeObjectMeta(frame);
    const idx = meta?.timing?.chunk_index;
    if (typeof idx !== "number") return;
    if (idx === 0) {
      this.initFrame = frame.slice();
      return;
    }

    this.segFrames.set(idx, frame.slice());
    while (this.segFrames.size > this.maxSegments) {
      const oldest = Math.min(...this.segFrames.keys());
      this.segFrames.delete(oldest);
    }
  }

  private sendFrame(ws: WebSocket, frame: Uint8Array) {
    // Mirror the Rust `direct_wire_send_frame` format:
    // Stream bytes are [u32be frame_len][frame]
    const out = new Uint8Array(4 + frame.length);
    const len = frame.length >>> 0;
    out[0] = (len >>> 24) & 0xff;
    out[1] = (len >>> 16) & 0xff;
    out[2] = (len >>> 8) & 0xff;
    out[3] = len & 0xff;
    out.set(frame, 4);

    for (let i = 0; i < out.length; i += DIRECT_WIRE_CHUNK_BYTES) {
      const chunk = out.subarray(i, Math.min(out.length, i + DIRECT_WIRE_CHUNK_BYTES));
      const msg = new Uint8Array(1 + chunk.length);
      msg[0] = DIRECT_WIRE_TAG_STREAM;
      msg.set(chunk, 1);
      ws.send(msg);
    }
  }
}

function concatU8(a: Uint8Array, b: Uint8Array): Uint8Array {
  if (a.length === 0) return b.slice();
  if (b.length === 0) return a.slice();
  const out = new Uint8Array(a.length + b.length);
  out.set(a, 0);
  out.set(b, a.length);
  return out;
}

function tryDecodeObjectMeta(frame: Uint8Array): ObjectMeta | null {
  if (frame.length < 4) return null;
  const metaLen = (frame[0] << 24) | (frame[1] << 16) | (frame[2] << 8) | frame[3];
  if (metaLen < 2 || frame.length < 4 + metaLen) return null;
  const metaBytes = frame.subarray(4, 4 + metaLen);
  try {
    const txt = new TextDecoder().decode(metaBytes);
    return JSON.parse(txt) as ObjectMeta;
  } catch {
    return null;
  }
}
