//! Node runner: orchestrates ingest, chunking, and MoQ publication.

mod blockchain;
mod nbc;
mod source;

use anyhow::{anyhow, Context, Result};
use blake3;
use blockchain::{ManifestObservationLocator, ObservationSink, ObservationSinkOptions};
use clap::ValueEnum;
use clap::{Parser, Subcommand};
use ec_chopper::{build_manifest_body_for_chunks, TsChunk};
use ec_core::{
    merkle_proof_for_index, verify_merkle_proof, Manifest, ManifestBody, ManifestSummary,
    ManifestVariant, MoqStreamDescriptor, StreamCatalogEntry, StreamControlAnnouncement,
    StreamDescriptor, StreamEncryptionInfo, StreamId, StreamKey, StreamMetadata,
    StreamTransportDescriptor, MERKLE_PROOF_ALG_BLAKE3,
};
use ec_crypto::{
    decrypt_stream_data, encrypt_stream_data, load_ethereum_manifest_keypair_from_env,
    load_manifest_keypair_from_env, sign_manifest_body_eip712, sign_manifest_id,
    verify_manifest_signature_with_body, ENCRYPTION_ALG,
};
use ec_direct::{decode_direct_link, encode_direct_link, DirectCodeV1};
use ec_eth::{
    control_announcement_commitments, manifest_commitments, manifest_commitments_match,
    stream_descriptor_commitments,
};
use ec_iroh::DiscoveryConfig;
use ec_moq::{
    chunk_duration_secs, decode_object_frame, encode_object_frame, FileRelay, GroupId, HlsWriter,
    MoqNode, MoqPublishSet, ObjectId, ObjectMeta, ObjectPayload, TimingMeta, TrackName,
    DEFAULT_MANIFEST_TRACK_NAME, DEFAULT_TRACK_NAME,
};
use futures_util::{SinkExt, StreamExt};
use iroh::Watcher;
use just_webrtc::types::{DataChannelOptions, ICEServer, PeerConfiguration, PeerConnectionState};
use just_webrtc::{DataChannelExt, PeerConnectionBuilder, PeerConnectionExt};
use nbc::{
    bootstrap_nbc_auth, nbc_capture_fps, resolve_nbc_chrome_path, resolve_nbc_profile_dir,
    spawn_nbc_frame_reader,
};
use source::{HdhrSource, HlsMode, HlsSource, LinuxDvbSource, StreamSource, TsSource};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::fs::File;
use std::future::Future;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::process::Command as TokioCommand;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_util::io::SyncIoBridge;
use url::Url;

const DIRECT_WIRE_TAG_FRAME: u8 = 0x00;
const DIRECT_WIRE_TAG_STREAM: u8 = 0x01;
const DIRECT_WIRE_TAG_PING: u8 = 0x02;
const DIRECT_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(8);
// Conservatively under typical SCTP data channel max message sizes.
const DIRECT_WIRE_CHUNK_BYTES: usize = 16 * 1024;
const WT_ARCHIVE_DEFAULT_TRACKS: &[&str] =
    &["catalog.json", "init.mp4", "video0.m4s", "audio0.m4s"];
const WT_PUBLISH_GOP_FRAMES: u32 = 1;
const WT_PUBLISH_VIDEO_FILTER: &str = "fps=6";
const WT_PUBLISH_MOVFLAGS: &str = "empty_moov+frag_keyframe+separate_moof+omit_tfhd_offset";
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::sync::RwLock;

#[derive(Parser, Debug)]
#[command(name = "ec-node")]
#[command(about = "every.channel node runner", long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Ingest a source and publish MoQ objects into a local relay directory.
    Ingest(IngestArgs),
    /// Ingest a source and publish via MoQ over iroh.
    MoqPublish(MoqPublishArgs),
    /// Subscribe to a MoQ stream and write HLS segments locally.
    MoqSubscribe(MoqSubscribeArgs),
    /// Publish and subscribe to a MoQ stream locally, verifying chunk hashes.
    MoqSelftest(MoqSelftestArgs),
    /// Publish a stream over a direct WebRTC data channel (manual copy/paste connect code).
    DirectPublish(DirectPublishArgs),
    /// Subscribe to a direct WebRTC stream (directory or offer link) and optionally capture an .mp4 proof.
    DirectSubscribe(DirectSubscribeArgs),
    /// Publish a stream to the global one-to-many relay (`/api/stream/ws`) and keep the directory entry live.
    WsPublish(WsPublishArgs),
    /// Subscribe to the global one-to-many relay (`/api/stream/ws`) and capture CMAF fragments + an mp4 proof.
    WsSubscribe(WsSubscribeArgs),
    /// Publish a CMAF (fMP4) stream to a MoQ relay over WebTransport (Cloudflare preview by default).
    WtPublish(WtPublishArgs),
    /// Warm an NBC / Adobe auth session in Chrome, only requiring operator interaction when the session is cold.
    NbcBootstrap(NbcBootstrapArgs),
    /// Publish a live NBC browser-backed stream to a MoQ relay over WebTransport.
    NbcWtPublish(NbcWtPublishArgs),
    /// Subscribe to a relay broadcast over WebTransport/MoQ and archive groups into CAS.
    WtArchive(WtArchiveArgs),
    /// Serve archived relay groups as DVR-style HLS playlists + object endpoints.
    WtArchiveServe(WtArchiveServeArgs),
    /// Announce stream transport availability over iroh gossip control topic.
    ControlAnnounce(ControlAnnounceArgs),
    /// Listen for stream transport announcements from iroh gossip control topic.
    ControlListen(ControlListenArgs),
    /// Resolve a stream id to the best currently-announced transport.
    ControlResolve(ControlResolveArgs),
    /// Bridge iroh control announcements into the website public stream directory.
    ControlBridgeWeb(ControlBridgeWebArgs),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum EncodeMode {
    /// Publish/subscribe CMAF-style fragmented MP4 segments (HLS fMP4) encoded with x264/AAC.
    Cmaf,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CmafLadderPreset {
    /// 3-rung ladder: 1080p@6000k, 720p@3000k, 480p@1200k (CBR-ish).
    Hd3,
}

#[derive(Parser, Debug)]
struct IngestArgs {
    /// Output directory for temporary chunks.
    #[arg(long, default_value = "./tmp/chunks")]
    chunk_dir: PathBuf,
    /// Relay directory to write MoQ objects.
    #[arg(long, default_value = "./tmp/relay")]
    relay_dir: PathBuf,
    /// Chunk duration in ms.
    #[arg(long, default_value_t = 2000)]
    chunk_ms: u64,
    /// Maximum number of chunks to write.
    #[arg(long)]
    max_chunks: Option<usize>,
    /// Optional stream id override.
    #[arg(long)]
    stream_id: Option<String>,
    /// Optional network secret (hex) for stream encryption.
    #[arg(long)]
    network_secret: Option<String>,
    /// Enable deterministic transcode before chunking.
    #[arg(long)]
    deterministic: bool,
    #[command(subcommand)]
    source: IngestSource,
}

#[derive(Parser, Debug)]
struct MoqPublishArgs {
    /// Output directory for temporary chunks.
    #[arg(long, default_value = "./tmp/chunks")]
    chunk_dir: PathBuf,
    /// Chunk duration in ms.
    #[arg(long, default_value_t = 2000)]
    chunk_ms: u64,
    /// Maximum number of chunks to write.
    #[arg(long)]
    max_chunks: Option<usize>,
    /// Optional stream id override.
    #[arg(long)]
    stream_id: Option<String>,
    /// Optional network secret (hex) for stream encryption.
    #[arg(long)]
    network_secret: Option<String>,
    /// Enable deterministic transcode before chunking.
    #[arg(long)]
    deterministic: bool,
    /// Broadcast name override (defaults to stream id).
    #[arg(long)]
    broadcast_name: Option<String>,
    /// Track name override.
    #[arg(long, default_value = DEFAULT_TRACK_NAME)]
    track_name: String,
    /// Publish chunk objects on the main track.
    #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
    publish_chunks: bool,
    /// Publish manifests alongside chunks.
    #[arg(long)]
    publish_manifests: bool,
    /// Track name for manifest frames.
    #[arg(long, default_value = DEFAULT_MANIFEST_TRACK_NAME)]
    manifest_track: String,
    /// Number of chunks per manifest epoch.
    #[arg(long, default_value_t = 1)]
    epoch_chunks: usize,
    /// Optional iroh secret key (hex).
    #[arg(long)]
    iroh_secret: Option<String>,
    /// Discovery modes to enable (comma-separated: dht, mdns, dns).
    #[arg(long)]
    discovery: Option<String>,
    /// Announce catalog entries over iroh-gossip (requires peers).
    #[arg(long)]
    announce: bool,
    /// Gossip peers to connect to (repeatable).
    #[arg(long)]
    gossip_peer: Vec<String>,
    /// Optional startup delay (ms) after binding/publishing tracks, before ingest begins.
    /// Useful for E2E tests that need time to connect subscribers.
    #[arg(long)]
    startup_delay_ms: Option<u64>,
    /// Encoding/container mode.
    #[arg(long, value_enum, default_value_t = EncodeMode::Cmaf)]
    encode: EncodeMode,
    /// Track name for CMAF init segment objects.
    #[arg(long, default_value = "init")]
    init_track: String,
    /// Publish a CMAF ladder (multiple quality variants) using x264/AAC.
    #[arg(long, value_enum)]
    cmaf_ladder: Option<CmafLadderPreset>,
    /// RPC URL for submitting manifest-derived observation headers.
    #[arg(long)]
    observation_rpc_url: Option<String>,
    /// Deployed EveryChannelObservationLedger address.
    #[arg(long)]
    observation_ledger: Option<String>,
    /// Witness private key for observation transactions (hex). Prefer --observation-private-key-file for services.
    #[arg(long)]
    observation_private_key: Option<String>,
    /// File containing the witness private key for observation transactions.
    #[arg(long)]
    observation_private_key_file: Option<PathBuf>,
    /// Parent observation hash to put into new headers.
    #[arg(long)]
    observation_parent_hash: Option<String>,
    /// Maximum time to wait for each observation transaction.
    #[arg(long, default_value_t = 30000)]
    observation_timeout_ms: u64,
    #[command(subcommand)]
    source: IngestSource,
}

#[derive(Parser, Debug)]
struct MoqSubscribeArgs {
    /// Output directory for HLS segments.
    #[arg(long, default_value = "./tmp/moq-hls")]
    output_dir: PathBuf,
    /// Fallback chunk duration in ms.
    #[arg(long, default_value_t = 2000)]
    chunk_ms: u64,
    /// HLS window size.
    #[arg(long, default_value_t = 6)]
    window: usize,
    /// Optional stream id override (for decryption).
    #[arg(long)]
    stream_id: Option<String>,
    /// Optional network secret (hex) for stream decryption.
    #[arg(long)]
    network_secret: Option<String>,
    /// Remote endpoint address (iroh EndpointAddr).
    #[arg(long)]
    remote: String,
    /// Optional remote endpoint address to fetch manifests from.
    /// Defaults to `--remote` when not provided.
    #[arg(long)]
    remote_manifests: Option<String>,
    /// Broadcast name to subscribe to.
    #[arg(long)]
    broadcast_name: String,
    /// Track name to subscribe to.
    #[arg(long, default_value = DEFAULT_TRACK_NAME)]
    track_name: String,
    /// Subscribe to manifest frames.
    #[arg(long)]
    subscribe_manifests: bool,
    /// Require a manifest to accept chunk data.
    #[arg(long)]
    require_manifest: bool,
    /// Track name for manifest frames.
    #[arg(long, default_value = DEFAULT_MANIFEST_TRACK_NAME)]
    manifest_track: String,
    /// Allowed manifest signer ids (comma-separated).
    #[arg(long)]
    manifest_signers: Option<String>,
    /// Maximum bytes per second to accept (anti-junk).
    #[arg(long)]
    max_bytes_per_sec: Option<u64>,
    /// Maximum burst bytes before throttling.
    #[arg(long)]
    max_bytes_burst: Option<u64>,
    /// Maximum invalid chunks before disconnect.
    #[arg(long, default_value_t = 1)]
    max_invalid_chunks: u32,
    /// Stop after writing this many segments (useful for E2E tests).
    #[arg(long)]
    stop_after: Option<u64>,
    /// Optional iroh secret key (hex).
    #[arg(long)]
    iroh_secret: Option<String>,
    /// Discovery modes to enable (comma-separated: dht, mdns, dns).
    #[arg(long)]
    discovery: Option<String>,
    /// Container mode for local HLS output.
    #[arg(long, value_enum, default_value_t = EncodeMode::Cmaf)]
    container: EncodeMode,
    /// Track name to subscribe to for CMAF init segment objects.
    #[arg(long, default_value = "init")]
    init_track: String,
    /// Subscribe to the init segment track (CMAF).
    #[arg(long)]
    subscribe_init: bool,
    /// Write raw CMAF init+segments (no HLS playlist) to `--output-dir`.
    #[arg(long)]
    raw_cmaf: bool,
}

#[derive(Parser, Debug)]
struct MoqSelftestArgs {
    /// Input TS file or URL (e.g. http://HDHR_HOST/auto/v8.1).
    input: String,
    /// Output directory for temporary chunks.
    #[arg(long, default_value = "./tmp/moq-selftest")]
    chunk_dir: PathBuf,
    /// Chunk duration in ms.
    #[arg(long, default_value_t = 2000)]
    chunk_ms: u64,
    /// Maximum number of chunks to publish/verify.
    #[arg(long, default_value_t = 8)]
    max_chunks: usize,
    /// Optional stream id override.
    #[arg(long)]
    stream_id: Option<String>,
    /// Track name override.
    #[arg(long, default_value = DEFAULT_TRACK_NAME)]
    track_name: String,
    /// Discovery modes to enable (comma-separated: dht, mdns, dns).
    #[arg(long)]
    discovery: Option<String>,
}

#[derive(Parser, Debug)]
struct DirectPublishArgs {
    /// Global stream id (used for directory/signaling). If omitted, a fresh id is generated.
    #[arg(long)]
    stream_id: Option<String>,
    /// Human-friendly title (used for directory listing).
    #[arg(long, default_value = "Live channel")]
    title: String,
    /// Optional directory base URL (e.g. https://every.channel). When set, the publisher
    /// announces the offer to `/api/announce` and polls `/api/answer`.
    #[arg(long)]
    directory_url: Option<String>,
    /// Offer TTL (ms) when announcing to the directory.
    #[arg(long, default_value_t = 20000)]
    announce_ttl_ms: u64,
    /// Output directory for temporary chunks.
    #[arg(long, default_value = "./tmp/direct-chunks")]
    chunk_dir: PathBuf,
    /// Chunk duration in ms.
    #[arg(long, default_value_t = 2000)]
    chunk_ms: u64,
    /// Maximum number of media segments to send.
    #[arg(long, default_value_t = 30)]
    max_segments: usize,
    /// Optional answer code/link to avoid interactive stdin.
    #[arg(long)]
    answer: Option<String>,
    /// How long to wait for a browser answer when using `--directory-url` or stdin (seconds).
    /// Set to 0 to wait indefinitely.
    #[arg(long, default_value_t = 900)]
    answer_timeout_secs: u64,
    /// Ingest source.
    #[command(subcommand)]
    source: IngestSource,
}

#[derive(Parser, Debug)]
struct DirectSubscribeArgs {
    /// Directory base URL (e.g. https://every.channel). Used to locate offers by stream_id
    /// and to POST answers back to the publisher.
    #[arg(long, default_value = "https://every.channel")]
    directory_url: String,
    /// Stream id to locate in the directory.
    #[arg(long)]
    stream_id: Option<String>,
    /// Direct offer link/code to use instead of looking up a stream in the directory.
    #[arg(long)]
    offer: Option<String>,
    /// Output directory for captured CMAF fragments (init+segments) and index.m3u8.
    #[arg(long, default_value = "./tmp/direct-subscribe")]
    out_dir: PathBuf,
    /// Maximum number of media segments to capture (init not counted).
    #[arg(long, default_value_t = 12)]
    max_segments: usize,
    /// Optional time limit (seconds). When set, capture stops after this duration even if `--max-segments` is not reached.
    #[arg(long)]
    duration_secs: Option<u64>,
    /// If set, remux the captured playlist to this mp4 path (best-effort, `ffmpeg -c copy`).
    #[arg(long)]
    mp4: Option<PathBuf>,
}

#[derive(Parser, Debug)]
struct WsPublishArgs {
    /// Global stream id (used for directory listing + relay instance key). If omitted, a fresh id is generated.
    #[arg(long)]
    stream_id: Option<String>,
    /// Human-friendly title (used for directory listing).
    #[arg(long, default_value = "Live channel")]
    title: String,
    /// Directory base URL (e.g. https://every.channel). Used for listing at `/api/announce`,
    /// and as the base for relay websocket URL (`/api/stream/ws`).
    #[arg(long, default_value = "https://every.channel")]
    directory_url: String,
    /// Offer TTL (ms) when announcing to the directory.
    #[arg(long, default_value_t = 20000)]
    announce_ttl_ms: u64,
    /// Output directory for temporary chunks.
    #[arg(long, default_value = "./tmp/ws-chunks")]
    chunk_dir: PathBuf,
    /// Chunk duration in ms.
    #[arg(long, default_value_t = 2000)]
    chunk_ms: u64,
    /// Maximum number of media segments to send (init not counted). Set high for "run forever" behavior.
    #[arg(long, default_value_t = 1_000_000)]
    max_segments: usize,
    /// Ingest source.
    #[command(subcommand)]
    source: IngestSource,
}

#[derive(Parser, Debug)]
struct WsSubscribeArgs {
    /// Directory base URL (e.g. https://every.channel). Used for relay websocket URL (`/api/stream/ws`).
    #[arg(long, default_value = "https://every.channel")]
    directory_url: String,
    /// Stream id to subscribe to.
    #[arg(long)]
    stream_id: String,
    /// Output directory for captured CMAF fragments (init+segments) and index.m3u8.
    #[arg(long, default_value = "./tmp/ws-subscribe")]
    out_dir: PathBuf,
    /// Maximum number of media segments to capture (init not counted).
    #[arg(long, default_value_t = 12)]
    max_segments: usize,
    /// Optional time limit (seconds). When set, capture stops after this duration even if `--max-segments` is not reached.
    #[arg(long)]
    duration_secs: Option<u64>,
    /// If set, remux the captured playlist to this mp4 path (best-effort, `ffmpeg -c copy`).
    #[arg(long)]
    mp4: Option<PathBuf>,
}

#[derive(Parser, Debug)]
struct WtPublishArgs {
    /// Relay URL (WebTransport) to connect to.
    /// Default points at moq.dev's public relay.
    #[arg(long, default_value = "https://cdn.moq.dev/anon")]
    url: String,
    /// Broadcast name to publish.
    ///
    /// This should be stable so you can share:
    /// `https://every.channel/watch?url=...&name=<broadcast>`.
    #[arg(long)]
    name: String,
    /// Input URL or file for ffmpeg (e.g. HDHomeRun `http://hdhomerun.local/auto/v4.1`).
    #[arg(long)]
    input: String,
    /// If set, transcode to H.264/AAC before fragmenting to fMP4.
    #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
    transcode: bool,
    /// ffmpeg video filter used by the transcode path.
    #[arg(long, default_value = WT_PUBLISH_VIDEO_FILTER)]
    video_filter: String,
    /// H.264 GOP/keyframe interval in frames for the transcode path.
    #[arg(long, default_value_t = WT_PUBLISH_GOP_FRAMES)]
    gop_frames: u32,
    /// fMP4 movflags used for WebTransport publishing.
    #[arg(long, default_value = WT_PUBLISH_MOVFLAGS)]
    movflags: String,
    /// Transmit fMP4 fragments directly (passthrough mode).
    /// When false, the importer may reframe into CMAF fragments.
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
    passthrough: bool,
    /// Danger: disable TLS verification for the relay.
    #[arg(long, default_value_t = false)]
    tls_disable_verify: bool,
    /// Announce this relay stream over iroh gossip control topic.
    #[arg(long, default_value_t = false)]
    control_announce: bool,
    /// Control gossip TTL (ms) for control announcements.
    #[arg(long, default_value_t = 15000)]
    control_ttl_ms: u64,
    /// Control gossip announce interval (ms).
    #[arg(long, default_value_t = 5000)]
    control_interval_ms: u64,
    /// Optional iroh secret key (hex) for control gossip endpoint identity.
    #[arg(long)]
    iroh_secret: Option<String>,
    /// Discovery modes to enable for control gossip endpoint (comma-separated: dht, mdns, dns).
    #[arg(long)]
    discovery: Option<String>,
    /// Gossip peers to connect to for control announcements (repeatable).
    #[arg(long)]
    gossip_peer: Vec<String>,
    /// Optional path to write this publisher's control endpoint address JSON.
    /// Useful for bootstrapping local control bridges without manual peer copy/paste.
    #[arg(long)]
    control_endpoint_addr_out: Option<PathBuf>,
}

#[derive(Parser, Debug)]
struct NbcBootstrapArgs {
    /// NBC watch or live URL to warm.
    #[arg(long)]
    source_url: String,
    /// Optional Chrome binary override.
    #[arg(long)]
    chrome_path: Option<PathBuf>,
    /// Optional persistent profile directory override.
    #[arg(long)]
    profile_dir: Option<PathBuf>,
    /// Optional path for a screenshot captured when interactive auth is required.
    #[arg(long)]
    screenshot_out: Option<PathBuf>,
}

#[derive(Parser, Debug)]
struct NbcWtPublishArgs {
    /// Relay URL (WebTransport) to connect to.
    #[arg(long, default_value = "https://cdn.moq.dev/anon")]
    url: String,
    /// Broadcast name to publish.
    #[arg(long)]
    name: String,
    /// NBC watch or live URL to open in Chrome.
    #[arg(long)]
    source_url: String,
    /// Transmit fMP4 fragments directly (passthrough mode).
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
    passthrough: bool,
    /// H.264 GOP/keyframe interval in frames.
    #[arg(long, default_value_t = WT_PUBLISH_GOP_FRAMES)]
    gop_frames: u32,
    /// fMP4 movflags used for WebTransport publishing.
    #[arg(long, default_value = WT_PUBLISH_MOVFLAGS)]
    movflags: String,
    /// Danger: disable TLS verification for the relay.
    #[arg(long, default_value_t = false)]
    tls_disable_verify: bool,
    /// Announce this relay stream over iroh gossip control topic.
    #[arg(long, default_value_t = false)]
    control_announce: bool,
    /// Control gossip TTL (ms) for control announcements.
    #[arg(long, default_value_t = 15000)]
    control_ttl_ms: u64,
    /// Control gossip announce interval (ms).
    #[arg(long, default_value_t = 5000)]
    control_interval_ms: u64,
    /// Optional iroh secret key (hex) for control gossip endpoint identity.
    #[arg(long)]
    iroh_secret: Option<String>,
    /// Discovery modes to enable for control gossip endpoint (comma-separated: dht, mdns, dns).
    #[arg(long)]
    discovery: Option<String>,
    /// Gossip peers to connect to for control announcements (repeatable).
    #[arg(long)]
    gossip_peer: Vec<String>,
    /// Optional path to write this publisher's control endpoint address JSON.
    #[arg(long)]
    control_endpoint_addr_out: Option<PathBuf>,
    /// Optional Chrome binary override.
    #[arg(long)]
    chrome_path: Option<PathBuf>,
    /// Optional persistent profile directory override.
    #[arg(long)]
    profile_dir: Option<PathBuf>,
}

#[derive(Parser, Debug)]
struct WtArchiveArgs {
    /// Relay URL (WebTransport) to connect to.
    #[arg(long, default_value = "https://cdn.moq.dev/anon")]
    url: String,
    /// Broadcast name to subscribe to.
    #[arg(long)]
    name: String,
    /// Output directory for CAS objects.
    #[arg(long, default_value = "./tmp/wt-archive")]
    output_dir: PathBuf,
    /// Optional manifest/index output directory.
    /// Defaults to `<output-dir>/manifests` when omitted.
    #[arg(long)]
    manifest_dir: Option<PathBuf>,
    /// Track names to archive (repeatable).
    /// When omitted, defaults to catalog+init+primary audio/video tracks.
    #[arg(long)]
    track: Vec<String>,
    /// Danger: disable TLS verification for the relay.
    #[arg(long, default_value_t = false)]
    tls_disable_verify: bool,
}

#[derive(Parser, Debug)]
struct WtArchiveServeArgs {
    /// Output directory used by `wt-archive`.
    #[arg(long, default_value = "./tmp/wt-archive")]
    output_dir: PathBuf,
    /// Optional manifest/index directory.
    /// Defaults to `<output-dir>/manifests` when omitted.
    #[arg(long)]
    manifest_dir: Option<PathBuf>,
    /// TCP listen address for HTTP replay endpoints.
    #[arg(long, default_value = "0.0.0.0:7788")]
    listen: String,
}

#[derive(Parser, Debug)]
struct ControlAnnounceArgs {
    /// Stable stream id to announce.
    #[arg(long)]
    stream_id: String,
    /// Optional human title. Defaults to stream id.
    #[arg(long)]
    title: Option<String>,
    /// Announcement TTL in milliseconds.
    #[arg(long, default_value_t = 15000)]
    ttl_ms: u64,
    /// Announcement interval in milliseconds.
    #[arg(long, default_value_t = 5000)]
    interval_ms: u64,
    /// Relay URL for relay transport advertisement.
    #[arg(long)]
    relay_url: Option<String>,
    /// Relay broadcast name for relay transport advertisement.
    #[arg(long)]
    relay_broadcast: Option<String>,
    /// Relay track name for relay transport advertisement.
    #[arg(long, default_value = "video0.m4s")]
    relay_track: String,
    /// Direct iroh endpoint address/id for direct transport advertisement.
    /// Defaults to this process endpoint id when `--direct-broadcast` is set.
    #[arg(long)]
    direct_endpoint: Option<String>,
    /// Direct iroh broadcast name for direct transport advertisement.
    #[arg(long)]
    direct_broadcast: Option<String>,
    /// Direct iroh track name for direct transport advertisement.
    #[arg(long, default_value = DEFAULT_TRACK_NAME)]
    direct_track: String,
    /// Optional iroh secret key (hex) for control gossip endpoint identity.
    #[arg(long)]
    iroh_secret: Option<String>,
    /// Discovery modes to enable (comma-separated: dht, mdns, dns).
    #[arg(long)]
    discovery: Option<String>,
    /// Gossip peers to connect to (repeatable).
    #[arg(long)]
    gossip_peer: Vec<String>,
}

#[derive(Parser, Debug)]
struct ControlListenArgs {
    /// Optional iroh secret key (hex) for control gossip endpoint identity.
    #[arg(long)]
    iroh_secret: Option<String>,
    /// Discovery modes to enable (comma-separated: dht, mdns, dns).
    #[arg(long)]
    discovery: Option<String>,
    /// Gossip peers to connect to (repeatable).
    #[arg(long)]
    gossip_peer: Vec<String>,
    /// Exit after the first announcement.
    #[arg(long, default_value_t = false)]
    once: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ControlTransportPreference {
    /// Prefer iroh direct transport when both are available.
    DirectFirst,
    /// Prefer relay transport when both are available.
    RelayFirst,
    /// Accept only iroh direct transport.
    DirectOnly,
    /// Accept only relay transport.
    RelayOnly,
}

#[derive(Parser, Debug)]
struct ControlResolveArgs {
    /// Stable stream id to resolve from control announcements.
    #[arg(long)]
    stream_id: String,
    /// Transport selection preference.
    #[arg(long, value_enum, default_value_t = ControlTransportPreference::DirectFirst)]
    prefer: ControlTransportPreference,
    /// Maximum wall-clock wait for a matching announcement (ms).
    #[arg(long, default_value_t = 30000)]
    timeout_ms: u64,
    /// Maximum accepted staleness from updated_unix_ms (ms).
    #[arg(long, default_value_t = 30000)]
    max_age_ms: u64,
    /// Include the full raw announcement in output JSON.
    #[arg(long, default_value_t = false)]
    include_announcement: bool,
    /// Optional iroh secret key (hex) for control gossip endpoint identity.
    #[arg(long)]
    iroh_secret: Option<String>,
    /// Discovery modes to enable (comma-separated: dht, mdns, dns).
    #[arg(long)]
    discovery: Option<String>,
    /// Gossip peers to connect to (repeatable).
    #[arg(long)]
    gossip_peer: Vec<String>,
}

#[derive(Parser, Debug)]
struct ControlBridgeWebArgs {
    /// Directory base URL (e.g. https://every.channel).
    #[arg(long, default_value = "https://every.channel")]
    directory_url: String,
    /// Optional bearer token for `/api/stream-upsert`.
    /// Falls back to `EVERY_CHANNEL_WEB_UPSERT_TOKEN` when omitted.
    #[arg(long)]
    auth_token: Option<String>,
    /// Maximum wall-clock wait for control announcements before exit (ms).
    /// Set to 0 to run forever.
    #[arg(long, default_value_t = 0)]
    timeout_ms: u64,
    /// Maximum accepted staleness from updated_unix_ms (ms).
    #[arg(long, default_value_t = 30000)]
    max_age_ms: u64,
    /// Optional stream id prefix filter.
    #[arg(long)]
    stream_prefix: Option<String>,
    /// Exit after the first successful upsert.
    #[arg(long, default_value_t = false)]
    once: bool,
    /// Optional iroh secret key (hex) for control gossip endpoint identity.
    #[arg(long)]
    iroh_secret: Option<String>,
    /// Discovery modes to enable (comma-separated: dht, mdns, dns).
    #[arg(long)]
    discovery: Option<String>,
    /// Gossip peers to connect to (repeatable).
    #[arg(long)]
    gossip_peer: Vec<String>,
}

#[derive(Subcommand, Debug)]
enum IngestSource {
    /// Ingest from an HDHomeRun device.
    Hdhr {
        /// Hostname or IP (e.g. 192.168.1.10). If omitted, auto-discover.
        #[arg(long)]
        host: Option<String>,
        /// Device ID (uses <deviceid>.local when host is omitted).
        #[arg(long)]
        device_id: Option<String>,
        /// Channel number (e.g. 8.1).
        #[arg(long)]
        channel: Option<String>,
        /// Channel name (e.g. KQED).
        #[arg(long)]
        name: Option<String>,
        /// Prefer mDNS (hdhomerun.local) before UDP discovery.
        #[arg(long)]
        prefer_mdns: bool,
    },
    /// Ingest from an HLS playlist URL.
    Hls {
        /// HLS playlist URL.
        url: String,
        /// Ingest mode (passthrough, remux, transcode).
        #[arg(long, value_enum, default_value_t = HlsMode::Passthrough)]
        mode: HlsMode,
    },
    /// Ingest from a Linux DVB device.
    LinuxDvb {
        /// DVB adapter index.
        #[arg(long, default_value_t = 0)]
        adapter: u32,
        /// DVR device index.
        #[arg(long, default_value_t = 0)]
        dvr: u32,
        /// Optional tune command (repeat for each arg).
        #[arg(long)]
        tune_cmd: Vec<String>,
        /// Optional tune wait (ms).
        #[arg(long)]
        tune_wait_ms: Option<u64>,
    },
    /// Ingest from a raw TS file or URL.
    Ts {
        /// Input TS file or URL.
        input: String,
    },
}

fn main() -> Result<()> {
    // rustls 0.23 requires an explicit process-level CryptoProvider when multiple providers
    // are enabled. We install a default provider once at startup.
    if rustls::crypto::CryptoProvider::get_default().is_none() {
        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
        let _ = rustls::crypto::ring::default_provider().install_default();
    }

    // Keep stdout reserved for machine-readable output (endpoint addr, etc).
    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
    tracing_subscriber::fmt()
        .with_writer(std::io::stderr)
        .with_env_filter(filter)
        .init();
    let cli = Cli::parse();

    match cli.command {
        Commands::Ingest(args) => ingest(args)?,
        Commands::MoqPublish(args) => run_async(moq_publish(args))?,
        Commands::MoqSubscribe(args) => run_async(moq_subscribe(args))?,
        Commands::MoqSelftest(args) => run_async(moq_selftest(args))?,
        Commands::DirectPublish(args) => run_async(direct_publish(args))?,
        Commands::DirectSubscribe(args) => run_async(direct_subscribe(args))?,
        Commands::WsPublish(args) => run_async(ws_publish(args))?,
        Commands::WsSubscribe(args) => run_async(ws_subscribe(args))?,
        Commands::WtPublish(args) => run_async(wt_publish(args))?,
        Commands::NbcBootstrap(args) => nbc_bootstrap(args)?,
        Commands::NbcWtPublish(args) => run_async(nbc_wt_publish(args))?,
        Commands::WtArchive(args) => run_async(wt_archive(args))?,
        Commands::WtArchiveServe(args) => run_async(wt_archive_serve(args))?,
        Commands::ControlAnnounce(args) => run_async(control_announce(args))?,
        Commands::ControlListen(args) => run_async(control_listen(args))?,
        Commands::ControlResolve(args) => run_async(control_resolve(args))?,
        Commands::ControlBridgeWeb(args) => run_async(control_bridge_web(args))?,
    }

    Ok(())
}

fn run_async<F>(future: F) -> Result<()>
where
    F: Future<Output = Result<()>>,
{
    let runtime = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()?;
    runtime.block_on(future)
}

fn ingest(args: IngestArgs) -> Result<()> {
    fs::create_dir_all(&args.chunk_dir)
        .with_context(|| format!("failed to create {}", args.chunk_dir.display()))?;

    let deterministic = deterministic_enabled(args.deterministic);
    let (source, _needs_transcode): (Box<dyn StreamSource>, bool) = match args.source {
        IngestSource::Hls { url, mut mode } => {
            if deterministic {
                mode = HlsMode::Transcode;
            }
            (Box::new(HlsSource { url, mode }), false)
        }
        IngestSource::Hdhr {
            host,
            device_id,
            channel,
            name,
            prefer_mdns,
        } => (
            Box::new(HdhrSource {
                host,
                device_id,
                channel,
                name,
                prefer_mdns,
            }),
            deterministic,
        ),
        IngestSource::LinuxDvb {
            adapter,
            dvr,
            tune_cmd,
            tune_wait_ms,
        } => (
            Box::new(LinuxDvbSource {
                adapter,
                dvr,
                tune_cmd,
                tune_wait_ms,
            }),
            deterministic,
        ),
        IngestSource::Ts { input } => (Box::new(TsSource { input }), deterministic),
    };

    let source_id = source.source_id();
    let reader = source.open_stream()?;
    let encoder_profile_id = if deterministic {
        "deterministic-h264-aac".to_string()
    } else {
        // NOTE: We still normalize into CMAF for interoperability, even when determinism is off.
        "h264-aac".to_string()
    };

    // CMAF-only: segment into init.mp4 + segment_*.m4s via ffmpeg.
    let out_dir = args.chunk_dir.join("cmaf");
    let (init_path, segments) = chunk_stream_cmaf_ffmpeg(
        reader,
        &out_dir,
        args.chunk_ms,
        args.max_chunks.unwrap_or(usize::MAX),
        deterministic,
    )?;
    let mut chunk_hashes = Vec::with_capacity(1 + segments.len());
    // Chunk index 0 is always init.mp4; segments are 1..N.
    let (init_bytes, init_hash) = read_chunk_bytes_and_hash(&init_path)?;
    chunk_hashes.push(init_hash);
    let mut segment_meta = Vec::with_capacity(segments.len());
    for seg_path in &segments {
        let (bytes, hash) = read_chunk_bytes_and_hash(seg_path)?;
        chunk_hashes.push(hash.clone());
        segment_meta.push((seg_path.clone(), bytes, hash));
    }
    let chunk_start_index = 0u64;
    let created_unix_ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64;
    let relay = FileRelay::new(args.relay_dir);
    let track = TrackName {
        namespace: "every.channel".to_string(),
        name: match args.stream_id {
            Some(stream_id) => stream_id,
            None => {
                default_stream_id_for_source(source.as_ref(), format!("chunk-{}ms", args.chunk_ms))?
            }
        },
    };
    let stream_id = StreamId(track.name.clone());
    let manifest_payload = build_manifest(
        stream_id,
        format!("epoch-{created_unix_ms}"),
        args.chunk_ms,
        chunk_start_index,
        encoder_profile_id,
        created_unix_ms,
        vec![StreamMetadata {
            key: "source_kind".to_string(),
            value: source_id.kind.clone(),
        }],
        chunk_hashes,
    )?;
    let manifest_id = manifest_payload.manifest_id.clone();
    let manifest_path = args.chunk_dir.join("manifest.json");
    fs::write(
        &manifest_path,
        serde_json::to_vec_pretty(&manifest_payload)?,
    )?;

    let network_secret = parse_network_secret(args.network_secret)?;

    // Publish init at chunk_index 0 to avoid colliding with segment_000000.
    publish_chunk_file(
        &relay,
        &track,
        TsChunk {
            index: 0,
            path: init_path,
            timing: ec_chopper::ChunkTiming {
                chunk_index: 0,
                chunk_start_27mhz: None,
                chunk_duration_27mhz: 0,
                utc_start_unix: None,
                sync_status: "init".to_string(),
            },
        },
        "video/mp4",
        Some(init_bytes),
        network_secret.as_deref(),
        Some(&manifest_id),
    )?;
    for (i, (seg_path, bytes, _hash)) in segment_meta.into_iter().enumerate() {
        let chunk_index = (i as u64) + 1;
        publish_chunk_file(
            &relay,
            &track,
            TsChunk {
                index: chunk_index,
                path: seg_path,
                timing: ec_chopper::ChunkTiming {
                    chunk_index,
                    chunk_start_27mhz: None,
                    chunk_duration_27mhz: args.chunk_ms * 27_000,
                    utc_start_unix: None,
                    sync_status: "cmaf".to_string(),
                },
            },
            "video/iso.segment",
            Some(bytes),
            network_secret.as_deref(),
            Some(&manifest_id),
        )?;
    }

    Ok(())
}

fn publish_chunk_file(
    relay: &FileRelay,
    track: &TrackName,
    chunk: TsChunk,
    content_type: &str,
    data_override: Option<Vec<u8>>,
    network_secret: Option<&[u8]>,
    manifest_id: Option<&str>,
) -> Result<()> {
    let (data, chunk_hash) = match data_override {
        Some(bytes) => {
            let hash = blake3::hash(&bytes).to_hex().to_string();
            (bytes, hash)
        }
        None => read_chunk_bytes_and_hash(&chunk.path)?,
    };
    let object = build_object(
        chunk,
        data,
        chunk_hash,
        None,
        network_secret,
        manifest_id,
        content_type,
        &track.name,
    )?;
    relay.write_object(
        track,
        GroupId(
            object
                .meta
                .timing
                .as_ref()
                .map(|t| t.chunk_index)
                .unwrap_or(0),
        ),
        ObjectId(
            object
                .meta
                .timing
                .as_ref()
                .map(|t| t.chunk_index)
                .unwrap_or(0),
        ),
        &object,
    )
}

fn chunk_stream_cmaf_ffmpeg(
    mut reader: Box<dyn Read + Send>,
    out_dir: &std::path::Path,
    chunk_ms: u64,
    max_segments: usize,
    deterministic: bool,
) -> Result<(std::path::PathBuf, Vec<std::path::PathBuf>)> {
    let _ = fs::remove_dir_all(out_dir);
    fs::create_dir_all(out_dir)
        .with_context(|| format!("failed to create {}", out_dir.display()))?;

    let profile = if deterministic {
        Some(ec_chopper::deterministic_h264_profile())
    } else {
        // For now, keep encoder args stable even when determinism is off.
        Some(ec_chopper::deterministic_h264_profile())
    };

    let mut cmd = Command::new("ffmpeg");
    cmd.current_dir(out_dir);
    cmd.arg("-hide_banner")
        .arg("-loglevel")
        .arg("error")
        .arg("-nostdin")
        .arg("-y")
        .arg("-i")
        .arg("pipe:0")
        // Keep stream mapping predictable.
        .arg("-map")
        .arg("0:v:0")
        .arg("-map")
        .arg("0:a:0?")
        .arg("-sn")
        .arg("-dn")
        .arg("-map_metadata")
        .arg("-1")
        // Reduce opportunities for non-deterministic scheduling in filters/decoders.
        .arg("-filter_threads")
        .arg("1")
        .arg("-filter_complex_threads")
        .arg("1")
        .arg("-threads")
        .arg("1");

    if let Some(profile) = profile {
        for arg in ec_chopper::ffmpeg_profile_args(&profile) {
            cmd.arg(arg);
        }
    }

    let seg_time = format!("{:.3}", chunk_ms as f64 / 1000.0);
    cmd.arg("-f")
        .arg("hls")
        .arg("-hls_time")
        .arg(seg_time)
        .arg("-hls_list_size")
        .arg("0")
        .arg("-hls_segment_type")
        .arg("fmp4")
        .arg("-hls_flags")
        .arg("independent_segments")
        .arg("-hls_fmp4_init_filename")
        .arg("init.mp4")
        .arg("-hls_segment_filename")
        .arg("segment_%06d.m4s")
        .arg("index.m3u8")
        .stdin(Stdio::piped())
        .stdout(Stdio::null())
        .stderr(Stdio::inherit());

    let mut child = cmd.spawn().with_context(|| "failed to spawn ffmpeg")?;
    let mut stdin = child
        .stdin
        .take()
        .ok_or_else(|| anyhow!("ffmpeg stdin unavailable"))?;
    let writer = std::thread::spawn(move || -> Result<()> {
        std::io::copy(&mut reader, &mut stdin)?;
        Ok(())
    });

    let init_path = out_dir.join("init.mp4");
    wait_for_stable_file(&init_path, Duration::from_secs(20))?;

    let mut segments = Vec::new();
    for i in 0..max_segments {
        let seg_path = out_dir.join(format!("segment_{i:06}.m4s"));
        match wait_for_stable_file(&seg_path, Duration::from_secs(30)) {
            Ok(()) => segments.push(seg_path),
            Err(err) => {
                // If ffmpeg ended cleanly, stop; otherwise bubble the error.
                if let Ok(Some(status)) = child.try_wait() {
                    if status.success() {
                        break;
                    }
                    return Err(anyhow!("ffmpeg exited with {status} ({err:#})"));
                }
                return Err(err);
            }
        }
    }

    let _ = child.kill();
    let _ = child.wait();
    let _ = writer.join();

    Ok((init_path, segments))
}

fn parse_network_secret(value: Option<String>) -> Result<Option<Vec<u8>>> {
    let value = value.or_else(|| std::env::var("EVERY_CHANNEL_NETWORK_SECRET").ok());
    let Some(value) = value else { return Ok(None) };
    let bytes = hex::decode(value).context("network secret must be hex")?;
    Ok(Some(bytes))
}

fn deterministic_enabled(flag: bool) -> bool {
    if flag {
        return true;
    }
    std::env::var("EVERY_CHANNEL_DETERMINISTIC")
        .ok()
        .map(|value| {
            let value = value.trim().to_ascii_lowercase();
            value == "1" || value == "true" || value == "yes" || value == "on"
        })
        .unwrap_or(false)
}

fn default_stream_id_for_source(
    source: &dyn StreamSource,
    profile: impl Into<String>,
) -> Result<String> {
    let source_id = source.source_id();
    let profile = profile.into();
    let stream_key = match source.broadcast_id()? {
        Some(broadcast) => StreamKey {
            version: 1,
            broadcast: Some(broadcast),
            source: None,
            profile: Some(profile.clone()),
            variant: None,
        },
        None => StreamKey {
            version: 1,
            broadcast: None,
            source: Some(source_id),
            profile: Some(profile),
            variant: None,
        },
    };

    Ok(stream_key.to_stream_id().0)
}

fn read_chunk_bytes_and_hash(path: &std::path::Path) -> Result<(Vec<u8>, String)> {
    let data = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
    let hash = blake3::hash(&data).to_hex().to_string();
    Ok((data, hash))
}

fn collect_manifest_signatures(
    manifest_id: &str,
    body: &ManifestBody,
) -> Result<Vec<ec_core::ManifestSignature>> {
    let mut signatures = Vec::new();
    if let Some(keypair) = load_manifest_keypair_from_env().map_err(|err| anyhow!(err))? {
        signatures.push(sign_manifest_id(manifest_id, &keypair));
    }
    if let Some(keypair) = load_ethereum_manifest_keypair_from_env().map_err(|err| anyhow!(err))? {
        signatures.push(sign_manifest_body_eip712(body, &keypair).map_err(|err| anyhow!(err))?);
    }
    Ok(signatures)
}

fn build_manifest(
    stream_id: StreamId,
    epoch_id: impl Into<String>,
    chunk_duration_ms: u64,
    chunk_start_index: u64,
    encoder_profile_id: impl Into<String>,
    created_unix_ms: u64,
    metadata: Vec<StreamMetadata>,
    chunk_hashes: Vec<String>,
) -> Result<Manifest> {
    let body = build_manifest_body_for_chunks(
        stream_id,
        epoch_id,
        chunk_duration_ms,
        chunk_start_index,
        encoder_profile_id,
        created_unix_ms,
        metadata,
        &chunk_hashes,
    )?;
    let manifest_id = body.manifest_id()?;
    let signatures = collect_manifest_signatures(&manifest_id, &body)?;
    let mut manifest = Manifest {
        body,
        manifest_id,
        signatures,
        commitments: Vec::new(),
    };
    manifest.commitments = manifest_commitments(&manifest).map_err(|err| anyhow!("{err}"))?;
    Ok(manifest)
}

async fn submit_manifest_observation(
    sink: Option<&ObservationSink>,
    manifest: &Manifest,
    broadcast_name: &str,
    track_name: &str,
    manifest_track: &str,
) -> Result<()> {
    let Some(sink) = sink else {
        return Ok(());
    };

    let locator = ManifestObservationLocator {
        transport: "moq-publish".to_string(),
        broadcast_name: broadcast_name.to_string(),
        track_name: track_name.to_string(),
        manifest_track: manifest_track.to_string(),
        stream_id: manifest.body.stream_id.0.clone(),
        epoch_id: manifest.body.epoch_id.clone(),
        manifest_id: manifest.manifest_id.clone(),
    };
    let submitted = sink.submit_manifest(manifest, locator).await?;
    eprintln!(
        "observation submitted: observation_hash={} slot_hash={} stream_hash={} epoch_hash={} data_root={} locator_hash={} sequence={}",
        submitted.observation_hash,
        submitted.slot_hash,
        submitted.stream_hash,
        submitted.epoch_hash,
        submitted.data_root,
        submitted.locator_hash,
        submitted.sequence
    );
    Ok(())
}

#[derive(Debug, Clone)]
struct CmafVariantSpec {
    id: String,
    width: u32,
    height: u32,
    video_bitrate_kbps: u32,
}

fn cmaf_ladder_variants(preset: CmafLadderPreset) -> Vec<CmafVariantSpec> {
    match preset {
        CmafLadderPreset::Hd3 => vec![
            CmafVariantSpec {
                id: "1080p".to_string(),
                width: 1920,
                height: 1080,
                video_bitrate_kbps: 6000,
            },
            CmafVariantSpec {
                id: "720p".to_string(),
                width: 1280,
                height: 720,
                video_bitrate_kbps: 3000,
            },
            CmafVariantSpec {
                id: "480p".to_string(),
                width: 854,
                height: 480,
                video_bitrate_kbps: 1200,
            },
        ],
    }
}

fn sanitize_component(value: &str) -> String {
    value
        .chars()
        .map(|c| match c {
            'a'..='z' | '0'..='9' | '-' | '_' | '/' => c,
            'A'..='Z' => c.to_ascii_lowercase(),
            _ => '_',
        })
        .collect()
}

fn derive_variant_stream_id(base_stream_id: &str, variant_id: &str) -> String {
    // Match StreamKey encoding style (`variant-...`) without requiring we reconstruct StreamKey.
    let v = sanitize_component(variant_id);
    format!("{}/variant-{}", base_stream_id.trim_end_matches('/'), v)
}

fn build_multi_variant_manifest(
    base_stream_id: StreamId,
    epoch_id: String,
    chunk_duration_ms: u64,
    chunk_start_index: u64,
    encoder_profile_id: String,
    created_unix_ms: u64,
    metadata: Vec<StreamMetadata>,
    variants: &[CmafVariantSpec],
    variant_chunk_start_index: u64,
    per_variant_hash: Vec<(String, String)>,
) -> Result<Manifest> {
    let mut entries = Vec::new();
    for variant in variants {
        let Some((_, hash)) = per_variant_hash.iter().find(|(id, _)| id == &variant.id) else {
            return Err(anyhow!("missing hash for variant {}", variant.id));
        };
        let chunk_hashes = vec![hash.clone()];
        let merkle_root =
            ec_core::merkle_root_from_hashes(&chunk_hashes).map_err(|err| anyhow!("{err}"))?;
        let stream_id = StreamId(derive_variant_stream_id(&base_stream_id.0, &variant.id));
        entries.push(ManifestVariant {
            variant_id: variant.id.clone(),
            stream_id,
            chunk_start_index: variant_chunk_start_index,
            total_chunks: 1,
            merkle_root,
            chunk_hashes,
            metadata: vec![
                StreamMetadata {
                    key: "width".to_string(),
                    value: variant.width.to_string(),
                },
                StreamMetadata {
                    key: "height".to_string(),
                    value: variant.height.to_string(),
                },
                StreamMetadata {
                    key: "video_bitrate_kbps".to_string(),
                    value: variant.video_bitrate_kbps.to_string(),
                },
            ],
        });
    }
    entries.sort_by(|a, b| a.variant_id.cmp(&b.variant_id));
    let roots = entries
        .iter()
        .map(|v| v.merkle_root.clone())
        .collect::<Vec<_>>();
    let body_root = ec_core::merkle_root_from_hashes(&roots).map_err(|err| anyhow!("{err}"))?;
    let body = ec_core::ManifestBody {
        stream_id: base_stream_id,
        epoch_id,
        chunk_duration_ms,
        total_chunks: 1,
        chunk_start_index,
        encoder_profile_id,
        merkle_root: body_root,
        created_unix_ms,
        metadata,
        chunk_hashes: Vec::new(),
        variants: Some(entries),
    };
    let manifest_id = body.manifest_id()?;
    let signatures = collect_manifest_signatures(&manifest_id, &body)?;
    let mut manifest = Manifest {
        body,
        manifest_id,
        signatures,
        commitments: Vec::new(),
    };
    manifest.commitments = manifest_commitments(&manifest).map_err(|err| anyhow!("{err}"))?;
    Ok(manifest)
}

struct EpochBuffer {
    capacity: usize,
    chunks: Vec<TsChunk>,
    data: Vec<Option<Vec<u8>>>,
    hashes: Vec<String>,
    start_index: Option<u64>,
}

impl EpochBuffer {
    fn new(capacity: usize) -> Self {
        Self {
            capacity: capacity.max(1),
            chunks: Vec::new(),
            data: Vec::new(),
            hashes: Vec::new(),
            start_index: None,
        }
    }

    fn push(&mut self, chunk: TsChunk, data: Option<Vec<u8>>, hash: String) {
        if self.start_index.is_none() {
            self.start_index = Some(chunk.timing.chunk_index);
        }
        self.chunks.push(chunk);
        self.data.push(data);
        self.hashes.push(hash);
    }

    fn is_full(&self) -> bool {
        self.chunks.len() >= self.capacity
    }

    fn is_empty(&self) -> bool {
        self.chunks.is_empty()
    }

    fn start_index(&self) -> u64 {
        self.start_index.unwrap_or(0)
    }

    fn take(&mut self) -> (Vec<TsChunk>, Vec<Option<Vec<u8>>>, Vec<String>) {
        self.start_index = None;
        let chunks = std::mem::take(&mut self.chunks);
        let data = std::mem::take(&mut self.data);
        let hashes = std::mem::take(&mut self.hashes);
        (chunks, data, hashes)
    }
}

fn parse_manifest_allowlist(value: Option<&str>) -> Option<HashSet<String>> {
    let value = value?.trim();
    if value.is_empty() {
        return None;
    }
    let set = value
        .split(|c: char| c == ',' || c == ';' || c.is_whitespace())
        .filter_map(|token| {
            let trimmed = token.trim();
            if trimmed.is_empty() {
                None
            } else {
                Some(trimmed.to_string())
            }
        })
        .collect::<HashSet<_>>();
    if set.is_empty() {
        None
    } else {
        Some(set)
    }
}

fn validate_manifest(manifest: &Manifest, allowlist: Option<&HashSet<String>>) -> bool {
    let body_id = match manifest.body.manifest_id() {
        Ok(id) => id,
        Err(_) => return false,
    };
    if body_id != manifest.manifest_id {
        return false;
    }
    if let Some(variants) = manifest.body.variants.as_ref().filter(|v| !v.is_empty()) {
        // Multi-variant: validate each variant and ensure body merkle_root commits to per-variant roots.
        let mut roots = Vec::with_capacity(variants.len());
        for variant in variants {
            if variant.chunk_hashes.len() != variant.total_chunks as usize {
                return false;
            }
            match ec_core::merkle_root_from_hashes(&variant.chunk_hashes) {
                Ok(root) if root == variant.merkle_root => {}
                _ => return false,
            }
            roots.push((variant.variant_id.clone(), variant.merkle_root.clone()));
        }
        roots.sort_by(|a, b| a.0.cmp(&b.0));
        let ordered = roots.into_iter().map(|(_, root)| root).collect::<Vec<_>>();
        match ec_core::merkle_root_from_hashes(&ordered) {
            Ok(root) if root == manifest.body.merkle_root => {}
            _ => return false,
        }
    } else if !manifest.body.chunk_hashes.is_empty() {
        if manifest.body.chunk_hashes.len() != manifest.body.total_chunks as usize {
            return false;
        }
        match ec_core::merkle_root_from_hashes(&manifest.body.chunk_hashes) {
            Ok(root) if root == manifest.body.merkle_root => {}
            _ => return false,
        }
    }
    if !matches!(manifest_commitments_match(manifest), Ok(true)) {
        return false;
    }

    if let Some(allowlist) = allowlist {
        return manifest.signatures.iter().any(|sig| {
            verify_manifest_signature_with_body(&manifest.manifest_id, &manifest.body, sig)
                && allowlist.contains(&sig.signer_id)
        });
    }

    if manifest.signatures.is_empty() {
        // Unsigned manifests are only acceptable when they include full hashes for verification.
        return !manifest.body.chunk_hashes.is_empty()
            || manifest
                .body
                .variants
                .as_ref()
                .is_some_and(|v| !v.is_empty());
    }
    manifest
        .signatures
        .iter()
        .any(|sig| verify_manifest_signature_with_body(&manifest.manifest_id, &manifest.body, sig))
}

fn strip_init_suffix(key_id: &str) -> &str {
    key_id.strip_suffix("/init").unwrap_or(key_id)
}

fn manifest_hash_for_chunk(
    manifest: &Manifest,
    stream_id: &str,
    chunk_index: u64,
) -> Option<String> {
    if let Some(variants) = manifest.body.variants.as_ref() {
        let variant = variants.iter().find(|v| v.stream_id.0 == stream_id)?;
        if chunk_index < variant.chunk_start_index {
            return None;
        }
        let offset = (chunk_index - variant.chunk_start_index) as usize;
        return variant.chunk_hashes.get(offset).cloned();
    }

    // Legacy single-variant manifests.
    if manifest.body.stream_id.0 != stream_id {
        return None;
    }
    if chunk_index < manifest.body.chunk_start_index {
        return None;
    }
    let offset = (chunk_index - manifest.body.chunk_start_index) as usize;
    manifest.body.chunk_hashes.get(offset).cloned()
}

fn manifest_covers_stream_index(manifest: &Manifest, stream_id: &str, chunk_index: u64) -> bool {
    if let Some(variants) = manifest.body.variants.as_ref() {
        let Some(variant) = variants.iter().find(|v| v.stream_id.0 == stream_id) else {
            return false;
        };
        if chunk_index < variant.chunk_start_index {
            return false;
        }
        let end = variant
            .chunk_start_index
            .saturating_add(variant.total_chunks as u64);
        return chunk_index < end;
    }

    if manifest.body.stream_id.0 != stream_id {
        return false;
    }
    if chunk_index < manifest.body.chunk_start_index {
        return false;
    }
    let end = manifest
        .body
        .chunk_start_index
        .saturating_add(manifest.body.total_chunks as u64);
    chunk_index < end
}

fn find_manifest_for_stream_index(
    store: &HashMap<String, Manifest>,
    stream_id: &str,
    chunk_index: u64,
) -> Option<Manifest> {
    // Prefer the latest manifest whose range covers the index for this stream.
    let mut best: Option<&Manifest> = None;
    for manifest in store.values() {
        if !manifest_covers_stream_index(manifest, stream_id, chunk_index) {
            continue;
        }
        match best {
            None => best = Some(manifest),
            Some(current) => {
                if manifest.body.chunk_start_index >= current.body.chunk_start_index {
                    best = Some(manifest);
                }
            }
        }
    }
    best.cloned()
}

fn build_object(
    chunk: TsChunk,
    data: Vec<u8>,
    chunk_hash: String,
    chunk_proof: Option<Vec<String>>,
    network_secret: Option<&[u8]>,
    manifest_id: Option<&str>,
    content_type: &str,
    key_id: &str,
) -> Result<ObjectPayload> {
    let created_unix_ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64;

    let timing = TimingMeta {
        chunk_index: chunk.timing.chunk_index,
        chunk_start_27mhz: chunk.timing.chunk_start_27mhz.unwrap_or(0),
        chunk_duration_27mhz: chunk.timing.chunk_duration_27mhz,
        utc_start_unix: chunk.timing.utc_start_unix,
        sync_status: chunk.timing.sync_status.clone(),
    };

    let encrypted = encrypt_stream_data(key_id, chunk.timing.chunk_index, &data, network_secret);
    let meta = ObjectMeta {
        created_unix_ms,
        content_type: content_type.to_string(),
        size_bytes: encrypted.ciphertext.len() as u64,
        timing: Some(timing),
        encryption: Some(ec_moq::EncryptionMeta {
            alg: encrypted.alg.to_string(),
            key_id: key_id.to_string(),
            nonce_hex: hex::encode(encrypted.nonce),
        }),
        chunk_hash: Some(chunk_hash),
        chunk_hash_alg: Some("blake3".to_string()),
        chunk_proof,
        chunk_proof_alg: Some(MERKLE_PROOF_ALG_BLAKE3.to_string()),
        manifest_id: manifest_id.map(|value| value.to_string()),
    };

    Ok(ObjectPayload {
        meta,
        data: encrypted.ciphertext,
    })
}

fn flush_epoch_publish(
    publish_set: &mut MoqPublishSet,
    object_track_name: &str,
    manifest_track_name: &str,
    publish_chunks: bool,
    publish_manifests: bool,
    epoch_buffer: &mut EpochBuffer,
    stream_id_value: &StreamId,
    chunk_ms: u64,
    encoder_profile_id: &str,
    source_kind: &str,
    network_secret: Option<&[u8]>,
    segment_content_type: &str,
    key_id: &str,
    object_sequence: &mut u64,
    manifest_sequence: &mut u64,
    announce_tx: Option<&tokio::sync::mpsc::UnboundedSender<ManifestSummary>>,
) -> Result<Option<Manifest>> {
    if epoch_buffer.is_empty() {
        return Ok(None);
    }

    let (chunks, datas, hashes) = epoch_buffer.take();
    let start_index = chunks
        .first()
        .map(|chunk| chunk.timing.chunk_index)
        .unwrap_or_else(|| epoch_buffer.start_index());
    let created_unix_ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64;

    let mut manifest_id = None;
    let mut published_manifest = None;
    if publish_manifests {
        let manifest = build_manifest(
            stream_id_value.clone(),
            format!("epoch-{start_index}"),
            chunk_ms,
            start_index,
            encoder_profile_id.to_string(),
            created_unix_ms,
            vec![StreamMetadata {
                key: "source_kind".to_string(),
                value: source_kind.to_string(),
            }],
            hashes.clone(),
        )?;
        manifest_id = Some(manifest.manifest_id.clone());
        publish_set.publish_manifest(manifest_track_name, *manifest_sequence, &manifest)?;
        *manifest_sequence += 1;
        if let Some(tx) = announce_tx {
            let _ = tx.send(manifest.summary());
        }
        published_manifest = Some(manifest);
    }

    // Compute per-chunk Merkle proofs so subscribers can validate membership
    // even if future manifests omit the full chunk hash list.
    let mut proofs = Vec::with_capacity(hashes.len());
    for (offset, _) in hashes.iter().enumerate() {
        proofs.push(merkle_proof_for_index(&hashes, offset)?);
    }

    if publish_chunks {
        for (((chunk, data), hash), proof) in chunks.into_iter().zip(datas).zip(hashes).zip(proofs)
        {
            let Some(data) = data else {
                return Err(anyhow!("missing chunk data for publish"));
            };
            let object = build_object(
                chunk,
                data,
                hash,
                Some(proof),
                network_secret,
                manifest_id.as_deref(),
                segment_content_type,
                key_id,
            )?;
            tracing::info!(
                "publish segment chunk_index={} bytes={} content_type={}",
                object
                    .meta
                    .timing
                    .as_ref()
                    .map(|t| t.chunk_index)
                    .unwrap_or(0),
                object.data.len(),
                object.meta.content_type
            );
            publish_set.publish_object(object_track_name, GroupId(*object_sequence), object)?;
            *object_sequence += 1;
        }
    }

    Ok(published_manifest)
}

#[cfg(test)]
mod tests {
    use super::*;
    use ec_core::BroadcastId;
    use std::io::Cursor;

    #[test]
    fn parse_manifest_allowlist_splits_and_trims() {
        let set = parse_manifest_allowlist(Some(" a,b ; c\t d  ")).unwrap();
        assert!(set.contains("a"));
        assert!(set.contains("b"));
        assert!(set.contains("c"));
        assert!(set.contains("d"));
    }

    #[test]
    fn deterministic_enabled_reads_env() {
        let prev = std::env::var("EVERY_CHANNEL_DETERMINISTIC").ok();
        std::env::set_var("EVERY_CHANNEL_DETERMINISTIC", "true");
        assert!(deterministic_enabled(false));
        std::env::set_var("EVERY_CHANNEL_DETERMINISTIC", "0");
        assert!(!deterministic_enabled(false));
        match prev {
            Some(value) => std::env::set_var("EVERY_CHANNEL_DETERMINISTIC", value),
            None => std::env::remove_var("EVERY_CHANNEL_DETERMINISTIC"),
        }
    }

    #[test]
    fn parse_network_secret_accepts_hex_and_rejects_invalid() {
        let out = parse_network_secret(Some("00".repeat(8))).unwrap().unwrap();
        assert_eq!(out.len(), 8);
        assert!(parse_network_secret(Some("not-hex".to_string())).is_err());
    }

    fn build_valid_manifest(unsigned: bool) -> Manifest {
        let chunk_hashes = vec![
            blake3::hash(b"c0").to_hex().to_string(),
            blake3::hash(b"c1").to_hex().to_string(),
        ];
        let body = build_manifest_body_for_chunks(
            StreamId("s".to_string()),
            "epoch-1",
            2000,
            10,
            "p",
            1,
            Vec::new(),
            &chunk_hashes,
        )
        .unwrap();
        let manifest_id = body.manifest_id().unwrap();
        let signatures = if unsigned {
            Vec::new()
        } else {
            let prev = std::env::var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY").ok();
            std::env::set_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", "11".repeat(32));
            let keypair = load_manifest_keypair_from_env().unwrap().unwrap();
            let sig = sign_manifest_id(&manifest_id, &keypair);
            match prev {
                Some(value) => std::env::set_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY", value),
                None => std::env::remove_var("EVERY_CHANNEL_MANIFEST_SIGNING_KEY"),
            }
            vec![sig]
        };

        let mut manifest = Manifest {
            body,
            manifest_id,
            signatures,
            commitments: Vec::new(),
        };
        manifest.commitments = manifest_commitments(&manifest).unwrap();
        manifest
    }

    #[test]
    fn validate_manifest_accepts_unsigned_only_with_hashes() {
        let mut manifest = build_valid_manifest(true);
        assert!(validate_manifest(&manifest, None));

        // Remove hashes: unsigned should be rejected.
        manifest.body.chunk_hashes.clear();
        manifest.body.total_chunks = 0;
        manifest.body.merkle_root = "00".repeat(32);
        manifest.manifest_id = manifest.body.manifest_id().unwrap();
        assert!(!validate_manifest(&manifest, None));
    }

    #[test]
    fn validate_manifest_accepts_signed_and_obeys_allowlist() {
        let manifest = build_valid_manifest(false);
        assert!(validate_manifest(&manifest, None));
        let signer = manifest.signatures[0].signer_id.clone();
        let allow = HashSet::from([signer.clone()]);
        assert!(validate_manifest(&manifest, Some(&allow)));
        let deny = HashSet::from(["other".to_string()]);
        assert!(!validate_manifest(&manifest, Some(&deny)));
    }

    #[test]
    fn validate_manifest_accepts_ethereum_signature() {
        let chunk_hashes = vec![blake3::hash(b"c0").to_hex().to_string()];
        let body = build_manifest_body_for_chunks(
            StreamId("s".to_string()),
            "epoch-eth",
            2000,
            0,
            "p",
            1,
            Vec::new(),
            &chunk_hashes,
        )
        .unwrap();
        let manifest_id = body.manifest_id().unwrap();
        let prev = std::env::var(ec_crypto::ETH_MANIFEST_SIGNING_KEY_ENV).ok();
        std::env::set_var(ec_crypto::ETH_MANIFEST_SIGNING_KEY_ENV, "22".repeat(32));
        let keypair = load_ethereum_manifest_keypair_from_env().unwrap().unwrap();
        let sig = sign_manifest_body_eip712(&body, &keypair).unwrap();
        match prev {
            Some(value) => std::env::set_var(ec_crypto::ETH_MANIFEST_SIGNING_KEY_ENV, value),
            None => std::env::remove_var(ec_crypto::ETH_MANIFEST_SIGNING_KEY_ENV),
        }

        let mut manifest = Manifest {
            body,
            manifest_id,
            signatures: vec![sig],
            commitments: Vec::new(),
        };
        manifest.commitments = manifest_commitments(&manifest).unwrap();
        assert!(validate_manifest(&manifest, None));
        let allow = HashSet::from([manifest.signatures[0].signer_id.clone()]);
        assert!(validate_manifest(&manifest, Some(&allow)));
    }

    #[test]
    fn validate_manifest_rejects_bad_ethereum_commitment() {
        let mut manifest = build_valid_manifest(true);
        manifest.commitments[0].digest = "0xdeadbeef".to_string();
        assert!(!validate_manifest(&manifest, None));
    }

    #[test]
    fn control_announcement_carries_stream_and_announcement_commitments() {
        let announcement = build_control_announcement(
            "ec/stream/v1/broadcast/atsc/tsid-42/program-3".to_string(),
            "KCBS".to_string(),
            vec![StreamTransportDescriptor::IrohDirect {
                endpoint: "ed25519:node".to_string(),
                broadcast_name: "kcbs".to_string(),
                track_name: "video0.m4s".to_string(),
            }],
            5_000,
        );
        assert!(!announcement.stream.commitments.is_empty());
        assert!(!announcement.commitments.is_empty());
    }

    #[test]
    fn manifest_hash_for_chunk_indexes_into_hash_list() {
        let manifest = build_valid_manifest(true);
        let sid = manifest.body.stream_id.0.as_str();
        assert!(manifest_hash_for_chunk(&manifest, sid, 9).is_none());
        assert_eq!(
            manifest_hash_for_chunk(&manifest, sid, 10).as_deref(),
            Some(manifest.body.chunk_hashes[0].as_str())
        );
        assert_eq!(
            manifest_hash_for_chunk(&manifest, sid, 11).as_deref(),
            Some(manifest.body.chunk_hashes[1].as_str())
        );
        assert!(manifest_hash_for_chunk(&manifest, sid, 12).is_none());
    }

    #[test]
    fn decode_archive_group_bytes_unwraps_concatenated_object_frames() {
        let meta_a = ObjectMeta {
            created_unix_ms: 1,
            content_type: "video/mp4".to_string(),
            size_bytes: 4,
            timing: None,
            encryption: None,
            chunk_hash: None,
            chunk_hash_alg: None,
            chunk_proof: None,
            chunk_proof_alg: None,
            manifest_id: None,
        };
        let meta_b = ObjectMeta {
            created_unix_ms: 2,
            content_type: "video/mp4".to_string(),
            size_bytes: 3,
            timing: None,
            encryption: None,
            chunk_hash: None,
            chunk_hash_alg: None,
            chunk_proof: None,
            chunk_proof_alg: None,
            manifest_id: None,
        };

        let mut group = encode_object_frame(&meta_a, b"init").unwrap();
        group.extend_from_slice(&encode_object_frame(&meta_b, b"seg").unwrap());

        let decoded = decode_archive_group_bytes(&group).unwrap();
        assert_eq!(decoded, b"initseg");
    }

    #[test]
    fn decode_archive_group_bytes_passes_through_raw_payloads() {
        let raw = b"\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomiso2";
        let decoded = decode_archive_group_bytes(raw).unwrap();
        assert_eq!(decoded, raw);
    }

    #[derive(Clone)]
    struct DummySource {
        source_id: ec_core::SourceId,
        broadcast_id: Option<BroadcastId>,
    }

    impl StreamSource for DummySource {
        fn open_stream(&self) -> Result<Box<dyn Read + Send>> {
            Ok(Box::new(Cursor::new(Vec::<u8>::new())))
        }

        fn source_id(&self) -> ec_core::SourceId {
            self.source_id.clone()
        }

        fn broadcast_id(&self) -> Result<Option<BroadcastId>> {
            Ok(self.broadcast_id.clone())
        }
    }

    #[test]
    fn default_stream_id_for_source_uses_broadcast_identity_when_present() {
        let source = DummySource {
            source_id: ec_core::SourceId {
                kind: "hdhr".to_string(),
                device_id: Some("ABCDEF01".to_string()),
                channel: Some("2.1".to_string()),
            },
            broadcast_id: Some(BroadcastId {
                standard: "atsc".to_string(),
                transport_stream_id: None,
                program_number: Some(3),
                virtual_channel: Some("2.1".to_string()),
                callsign: Some("KCBS".to_string()),
                region: None,
                frequency: None,
            }),
        };

        let stream_id = default_stream_id_for_source(&source, "chunk-2000ms").unwrap();
        assert_eq!(
            stream_id,
            "ec/stream/v1/broadcast/atsc/program-3/channel-2_1/callsign-kcbs/profile-chunk-2000ms"
        );
    }

    #[test]
    fn default_stream_id_for_source_falls_back_to_source_identity() {
        let source = DummySource {
            source_id: ec_core::SourceId {
                kind: "ts".to_string(),
                device_id: None,
                channel: Some("capture.ts".to_string()),
            },
            broadcast_id: None,
        };

        let stream_id = default_stream_id_for_source(&source, "chunk-2000ms").unwrap();
        assert_eq!(
            stream_id,
            "ec/stream/v1/source/ts/channel-capture_ts/profile-chunk-2000ms"
        );
    }
}

async fn moq_publish(args: MoqPublishArgs) -> Result<()> {
    fs::create_dir_all(&args.chunk_dir)
        .with_context(|| format!("failed to create {}", args.chunk_dir.display()))?;

    let observation_sink = ObservationSink::from_options(ObservationSinkOptions {
        rpc_url: args.observation_rpc_url.clone(),
        ledger: args.observation_ledger.clone(),
        private_key: args.observation_private_key.clone(),
        private_key_file: args.observation_private_key_file.clone(),
        parent_hash: args.observation_parent_hash.clone(),
        timeout_ms: args.observation_timeout_ms,
    })?;

    let deterministic = deterministic_enabled(args.deterministic);
    let (source, _needs_transcode): (Box<dyn StreamSource + Send>, bool) = match args.source {
        IngestSource::Hls { url, mut mode } => {
            if deterministic {
                mode = HlsMode::Transcode;
            }
            (Box::new(HlsSource { url, mode }), false)
        }
        IngestSource::Hdhr {
            host,
            device_id,
            channel,
            name,
            prefer_mdns,
        } => (
            Box::new(HdhrSource {
                host,
                device_id,
                channel,
                name,
                prefer_mdns,
            }),
            deterministic,
        ),
        IngestSource::LinuxDvb {
            adapter,
            dvr,
            tune_cmd,
            tune_wait_ms,
        } => (
            Box::new(LinuxDvbSource {
                adapter,
                dvr,
                tune_cmd,
                tune_wait_ms,
            }),
            deterministic,
        ),
        IngestSource::Ts { input } => (Box::new(TsSource { input }), deterministic),
    };

    let source_id = source.source_id();

    let stream_id = match args.stream_id {
        Some(stream_id) => stream_id,
        None => {
            default_stream_id_for_source(source.as_ref(), format!("chunk-{}ms", args.chunk_ms))?
        }
    };

    let broadcast_name = args.broadcast_name.unwrap_or_else(|| stream_id.clone());
    let track_name = args.track_name.clone();

    let secret = parse_iroh_secret(args.iroh_secret)?;
    let discovery = parse_discovery(args.discovery.as_deref())?;
    let node = MoqNode::bind_with_discovery(secret, discovery).await?;

    // Wait briefly for direct addresses so "remote" can connect without needing discovery.
    let mut endpoint_addr = node.endpoint_addr();
    if endpoint_addr.addrs.is_empty() {
        let mut watcher = node.endpoint().watch_addr();
        let start = Instant::now();
        while endpoint_addr.addrs.is_empty() && start.elapsed() < Duration::from_secs(3) {
            tokio::time::sleep(Duration::from_millis(200)).await;
            endpoint_addr = watcher.get();
        }
    }

    println!("moq endpoint id: {}", node.endpoint().id());
    if let Ok(addr_json) = serde_json::to_string(&endpoint_addr) {
        println!("moq endpoint addr: {}", addr_json);
    }
    println!("moq broadcast: {}", broadcast_name);
    println!("moq track: {}", track_name);

    let publish_chunks = args.publish_chunks;
    let ladder = args.cmaf_ladder;
    let ladder_variants = ladder.map(cmaf_ladder_variants);

    let mut object_tracks = Vec::new();
    if let Some(variants) = ladder_variants.as_ref() {
        if !publish_chunks {
            return Err(anyhow!("--cmaf-ladder requires --publish-chunks true"));
        }
        for variant in variants {
            object_tracks.push(format!("{}/{}", track_name, variant.id));
            object_tracks.push(format!("{}/{}", args.init_track, variant.id));
        }
    } else {
        object_tracks.push(track_name.clone());
        if publish_chunks {
            object_tracks.push(args.init_track.clone());
        }
    }
    let mut manifest_tracks = Vec::new();
    if args.publish_manifests {
        manifest_tracks.push(args.manifest_track.clone());
    }
    let mut publish_set = node
        .publish_track_set(&broadcast_name, object_tracks, manifest_tracks)
        .await?;

    if let Some(ms) = args.startup_delay_ms {
        tokio::time::sleep(Duration::from_millis(ms)).await;
    }

    let network_secret = parse_network_secret(args.network_secret)?;

    let track = TrackName {
        namespace: "every.channel".to_string(),
        name: stream_id.clone(),
    };
    let stream_id_value = StreamId(track.name.clone());
    let source_kind = source_id.kind.clone();
    let encoder_profile_id = "deterministic-h264-aac".to_string();

    if let Some(variants) = ladder_variants {
        if args.epoch_chunks != 1 {
            return Err(anyhow!("--cmaf-ladder currently requires --epoch-chunks 1"));
        }
        if !args.publish_manifests {
            return Err(anyhow!(
                "--cmaf-ladder currently requires --publish-manifests"
            ));
        }

        #[derive(Debug)]
        enum PendingPublish {
            Object {
                track: String,
                group: u64,
                object: ObjectPayload,
            },
            Manifest {
                track: String,
                sequence: u64,
                manifest: Manifest,
            },
        }

        let (tx, mut rx) = mpsc::channel::<PendingPublish>(16);
        let chunk_ms = args.chunk_ms;
        let max_chunks = args.max_chunks.unwrap_or(usize::MAX);
        let out_dir = args.chunk_dir.join("cmaf-ladder");
        let init_track_prefix = args.init_track.clone();
        let chunk_track_prefix = track_name.clone();
        let manifest_track = args.manifest_track.clone();
        let publish_manifests = args.publish_manifests;
        let source_kind = source_id.kind.clone();
        let base_stream_id = stream_id.clone();
        let network_secret_bytes = network_secret.clone();
        let startup_delay_ms = args.startup_delay_ms;

        let chunk_task = tokio::task::spawn_blocking(move || -> Result<()> {
            let _ = fs::remove_dir_all(&out_dir);
            fs::create_dir_all(&out_dir)
                .with_context(|| format!("failed to create {}", out_dir.display()))?;
            for variant in &variants {
                fs::create_dir_all(out_dir.join(&variant.id))?;
            }

            let mut cmd = Command::new("ffmpeg");
            cmd.current_dir(&out_dir);
            cmd.arg("-hide_banner")
                .arg("-loglevel")
                .arg("error")
                .arg("-nostdin")
                .arg("-y")
                .arg("-i")
                .arg("pipe:0")
                // Reduce opportunities for non-deterministic scheduling in filters/decoders.
                .arg("-filter_threads")
                .arg("1")
                .arg("-filter_complex_threads")
                .arg("1")
                .arg("-threads")
                .arg("1")
                .arg("-map")
                .arg("0:v:0")
                .arg("-map")
                .arg("0:a:0?")
                .arg("-sn")
                .arg("-dn")
                .arg("-map_metadata")
                .arg("-1");

            // Build a split+scale filter graph for all variants.
            // NOTE: We keep it simple: split video N ways, then scale each output.
            let mut filter = String::new();
            filter.push_str(&format!("[0:v]split={}", variants.len()));
            for (i, _) in variants.iter().enumerate() {
                filter.push_str(&format!("[v{i}]"));
            }
            filter.push(';');
            for (i, variant) in variants.iter().enumerate() {
                // Scale flags influence quality but should be deterministic.
                filter.push_str(&format!(
                    "[v{i}]scale=w={}:h={}:flags=bicubic[v{i}o];",
                    variant.width, variant.height
                ));
            }
            cmd.arg("-filter_complex").arg(filter);

            let seg_time = format!("{:.3}", chunk_ms as f64 / 1000.0);
            for (i, variant) in variants.iter().enumerate() {
                let v_bitrate = format!("{}k", variant.video_bitrate_kbps);
                let bufsize = format!("{}k", variant.video_bitrate_kbps.saturating_mul(2));
                let out_variant_dir = out_dir.join(&variant.id);
                let seg_template = out_variant_dir.join("segment_%06d.m4s");
                let seg_template = seg_template
                    .to_str()
                    .ok_or_else(|| anyhow!("invalid segment template path"))?
                    .to_string();

                cmd.arg("-map")
                    .arg(format!("[v{i}o]"))
                    .arg("-map")
                    .arg("0:a:0?")
                    .arg("-c:v")
                    .arg("libx264")
                    // Force keyframes aligned to segment boundaries (chunk_ms).
                    .arg("-force_key_frames")
                    .arg(format!(
                        "expr:gte(t,n_forced*{:.3})",
                        chunk_ms as f64 / 1000.0
                    ))
                    .arg("-b:v")
                    .arg(&v_bitrate)
                    .arg("-minrate")
                    .arg(&v_bitrate)
                    .arg("-maxrate")
                    .arg(&v_bitrate)
                    .arg("-bufsize")
                    .arg(&bufsize)
                    .arg("-c:a")
                    .arg("aac")
                    .arg("-profile:a")
                    .arg("aac_low")
                    .arg("-b:a")
                    .arg("128k")
                    .arg("-ac")
                    .arg("2")
                    .arg("-ar")
                    .arg("48000")
                    .arg("-af")
                    .arg(ec_chopper::LIVE_AUDIO_RESAMPLE_FILTER)
                    .arg("-max_muxing_queue_size")
                    .arg("2048")
                    .arg("-pix_fmt")
                    .arg("yuv420p")
                    .arg("-g")
                    .arg("60")
                    .arg("-keyint_min")
                    .arg("60")
                    .arg("-sc_threshold")
                    .arg("0")
                    .arg("-bf")
                    .arg("0")
                    .arg("-threads")
                    .arg("1")
                    .arg("-fflags")
                    .arg("+bitexact")
                    .arg("-flags:v")
                    .arg("+bitexact")
                    .arg("-flags:a")
                    .arg("+bitexact")
                    .arg("-f")
                    .arg("hls")
                    .arg("-hls_time")
                    .arg(&seg_time)
                    .arg("-hls_list_size")
                    .arg("0")
                    .arg("-hls_segment_type")
                    .arg("fmp4")
                    .arg("-hls_flags")
                    .arg("independent_segments")
                    .arg("-hls_fmp4_init_filename")
                    .arg("init.mp4")
                    .arg("-hls_segment_filename")
                    .arg(seg_template)
                    .arg(out_variant_dir.join("index.m3u8"));
            }

            cmd.stdin(Stdio::piped())
                .stdout(Stdio::null())
                .stderr(Stdio::inherit());

            let mut child = cmd.spawn().with_context(|| "failed to spawn ffmpeg")?;
            let mut stdin = child
                .stdin
                .take()
                .ok_or_else(|| anyhow!("ffmpeg stdin unavailable"))?;
            let mut reader = source.open_stream()?;
            let writer = std::thread::spawn(move || -> Result<()> {
                std::io::copy(&mut reader, &mut stdin)?;
                Ok(())
            });

            // Optional startup delay to allow subscribers to connect.
            if let Some(ms) = startup_delay_ms {
                std::thread::sleep(Duration::from_millis(ms));
            }

            // Publish init per variant as soon as they exist.
            for variant in &variants {
                let init_path = out_dir.join(&variant.id).join("init.mp4");
                wait_for_stable_file(&init_path, Duration::from_secs(20))?;
                let data = fs::read(&init_path)?;
                let hash = blake3::hash(&data).to_hex().to_string();
                let key_id = format!(
                    "{}/init",
                    derive_variant_stream_id(&base_stream_id, &variant.id)
                );
                let object = build_object(
                    TsChunk {
                        index: 0,
                        path: init_path,
                        timing: ec_chopper::ChunkTiming {
                            chunk_index: 0,
                            chunk_start_27mhz: None,
                            chunk_duration_27mhz: 0,
                            utc_start_unix: None,
                            sync_status: "init".to_string(),
                        },
                    },
                    data,
                    hash,
                    None,
                    network_secret_bytes.as_deref(),
                    None,
                    "video/mp4",
                    &key_id,
                )?;
                tx.blocking_send(PendingPublish::Object {
                    track: format!("{}/{}", init_track_prefix, variant.id),
                    group: 0,
                    object,
                })
                .map_err(|_| anyhow!("publish channel closed"))?;
            }

            let mut manifest_seq: u64 = 0;
            for index in 0..max_chunks {
                // Wait for each variant's segment for this index.
                let mut per_variant_segments = Vec::with_capacity(variants.len());
                let mut per_variant_hashes = Vec::with_capacity(variants.len());
                for variant in &variants {
                    let seg_path = out_dir
                        .join(&variant.id)
                        .join(format!("segment_{index:06}.m4s"));
                    wait_for_stable_file(&seg_path, Duration::from_secs(30))?;
                    let data = fs::read(&seg_path)?;
                    let hash = blake3::hash(&data).to_hex().to_string();
                    per_variant_segments.push((variant, seg_path, data, hash.clone()));
                    per_variant_hashes.push((variant, hash));
                }

                let created_unix_ms = SystemTime::now()
                    .duration_since(UNIX_EPOCH)
                    .unwrap_or_default()
                    .as_millis() as u64;
                let epoch_id = format!("epoch-{created_unix_ms}");

                let manifest = if publish_manifests {
                    build_multi_variant_manifest(
                        StreamId(base_stream_id.clone()),
                        epoch_id,
                        chunk_ms,
                        index as u64,
                        encoder_profile_id.clone(),
                        created_unix_ms,
                        vec![StreamMetadata {
                            key: "source_kind".to_string(),
                            value: source_kind.clone(),
                        }],
                        &variants,
                        index as u64,
                        per_variant_hashes
                            .iter()
                            .map(|(v, h)| (v.id.clone(), h.clone()))
                            .collect(),
                    )?
                } else {
                    // Still build an unsigned manifest for internal linkage if desired.
                    build_multi_variant_manifest(
                        StreamId(base_stream_id.clone()),
                        epoch_id,
                        chunk_ms,
                        index as u64,
                        encoder_profile_id.clone(),
                        created_unix_ms,
                        vec![StreamMetadata {
                            key: "source_kind".to_string(),
                            value: source_kind.clone(),
                        }],
                        &variants,
                        index as u64,
                        per_variant_hashes
                            .iter()
                            .map(|(v, h)| (v.id.clone(), h.clone()))
                            .collect(),
                    )?
                };

                tx.blocking_send(PendingPublish::Manifest {
                    track: manifest_track.clone(),
                    sequence: manifest_seq,
                    manifest: manifest.clone(),
                })
                .map_err(|_| anyhow!("publish channel closed"))?;
                manifest_seq += 1;

                // Publish segment objects for each variant, linked to the manifest.
                for (variant, seg_path, data, hash) in per_variant_segments {
                    let key_id = derive_variant_stream_id(&base_stream_id, &variant.id);
                    let chunk = TsChunk {
                        index: index as u64,
                        path: seg_path,
                        timing: ec_chopper::ChunkTiming {
                            chunk_index: index as u64,
                            chunk_start_27mhz: None,
                            chunk_duration_27mhz: chunk_ms * 27_000,
                            utc_start_unix: None,
                            sync_status: "cmaf".to_string(),
                        },
                    };
                    let object = build_object(
                        chunk,
                        data,
                        hash,
                        None,
                        network_secret_bytes.as_deref(),
                        Some(&manifest.manifest_id),
                        "video/iso.segment",
                        &key_id,
                    )?;
                    tx.blocking_send(PendingPublish::Object {
                        track: format!("{}/{}", chunk_track_prefix, variant.id),
                        group: (index as u64) + 1,
                        object,
                    })
                    .map_err(|_| anyhow!("publish channel closed"))?;
                }
            }

            let _ = child.kill();
            let _ = child.wait();
            let _ = writer.join();
            Ok(())
        });

        while let Some(item) = rx.recv().await {
            match item {
                PendingPublish::Object {
                    track,
                    group,
                    object,
                } => {
                    publish_set.publish_object(&track, GroupId(group), object)?;
                }
                PendingPublish::Manifest {
                    track,
                    sequence,
                    manifest,
                } => {
                    publish_set.publish_manifest(&track, sequence, &manifest)?;
                    submit_manifest_observation(
                        observation_sink.as_ref(),
                        &manifest,
                        &broadcast_name,
                        &track_name,
                        &track,
                    )
                    .await?;
                }
            }
        }

        chunk_task
            .await
            .map_err(|err| anyhow!("chunk task join error: {err}"))??;

        return Ok(());
    }
    let needs_init_track = publish_chunks;
    let mut epoch_buffer = EpochBuffer::new(args.epoch_chunks);
    // Some early MoQ implementations have surprising assumptions around group sequence ids.
    // In CMAF mode we reserve group 0 for the init segment on the init track and start
    // segment groups at 1 to avoid any potential cross-track collisions.
    let mut object_sequence: u64 = if needs_init_track { 1 } else { 0 };
    let mut manifest_sequence: u64 = 0;

    let announce_tx = if args.announce {
        let gossip_peers = parse_gossip_peers(args.gossip_peer.clone());
        Some(
            spawn_catalog_announcer(&node, &track, &broadcast_name, &track_name, gossip_peers)
                .await?,
        )
    } else {
        None
    };

    #[derive(Debug)]
    enum PendingKind {
        Init,
        Segment,
    }

    #[derive(Debug)]
    struct PendingChunk {
        kind: PendingKind,
        chunk: TsChunk,
        data: Option<Vec<u8>>,
        hash: String,
    }

    let segment_content_type = "video/iso.segment";

    // Chunking is CPU and IO heavy and must not block the async runtime.
    // We do ingest + chunking on a blocking thread and feed finalized chunks
    // to the async publisher over a channel.
    let (tx, mut rx) = mpsc::channel::<PendingChunk>(8);
    let chunk_dir = args.chunk_dir.clone();
    let chunk_ms = args.chunk_ms;
    let max_chunks = args.max_chunks;
    let chunk_task = tokio::task::spawn_blocking(move || -> Result<()> {
        let out_dir = chunk_dir.join("cmaf");
        let _ = fs::remove_dir_all(&out_dir);
        fs::create_dir_all(&out_dir)
            .with_context(|| format!("failed to create {}", out_dir.display()))?;

        let profile = ec_chopper::deterministic_h264_profile();
        let mut cmd = std::process::Command::new("ffmpeg");
        cmd.current_dir(&out_dir);
        cmd.arg("-hide_banner")
            .arg("-loglevel")
            .arg("error")
            .arg("-nostdin")
            .arg("-y")
            .arg("-i")
            .arg("pipe:0");

        // Reduce non-determinism and "surprise streams" (data/subtitles, extra audio).
        // We intentionally pick the first video stream and (optionally) the first audio stream.
        cmd.arg("-map").arg("0:v:0");
        cmd.arg("-map").arg("0:a:0?");
        cmd.arg("-sn").arg("-dn");
        cmd.arg("-map_metadata").arg("-1");

        for arg in ec_chopper::ffmpeg_profile_args(&profile) {
            cmd.arg(arg);
        }
        let seg_time = format!("{:.3}", chunk_ms as f64 / 1000.0);
        let seg_template = "segment_%06d.m4s".to_string();
        let init_filename = "init.mp4".to_string();
        let playlist = "index.m3u8".to_string();

        cmd.arg("-f")
            .arg("hls")
            .arg("-hls_time")
            .arg(seg_time)
            .arg("-hls_list_size")
            .arg("0")
            .arg("-hls_segment_type")
            .arg("fmp4")
            .arg("-hls_flags")
            .arg("independent_segments")
            .arg("-hls_fmp4_init_filename")
            .arg(init_filename)
            .arg("-hls_segment_filename")
            .arg(seg_template)
            .arg(playlist)
            .stdin(std::process::Stdio::piped())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::inherit());

        let mut child = cmd
            .spawn()
            .with_context(|| "failed to spawn ffmpeg".to_string())?;
        let mut stdin = child
            .stdin
            .take()
            .ok_or_else(|| anyhow!("ffmpeg stdin unavailable"))?;
        let mut reader = source.open_stream()?;
        let writer = std::thread::spawn(move || -> Result<()> {
            std::io::copy(&mut reader, &mut stdin)?;
            Ok(())
        });

        let init_path = out_dir.join("init.mp4");
        if publish_chunks {
            wait_for_stable_file(&init_path, Duration::from_secs(10))?;
            let data = fs::read(&init_path)?;
            let hash = blake3::hash(&data).to_hex().to_string();
            let chunk = TsChunk {
                index: 0,
                path: init_path.clone(),
                timing: ec_chopper::ChunkTiming {
                    chunk_index: 0,
                    chunk_start_27mhz: None,
                    chunk_duration_27mhz: 0,
                    utc_start_unix: None,
                    sync_status: "init".to_string(),
                },
            };
            tx.blocking_send(PendingChunk {
                kind: PendingKind::Init,
                chunk,
                data: Some(data),
                hash,
            })
            .map_err(|_| anyhow!("publish channel closed"))?;
        }

        let limit = max_chunks.unwrap_or(usize::MAX);
        for index in 0..limit {
            let seg_path = out_dir.join(format!("segment_{index:06}.m4s"));
            // If the segment never appears and ffmpeg exited, we are done (short input).
            // If the segment never appears and ffmpeg is still running, treat as error.
            match wait_for_stable_file(&seg_path, Duration::from_secs(20)) {
                Ok(()) => {}
                Err(err) => {
                    if let Ok(Some(status)) = child.try_wait() {
                        if status.success() {
                            break;
                        }
                        return Err(anyhow!("ffmpeg exited with {status} ({err:#})"));
                    }
                    return Err(err);
                }
            }
            let data = if publish_chunks {
                Some(fs::read(&seg_path)?)
            } else {
                None
            };
            let hash = if let Some(ref bytes) = data {
                blake3::hash(bytes).to_hex().to_string()
            } else {
                ec_chopper::hash_file_blake3(&seg_path)?
            };

            let chunk = TsChunk {
                index: index as u64,
                path: seg_path.clone(),
                timing: ec_chopper::ChunkTiming {
                    chunk_index: index as u64,
                    chunk_start_27mhz: None,
                    chunk_duration_27mhz: chunk_ms * 27_000,
                    utc_start_unix: None,
                    sync_status: "cmaf".to_string(),
                },
            };

            tx.blocking_send(PendingChunk {
                kind: PendingKind::Segment,
                chunk,
                data,
                hash,
            })
            .map_err(|_| anyhow!("publish channel closed"))?;
        }

        let _ = child.kill();
        let _ = child.wait();
        let _ = writer.join();
        Ok(())
    });

    while let Some(pending) = rx.recv().await {
        match pending.kind {
            PendingKind::Init => {
                let Some(data) = pending.data else {
                    continue;
                };
                let key_id = format!("{}/init", track.name);
                let object = build_object(
                    pending.chunk,
                    data,
                    pending.hash,
                    None,
                    network_secret.as_deref(),
                    None,
                    "video/mp4",
                    &key_id,
                )?;
                tracing::info!(
                    "publish init bytes={} track={}",
                    object.data.len(),
                    args.init_track
                );
                publish_set.publish_object(&args.init_track, GroupId(0), object)?;
            }
            PendingKind::Segment => {
                epoch_buffer.push(pending.chunk, pending.data, pending.hash);
                if epoch_buffer.is_full() {
                    if let Some(manifest) = flush_epoch_publish(
                        &mut publish_set,
                        &track_name,
                        &args.manifest_track,
                        publish_chunks,
                        args.publish_manifests,
                        &mut epoch_buffer,
                        &stream_id_value,
                        args.chunk_ms,
                        &encoder_profile_id,
                        &source_kind,
                        network_secret.as_deref(),
                        segment_content_type,
                        &track.name,
                        &mut object_sequence,
                        &mut manifest_sequence,
                        announce_tx.as_ref(),
                    )? {
                        submit_manifest_observation(
                            observation_sink.as_ref(),
                            &manifest,
                            &broadcast_name,
                            &track_name,
                            &args.manifest_track,
                        )
                        .await?;
                    }
                }
            }
        }
    }

    // Ensure chunking completed successfully.
    chunk_task
        .await
        .map_err(|err| anyhow!("chunk task join error: {err}"))??;

    if let Some(manifest) = flush_epoch_publish(
        &mut publish_set,
        &track_name,
        &args.manifest_track,
        publish_chunks,
        args.publish_manifests,
        &mut epoch_buffer,
        &stream_id_value,
        args.chunk_ms,
        &encoder_profile_id,
        &source_kind,
        network_secret.as_deref(),
        segment_content_type,
        &track.name,
        &mut object_sequence,
        &mut manifest_sequence,
        announce_tx.as_ref(),
    )? {
        submit_manifest_observation(
            observation_sink.as_ref(),
            &manifest,
            &broadcast_name,
            &track_name,
            &args.manifest_track,
        )
        .await?;
    }

    Ok(())
}

async fn moq_subscribe(args: MoqSubscribeArgs) -> Result<()> {
    if args.require_manifest && !args.subscribe_manifests {
        return Err(anyhow!("--require-manifest requires --subscribe-manifests"));
    }
    let secret = parse_iroh_secret(args.iroh_secret)?;
    let discovery = parse_discovery(args.discovery.as_deref())?;
    let node = MoqNode::bind_with_discovery(secret, discovery).await?;
    let remote = ec_iroh::parse_endpoint_addr(&args.remote)?;
    let remote_manifests = if let Some(value) = args.remote_manifests.as_deref() {
        ec_iroh::parse_endpoint_addr(value)?
    } else {
        remote.clone()
    };
    let mut stream = node
        .subscribe_objects(remote.clone(), &args.broadcast_name, &args.track_name)
        .await?;

    let manifest_allowlist = parse_manifest_allowlist(args.manifest_signers.as_deref());
    let manifest_store = if args.subscribe_manifests || args.require_manifest {
        let mut manifest_stream = node
            .subscribe_manifests(remote_manifests, &args.broadcast_name, &args.manifest_track)
            .await?;
        let store = Arc::new(RwLock::new(HashMap::new()));
        let store_clone = Arc::clone(&store);
        let allowlist = manifest_allowlist.clone();
        tokio::spawn(async move {
            while let Some(manifest) = manifest_stream.recv().await {
                if !validate_manifest(&manifest, allowlist.as_ref()) {
                    tracing::warn!("rejected manifest {}", manifest.manifest_id);
                    continue;
                }
                let manifest_id = manifest.manifest_id.clone();
                store_clone.write().await.insert(manifest_id, manifest);
            }
        });
        Some(store)
    } else {
        None
    };

    let network_secret = parse_network_secret(args.network_secret)?;

    let mut hls =
        HlsWriter::new_cmaf(&args.output_dir, args.chunk_ms as f64 / 1000.0, args.window)?;

    let needs_init = args.subscribe_init;
    let mut init_ready = !needs_init;
    let mut buffered_segments: Vec<(u64, f64, Vec<u8>)> = Vec::new();

    let mut init_rx = if needs_init {
        let (tx, rx) = tokio::sync::oneshot::channel::<Result<Vec<u8>>>();
        let mut init_stream = node
            .subscribe_objects(remote.clone(), &args.broadcast_name, &args.init_track)
            .await?;
        let remote_str = args.remote.clone();
        let init_track = args.init_track.clone();
        let secret = network_secret.clone();
        tokio::spawn(async move {
            let deadline = Instant::now() + Duration::from_secs(60);
            while Instant::now() < deadline {
                let remaining = deadline.saturating_duration_since(Instant::now());
                let recv =
                    tokio::time::timeout(remaining.min(Duration::from_secs(2)), init_stream.recv())
                        .await;
                let Ok(Some(object)) = recv else { continue };

                let init_index = object
                    .meta
                    .timing
                    .as_ref()
                    .map(|t| t.chunk_index)
                    .unwrap_or(0);

                let data = if let Some(enc) = &object.meta.encryption {
                    if enc.alg != ENCRYPTION_ALG {
                        tracing::warn!("init: unsupported encryption {}", enc.alg);
                        continue;
                    }
                    tracing::info!(
                        "init: received encrypted object bytes={} key_id={} chunk_index={}",
                        object.data.len(),
                        enc.key_id,
                        init_index
                    );
                    match decrypt_stream_data(
                        &enc.key_id,
                        init_index,
                        &object.data,
                        secret.as_deref(),
                    ) {
                        Some(plaintext) => plaintext,
                        None => {
                            tracing::warn!(
                                "init: decryption failed key_id={} chunk_index={}",
                                enc.key_id,
                                init_index
                            );
                            continue;
                        }
                    }
                } else {
                    tracing::info!(
                        "init: received plaintext object bytes={} chunk_index={}",
                        object.data.len(),
                        init_index
                    );
                    object.data
                };
                let _ = tx.send(Ok(data));
                return;
            }
            let _ = tx.send(Err(anyhow!(
                "timed out waiting for CMAF init segment on track '{}' from {}",
                init_track,
                remote_str
            )));
        });
        Some(rx)
    } else {
        None
    };

    let fallback = Duration::from_millis(args.chunk_ms);
    let mut fallback_index = 0u64;
    let mut invalid_chunks = 0u32;
    let mut written_chunks = 0u64;
    let mut quota = args.max_bytes_per_sec.map(|rate| {
        let burst = args.max_bytes_burst.unwrap_or(rate.saturating_mul(2));
        ec_iroh::TokenBucket::new(burst, rate)
    });

    loop {
        tokio::select! {
            biased;
            init_res = async { if let Some(rx) = init_rx.as_mut() { Some(rx.await) } else { None } }, if init_rx.is_some() => {
                let Some(init_res) = init_res else { continue };
                let init = match init_res {
                    Ok(inner) => inner?,
                    Err(_) => return Err(anyhow!("init receiver task cancelled")),
                };
                if args.raw_cmaf {
                    fs::create_dir_all(&args.output_dir)?;
                    fs::write(args.output_dir.join("init.mp4"), &init)?;
                } else {
                    let _ = hls.write_init_segment(&init)?;
                }
                init_ready = true;
                init_rx = None;

                // Flush any segments we buffered while waiting for init.
                buffered_segments.sort_by_key(|(idx, _, _)| *idx);
                for (idx, dur, bytes) in buffered_segments.drain(..) {
                    if args.raw_cmaf {
                        fs::create_dir_all(&args.output_dir)?;
                        fs::write(args.output_dir.join(format!("segment_{idx:06}.m4s")), &bytes)?;
                    } else {
                        let _ = hls.write_segment(idx, dur, &bytes)?;
                    }
                    written_chunks += 1;
                    if let Some(limit) = args.stop_after {
                        if written_chunks >= limit {
                            return Ok(());
                        }
                    }
                }
                continue;
            }
            object = stream.recv() => {
                let Some(object) = object else { break };
        if let Some(bucket) = quota.as_mut() {
            if !bucket.allow(object.data.len() as u64) {
                tracing::warn!("quota exceeded; dropping chunk");
                continue;
            }
        }
        let stream_id_for_manifest = object
            .meta
            .encryption
            .as_ref()
            .map(|enc| strip_init_suffix(enc.key_id.as_str()).to_string());
        let index = object
            .meta
            .timing
            .as_ref()
            .map(|t| t.chunk_index)
            .unwrap_or_else(|| {
                let current = fallback_index;
                fallback_index += 1;
                current
            });
        let stream_id = args
            .stream_id
            .as_deref()
            .or_else(|| object.meta.encryption.as_ref().map(|enc| enc.key_id.as_str()));

        let data = if let Some(enc) = &object.meta.encryption {
            if enc.alg != ENCRYPTION_ALG {
                tracing::warn!("unsupported encryption {}", enc.alg);
                continue;
            }
            let Some(stream_id) = stream_id else {
                tracing::warn!("missing stream id for decryption");
                continue;
            };
            match decrypt_stream_data(stream_id, index, &object.data, network_secret.as_deref()) {
                Some(plaintext) => plaintext,
                None => {
                    tracing::warn!("decryption failed for chunk {}", index);
                    continue;
                }
            }
        } else {
            object.data
        };

        if let Some(store) = manifest_store.as_ref() {
            let manifest = {
                let store = store.read().await;
                if let Some(manifest_id) = object.meta.manifest_id.as_ref() {
                    store.get(manifest_id).cloned()
                } else {
                    if let Some(stream_id) = stream_id_for_manifest.as_deref() {
                        find_manifest_for_stream_index(&store, stream_id, index)
                    } else {
                        None
                    }
                }
            };

            if let Some(manifest) = manifest {
                    let expected = stream_id_for_manifest
                        .as_deref()
                        .and_then(|sid| manifest_hash_for_chunk(&manifest, sid, index));
                    if let Some(expected) = expected {
                        if let Some(meta_hash) = object.meta.chunk_hash.as_ref() {
                            if expected != *meta_hash {
                                tracing::warn!(
                                    "manifest mismatch for chunk {} (expected {}, got {})",
                                    index,
                                    expected,
                                    meta_hash
                                );
                                invalid_chunks += 1;
                                if invalid_chunks > args.max_invalid_chunks {
                                    tracing::warn!("too many invalid chunks; closing");
                                    break;
                                }
                                continue;
                            }
                        } else if args.require_manifest {
                            tracing::warn!("missing chunk hash for manifest");
                            invalid_chunks += 1;
                            if invalid_chunks > args.max_invalid_chunks {
                                tracing::warn!("too many invalid chunks; closing");
                                break;
                            }
                            continue;
                        }
                    } else {
                        // If the manifest doesn't include hash lists, fall back to Merkle proof.
                        if let (Some(meta_hash), Some(proof)) =
                            (object.meta.chunk_hash.as_ref(), object.meta.chunk_proof.as_ref())
                        {
                            let offset = (index - manifest.body.chunk_start_index) as usize;
                            if !verify_merkle_proof(
                                meta_hash,
                                offset,
                                proof,
                                &manifest.body.merkle_root,
                            ) {
                                tracing::warn!("chunk {} proof invalid for manifest", index);
                                invalid_chunks += 1;
                                if invalid_chunks > args.max_invalid_chunks {
                                    tracing::warn!("too many invalid chunks; closing");
                                    break;
                                }
                                continue;
                            }
                        } else if args.require_manifest {
                            tracing::warn!("chunk {} outside manifest", index);
                            invalid_chunks += 1;
                            if invalid_chunks > args.max_invalid_chunks {
                                tracing::warn!("too many invalid chunks; closing");
                                break;
                            }
                            continue;
                        }
                    }
            } else if args.require_manifest {
                tracing::warn!("missing manifest covering chunk {}", index);
                continue;
            }
        }

        if let Some(expected) = object.meta.chunk_hash.as_ref() {
            let actual = blake3::hash(&data).to_hex().to_string();
            if &actual != expected {
                tracing::warn!(
                    "chunk {} hash mismatch (expected {}, got {})",
                    index,
                    expected,
                    actual
                );
                invalid_chunks += 1;
                if invalid_chunks > args.max_invalid_chunks {
                    tracing::warn!("too many invalid chunks; closing");
                    break;
                }
                continue;
            }
        }

        let duration = chunk_duration_secs(&object.meta, fallback);
        if !init_ready {
            // Keep draining the MoQ track to avoid flow-control stalls while init is pending.
            buffered_segments.push((index, duration, data));
        } else {
            if args.raw_cmaf {
                fs::create_dir_all(&args.output_dir)?;
                fs::write(args.output_dir.join(format!("segment_{index:06}.m4s")), &data)?;
            } else {
                let _ = hls.write_segment(index, duration, &data)?;
            }
            written_chunks += 1;
        }
        if let Some(limit) = args.stop_after {
            if written_chunks >= limit {
                break;
            }
        }
            }
        }
    }

    if needs_init && !init_ready {
        return Err(anyhow!(
            "stream ended before receiving CMAF init segment on track '{}' from {}",
            args.init_track,
            args.remote
        ));
    }

    Ok(())
}

async fn moq_selftest(args: MoqSelftestArgs) -> Result<()> {
    let discovery = parse_discovery(args.discovery.as_deref())?;
    let publisher_node = MoqNode::bind_with_discovery(None, discovery).await?;
    let subscriber_node = MoqNode::bind_with_discovery(None, discovery).await?;

    publisher_node.endpoint().online().await;
    subscriber_node.endpoint().online().await;

    let stream_id = args.stream_id.unwrap_or_else(|| {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_millis();
        format!("selftest-{ts}")
    });
    let broadcast_name = stream_id.clone();
    let track_name = args.track_name.clone();

    let mut publisher = publisher_node
        .publish_objects(&broadcast_name, &track_name)
        .await?;
    let mut endpoint_addr = publisher_node.endpoint().addr();
    if endpoint_addr.addrs.is_empty() {
        let mut watcher = publisher_node.endpoint().watch_addr();
        let start = Instant::now();
        while endpoint_addr.addrs.is_empty() && start.elapsed() < Duration::from_secs(3) {
            tokio::time::sleep(Duration::from_millis(200)).await;
            endpoint_addr = watcher.get();
        }
    }
    if endpoint_addr.addrs.is_empty() {
        tracing::warn!(
            "publisher endpoint has no direct addrs; selftest may rely on discovery/relays"
        );
    }

    let mut stream = subscriber_node
        .subscribe_objects(endpoint_addr, &broadcast_name, &track_name)
        .await?;

    let expected: Arc<Mutex<BTreeMap<u64, blake3::Hash>>> = Arc::new(Mutex::new(BTreeMap::new()));
    let (done_tx, mut done_rx) = tokio::sync::watch::channel(0usize);
    let (progress_tx, mut progress_rx) = tokio::sync::mpsc::channel::<u64>(8);

    let subscriber_task = tokio::spawn(async move {
        let mut received: BTreeMap<u64, blake3::Hash> = BTreeMap::new();
        loop {
            tokio::select! {
                item = stream.recv() => {
                    match item {
                        Some(object) => {
                            let index = object
                                .meta
                                .timing
                                .as_ref()
                                .map(|timing| timing.chunk_index)
                                .unwrap_or(0);
                            let hash = blake3::hash(&object.data);
                            received.insert(index, hash);
                            let _ = progress_tx.send(index).await;
                            let expected_total = *done_rx.borrow();
                            if expected_total > 0 && received.len() >= expected_total {
                                break;
                            }
                        }
                        None => break,
                    }
                }
                _ = done_rx.changed() => {
                    let expected_total = *done_rx.borrow();
                    if expected_total > 0 && received.len() >= expected_total {
                        break;
                    }
                }
            }
        }
        Ok::<_, anyhow::Error>(received)
    });

    let chunk_dir = args.chunk_dir.clone();
    let input = args.input.clone();
    let max_chunks = args.max_chunks;
    let track = TrackName {
        namespace: "every.channel".to_string(),
        name: stream_id.clone(),
    };

    let objects =
        tokio::task::spawn_blocking(move || -> Result<Vec<(u64, ObjectPayload, blake3::Hash)>> {
            let _ = fs::remove_dir_all(&chunk_dir);
            fs::create_dir_all(&chunk_dir)
                .with_context(|| format!("failed to create {}", chunk_dir.display()))?;

            let reader: Box<dyn Read + Send> = if input.starts_with("http://")
                || input.starts_with("https://")
            {
                Box::new(ec_hdhomerun::open_stream_url(&input, None)?)
            } else {
                Box::new(File::open(&input).with_context(|| format!("failed to open {}", input))?)
            };

            let mut objects = Vec::new();
            let out_dir = chunk_dir.join("cmaf");
            let (init_path, segments) =
                chunk_stream_cmaf_ffmpeg(reader, &out_dir, args.chunk_ms, max_chunks, true)?;

            // Index 0: init.mp4
            let (init_bytes, init_hash) = read_chunk_bytes_and_hash(&init_path)?;
            let init_chunk = TsChunk {
                index: 0,
                path: init_path,
                timing: ec_chopper::ChunkTiming {
                    chunk_index: 0,
                    chunk_start_27mhz: None,
                    chunk_duration_27mhz: 0,
                    utc_start_unix: None,
                    sync_status: "init".to_string(),
                },
            };
            let object = build_object(
                init_chunk,
                init_bytes,
                init_hash,
                None,
                None,
                None,
                "video/mp4",
                &track.name,
            )?;
            let hash = blake3::hash(&object.data);
            objects.push((0, object, hash));

            // Index 1..N: segments
            for (i, seg_path) in segments.into_iter().enumerate() {
                let index = (i as u64) + 1;
                let (bytes, hash_hex) = read_chunk_bytes_and_hash(&seg_path)?;
                let chunk = TsChunk {
                    index,
                    path: seg_path,
                    timing: ec_chopper::ChunkTiming {
                        chunk_index: index,
                        chunk_start_27mhz: None,
                        chunk_duration_27mhz: args.chunk_ms * 27_000,
                        utc_start_unix: None,
                        sync_status: "cmaf".to_string(),
                    },
                };
                let object = build_object(
                    chunk,
                    bytes,
                    hash_hex,
                    None,
                    None,
                    None,
                    "video/iso.segment",
                    &track.name,
                )?;
                let hash = blake3::hash(&object.data);
                objects.push((index, object, hash));
            }

            Ok(objects)
        })
        .await
        .map_err(|err| anyhow!("chunking task failed: {err}"))??;

    {
        let mut map = expected.lock().expect("mutex poisoned");
        for (index, _, hash) in &objects {
            map.insert(*index, *hash);
        }
        let _ = done_tx.send(map.len());
    }

    tokio::time::sleep(Duration::from_millis(250)).await;

    for (index, object, _) in objects {
        publisher.publish_object(GroupId(index), object)?;
        match tokio::time::timeout(Duration::from_secs(2), progress_rx.recv()).await {
            Ok(Some(received)) if received == index => {}
            Ok(Some(received)) => {
                tracing::warn!("selftest received chunk {received} while waiting for {index}");
            }
            Ok(None) => {
                return Err(anyhow!("selftest subscriber closed before chunk {index}"));
            }
            Err(_) => {
                return Err(anyhow!("selftest timed out waiting for chunk {index}"));
            }
        }
    }

    let received = tokio::time::timeout(Duration::from_secs(20), subscriber_task)
        .await
        .map_err(|_| anyhow!("selftest timed out waiting for subscriber"))?
        .map_err(|err| anyhow!("subscriber task failed: {err}"))??;

    let expected_map = expected.lock().expect("mutex poisoned");
    let mut mismatches = 0usize;

    for (index, expected_hash) in expected_map.iter() {
        match received.get(index) {
            Some(actual) if actual == expected_hash => {}
            Some(_) => {
                mismatches += 1;
                tracing::warn!("hash mismatch at chunk {index}");
            }
            None => {
                mismatches += 1;
                tracing::warn!("missing chunk {index}");
            }
        }
    }

    if received.len() > expected_map.len() {
        mismatches += received.len() - expected_map.len();
        tracing::warn!(
            "received extra chunks ({})",
            received.len() - expected_map.len()
        );
    }

    if mismatches > 0 {
        return Err(anyhow!("moq selftest failed with {mismatches} mismatches"));
    }

    println!(
        "moq selftest ok: {} chunks verified (broadcast {}, track {})",
        expected_map.len(),
        broadcast_name,
        track_name
    );

    Ok(())
}

#[derive(serde::Deserialize)]
struct TurnResp {
    ice_servers: Vec<ICEServer>,
}

async fn fetch_turn_ice_servers(client: &reqwest::Client, dir: &str) -> Option<Vec<ICEServer>> {
    let base = dir.trim_end_matches('/');
    let url = format!("{base}/api/turn");
    let res = client.get(url).send().await.ok()?;
    if !res.status().is_success() {
        return None;
    }
    let body: TurnResp = res.json().await.ok()?;
    if body.ice_servers.is_empty() {
        return None;
    }
    Some(body.ice_servers)
}

async fn direct_publish(args: DirectPublishArgs) -> Result<()> {
    fs::create_dir_all(&args.chunk_dir)
        .with_context(|| format!("failed to create {}", args.chunk_dir.display()))?;

    // For browser interop, we currently always normalize into deterministic H.264/AAC CMAF.
    // This also keeps the codec stable for MSE playback.
    let deterministic = true;

    let source: Box<dyn StreamSource + Send> = match args.source {
        IngestSource::Hls { url, .. } => Box::new(HlsSource {
            url,
            mode: HlsMode::Transcode,
        }),
        IngestSource::Hdhr {
            host,
            device_id,
            channel,
            name,
            prefer_mdns,
        } => Box::new(HdhrSource {
            host,
            device_id,
            channel,
            name,
            prefer_mdns,
        }),
        IngestSource::LinuxDvb {
            adapter,
            dvr,
            tune_cmd,
            tune_wait_ms,
        } => Box::new(LinuxDvbSource {
            adapter,
            dvr,
            tune_cmd,
            tune_wait_ms,
        }),
        IngestSource::Ts { input } => Box::new(TsSource { input }),
    };

    fn normalize_base(url: &str) -> String {
        url.trim_end_matches('/').to_string()
    }

    let directory_url = args.directory_url.as_deref().map(normalize_base);
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(8))
        .build()
        .with_context(|| "failed to build http client")?;

    let mut cfg = PeerConfiguration::default();
    if let Some(dir) = directory_url.as_deref() {
        if let Some(ice) = fetch_turn_ice_servers(&client, dir).await {
            cfg.ice_servers = ice;
        }
    }

    let stream_id = args.stream_id.clone().unwrap_or_else(|| {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_millis();
        format!("every.channel/direct/{ts}")
    });

    #[derive(serde::Serialize)]
    struct AnnounceReq<'a> {
        stream_id: &'a str,
        title: &'a str,
        offer: &'a str,
        expires_ms: u64,
    }

    #[derive(serde::Deserialize)]
    struct AnswerResp {
        answer: String,
    }
    let title = args.title.clone();
    let out_dir = args.chunk_dir.join("cmaf");
    let chunk_ms = args.chunk_ms;
    let max_segments = args.max_segments;
    let answer_override = args.answer.clone();

    loop {
        // New offerer per session so viewers can reconnect (or a new viewer can take over).
        let offerer = PeerConnectionBuilder::new()
            .set_config(cfg.clone())
            .with_channel_options(vec![(
                "simple_channel_".to_string(),
                DataChannelOptions {
                    ordered: Some(true),
                    ..Default::default()
                },
            )])
            .map_err(|e| anyhow!("{e:#}"))?
            .build()
            .await
            .map_err(|e| anyhow!("{e:#}"))?;

        let offer_desc = offerer
            .get_local_description()
            .await
            .ok_or_else(|| anyhow!("missing local offer description"))?;
        let offer_candidates = offerer
            .collect_ice_candidates()
            .await
            .map_err(|e| anyhow!("{e:#}"))?;
        let offer_link = encode_direct_link(&DirectCodeV1 {
            v: 1,
            desc: offer_desc,
            candidates: offer_candidates,
            label: Some("every.channel0".to_string()),
        })?;

        println!("{offer_link}");

        let stop_refresh = tokio::sync::watch::channel(false);
        if let Some(dir) = directory_url.as_deref() {
            let now = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_millis() as u64;
            let req = AnnounceReq {
                stream_id: &stream_id,
                title: &title,
                offer: &offer_link,
                expires_ms: now.saturating_add(args.announce_ttl_ms),
            };
            let url = format!("{dir}/api/announce");
            let res = client.post(url).json(&req).send().await?;
            if !res.status().is_success() {
                tracing::warn!("directory announce failed: {}", res.status());
            } else {
                tracing::info!("announced offer for {}", stream_id);
            }

            // Best-effort refresh while waiting for an answer (stops when the session starts).
            let mut stop_rx = stop_refresh.1.clone();
            let client2 = client.clone();
            let dir2 = dir.to_string();
            let stream_id2 = stream_id.clone();
            let title2 = title.clone();
            let offer2 = offer_link.clone();
            let ttl = args.announce_ttl_ms;
            tokio::spawn(async move {
                loop {
                    if *stop_rx.borrow() {
                        break;
                    }
                    tokio::time::sleep(Duration::from_millis(ttl.saturating_mul(3) / 4)).await;
                    if *stop_rx.borrow() {
                        break;
                    }
                    let now = SystemTime::now()
                        .duration_since(UNIX_EPOCH)
                        .unwrap_or_default()
                        .as_millis() as u64;
                    let req = AnnounceReq {
                        stream_id: &stream_id2,
                        title: &title2,
                        offer: &offer2,
                        expires_ms: now.saturating_add(ttl),
                    };
                    let url = format!("{dir2}/api/announce");
                    let fut = client2.post(url).json(&req).send();
                    let _ = tokio::time::timeout(Duration::from_secs(5), fut).await;
                }
            });
        }

        let answer = if let Some(answer) = answer_override.clone() {
            answer
        } else if let Some(dir) = directory_url.as_deref() {
            eprintln!("waiting for browser answer via {dir}/api/answer?stream_id=...");
            let url = format!(
                "{dir}/api/answer?stream_id={}",
                urlencoding::encode(&stream_id)
            );
            let deadline = if args.answer_timeout_secs == 0 {
                None
            } else {
                Some(Instant::now() + Duration::from_secs(args.answer_timeout_secs))
            };
            loop {
                if deadline.is_some_and(|d| Instant::now() > d) {
                    return Err(anyhow!(
                        "timed out waiting for answer for stream_id {stream_id}"
                    ));
                }
                match client.get(&url).send().await {
                    Ok(res) if res.status().is_success() => {
                        let body: AnswerResp = res.json().await?;
                        break body.answer;
                    }
                    _ => {
                        tokio::time::sleep(Duration::from_millis(300)).await;
                    }
                }
            }
        } else {
            eprintln!("paste direct answer link/code, then press enter:");
            let mut line = String::new();
            std::io::stdin().read_line(&mut line)?;
            line
        };

        // Stop refreshing announcements. If we keep the listing live while connected, people
        // will try to join and silently fail (this direct path is 1:1 today).
        let _ = stop_refresh.0.send(true);

        let answer = decode_direct_link(&answer)?;
        offerer
            .set_remote_description(answer.desc)
            .await
            .map_err(|e| anyhow!("{e:#}"))?;
        offerer
            .add_ice_candidates(answer.candidates)
            .await
            .map_err(|e| anyhow!("{e:#}"))?;

        let channel = offerer
            .receive_channel()
            .await
            .map_err(|e| anyhow!("{e:#}"))?;
        channel.wait_ready().await;
        eprintln!("direct channel open: {}", channel.label());

        // Detect viewer disconnect promptly. Relying only on `channel.send` errors can lag
        // (depending on buffering), leaving the directory entry effectively "stuck".
        let (pc_dead_tx, mut pc_dead_rx) = tokio::sync::oneshot::channel::<PeerConnectionState>();
        let (pc_stop_tx, mut pc_stop_rx) = tokio::sync::watch::channel(false);
        tokio::spawn(async move {
            loop {
                tokio::select! {
                    _ = pc_stop_rx.changed() => {
                        if *pc_stop_rx.borrow() {
                            break;
                        }
                    }
                    st = offerer.state_change() => {
                        if matches!(
                            st,
                            PeerConnectionState::Disconnected | PeerConnectionState::Failed | PeerConnectionState::Closed
                        ) {
                            let _ = pc_dead_tx.send(st);
                            break;
                        }
                    }
                }
            }
        });

        // Expire the listing quickly now that we have a live session.
        if let Some(dir) = directory_url.as_deref() {
            let now = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_millis() as u64;
            let req = AnnounceReq {
                stream_id: &stream_id,
                title: &title,
                offer: &offer_link,
                expires_ms: now.saturating_add(1500),
            };
            let url = format!("{dir}/api/announce");
            let _ = client.post(url).json(&req).send().await;
        }

        enum ChunkItem {
            Init(Vec<u8>),
            Segment { index: u64, bytes: Vec<u8> },
        }

        let (tx, mut rx) = mpsc::channel::<ChunkItem>(8);
        let reader = source.open_stream()?;
        let out_dir2 = out_dir.clone();
        let chunk_task = tokio::task::spawn_blocking(move || -> Result<()> {
            let _ = fs::remove_dir_all(&out_dir2);
            fs::create_dir_all(&out_dir2)
                .with_context(|| format!("failed to create {}", out_dir2.display()))?;

            let profile = if deterministic {
                ec_chopper::deterministic_h264_profile()
            } else {
                ec_chopper::deterministic_h264_profile()
            };

            let mut cmd = Command::new("ffmpeg");
            cmd.current_dir(&out_dir2);
            cmd.arg("-hide_banner")
                .arg("-loglevel")
                .arg("error")
                .arg("-nostdin")
                .arg("-y")
                .arg("-i")
                .arg("pipe:0")
                // predictable mapping
                .arg("-map")
                .arg("0:v:0")
                .arg("-map")
                .arg("0:a:0?")
                .arg("-sn")
                .arg("-dn")
                .arg("-map_metadata")
                .arg("-1")
                // reduce scheduling nondeterminism
                .arg("-filter_threads")
                .arg("1")
                .arg("-filter_complex_threads")
                .arg("1")
                .arg("-threads")
                .arg("1");

            for arg in ec_chopper::ffmpeg_profile_args(&profile) {
                cmd.arg(arg);
            }

            let seg_time = format!("{:.3}", chunk_ms as f64 / 1000.0);
            cmd.arg("-f")
                .arg("hls")
                .arg("-hls_time")
                .arg(seg_time)
                .arg("-hls_list_size")
                .arg("0")
                .arg("-hls_segment_type")
                .arg("fmp4")
                .arg("-hls_flags")
                .arg("independent_segments")
                .arg("-hls_fmp4_init_filename")
                .arg("init.mp4")
                .arg("-hls_segment_filename")
                .arg("segment_%06d.m4s")
                .arg("index.m3u8")
                .stdin(Stdio::piped())
                .stdout(Stdio::null())
                .stderr(Stdio::inherit());

            let mut child = cmd.spawn().with_context(|| "failed to spawn ffmpeg")?;
            let mut stdin = child
                .stdin
                .take()
                .ok_or_else(|| anyhow!("ffmpeg stdin unavailable"))?;
            let mut reader = reader;
            let writer = std::thread::spawn(move || -> Result<()> {
                std::io::copy(&mut reader, &mut stdin)?;
                Ok(())
            });

            let init_path = out_dir2.join("init.mp4");
            wait_for_stable_file(&init_path, Duration::from_secs(20))?;
            let init = fs::read(&init_path)?;
            tx.blocking_send(ChunkItem::Init(init))
                .map_err(|_| anyhow!("receiver closed"))?;

            for i in 0..max_segments {
                let seg_path = out_dir2.join(format!("segment_{i:06}.m4s"));
                match wait_for_stable_file(&seg_path, Duration::from_secs(30)) {
                    Ok(()) => {}
                    Err(err) => {
                        if let Ok(Some(status)) = child.try_wait() {
                            if status.success() {
                                break;
                            }
                            return Err(anyhow!("ffmpeg exited with {status} ({err:#})"));
                        }
                        return Err(err);
                    }
                }
                let bytes = fs::read(&seg_path)?;
                tx.blocking_send(ChunkItem::Segment {
                    index: (i as u64) + 1,
                    bytes,
                })
                .map_err(|_| anyhow!("receiver closed"))?;
            }

            let _ = child.kill();
            let _ = child.wait();
            let _ = writer.join();
            Ok(())
        });

        let mut send_failed = false;
        let mut have_heartbeat = false;
        let mut last_heartbeat = Instant::now();
        let mut heartbeat_check = tokio::time::interval(Duration::from_secs(1));
        heartbeat_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
        loop {
            tokio::select! {
                biased;
                st = (&mut pc_dead_rx) => {
                    match st {
                        Ok(st) => tracing::info!("peer connection ended ({st:?}); restarting session"),
                        Err(_) => tracing::info!("peer connection ended; restarting session"),
                    }
                    send_failed = true;
                    break;
                }
                _ = heartbeat_check.tick() => {
                    if have_heartbeat && last_heartbeat.elapsed() > DIRECT_HEARTBEAT_TIMEOUT {
                        tracing::info!("direct session heartbeat timed out; restarting session");
                        send_failed = true;
                        break;
                    }
                }
                msg = channel.receive() => {
                    match msg {
                        Ok(b) => {
                            // Any message from the subscriber counts as a heartbeat today.
                            // (We reserve DIRECT_WIRE_TAG_PING for explicit pings, but don't require it.)
                            have_heartbeat = true;
                            last_heartbeat = Instant::now();
                            if !b.is_empty() && b[0] == DIRECT_WIRE_TAG_PING {
                                // ignore
                            }
                        }
                        Err(_) => {
                            send_failed = true;
                            break;
                        }
                    }
                }
                item = rx.recv() => {
                    let Some(item) = item else { break };
                    let (index, content_type, bytes, duration_27mhz, sync_status) = match item {
                        ChunkItem::Init(bytes) => (0u64, "video/mp4", bytes, 0u64, "init"),
                        ChunkItem::Segment { index, bytes } => {
                            (index, "video/iso.segment", bytes, chunk_ms * 27_000, "cmaf")
                        }
                    };
                    if index == 0 {
                        tracing::info!("direct send: init.mp4 ({} bytes)", bytes.len());
                    } else if index % 5 == 0 {
                        tracing::info!("direct send: segment {index} ({} bytes)", bytes.len());
                    }
                    let hash = blake3::hash(&bytes).to_hex().to_string();
                    let meta = ObjectMeta {
                        created_unix_ms: SystemTime::now()
                            .duration_since(UNIX_EPOCH)
                            .unwrap_or_default()
                            .as_millis() as u64,
                        content_type: content_type.to_string(),
                        size_bytes: bytes.len() as u64,
                        timing: Some(TimingMeta {
                            chunk_index: index,
                            chunk_start_27mhz: 0,
                            chunk_duration_27mhz: duration_27mhz,
                            utc_start_unix: None,
                            sync_status: sync_status.to_string(),
                        }),
                        encryption: None,
                        chunk_hash: Some(hash),
                        chunk_hash_alg: Some("blake3".to_string()),
                        chunk_proof: None,
                        chunk_proof_alg: None,
                        manifest_id: None,
                    };
                    let frame = encode_object_frame(&meta, &bytes)?;
                    match tokio::time::timeout(
                        Duration::from_secs(5),
                        direct_wire_send_frame(&channel, &frame),
                    )
                    .await
                    {
                        Ok(Ok(())) => {}
                        Ok(Err(err)) => {
                            tracing::warn!("direct send failed (restarting session): {err:#}");
                            send_failed = true;
                            break;
                        }
                        Err(_) => {
                            tracing::warn!("direct send timed out (restarting session)");
                            send_failed = true;
                            break;
                        }
                    }
                }
            }
        }

        // Ensure the peer connection is dropped if we restart for non-ICE reasons (e.g. ffmpeg end).
        let _ = pc_stop_tx.send(true);

        drop(rx);
        match chunk_task.await {
            Ok(Ok(())) => {}
            Ok(Err(err)) => {
                // Common when the viewer disconnects: the receiver is dropped and the
                // blocking sender errors out. Treat as a reconnect.
                tracing::debug!("chunk task ended: {err:#}");
            }
            Err(err) => tracing::debug!("chunk task join error: {err}"),
        }

        if answer_override.is_some() {
            // For manual mode, we can't reasonably loop.
            break;
        }
        if !send_failed {
            // ffmpeg ended or source ended; try again.
            tokio::time::sleep(Duration::from_millis(500)).await;
        }
    }

    Ok(())
}

async fn direct_wire_send_frame(channel: &impl DataChannelExt, frame: &[u8]) -> Result<()> {
    let len = u32::try_from(frame.len()).map_err(|_| anyhow!("frame too large"))?;
    let mut stream = Vec::with_capacity(4 + frame.len());
    stream.extend_from_slice(&len.to_be_bytes());
    stream.extend_from_slice(frame);

    for chunk in stream.chunks(DIRECT_WIRE_CHUNK_BYTES) {
        let mut msg = Vec::with_capacity(1 + chunk.len());
        msg.push(DIRECT_WIRE_TAG_STREAM);
        msg.extend_from_slice(chunk);
        channel
            .send(&bytes::Bytes::from(msg))
            .await
            .map_err(|e| anyhow!("{e:#}"))?;
    }
    Ok(())
}

#[derive(Debug, Default)]
struct DirectWireDecoder {
    buf: Vec<u8>,
    pos: usize,
    want: Option<usize>,
}

impl DirectWireDecoder {
    fn push(&mut self, msg: &[u8]) -> Result<Vec<Vec<u8>>> {
        if msg.is_empty() {
            return Ok(Vec::new());
        }

        match msg[0] {
            DIRECT_WIRE_TAG_FRAME => return Ok(vec![msg[1..].to_vec()]),
            DIRECT_WIRE_TAG_STREAM => {
                self.buf.extend_from_slice(&msg[1..]);
            }
            DIRECT_WIRE_TAG_PING => {
                // Control message; ignore.
                return Ok(Vec::new());
            }
            _ => {
                // Unknown tag: treat as legacy "whole frame per message".
                return Ok(vec![msg.to_vec()]);
            }
        }

        let mut out = Vec::new();
        loop {
            if self.want.is_none() {
                if self.buf.len().saturating_sub(self.pos) < 4 {
                    break;
                }
                let start = self.pos;
                let meta = &self.buf[start..start + 4];
                let len = u32::from_be_bytes([meta[0], meta[1], meta[2], meta[3]]) as usize;
                self.pos += 4;
                self.want = Some(len);
            }

            let Some(want) = self.want else { break };
            if self.buf.len().saturating_sub(self.pos) < want {
                break;
            }
            let start = self.pos;
            let end = start + want;
            out.push(self.buf[start..end].to_vec());
            self.pos = end;
            self.want = None;

            // Avoid unbounded growth: occasionally compact the buffer.
            if self.pos > 64 * 1024 {
                self.buf.drain(0..self.pos);
                self.pos = 0;
            }
        }
        Ok(out)
    }
}

async fn direct_subscribe(args: DirectSubscribeArgs) -> Result<()> {
    fs::create_dir_all(&args.out_dir)
        .with_context(|| format!("failed to create {}", args.out_dir.display()))?;

    #[derive(serde::Deserialize)]
    struct DirectoryResp {
        entries: Vec<DirectoryEntry>,
    }

    #[derive(serde::Deserialize)]
    struct DirectoryEntry {
        stream_id: String,
        title: String,
        offer: String,
        #[allow(dead_code)]
        expires_ms: Option<u64>,
    }

    fn normalize_base(url: &str) -> String {
        url.trim_end_matches('/').to_string()
    }

    let dir = normalize_base(&args.directory_url);
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(8))
        .build()
        .with_context(|| "failed to build http client")?;

    let (stream_id, offer_link) = match (args.stream_id.clone(), args.offer.clone()) {
        (_, Some(offer)) => (args.stream_id.clone(), offer),
        (Some(stream_id), None) => {
            let url = format!("{dir}/api/directory");
            let res = client.get(&url).send().await?;
            if !res.status().is_success() {
                return Err(anyhow!("directory GET failed: {}", res.status()));
            }
            let body: DirectoryResp = res.json().await?;
            let entry = body
                .entries
                .into_iter()
                .find(|e| e.stream_id == stream_id)
                .ok_or_else(|| anyhow!("stream_id not found in directory: {stream_id}"))?;
            (Some(stream_id), entry.offer)
        }
        (None, None) => {
            return Err(anyhow!("either --offer or --stream-id is required"));
        }
    };

    let offer = decode_direct_link(&offer_link)?;
    let mut cfg = PeerConfiguration::default();
    if let Some(ice) = fetch_turn_ice_servers(&client, &dir).await {
        cfg.ice_servers = ice;
    }
    let pc = PeerConnectionBuilder::new()
        .set_config(cfg)
        .with_remote_offer(Some(offer.desc))
        .map_err(|e| anyhow!("{e:#}"))?
        .build()
        .await
        .map_err(|e| anyhow!("{e:#}"))?;
    pc.add_ice_candidates(offer.candidates)
        .await
        .map_err(|e| anyhow!("{e:#}"))?;

    // Build our answer link (used both for directory POST and manual copy/paste).
    let desc = pc
        .get_local_description()
        .await
        .ok_or_else(|| anyhow!("missing local answer description"))?;
    let candidates = pc
        .collect_ice_candidates()
        .await
        .map_err(|e| anyhow!("{e:#}"))?;
    let answer_link = encode_direct_link(&DirectCodeV1 {
        v: 1,
        desc,
        candidates,
        label: Some("every.channel0".to_string()),
    })?;

    if let Some(stream_id) = stream_id.as_deref() {
        #[derive(serde::Serialize)]
        struct AnswerReq<'a> {
            stream_id: &'a str,
            answer: &'a str,
        }
        let url = format!("{dir}/api/answer");
        let res = client
            .post(url)
            .json(&AnswerReq {
                stream_id,
                answer: &answer_link,
            })
            .send()
            .await?;
        if !res.status().is_success() {
            return Err(anyhow!(
                "directory POST /api/answer failed: {}",
                res.status()
            ));
        }
    } else {
        eprintln!("answer link (paste into publisher):\n{answer_link}");
    }

    let ch = pc.receive_channel().await.map_err(|e| anyhow!("{e:#}"))?;
    ch.wait_ready().await;
    eprintln!("direct channel open: {}", ch.label());

    let cmaf_dir = args.out_dir.join("cmaf");
    fs::create_dir_all(&cmaf_dir)
        .with_context(|| format!("failed to create {}", cmaf_dir.display()))?;

    let mut init_written = false;
    let mut durations = Vec::<f64>::new();
    let mut captured_segments = 0usize;
    let mut decoder = DirectWireDecoder::default();

    let mut ping = tokio::time::interval(Duration::from_secs(1));
    ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);

    let deadline = args
        .duration_secs
        .map(|s| Instant::now() + Duration::from_secs(s));
    while captured_segments < args.max_segments {
        if deadline.is_some_and(|d| Instant::now() > d) {
            break;
        }
        let msg = tokio::select! {
            _ = ping.tick() => {
                // Heartbeat to help the publisher detect disconnects promptly.
                let _ = ch.send(&bytes::Bytes::from(vec![DIRECT_WIRE_TAG_PING])).await;
                continue;
            }
            msg = ch.receive() => msg.map_err(|e| anyhow!("{e:#}"))?,
        };
        for frame in decoder.push(&msg)? {
            let payload = decode_object_frame(&frame)?;
            let idx = payload
                .meta
                .timing
                .as_ref()
                .map(|t| t.chunk_index)
                .unwrap_or(0);

            if idx == 0 {
                tracing::info!("direct recv: init.mp4 ({} bytes)", payload.data.len());
                let path = cmaf_dir.join("init.mp4");
                fs::write(&path, &payload.data)
                    .with_context(|| format!("failed to write {}", path.display()))?;
                init_written = true;
                continue;
            }

            if !init_written {
                // Ignore segments until init arrives.
                continue;
            }

            // Publisher indexes segments starting at 1; our filenames start at 0.
            let seg = idx.saturating_sub(1);
            let path = cmaf_dir.join(format!("segment_{seg:06}.m4s"));
            fs::write(&path, &payload.data)
                .with_context(|| format!("failed to write {}", path.display()))?;
            tracing::info!("direct recv: segment {idx} ({} bytes)", payload.data.len());

            let dur = payload
                .meta
                .timing
                .as_ref()
                .map(|t| t.chunk_duration_27mhz as f64 / 27_000_000.0)
                .unwrap_or(2.0);
            durations.push(dur);
            captured_segments += 1;
            if captured_segments >= args.max_segments {
                break;
            }
        }
    }

    if !init_written || durations.is_empty() {
        return Err(anyhow!("no media captured (missing init or segments)"));
    }

    // Write a VOD playlist so ffmpeg can remux the fragments.
    let target = durations
        .iter()
        .copied()
        .fold(0.0_f64, f64::max)
        .ceil()
        .max(1.0) as u64;
    let mut m3u8 = String::new();
    m3u8.push_str("#EXTM3U\n");
    m3u8.push_str("#EXT-X-VERSION:7\n");
    m3u8.push_str(&format!("#EXT-X-TARGETDURATION:{target}\n"));
    m3u8.push_str("#EXT-X-PLAYLIST-TYPE:VOD\n");
    m3u8.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
    m3u8.push_str("#EXT-X-MAP:URI=\"init.mp4\"\n");
    for (i, dur) in durations.iter().enumerate() {
        m3u8.push_str(&format!("#EXTINF:{:.3},\n", dur));
        m3u8.push_str(&format!("segment_{i:06}.m4s\n"));
    }
    m3u8.push_str("#EXT-X-ENDLIST\n");
    let playlist_path = cmaf_dir.join("index.m3u8");
    fs::write(&playlist_path, m3u8)
        .with_context(|| format!("failed to write {}", playlist_path.display()))?;

    let mp4_path = args
        .mp4
        .clone()
        .unwrap_or_else(|| args.out_dir.join("capture.mp4"));
    let status = Command::new("ffmpeg")
        .arg("-hide_banner")
        .arg("-loglevel")
        .arg("error")
        .arg("-nostdin")
        .arg("-y")
        .arg("-protocol_whitelist")
        .arg("file,crypto,data")
        .arg("-i")
        .arg(&playlist_path)
        .arg("-c")
        .arg("copy")
        .arg(&mp4_path)
        .status();
    match status {
        Ok(s) if s.success() => {
            println!("{}", mp4_path.display());
        }
        Ok(s) => {
            eprintln!("ffmpeg remux failed: {s}");
            println!("{}", playlist_path.display());
        }
        Err(err) => {
            eprintln!("failed to run ffmpeg: {err}");
            println!("{}", playlist_path.display());
        }
    }

    Ok(())
}

fn ws_url_for(base: &str, stream_id: &str, role: &str) -> String {
    let base = base.trim_end_matches('/');
    let ws_base = if let Some(rest) = base.strip_prefix("https://") {
        format!("wss://{rest}")
    } else if let Some(rest) = base.strip_prefix("http://") {
        format!("ws://{rest}")
    } else if base.starts_with("ws://") || base.starts_with("wss://") {
        base.to_string()
    } else {
        format!("wss://{base}")
    };
    format!(
        "{ws_base}/api/stream/ws?stream_id={}&role={role}",
        urlencoding::encode(stream_id)
    )
}

async fn ws_send_frame(
    ws: &mut tokio_tungstenite::WebSocketStream<
        tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
    >,
    frame: &[u8],
) -> Result<()> {
    let len = u32::try_from(frame.len()).map_err(|_| anyhow!("frame too large"))?;
    let mut stream = Vec::with_capacity(4 + frame.len());
    stream.extend_from_slice(&len.to_be_bytes());
    stream.extend_from_slice(frame);
    for chunk in stream.chunks(DIRECT_WIRE_CHUNK_BYTES) {
        let mut msg = Vec::with_capacity(1 + chunk.len());
        msg.push(DIRECT_WIRE_TAG_STREAM);
        msg.extend_from_slice(chunk);
        ws.send(WsMessage::Binary(msg)).await?;
    }
    Ok(())
}

async fn ws_publish(args: WsPublishArgs) -> Result<()> {
    fs::create_dir_all(&args.chunk_dir)
        .with_context(|| format!("failed to create {}", args.chunk_dir.display()))?;

    // For browser interop, we currently always normalize into deterministic H.264/AAC CMAF.
    let deterministic = true;

    let source: Box<dyn StreamSource + Send> = match args.source {
        IngestSource::Hls { url, .. } => Box::new(HlsSource {
            url,
            mode: HlsMode::Transcode,
        }),
        IngestSource::Hdhr {
            host,
            device_id,
            channel,
            name,
            prefer_mdns,
        } => Box::new(HdhrSource {
            host,
            device_id,
            channel,
            name,
            prefer_mdns,
        }),
        IngestSource::LinuxDvb {
            adapter,
            dvr,
            tune_cmd,
            tune_wait_ms,
        } => Box::new(LinuxDvbSource {
            adapter,
            dvr,
            tune_cmd,
            tune_wait_ms,
        }),
        IngestSource::Ts { input } => Box::new(TsSource { input }),
    };

    fn normalize_base(url: &str) -> String {
        url.trim_end_matches('/').to_string()
    }

    let directory_url = normalize_base(&args.directory_url);
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(8))
        .build()
        .with_context(|| "failed to build http client")?;

    let stream_id = args.stream_id.clone().unwrap_or_else(|| {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_millis();
        format!("every.channel/ws/{ts}")
    });
    let title = args.title.clone();

    #[derive(serde::Serialize)]
    struct AnnounceReq<'a> {
        stream_id: &'a str,
        title: &'a str,
        offer: &'a str,
        expires_ms: u64,
    }

    // Directory listing is still "offer"-shaped today; keep a non-empty placeholder for legacy clients.
    let offer_placeholder = format!("every.channel://watch?stream_id={}", stream_id);

    // Refresh listing forever while publishing (one-to-many relay supports multiple viewers).
    let ttl = args.announce_ttl_ms;
    let client2 = client.clone();
    let dir2 = directory_url.clone();
    let stream_id2 = stream_id.clone();
    let title2 = title.clone();
    let offer2 = offer_placeholder.clone();
    tokio::spawn(async move {
        loop {
            let now = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_millis() as u64;
            let req = AnnounceReq {
                stream_id: &stream_id2,
                title: &title2,
                offer: &offer2,
                expires_ms: now.saturating_add(ttl),
            };
            let url = format!("{dir2}/api/announce");
            let _ =
                tokio::time::timeout(Duration::from_secs(5), client2.post(url).json(&req).send())
                    .await;
            tokio::time::sleep(Duration::from_millis(ttl.saturating_mul(3) / 4)).await;
        }
    });

    // Connect to relay websocket as publisher.
    let ws_url = ws_url_for(&directory_url, &stream_id, "pub");
    eprintln!("ws publish: {stream_id}");
    eprintln!("ws url: {ws_url}");
    let (mut ws, _resp) = tokio_tungstenite::connect_async(&ws_url).await?;

    enum ChunkItem {
        Init(Vec<u8>),
        Segment { index: u64, bytes: Vec<u8> },
    }
    let out_dir = args.chunk_dir.join("cmaf");
    let chunk_ms = args.chunk_ms;
    let max_segments = args.max_segments;

    let (tx, mut rx) = mpsc::channel::<ChunkItem>(8);
    let reader = source.open_stream()?;
    let out_dir2 = out_dir.clone();
    let chunk_task = tokio::task::spawn_blocking(move || -> Result<()> {
        let _ = fs::remove_dir_all(&out_dir2);
        fs::create_dir_all(&out_dir2)
            .with_context(|| format!("failed to create {}", out_dir2.display()))?;

        let profile = if deterministic {
            ec_chopper::deterministic_h264_profile()
        } else {
            ec_chopper::deterministic_h264_profile()
        };

        let mut cmd = Command::new("ffmpeg");
        cmd.current_dir(&out_dir2);
        cmd.arg("-hide_banner")
            .arg("-loglevel")
            .arg("error")
            .arg("-nostdin")
            .arg("-y")
            .arg("-i")
            .arg("pipe:0")
            // predictable mapping
            .arg("-map")
            .arg("0:v:0")
            .arg("-map")
            .arg("0:a:0?")
            .arg("-sn")
            .arg("-dn")
            .arg("-map_metadata")
            .arg("-1")
            // reduce scheduling nondeterminism
            .arg("-filter_threads")
            .arg("1")
            .arg("-filter_complex_threads")
            .arg("1")
            .arg("-threads")
            .arg("1");

        for arg in ec_chopper::ffmpeg_profile_args(&profile) {
            cmd.arg(arg);
        }

        let seg_time = format!("{:.3}", chunk_ms as f64 / 1000.0);
        cmd.arg("-f")
            .arg("hls")
            .arg("-hls_time")
            .arg(seg_time)
            .arg("-hls_list_size")
            .arg("0")
            .arg("-hls_segment_type")
            .arg("fmp4")
            .arg("-hls_flags")
            .arg("independent_segments")
            .arg("-hls_fmp4_init_filename")
            .arg("init.mp4")
            .arg("-hls_segment_filename")
            .arg("segment_%06d.m4s")
            .arg("index.m3u8")
            .stdin(Stdio::piped())
            .stdout(Stdio::null())
            .stderr(Stdio::inherit());

        let mut child = cmd.spawn().with_context(|| "failed to spawn ffmpeg")?;
        let mut stdin = child
            .stdin
            .take()
            .ok_or_else(|| anyhow!("ffmpeg stdin unavailable"))?;
        let mut reader = reader;
        let writer = std::thread::spawn(move || -> Result<()> {
            std::io::copy(&mut reader, &mut stdin)?;
            Ok(())
        });

        let init_path = out_dir2.join("init.mp4");
        wait_for_stable_file(&init_path, Duration::from_secs(20))?;
        let init = fs::read(&init_path)?;
        tx.blocking_send(ChunkItem::Init(init))
            .map_err(|_| anyhow!("receiver closed"))?;

        for i in 0..max_segments {
            let seg_path = out_dir2.join(format!("segment_{i:06}.m4s"));
            match wait_for_stable_file(&seg_path, Duration::from_secs(30)) {
                Ok(()) => {}
                Err(err) => {
                    if let Ok(Some(status)) = child.try_wait() {
                        if status.success() {
                            break;
                        }
                        return Err(anyhow!("ffmpeg exited with {status} ({err:#})"));
                    }
                    return Err(err);
                }
            }
            let bytes = fs::read(&seg_path)?;
            tx.blocking_send(ChunkItem::Segment {
                index: (i as u64) + 1,
                bytes,
            })
            .map_err(|_| anyhow!("receiver closed"))?;
        }

        let _ = child.kill();
        let _ = child.wait();
        let _ = writer.join();
        Ok(())
    });

    while let Some(item) = rx.recv().await {
        let (index, content_type, bytes, duration_27mhz, sync_status) = match item {
            ChunkItem::Init(bytes) => (0u64, "video/mp4", bytes, 0u64, "init"),
            ChunkItem::Segment { index, bytes } => {
                (index, "video/iso.segment", bytes, chunk_ms * 27_000, "cmaf")
            }
        };
        if index == 0 {
            tracing::info!("ws send: init.mp4 ({} bytes)", bytes.len());
        } else if index % 10 == 0 {
            tracing::info!("ws send: segment {index} ({} bytes)", bytes.len());
        }

        let hash = blake3::hash(&bytes).to_hex().to_string();
        let meta = ObjectMeta {
            created_unix_ms: SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_millis() as u64,
            content_type: content_type.to_string(),
            size_bytes: bytes.len() as u64,
            timing: Some(TimingMeta {
                chunk_index: index,
                chunk_start_27mhz: 0,
                chunk_duration_27mhz: duration_27mhz,
                utc_start_unix: None,
                sync_status: sync_status.to_string(),
            }),
            encryption: None,
            chunk_hash: Some(hash),
            chunk_hash_alg: Some("blake3".to_string()),
            chunk_proof: None,
            chunk_proof_alg: None,
            manifest_id: None,
        };
        let frame = encode_object_frame(&meta, &bytes)?;
        tokio::time::timeout(Duration::from_secs(5), ws_send_frame(&mut ws, &frame)).await??;
    }

    match chunk_task.await {
        Ok(Ok(())) => Ok(()),
        Ok(Err(err)) => Err(err),
        Err(err) => Err(anyhow!("chunk task join error: {err}")),
    }
}

async fn ws_subscribe(args: WsSubscribeArgs) -> Result<()> {
    fs::create_dir_all(&args.out_dir)
        .with_context(|| format!("failed to create {}", args.out_dir.display()))?;

    let ws_url = ws_url_for(&args.directory_url, &args.stream_id, "sub");
    eprintln!("ws subscribe: {} ({ws_url})", args.stream_id);
    let (mut ws, _resp) = tokio_tungstenite::connect_async(&ws_url).await?;

    let cmaf_dir = args.out_dir.join("cmaf");
    fs::create_dir_all(&cmaf_dir)
        .with_context(|| format!("failed to create {}", cmaf_dir.display()))?;

    let mut init_written = false;
    let mut durations = Vec::<f64>::new();
    let mut captured_segments = 0usize;
    let mut decoder = DirectWireDecoder::default();

    let deadline = args
        .duration_secs
        .map(|s| Instant::now() + Duration::from_secs(s));

    while captured_segments < args.max_segments {
        if deadline.is_some_and(|d| Instant::now() > d) {
            break;
        }
        let next = ws
            .next()
            .await
            .ok_or_else(|| anyhow!("websocket closed"))??;
        let bytes = match next {
            WsMessage::Binary(b) => b,
            WsMessage::Close(_) => break,
            _ => continue,
        };
        for frame in decoder.push(&bytes)? {
            let payload = decode_object_frame(&frame)?;
            let idx = payload
                .meta
                .timing
                .as_ref()
                .map(|t| t.chunk_index)
                .unwrap_or(0);

            if idx == 0 {
                tracing::info!("ws recv: init.mp4 ({} bytes)", payload.data.len());
                let path = cmaf_dir.join("init.mp4");
                fs::write(&path, &payload.data)
                    .with_context(|| format!("failed to write {}", path.display()))?;
                init_written = true;
                continue;
            }

            if !init_written {
                continue;
            }

            let seg = idx.saturating_sub(1);
            let path = cmaf_dir.join(format!("segment_{seg:06}.m4s"));
            fs::write(&path, &payload.data)
                .with_context(|| format!("failed to write {}", path.display()))?;
            if idx % 10 == 0 {
                tracing::info!("ws recv: segment {idx} ({} bytes)", payload.data.len());
            }

            let dur = payload
                .meta
                .timing
                .as_ref()
                .map(|t| t.chunk_duration_27mhz as f64 / 27_000_000.0)
                .unwrap_or(2.0);
            durations.push(dur);
            captured_segments += 1;
            if captured_segments >= args.max_segments {
                break;
            }
        }
    }

    if !init_written || durations.is_empty() {
        return Err(anyhow!("no media captured (missing init or segments)"));
    }

    let target = durations
        .iter()
        .copied()
        .fold(0.0_f64, f64::max)
        .ceil()
        .max(1.0) as u64;
    let mut m3u8 = String::new();
    m3u8.push_str("#EXTM3U\n");
    m3u8.push_str("#EXT-X-VERSION:7\n");
    m3u8.push_str(&format!("#EXT-X-TARGETDURATION:{target}\n"));
    m3u8.push_str("#EXT-X-PLAYLIST-TYPE:VOD\n");
    m3u8.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
    m3u8.push_str("#EXT-X-MAP:URI=\"init.mp4\"\n");
    for (i, dur) in durations.iter().enumerate() {
        m3u8.push_str(&format!("#EXTINF:{:.3},\n", dur));
        m3u8.push_str(&format!("segment_{i:06}.m4s\n"));
    }
    m3u8.push_str("#EXT-X-ENDLIST\n");
    let playlist_path = cmaf_dir.join("index.m3u8");
    fs::write(&playlist_path, m3u8)
        .with_context(|| format!("failed to write {}", playlist_path.display()))?;

    let mp4_path = args
        .mp4
        .clone()
        .unwrap_or_else(|| args.out_dir.join("capture.mp4"));
    let status = Command::new("ffmpeg")
        .arg("-hide_banner")
        .arg("-loglevel")
        .arg("error")
        .arg("-nostdin")
        .arg("-y")
        .arg("-protocol_whitelist")
        .arg("file,crypto,data")
        .arg("-i")
        .arg(&playlist_path)
        .arg("-c")
        .arg("copy")
        .arg(&mp4_path)
        .status();
    match status {
        Ok(s) if s.success() => {
            println!("{}", mp4_path.display());
        }
        Ok(s) => {
            eprintln!("ffmpeg remux failed: {s}");
            println!("{}", playlist_path.display());
        }
        Err(err) => {
            eprintln!("failed to run ffmpeg: {err}");
            println!("{}", playlist_path.display());
        }
    }

    Ok(())
}

fn parse_iroh_secret(value: Option<String>) -> Result<Option<iroh::SecretKey>> {
    let value = value.or_else(|| std::env::var("IROH_SECRET").ok());
    let Some(value) = value else { return Ok(None) };
    let secret =
        iroh::SecretKey::from_str(&value).with_context(|| "failed to parse IROH_SECRET")?;
    Ok(Some(secret))
}

fn parse_discovery(value: Option<&str>) -> Result<DiscoveryConfig> {
    match value {
        Some(value) => DiscoveryConfig::from_list(value),
        None => DiscoveryConfig::from_env(),
    }
}

fn parse_gossip_peers(mut peers: Vec<String>) -> Vec<String> {
    if let Ok(env_peers) = std::env::var("EVERY_CHANNEL_GOSSIP_PEERS") {
        for raw in env_peers.split(|c: char| c == ',' || c == ';' || c.is_whitespace()) {
            let peer = raw.trim();
            if peer.is_empty() {
                continue;
            }
            if !peers.iter().any(|existing| existing == peer) {
                peers.push(peer.to_string());
            }
        }
    }
    peers
}

async fn spawn_catalog_announcer(
    node: &MoqNode,
    track: &TrackName,
    broadcast_name: &str,
    track_name: &str,
    peers: Vec<String>,
) -> Result<tokio::sync::mpsc::UnboundedSender<ManifestSummary>> {
    let endpoint = serde_json::to_string(&node.endpoint_addr())
        .unwrap_or_else(|_| node.endpoint().id().to_string());
    let track = track.clone();
    let broadcast_name = broadcast_name.to_string();
    let track_name = track_name.to_string();
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<ManifestSummary>();
    let endpoint_clone = endpoint.clone();
    let node_endpoint = node.endpoint().clone();

    tokio::spawn(async move {
        let mut gossip = match ec_iroh::CatalogGossip::join(node_endpoint, &peers).await {
            Ok(gossip) => gossip,
            Err(err) => {
                tracing::warn!("catalog gossip join failed: {err:#}");
                return;
            }
        };
        let entry = build_catalog_entry(
            endpoint_clone.clone(),
            &track,
            &broadcast_name,
            &track_name,
            None,
        );
        if let Err(err) = gossip.announce(entry).await {
            tracing::warn!("catalog announce failed: {err:#}");
        }
        while let Some(summary) = rx.recv().await {
            let entry = build_catalog_entry(
                endpoint_clone.clone(),
                &track,
                &broadcast_name,
                &track_name,
                Some(summary),
            );
            if let Err(err) = gossip.announce(entry).await {
                tracing::warn!("catalog update failed: {err:#}");
            }
        }
    });

    Ok(tx)
}

fn build_catalog_entry(
    endpoint: String,
    track: &TrackName,
    broadcast_name: &str,
    track_name: &str,
    manifest: Option<ManifestSummary>,
) -> StreamCatalogEntry {
    let stream = StreamDescriptor {
        id: StreamId(track.name.clone()),
        title: track.name.clone(),
        number: None,
        source: "moq".to_string(),
        metadata: vec![StreamMetadata {
            key: "broadcast".to_string(),
            value: broadcast_name.to_string(),
        }],
        commitments: Vec::new(),
    };
    let mut stream = stream;
    stream.commitments = stream_descriptor_commitments(&stream);

    let encryption = StreamEncryptionInfo {
        alg: ENCRYPTION_ALG.to_string(),
        key_id: track.name.clone(),
        nonce_scheme: "stream-id+chunk-index".to_string(),
    };

    let moq = MoqStreamDescriptor {
        endpoint,
        broadcast_name: broadcast_name.to_string(),
        track_name: track_name.to_string(),
        encryption: Some(encryption),
    };

    StreamCatalogEntry {
        stream,
        moq: Some(moq),
        manifest,
        updated_unix_ms: SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_millis() as u64,
    }
}

fn now_unix_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis() as u64
}

fn build_control_announcement(
    stream_id: String,
    title: String,
    transports: Vec<StreamTransportDescriptor>,
    ttl_ms: u64,
) -> StreamControlAnnouncement {
    let mut stream = StreamDescriptor {
        id: StreamId(stream_id.clone()),
        title,
        number: None,
        source: "control".to_string(),
        metadata: vec![StreamMetadata {
            key: "stream_id".to_string(),
            value: stream_id,
        }],
        commitments: Vec::new(),
    };
    stream.commitments = stream_descriptor_commitments(&stream);

    let mut announcement = StreamControlAnnouncement {
        stream,
        transports,
        updated_unix_ms: now_unix_ms(),
        ttl_ms,
        commitments: Vec::new(),
    };
    announcement.commitments = control_announcement_commitments(&announcement);
    announcement
}

async fn spawn_control_announcer_task(
    endpoint: iroh::Endpoint,
    peers: Vec<String>,
    mut announcement: StreamControlAnnouncement,
    interval: Duration,
) -> Result<oneshot::Sender<()>> {
    let mut gossip = tokio::time::timeout(
        Duration::from_secs(10),
        ec_iroh::ControlGossip::join(endpoint, &peers),
    )
    .await
    .context("timed out joining control gossip topic")??;
    let (stop_tx, mut stop_rx) = oneshot::channel::<()>();

    tokio::spawn(async move {
        let mut ticker = tokio::time::interval(interval.max(Duration::from_secs(1)));
        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);

        loop {
            announcement.updated_unix_ms = now_unix_ms();
            if let Err(err) = gossip.announce(announcement.clone()).await {
                tracing::warn!("control announce failed: {err:#}");
            }

            tokio::select! {
                _ = ticker.tick() => {}
                _ = &mut stop_rx => break,
            }
        }
    });

    Ok(stop_tx)
}

async fn control_announce(args: ControlAnnounceArgs) -> Result<()> {
    let secret = parse_iroh_secret(args.iroh_secret)?;
    let discovery = parse_discovery(args.discovery.as_deref())?;
    let endpoint = ec_iroh::build_endpoint(secret, discovery).await?;
    let gossip_peers = parse_gossip_peers(args.gossip_peer.clone());
    let endpoint_addr =
        serde_json::to_string(&endpoint.addr()).unwrap_or_else(|_| endpoint.id().to_string());

    let mut transports = Vec::new();

    if let (Some(url), Some(broadcast_name)) =
        (args.relay_url.as_ref(), args.relay_broadcast.as_ref())
    {
        transports.push(StreamTransportDescriptor::RelayMoq {
            url: url.clone(),
            broadcast_name: broadcast_name.clone(),
            track_name: args.relay_track.clone(),
        });
    }

    if let Some(broadcast_name) = args.direct_broadcast.as_ref() {
        let endpoint_addr = args
            .direct_endpoint
            .clone()
            .unwrap_or_else(|| endpoint.id().to_string());
        transports.push(StreamTransportDescriptor::IrohDirect {
            endpoint: endpoint_addr,
            broadcast_name: broadcast_name.clone(),
            track_name: args.direct_track.clone(),
        });
    }

    if transports.is_empty() {
        return Err(anyhow!(
            "no transports configured; set relay (--relay-url + --relay-broadcast) and/or direct (--direct-broadcast)"
        ));
    }

    let title = args.title.clone().unwrap_or_else(|| args.stream_id.clone());
    let announcement =
        build_control_announcement(args.stream_id.clone(), title, transports, args.ttl_ms);

    let stop_tx = spawn_control_announcer_task(
        endpoint.clone(),
        gossip_peers,
        announcement,
        Duration::from_millis(args.interval_ms.max(1000)),
    )
    .await?;

    eprintln!("control endpoint id: {}", endpoint.id());
    eprintln!("control endpoint addr: {}", endpoint_addr);
    eprintln!("control stream_id: {}", args.stream_id);
    eprintln!(
        "control announce interval_ms: {}",
        args.interval_ms.max(1000)
    );
    eprintln!("control ttl_ms: {}", args.ttl_ms);

    tokio::signal::ctrl_c().await?;
    let _ = stop_tx.send(());
    tokio::time::sleep(Duration::from_millis(100)).await;
    Ok(())
}

async fn control_listen(args: ControlListenArgs) -> Result<()> {
    let secret = parse_iroh_secret(args.iroh_secret)?;
    let discovery = parse_discovery(args.discovery.as_deref())?;
    let endpoint = ec_iroh::build_endpoint(secret, discovery).await?;
    let gossip_peers = parse_gossip_peers(args.gossip_peer.clone());
    eprintln!("control endpoint id: {}", endpoint.id());
    eprintln!(
        "control endpoint addr: {}",
        serde_json::to_string(&endpoint.addr()).unwrap_or_else(|_| endpoint.id().to_string())
    );
    let mut gossip = tokio::time::timeout(
        Duration::from_secs(30),
        ec_iroh::ControlGossip::join(endpoint.clone(), &gossip_peers),
    )
    .await
    .context("timed out joining control gossip topic")??;

    loop {
        tokio::select! {
            maybe = gossip.next_announcement() => {
                let Some(announcement) = maybe? else {
                    continue;
                };
                println!("{}", serde_json::to_string(&announcement)?);
                if args.once {
                    break;
                }
            }
            _ = tokio::signal::ctrl_c() => {
                break;
            }
        }
    }

    Ok(())
}

fn control_announcement_is_fresh(
    announcement: &StreamControlAnnouncement,
    max_age_ms: u64,
) -> bool {
    let now = now_unix_ms();
    let freshness_ms = announcement.ttl_ms.min(max_age_ms.max(1));
    now <= announcement.updated_unix_ms.saturating_add(freshness_ms)
}

fn select_control_transport(
    transports: &[StreamTransportDescriptor],
    prefer: ControlTransportPreference,
) -> Option<StreamTransportDescriptor> {
    let is_direct =
        |t: &StreamTransportDescriptor| matches!(t, StreamTransportDescriptor::IrohDirect { .. });
    let is_relay =
        |t: &StreamTransportDescriptor| matches!(t, StreamTransportDescriptor::RelayMoq { .. });

    match prefer {
        ControlTransportPreference::DirectOnly => transports.iter().find(|t| is_direct(t)).cloned(),
        ControlTransportPreference::RelayOnly => transports.iter().find(|t| is_relay(t)).cloned(),
        ControlTransportPreference::DirectFirst => transports
            .iter()
            .find(|t| is_direct(t))
            .cloned()
            .or_else(|| transports.iter().find(|t| is_relay(t)).cloned()),
        ControlTransportPreference::RelayFirst => transports
            .iter()
            .find(|t| is_relay(t))
            .cloned()
            .or_else(|| transports.iter().find(|t| is_direct(t)).cloned()),
    }
}

async fn control_resolve(args: ControlResolveArgs) -> Result<()> {
    let secret = parse_iroh_secret(args.iroh_secret)?;
    let discovery = parse_discovery(args.discovery.as_deref())?;
    let endpoint = ec_iroh::build_endpoint(secret, discovery).await?;
    let gossip_peers = parse_gossip_peers(args.gossip_peer.clone());
    eprintln!("control endpoint id: {}", endpoint.id());
    eprintln!(
        "control endpoint addr: {}",
        serde_json::to_string(&endpoint.addr()).unwrap_or_else(|_| endpoint.id().to_string())
    );

    let mut gossip = tokio::time::timeout(
        Duration::from_secs(30),
        ec_iroh::ControlGossip::join(endpoint.clone(), &gossip_peers),
    )
    .await
    .context("timed out joining control gossip topic")??;

    let timeout = Duration::from_millis(args.timeout_ms.max(1000));
    let deadline = Instant::now() + timeout;

    loop {
        let now = Instant::now();
        if now >= deadline {
            break;
        }
        let remaining = deadline - now;

        let maybe = tokio::time::timeout(remaining, gossip.next_announcement())
            .await
            .context("timed out waiting for control announcement")??;
        let Some(announcement) = maybe else {
            continue;
        };
        if announcement.stream.id.0.as_str() != args.stream_id {
            continue;
        }
        if !control_announcement_is_fresh(&announcement, args.max_age_ms) {
            continue;
        }
        let Some(transport) = select_control_transport(&announcement.transports, args.prefer)
        else {
            continue;
        };

        let stream_id = announcement.stream.id.0.clone();
        let title = announcement.stream.title.clone();

        let output = if args.include_announcement {
            serde_json::json!({
                "stream_id": stream_id,
                "title": title,
                "transport": transport,
                "updated_unix_ms": announcement.updated_unix_ms,
                "ttl_ms": announcement.ttl_ms,
                "announcement": announcement,
            })
        } else {
            serde_json::json!({
                "stream_id": stream_id,
                "title": title,
                "transport": transport,
                "updated_unix_ms": announcement.updated_unix_ms,
                "ttl_ms": announcement.ttl_ms,
            })
        };
        println!("{}", serde_json::to_string(&output)?);
        return Ok(());
    }

    Err(anyhow!(
        "timed out resolving stream_id '{}' from control topic",
        args.stream_id
    ))
}

fn select_relay_transport_for_web(
    transports: &[StreamTransportDescriptor],
) -> Option<(String, String, String)> {
    for transport in transports {
        if let StreamTransportDescriptor::RelayMoq {
            url,
            broadcast_name,
            track_name,
        } = transport
        {
            return Some((url.clone(), broadcast_name.clone(), track_name.clone()));
        }
    }
    None
}

#[derive(Debug, serde::Serialize)]
struct WebStreamUpsertReq<'a> {
    stream_id: &'a str,
    title: &'a str,
    relay_url: &'a str,
    broadcast_name: &'a str,
    track_name: &'a str,
    expires_ms: u64,
}

async fn control_bridge_web(args: ControlBridgeWebArgs) -> Result<()> {
    let secret = parse_iroh_secret(args.iroh_secret)?;
    let discovery = parse_discovery(args.discovery.as_deref())?;
    let endpoint = ec_iroh::build_endpoint(secret, discovery).await?;
    let gossip_peers = parse_gossip_peers(args.gossip_peer.clone());
    eprintln!("control endpoint id: {}", endpoint.id());
    eprintln!(
        "control endpoint addr: {}",
        serde_json::to_string(&endpoint.addr()).unwrap_or_else(|_| endpoint.id().to_string())
    );

    let mut gossip = tokio::time::timeout(
        Duration::from_secs(30),
        ec_iroh::ControlGossip::join(endpoint.clone(), &gossip_peers),
    )
    .await
    .context("timed out joining control gossip topic")??;

    let token = args
        .auth_token
        .or_else(|| std::env::var("EVERY_CHANNEL_WEB_UPSERT_TOKEN").ok())
        .map(|v| v.trim().to_string())
        .filter(|v| !v.is_empty());
    let directory_url = args.directory_url.trim_end_matches('/').to_string();
    let upsert_url = format!("{directory_url}/api/stream-upsert");
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(8))
        .build()
        .context("failed to build web bridge http client")?;
    let mut last_upserted_unix_ms = HashMap::<String, u64>::new();
    let timeout_deadline = if args.timeout_ms == 0 {
        None
    } else {
        Some(Instant::now() + Duration::from_millis(args.timeout_ms.max(1000)))
    };

    eprintln!("web upsert url: {}", upsert_url);

    loop {
        let maybe = if let Some(deadline) = timeout_deadline {
            let now = Instant::now();
            if now >= deadline {
                return Err(anyhow!("timed out waiting for control announcements"));
            }
            tokio::time::timeout(deadline - now, gossip.next_announcement())
                .await
                .context("timed out waiting for control announcement")??
        } else {
            gossip.next_announcement().await?
        };
        let Some(announcement) = maybe else {
            continue;
        };

        if !control_announcement_is_fresh(&announcement, args.max_age_ms) {
            continue;
        }

        let stream_id = announcement.stream.id.0.clone();
        if let Some(prefix) = args.stream_prefix.as_deref() {
            if !stream_id.starts_with(prefix) {
                continue;
            }
        }

        let Some((relay_url, broadcast_name, track_name)) =
            select_relay_transport_for_web(&announcement.transports)
        else {
            continue;
        };

        if last_upserted_unix_ms
            .get(&stream_id)
            .is_some_and(|seen| *seen >= announcement.updated_unix_ms)
        {
            continue;
        }

        let ttl_ms = announcement.ttl_ms.clamp(5_000, 60_000);
        let payload = WebStreamUpsertReq {
            stream_id: &stream_id,
            title: &announcement.stream.title,
            relay_url: &relay_url,
            broadcast_name: &broadcast_name,
            track_name: &track_name,
            expires_ms: now_unix_ms().saturating_add(ttl_ms),
        };

        let mut req = client.post(&upsert_url).json(&payload);
        if let Some(token) = token.as_ref() {
            req = req.bearer_auth(token);
        }
        let res = req
            .send()
            .await
            .with_context(|| format!("failed posting to {upsert_url}"))?;
        if !res.status().is_success() {
            let status = res.status();
            let body = res.text().await.unwrap_or_default();
            tracing::warn!(
                stream = %stream_id,
                status = %status,
                body = %body,
                "web stream upsert failed"
            );
            continue;
        }

        last_upserted_unix_ms.insert(stream_id.clone(), announcement.updated_unix_ms);
        tracing::info!(
            stream = %stream_id,
            relay = %relay_url,
            broadcast = %broadcast_name,
            "web stream upserted"
        );
        if args.once {
            break;
        }
    }

    Ok(())
}

fn wait_for_stable_file(path: &Path, timeout: Duration) -> Result<()> {
    let start = Instant::now();
    let mut last_len: Option<u64> = None;
    let mut stable_ms: u64 = 0;

    // We want "write is done" behavior. File size staying constant for a few polls
    // is a pragmatic signal that ffmpeg is done producing the segment.
    while start.elapsed() < timeout {
        if let Ok(meta) = fs::metadata(path) {
            let len = meta.len();
            if len > 0 {
                if Some(len) == last_len {
                    stable_ms += 100;
                    if stable_ms >= 300 {
                        return Ok(());
                    }
                } else {
                    last_len = Some(len);
                    stable_ms = 0;
                }
            }
        }
        std::thread::sleep(Duration::from_millis(100));
    }

    Err(anyhow!(
        "timed out waiting for stable file {} after {:?}",
        path.display(),
        timeout
    ))
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct ArchiveIndexRecord {
    received_unix_ms: u64,
    relay_url: String,
    broadcast_name: String,
    track_name: String,
    group_sequence: u64,
    frame_count: u64,
    size_bytes: usize,
    blake3: String,
    cas_path: String,
}

fn sanitize_path_component(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for ch in value.chars() {
        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
            out.push(ch);
        } else {
            out.push('_');
        }
    }
    if out.is_empty() {
        "_".to_string()
    } else {
        out
    }
}

fn default_wt_archive_tracks() -> Vec<String> {
    WT_ARCHIVE_DEFAULT_TRACKS
        .iter()
        .map(|value| (*value).to_string())
        .collect()
}

fn cas_store_blob(cas_root: &Path, data: &[u8]) -> Result<(String, PathBuf, bool)> {
    let hash = blake3::hash(data).to_hex().to_string();
    let shard = &hash[0..2];
    let rel_path = PathBuf::from("objects")
        .join("blake3")
        .join(shard)
        .join(format!("{hash}.bin"));
    let out_path = cas_root.join(shard).join(format!("{hash}.bin"));

    if out_path.exists() {
        return Ok((hash, rel_path, false));
    }

    let parent = out_path
        .parent()
        .ok_or_else(|| anyhow!("invalid CAS path: {}", out_path.display()))?;
    fs::create_dir_all(parent)
        .with_context(|| format!("failed to create CAS shard dir {}", parent.display()))?;

    let tmp = parent.join(format!(
        ".{}.tmp-{}-{}",
        out_path
            .file_name()
            .and_then(|f| f.to_str())
            .unwrap_or("blob"),
        std::process::id(),
        SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
    ));
    fs::write(&tmp, data).with_context(|| format!("failed to write {}", tmp.display()))?;
    match fs::rename(&tmp, &out_path) {
        Ok(()) => {}
        Err(err) if out_path.exists() => {
            let _ = fs::remove_file(&tmp);
            tracing::debug!(
                path = %out_path.display(),
                err = %err,
                "CAS object already exists during rename race"
            );
            return Ok((hash, rel_path, false));
        }
        Err(err) => {
            let _ = fs::remove_file(&tmp);
            return Err(err).with_context(|| {
                format!(
                    "failed to move CAS blob {} -> {}",
                    tmp.display(),
                    out_path.display()
                )
            });
        }
    }

    Ok((hash, rel_path, true))
}

fn append_archive_index_record(path: &Path, record: &ArchiveIndexRecord) -> Result<()> {
    let parent = path
        .parent()
        .ok_or_else(|| anyhow!("invalid manifest path: {}", path.display()))?;
    fs::create_dir_all(parent)
        .with_context(|| format!("failed to create manifest dir {}", parent.display()))?;

    let mut file = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .with_context(|| format!("failed to open manifest {}", path.display()))?;
    serde_json::to_writer(&mut file, record)
        .with_context(|| format!("failed to write manifest {}", path.display()))?;
    file.write_all(b"\n")
        .with_context(|| format!("failed to newline-terminate {}", path.display()))?;
    Ok(())
}

async fn archive_track_loop(
    mut track: moq_lite::TrackConsumer,
    relay_url: String,
    broadcast_name: String,
    track_name: String,
    cas_root: PathBuf,
    manifest_path: PathBuf,
) -> Result<()> {
    let mut archived_groups: u64 = 0;
    loop {
        let next_group = track.next_group().await;
        let Some(mut group) = next_group.with_context(|| {
            format!(
                "track {} read failed for broadcast {}",
                track_name, broadcast_name
            )
        })?
        else {
            return Err(anyhow!(
                "track {} ended for broadcast {}",
                track_name,
                broadcast_name
            ));
        };

        let group_sequence = group.info.sequence;
        let mut frame_count: u64 = 0;
        let mut bytes = Vec::new();
        loop {
            match group.read_frame().await {
                Ok(Some(frame)) => {
                    frame_count += 1;
                    bytes.extend_from_slice(&frame);
                }
                Ok(None) => break,
                Err(err) => {
                    return Err(anyhow!(
                        "track {} group {} read frame failed: {err:#}",
                        track_name,
                        group_sequence
                    ));
                }
            }
        }

        if bytes.is_empty() {
            continue;
        }

        let size_bytes = bytes.len();
        let (hash, rel_path, inserted) = cas_store_blob(&cas_root, &bytes)?;
        let record = ArchiveIndexRecord {
            received_unix_ms: now_unix_ms(),
            relay_url: relay_url.clone(),
            broadcast_name: broadcast_name.clone(),
            track_name: track_name.clone(),
            group_sequence,
            frame_count,
            size_bytes,
            blake3: hash.clone(),
            cas_path: rel_path.display().to_string(),
        };
        append_archive_index_record(&manifest_path, &record)?;
        archived_groups += 1;

        if archived_groups % 50 == 0 {
            tracing::info!(
                relay = %relay_url,
                broadcast = %broadcast_name,
                track = %track_name,
                archived_groups,
                inserted,
                size_bytes,
                hash = %hash,
                "archived relay group"
            );
        }
    }
}

async fn wt_archive(args: WtArchiveArgs) -> Result<()> {
    let relay_url =
        Url::parse(&args.url).with_context(|| format!("invalid relay url: {}", args.url))?;
    let relay_url_str = relay_url.to_string();
    let mut tracks = if args.track.is_empty() {
        default_wt_archive_tracks()
    } else {
        args.track.clone()
    };
    tracks.sort();
    tracks.dedup();
    if tracks.is_empty() {
        return Err(anyhow!("no tracks configured for wt-archive"));
    }

    let cas_root = args.output_dir.join("objects").join("blake3");
    let manifest_root = args
        .manifest_dir
        .unwrap_or_else(|| args.output_dir.join("manifests"));
    fs::create_dir_all(&cas_root)
        .with_context(|| format!("failed to create CAS root {}", cas_root.display()))?;
    fs::create_dir_all(&manifest_root)
        .with_context(|| format!("failed to create manifest root {}", manifest_root.display()))?;

    let consume = moq_lite::Origin::produce();
    let mut consume_updates = consume.consume();

    #[derive(Clone)]
    struct ProtocolOverride {
        inner: web_transport_quinn::Session,
        protocol: Option<String>,
    }

    impl web_transport_trait::Session for ProtocolOverride {
        type SendStream =
            <web_transport_quinn::Session as web_transport_trait::Session>::SendStream;
        type RecvStream =
            <web_transport_quinn::Session as web_transport_trait::Session>::RecvStream;
        type Error = <web_transport_quinn::Session as web_transport_trait::Session>::Error;

        fn accept_bi(
            &self,
        ) -> impl Future<Output = Result<(Self::SendStream, Self::RecvStream), Self::Error>>
               + web_transport_trait::MaybeSend {
            self.inner.accept_bi()
        }

        fn accept_uni(
            &self,
        ) -> impl Future<Output = Result<Self::RecvStream, Self::Error>> + web_transport_trait::MaybeSend
        {
            self.inner.accept_uni()
        }

        fn open_bi(
            &self,
        ) -> impl Future<Output = Result<(Self::SendStream, Self::RecvStream), Self::Error>>
               + web_transport_trait::MaybeSend {
            self.inner.open_bi()
        }

        fn open_uni(
            &self,
        ) -> impl Future<Output = Result<Self::SendStream, Self::Error>> + web_transport_trait::MaybeSend
        {
            self.inner.open_uni()
        }

        fn send_datagram(&self, payload: bytes::Bytes) -> Result<(), Self::Error> {
            self.inner.send_datagram(payload)
        }

        fn recv_datagram(
            &self,
        ) -> impl Future<Output = Result<bytes::Bytes, Self::Error>> + web_transport_trait::MaybeSend
        {
            self.inner.recv_datagram()
        }

        fn max_datagram_size(&self) -> usize {
            self.inner.max_datagram_size()
        }

        fn protocol(&self) -> Option<&str> {
            self.protocol.as_deref().or_else(|| self.inner.protocol())
        }

        fn close(&self, code: u32, reason: &str) {
            web_transport_trait::Session::close(&self.inner, code, reason)
        }

        fn closed(&self) -> impl Future<Output = Self::Error> + web_transport_trait::MaybeSend {
            self.inner.closed()
        }
    }

    async fn connect_moq_session(
        relay_url: &Url,
        consume: moq_lite::OriginProducer,
        tls_disable_verify: bool,
    ) -> Result<moq_lite::Session> {
        let host = relay_url
            .host_str()
            .ok_or_else(|| anyhow!("relay url missing host: {relay_url}"))?
            .to_string();
        let port = relay_url.port().unwrap_or(443);

        let mut roots = rustls::RootCertStore::empty();
        let native = rustls_native_certs::load_native_certs();
        if !native.errors.is_empty() {
            tracing::warn!(
                errors = ?native.errors,
                "some native root certs could not be loaded"
            );
        }
        for cert in native.certs {
            let _ = roots.add(cert);
        }

        let mut tls = rustls::ClientConfig::builder()
            .with_root_certificates(roots)
            .with_no_client_auth();

        if tls_disable_verify {
            #[derive(Debug)]
            struct NoCertificateVerification(Arc<rustls::crypto::CryptoProvider>);

            impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification {
                fn verify_server_cert(
                    &self,
                    _end_entity: &rustls::pki_types::CertificateDer<'_>,
                    _intermediates: &[rustls::pki_types::CertificateDer<'_>],
                    _server_name: &rustls::pki_types::ServerName<'_>,
                    _ocsp: &[u8],
                    _now: rustls::pki_types::UnixTime,
                ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error>
                {
                    Ok(rustls::client::danger::ServerCertVerified::assertion())
                }

                fn verify_tls12_signature(
                    &self,
                    message: &[u8],
                    cert: &rustls::pki_types::CertificateDer<'_>,
                    dss: &rustls::DigitallySignedStruct,
                ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
                {
                    rustls::crypto::verify_tls12_signature(
                        message,
                        cert,
                        dss,
                        &self.0.signature_verification_algorithms,
                    )
                }

                fn verify_tls13_signature(
                    &self,
                    message: &[u8],
                    cert: &rustls::pki_types::CertificateDer<'_>,
                    dss: &rustls::DigitallySignedStruct,
                ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
                {
                    rustls::crypto::verify_tls13_signature(
                        message,
                        cert,
                        dss,
                        &self.0.signature_verification_algorithms,
                    )
                }

                fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
                    self.0.signature_verification_algorithms.supported_schemes()
                }
            }

            let provider = rustls::crypto::CryptoProvider::get_default()
                .cloned()
                .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider()));
            tls.dangerous()
                .set_certificate_verifier(Arc::new(NoCertificateVerification(provider)));
        }

        tls.alpn_protocols = vec![web_transport_quinn::ALPN.as_bytes().to_vec()];

        let socket = std::net::UdpSocket::bind("[::]:0").context("failed to bind UDP socket")?;
        let mut transport = quinn::TransportConfig::default();
        transport.max_idle_timeout(Some(Duration::from_secs(10).try_into().unwrap()));
        transport.keep_alive_interval(Some(Duration::from_secs(4)));
        transport.mtu_discovery_config(None);

        let transport = Arc::new(transport);
        let runtime = quinn::default_runtime().context("no async runtime")?;
        let endpoint_config = quinn::EndpointConfig::default();
        let endpoint = quinn::Endpoint::new(endpoint_config, None, socket, runtime)
            .context("failed to create QUIC endpoint")?;

        let ip = tokio::net::lookup_host((host.clone(), port))
            .await
            .context("failed DNS lookup")?
            .next()
            .context("no DNS entries")?;

        let quic: quinn::crypto::rustls::QuicClientConfig = tls.try_into()?;
        let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic));
        client_cfg.transport_config(transport);

        tracing::debug!(%ip, %host, %relay_url, "connecting QUIC");
        let connection = endpoint
            .connect_with(client_cfg, ip, &host)?
            .await
            .context("failed QUIC connect")?;

        let mut request = web_transport_quinn::proto::ConnectRequest::new(relay_url.clone());
        for alpn in moq_lite::ALPNS {
            request = request.with_protocol(alpn.to_string());
        }
        let wt = web_transport_quinn::Session::connect(connection, request)
            .await
            .context("failed WebTransport CONNECT")?;

        let client = moq_lite::Client::new().with_consume(consume);
        match client.connect(wt.clone()).await {
            Ok(session) => {
                tracing::info!("connected to relay (native protocol negotiation)");
                return Ok(session);
            }
            Err(err) => {
                tracing::debug!(err = %err, "native MoQ SETUP failed; trying protocol overrides");
            }
        }

        let attempts: [&str; 4] = ["moqt-16", "moqt-15", "moq-00", ""];
        let mut last_err: Option<anyhow::Error> = None;
        for p in attempts {
            let session = ProtocolOverride {
                inner: wt.clone(),
                protocol: (!p.is_empty()).then(|| p.to_string()),
            };
            match client.connect(session).await {
                Ok(session) => {
                    tracing::info!(protocol = %p, "connected to relay");
                    return Ok(session);
                }
                Err(err) => {
                    last_err = Some(anyhow::Error::new(err));
                    tracing::debug!(
                        protocol = %p,
                        err = %last_err.as_ref().unwrap(),
                        "MoQ SETUP failed; retrying"
                    );
                }
            }
        }

        Err(last_err.unwrap_or_else(|| anyhow!("failed to connect"))).context("failed MoQ SETUP")
    }

    tracing::info!(url=%relay_url, name=%args.name, "connecting to relay for archival");
    let session = connect_moq_session(&relay_url, consume.clone(), args.tls_disable_verify).await?;

    let broadcast = if let Some(active) = consume.consume_broadcast(args.name.as_str()) {
        active
    } else {
        tracing::info!(name=%args.name, "waiting for relay broadcast announcement");
        loop {
            let Some((path, active)) = consume_updates.announced().await else {
                return Err(anyhow!("relay announcement stream closed"));
            };
            if path.to_string() != args.name {
                continue;
            }
            if let Some(active) = active {
                break active;
            }
        }
    };

    let broadcast_dir = sanitize_path_component(&args.name);
    let mut tasks = tokio::task::JoinSet::new();
    for track_name in tracks {
        let track = broadcast.subscribe_track(&moq_lite::Track::new(&track_name));
        let track_file = sanitize_path_component(&track_name);
        let manifest_path = manifest_root
            .join(&broadcast_dir)
            .join(format!("{track_file}.jsonl"));
        tasks.spawn(archive_track_loop(
            track,
            relay_url_str.clone(),
            args.name.clone(),
            track_name.clone(),
            cas_root.clone(),
            manifest_path,
        ));
        tracing::info!(
            relay = %relay_url_str,
            broadcast = %args.name,
            track = %track_name,
            "archival track subscribed"
        );
    }

    if tasks.is_empty() {
        return Err(anyhow!("no tracks spawned for archival"));
    }

    loop {
        tokio::select! {
            next = tasks.join_next() => {
                match next {
                    Some(Ok(Ok(()))) => {
                        return Err(anyhow!("archival track task exited unexpectedly"));
                    }
                    Some(Ok(Err(err))) => return Err(err),
                    Some(Err(err)) => return Err(anyhow!("archival track task join failure: {err}")),
                    None => return Err(anyhow!("all archival track tasks exited")),
                }
            }
            _ = session.closed() => {
                return Err(anyhow!("relay session closed"));
            }
            _ = tokio::signal::ctrl_c() => {
                tracing::info!("ctrl-c; shutting down wt-archive");
                break;
            }
        }
    }

    Ok(())
}

#[derive(Debug, Clone)]
struct ArchiveReplayState {
    cas_root: PathBuf,
    manifest_root: PathBuf,
}

#[derive(Debug, serde::Serialize)]
struct ArchiveTimelineResponse {
    broadcast_name: String,
    start_unix_ms: Option<u64>,
    end_unix_ms: Option<u64>,
    video_segments: usize,
    audio_segments: usize,
}

#[derive(Debug, Clone)]
struct ArchiveHlsSegment {
    sequence: u64,
    duration_secs: f64,
    hash: String,
}

#[derive(Debug)]
struct ArchiveHttpResponse {
    status: u16,
    content_type: String,
    body: Vec<u8>,
}

fn read_archive_records(
    manifest_root: &Path,
    broadcast_name: &str,
    track_name: &str,
) -> Result<Vec<ArchiveIndexRecord>> {
    let broadcast_dir = sanitize_path_component(broadcast_name);
    let track_file = sanitize_path_component(track_name);
    let path = manifest_root
        .join(broadcast_dir)
        .join(format!("{track_file}.jsonl"));
    if !path.exists() {
        return Ok(Vec::new());
    }
    let mut out = Vec::new();
    let data = fs::read_to_string(&path)
        .with_context(|| format!("failed to read archive index {}", path.display()))?;
    for line in data.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        match serde_json::from_str::<ArchiveIndexRecord>(line) {
            Ok(record) => out.push(record),
            Err(err) => {
                tracing::warn!(
                    path = %path.display(),
                    err = %err,
                    "failed to parse archive index line"
                );
            }
        }
    }
    Ok(out)
}

fn dedupe_by_group_sequence(mut records: Vec<ArchiveIndexRecord>) -> Vec<ArchiveIndexRecord> {
    records.sort_by_key(|record| record.group_sequence);
    let mut out: Vec<ArchiveIndexRecord> = Vec::with_capacity(records.len());
    for record in records {
        if let Some(last) = out.last_mut() {
            if last.group_sequence == record.group_sequence {
                *last = record;
                continue;
            }
        }
        out.push(record);
    }
    out
}

fn default_segment_duration_ms(records: &[ArchiveIndexRecord]) -> u64 {
    if records.len() < 2 {
        return 1000;
    }
    let mut deltas = Vec::with_capacity(records.len().saturating_sub(1));
    for pair in records.windows(2) {
        let current = pair[0].received_unix_ms;
        let next = pair[1].received_unix_ms;
        if next > current {
            deltas.push(next - current);
        }
    }
    if deltas.is_empty() {
        return 1000;
    }
    deltas.sort_unstable();
    deltas[deltas.len() / 2].clamp(200, 10000)
}

fn build_hls_segments(records: &[ArchiveIndexRecord]) -> Vec<ArchiveHlsSegment> {
    if records.is_empty() {
        return Vec::new();
    }
    let fallback_ms = default_segment_duration_ms(records);
    let mut out = Vec::with_capacity(records.len());
    for (idx, record) in records.iter().enumerate() {
        let mut dur_ms = fallback_ms;
        if let Some(next) = records.get(idx + 1) {
            if next.received_unix_ms > record.received_unix_ms {
                dur_ms = (next.received_unix_ms - record.received_unix_ms).clamp(200, 10000);
            }
        }
        out.push(ArchiveHlsSegment {
            sequence: record.group_sequence,
            duration_secs: (dur_ms as f64) / 1000.0,
            hash: record.blake3.clone(),
        });
    }
    out
}

fn latest_init_hash(
    manifest_root: &Path,
    broadcast_name: &str,
    fallback_track: &str,
) -> Result<Option<String>> {
    let mut init_records = dedupe_by_group_sequence(read_archive_records(
        manifest_root,
        broadcast_name,
        "init.mp4",
    )?);
    if let Some(record) = init_records.pop() {
        return Ok(Some(record.blake3));
    }
    let fallback = dedupe_by_group_sequence(read_archive_records(
        manifest_root,
        broadcast_name,
        fallback_track,
    )?);
    Ok(fallback.first().map(|record| record.blake3.clone()))
}

fn parse_limit(url: &Url) -> Option<usize> {
    url.query_pairs()
        .find(|(k, _)| k == "limit")
        .and_then(|(_, v)| v.parse::<usize>().ok())
}

fn parse_from_ms(url: &Url) -> Option<u64> {
    url.query_pairs()
        .find(|(k, _)| k == "from_ms")
        .and_then(|(_, v)| v.parse::<u64>().ok())
}

fn parse_archive_track(
    manifest_root: &Path,
    broadcast_name: &str,
    track_name: &str,
    from_ms: Option<u64>,
    limit: Option<usize>,
) -> Result<Vec<ArchiveIndexRecord>> {
    let mut records = dedupe_by_group_sequence(read_archive_records(
        manifest_root,
        broadcast_name,
        track_name,
    )?);
    if let Some(start_ms) = from_ms {
        records.retain(|record| record.received_unix_ms >= start_ms);
    }
    if let Some(max_items) = limit {
        if max_items > 0 && records.len() > max_items {
            records = records.split_off(records.len() - max_items);
        }
    }
    Ok(records)
}

fn hls_playlist_for_track(
    broadcast_name: &str,
    track_name: &str,
    init_hash: &str,
    records: &[ArchiveIndexRecord],
) -> String {
    let segments = build_hls_segments(records);
    let target_duration = segments
        .iter()
        .map(|segment| segment.duration_secs.ceil() as u64)
        .max()
        .unwrap_or(2)
        .max(1);
    let media_sequence = segments
        .first()
        .map(|segment| segment.sequence)
        .unwrap_or(0);

    let mut out = String::new();
    out.push_str("#EXTM3U\n");
    out.push_str("#EXT-X-VERSION:7\n");
    out.push_str("#EXT-X-PLAYLIST-TYPE:EVENT\n");
    out.push_str("#EXT-X-INDEPENDENT-SEGMENTS\n");
    out.push_str(&format!("#EXT-X-TARGETDURATION:{target_duration}\n"));
    out.push_str(&format!("#EXT-X-MEDIA-SEQUENCE:{media_sequence}\n"));
    out.push_str(&format!(
        "#EXT-X-MAP:URI=\"/archive/{}/init.mp4?hash={}\"\n",
        urlencoding::encode(broadcast_name),
        init_hash
    ));
    for segment in segments {
        out.push_str(&format!("#EXTINF:{:.3},\n", segment.duration_secs));
        out.push_str(&format!(
            "/archive/{}/segment/{}.m4s?track={}\n",
            urlencoding::encode(broadcast_name),
            segment.hash,
            urlencoding::encode(track_name)
        ));
    }
    out
}

fn validate_blake3_hex(hash: &str) -> bool {
    hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit())
}

fn cas_path_for_hash(cas_root: &Path, hash: &str) -> Result<PathBuf> {
    if !validate_blake3_hex(hash) {
        return Err(anyhow!("invalid hash"));
    }
    let shard = &hash[0..2];
    Ok(cas_root.join(shard).join(format!("{hash}.bin")))
}

fn archive_response(status: u16, content_type: &str, body: Vec<u8>) -> ArchiveHttpResponse {
    ArchiveHttpResponse {
        status,
        content_type: content_type.to_string(),
        body,
    }
}

fn archive_error(status: u16, message: &str) -> ArchiveHttpResponse {
    archive_response(
        status,
        "application/json; charset=utf-8",
        serde_json::json!({ "error": message })
            .to_string()
            .into_bytes(),
    )
}

fn decode_archive_group_bytes(bytes: &[u8]) -> Result<Vec<u8>> {
    let mut pos = 0usize;
    let mut out = Vec::new();
    let mut decoded_any = false;

    while pos < bytes.len() {
        if bytes.len().saturating_sub(pos) < 4 {
            if decoded_any {
                return Err(anyhow!("archive group ended mid-frame header"));
            }
            return Ok(bytes.to_vec());
        }

        let meta_len =
            u32::from_be_bytes([bytes[pos], bytes[pos + 1], bytes[pos + 2], bytes[pos + 3]])
                as usize;
        let meta_start = pos + 4;
        let meta_end = meta_start + meta_len;
        if meta_end > bytes.len() {
            if decoded_any {
                return Err(anyhow!("archive group ended mid-frame metadata"));
            }
            return Ok(bytes.to_vec());
        }

        let meta: ObjectMeta = match serde_json::from_slice(&bytes[meta_start..meta_end]) {
            Ok(meta) => meta,
            Err(err) => {
                if decoded_any {
                    return Err(anyhow!("archive group metadata parse failed: {err:#}"));
                }
                return Ok(bytes.to_vec());
            }
        };
        let data_len = usize::try_from(meta.size_bytes)
            .map_err(|_| anyhow!("archive object size exceeds addressable memory"))?;
        let data_start = meta_end;
        let data_end = data_start + data_len;
        if data_end > bytes.len() {
            if decoded_any {
                return Err(anyhow!("archive group ended mid-frame payload"));
            }
            return Ok(bytes.to_vec());
        }

        out.extend_from_slice(&bytes[data_start..data_end]);
        pos = data_end;
        decoded_any = true;
    }

    Ok(out)
}

fn archive_status_text(status: u16) -> &'static str {
    match status {
        200 => "OK",
        204 => "No Content",
        400 => "Bad Request",
        404 => "Not Found",
        405 => "Method Not Allowed",
        500 => "Internal Server Error",
        502 => "Bad Gateway",
        _ => "OK",
    }
}

fn handle_archive_http_request(
    state: &ArchiveReplayState,
    method: &str,
    target: &str,
) -> ArchiveHttpResponse {
    if method == "OPTIONS" {
        return archive_response(204, "text/plain; charset=utf-8", Vec::new());
    }
    if method != "GET" && method != "HEAD" {
        return archive_error(405, "method not allowed");
    }

    let req_url = match Url::parse(&format!("http://localhost{target}")) {
        Ok(url) => url,
        Err(_) => return archive_error(400, "invalid url"),
    };
    let path = req_url.path();
    if path == "/archive/healthz" {
        return archive_response(
            200,
            "application/json; charset=utf-8",
            serde_json::json!({ "ok": true }).to_string().into_bytes(),
        );
    }

    let parts = path.trim_start_matches('/').split('/').collect::<Vec<_>>();
    if parts.len() < 3 || parts[0] != "archive" {
        return archive_error(404, "not found");
    }

    let broadcast_name = match urlencoding::decode(parts[1]) {
        Ok(v) => v.to_string(),
        Err(_) => return archive_error(400, "invalid broadcast"),
    };
    if broadcast_name.is_empty() {
        return archive_error(400, "missing broadcast");
    }

    let from_ms = parse_from_ms(&req_url);
    let limit = parse_limit(&req_url);

    match parts[2] {
        "timeline.json" if parts.len() == 3 => {
            let video = match parse_archive_track(
                &state.manifest_root,
                &broadcast_name,
                "video0.m4s",
                from_ms,
                limit,
            ) {
                Ok(records) => records,
                Err(err) => return archive_error(500, &format!("{err:#}")),
            };
            let audio = match parse_archive_track(
                &state.manifest_root,
                &broadcast_name,
                "audio0.m4s",
                from_ms,
                limit,
            ) {
                Ok(records) => records,
                Err(err) => return archive_error(500, &format!("{err:#}")),
            };
            let min_ms = video
                .first()
                .map(|record| record.received_unix_ms)
                .into_iter()
                .chain(audio.first().map(|record| record.received_unix_ms))
                .min();
            let max_ms = video
                .last()
                .map(|record| record.received_unix_ms)
                .into_iter()
                .chain(audio.last().map(|record| record.received_unix_ms))
                .max();
            let body = ArchiveTimelineResponse {
                broadcast_name,
                start_unix_ms: min_ms,
                end_unix_ms: max_ms,
                video_segments: video.len(),
                audio_segments: audio.len(),
            };
            match serde_json::to_vec(&body) {
                Ok(buf) => archive_response(200, "application/json; charset=utf-8", buf),
                Err(err) => archive_error(500, &format!("{err:#}")),
            }
        }
        "master.m3u8" if parts.len() == 3 => {
            let encoded = urlencoding::encode(&broadcast_name);
            let playlist = format!(
                "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",NAME=\"main\",DEFAULT=YES,AUTOSELECT=YES,URI=\"/archive/{encoded}/audio.m3u8\"\n#EXT-X-STREAM-INF:BANDWIDTH=3500000,CODECS=\"avc1.640028,mp4a.40.2\",AUDIO=\"audio\"\n/archive/{encoded}/video.m3u8\n"
            );
            archive_response(
                200,
                "application/vnd.apple.mpegurl; charset=utf-8",
                playlist.into_bytes(),
            )
        }
        "video.m3u8" if parts.len() == 3 => {
            let init_hash =
                match latest_init_hash(&state.manifest_root, &broadcast_name, "video0.m4s") {
                    Ok(Some(hash)) => hash,
                    Ok(None) => return archive_error(404, "missing init segment"),
                    Err(err) => return archive_error(500, &format!("{err:#}")),
                };
            let records = match parse_archive_track(
                &state.manifest_root,
                &broadcast_name,
                "video0.m4s",
                from_ms,
                limit,
            ) {
                Ok(records) => records,
                Err(err) => return archive_error(500, &format!("{err:#}")),
            };
            if records.is_empty() {
                return archive_error(404, "video archive is empty");
            }
            let playlist =
                hls_playlist_for_track(&broadcast_name, "video0.m4s", &init_hash, &records);
            archive_response(
                200,
                "application/vnd.apple.mpegurl; charset=utf-8",
                playlist.into_bytes(),
            )
        }
        "audio.m3u8" if parts.len() == 3 => {
            let init_hash =
                match latest_init_hash(&state.manifest_root, &broadcast_name, "audio0.m4s") {
                    Ok(Some(hash)) => hash,
                    Ok(None) => return archive_error(404, "missing init segment"),
                    Err(err) => return archive_error(500, &format!("{err:#}")),
                };
            let records = match parse_archive_track(
                &state.manifest_root,
                &broadcast_name,
                "audio0.m4s",
                from_ms,
                limit,
            ) {
                Ok(records) => records,
                Err(err) => return archive_error(500, &format!("{err:#}")),
            };
            if records.is_empty() {
                return archive_error(404, "audio archive is empty");
            }
            let playlist =
                hls_playlist_for_track(&broadcast_name, "audio0.m4s", &init_hash, &records);
            archive_response(
                200,
                "application/vnd.apple.mpegurl; charset=utf-8",
                playlist.into_bytes(),
            )
        }
        "init.mp4" if parts.len() == 3 => {
            let hash = req_url
                .query_pairs()
                .find(|(k, _)| k == "hash")
                .map(|(_, v)| v.to_string())
                .or_else(|| {
                    latest_init_hash(&state.manifest_root, &broadcast_name, "video0.m4s")
                        .ok()
                        .flatten()
                })
                .or_else(|| {
                    latest_init_hash(&state.manifest_root, &broadcast_name, "audio0.m4s")
                        .ok()
                        .flatten()
                });
            let Some(hash) = hash else {
                return archive_error(404, "missing init segment");
            };
            let path = match cas_path_for_hash(&state.cas_root, &hash) {
                Ok(path) => path,
                Err(_) => return archive_error(400, "invalid hash"),
            };
            match fs::read(path) {
                Ok(bytes) => match decode_archive_group_bytes(&bytes) {
                    Ok(payload) => archive_response(200, "video/mp4", payload),
                    Err(err) => archive_error(500, &format!("{err:#}")),
                },
                Err(_) => archive_error(404, "init segment not found"),
            }
        }
        "segment" if parts.len() == 4 => {
            let hash_part = parts[3].strip_suffix(".m4s").unwrap_or(parts[3]);
            let path = match cas_path_for_hash(&state.cas_root, hash_part) {
                Ok(path) => path,
                Err(_) => return archive_error(400, "invalid hash"),
            };
            match fs::read(path) {
                Ok(bytes) => match decode_archive_group_bytes(&bytes) {
                    Ok(payload) => archive_response(200, "video/mp4", payload),
                    Err(err) => archive_error(500, &format!("{err:#}")),
                },
                Err(_) => archive_error(404, "segment not found"),
            }
        }
        _ => archive_error(404, "not found"),
    }
}

async fn handle_archive_http_connection(
    mut stream: tokio::net::TcpStream,
    state: ArchiveReplayState,
) -> Result<()> {
    let mut buf = Vec::with_capacity(4096);
    let mut tmp = [0u8; 2048];
    loop {
        let read = stream
            .read(&mut tmp)
            .await
            .context("failed to read replay request")?;
        if read == 0 {
            return Ok(());
        }
        buf.extend_from_slice(&tmp[..read]);
        if buf.windows(4).any(|w| w == b"\r\n\r\n") {
            break;
        }
        if buf.len() > 16 * 1024 {
            let response = archive_error(400, "request headers too large");
            let head = format!(
                "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET,HEAD,OPTIONS\r\nAccess-Control-Allow-Headers: *\r\nCache-Control: no-store\r\n\r\n",
                response.status,
                archive_status_text(response.status),
                response.content_type,
                response.body.len(),
            );
            stream.write_all(head.as_bytes()).await?;
            return Ok(());
        }
    }

    let req = String::from_utf8_lossy(&buf);
    let mut lines = req.lines();
    let first = lines.next().unwrap_or_default();
    let mut parts = first.split_whitespace();
    let method = parts.next().unwrap_or("");
    let target = parts.next().unwrap_or("/");
    let response = handle_archive_http_request(&state, method, target);
    let is_head = method == "HEAD";
    let body_len = if is_head { 0 } else { response.body.len() };
    let head = format!(
        "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET,HEAD,OPTIONS\r\nAccess-Control-Allow-Headers: *\r\nCache-Control: no-store\r\n\r\n",
        response.status,
        archive_status_text(response.status),
        response.content_type,
        body_len
    );
    stream
        .write_all(head.as_bytes())
        .await
        .context("failed to write replay response headers")?;
    if !is_head {
        stream
            .write_all(&response.body)
            .await
            .context("failed to write replay response body")?;
    }
    Ok(())
}

#[derive(Debug, Clone)]
struct WtPublishRelayArgs {
    url: String,
    name: String,
    tls_disable_verify: bool,
    control_announce: bool,
    control_ttl_ms: u64,
    control_interval_ms: u64,
    iroh_secret: Option<String>,
    discovery: Option<String>,
    gossip_peer: Vec<String>,
    control_endpoint_addr_out: Option<PathBuf>,
}

struct WtPublishRelayState {
    session: moq_lite::Session,
    broadcast: moq_lite::BroadcastProducer,
    catalog: hang::CatalogProducer,
    control_stop: Option<oneshot::Sender<()>>,
}

#[derive(Clone)]
struct ProtocolOverride<S> {
    inner: S,
    protocol: Option<String>,
}

impl<S: web_transport_trait::Session> web_transport_trait::Session for ProtocolOverride<S> {
    type SendStream = S::SendStream;
    type RecvStream = S::RecvStream;
    type Error = S::Error;

    fn accept_uni(
        &self,
    ) -> impl Future<Output = Result<Self::RecvStream, Self::Error>> + web_transport_trait::MaybeSend
    {
        self.inner.accept_uni()
    }

    fn accept_bi(
        &self,
    ) -> impl Future<Output = Result<(Self::SendStream, Self::RecvStream), Self::Error>>
           + web_transport_trait::MaybeSend {
        self.inner.accept_bi()
    }

    fn open_bi(
        &self,
    ) -> impl Future<Output = Result<(Self::SendStream, Self::RecvStream), Self::Error>>
           + web_transport_trait::MaybeSend {
        self.inner.open_bi()
    }

    fn open_uni(
        &self,
    ) -> impl Future<Output = Result<Self::SendStream, Self::Error>> + web_transport_trait::MaybeSend
    {
        self.inner.open_uni()
    }

    fn send_datagram(&self, payload: bytes::Bytes) -> Result<(), Self::Error> {
        self.inner.send_datagram(payload)
    }

    fn recv_datagram(
        &self,
    ) -> impl Future<Output = Result<bytes::Bytes, Self::Error>> + web_transport_trait::MaybeSend
    {
        self.inner.recv_datagram()
    }

    fn max_datagram_size(&self) -> usize {
        self.inner.max_datagram_size()
    }

    fn protocol(&self) -> Option<&str> {
        self.protocol.as_deref().or_else(|| self.inner.protocol())
    }

    fn close(&self, code: u32, reason: &str) {
        self.inner.close(code, reason)
    }

    fn closed(&self) -> impl Future<Output = Self::Error> + web_transport_trait::MaybeSend {
        self.inner.closed()
    }
}

async fn connect_moq_session(
    relay_url: &Url,
    publish: moq_lite::OriginConsumer,
    tls_disable_verify: bool,
) -> Result<moq_lite::Session> {
    let host = relay_url
        .host_str()
        .ok_or_else(|| anyhow!("relay url missing host: {relay_url}"))?
        .to_string();
    let port = relay_url.port().unwrap_or(443);

    let mut roots = rustls::RootCertStore::empty();
    let native = rustls_native_certs::load_native_certs();
    if !native.errors.is_empty() {
        tracing::warn!(
            errors = ?native.errors,
            "some native root certs could not be loaded"
        );
    }
    for cert in native.certs {
        let _ = roots.add(cert);
    }

    let mut tls = rustls::ClientConfig::builder()
        .with_root_certificates(roots)
        .with_no_client_auth();

    if tls_disable_verify {
        #[derive(Debug)]
        struct NoCertificateVerification(Arc<rustls::crypto::CryptoProvider>);

        impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification {
            fn verify_server_cert(
                &self,
                _end_entity: &rustls::pki_types::CertificateDer<'_>,
                _intermediates: &[rustls::pki_types::CertificateDer<'_>],
                _server_name: &rustls::pki_types::ServerName<'_>,
                _ocsp: &[u8],
                _now: rustls::pki_types::UnixTime,
            ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
                Ok(rustls::client::danger::ServerCertVerified::assertion())
            }

            fn verify_tls12_signature(
                &self,
                message: &[u8],
                cert: &rustls::pki_types::CertificateDer<'_>,
                dss: &rustls::DigitallySignedStruct,
            ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
            {
                rustls::crypto::verify_tls12_signature(
                    message,
                    cert,
                    dss,
                    &self.0.signature_verification_algorithms,
                )
            }

            fn verify_tls13_signature(
                &self,
                message: &[u8],
                cert: &rustls::pki_types::CertificateDer<'_>,
                dss: &rustls::DigitallySignedStruct,
            ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
            {
                rustls::crypto::verify_tls13_signature(
                    message,
                    cert,
                    dss,
                    &self.0.signature_verification_algorithms,
                )
            }

            fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
                self.0.signature_verification_algorithms.supported_schemes()
            }
        }

        let provider = rustls::crypto::CryptoProvider::get_default()
            .cloned()
            .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider()));
        tls.dangerous()
            .set_certificate_verifier(Arc::new(NoCertificateVerification(provider)));
    }

    tls.alpn_protocols = vec![web_transport_quinn::ALPN.as_bytes().to_vec()];

    let socket = std::net::UdpSocket::bind("[::]:0").context("failed to bind UDP socket")?;

    let mut transport = quinn::TransportConfig::default();
    transport.max_idle_timeout(Some(Duration::from_secs(10).try_into().unwrap()));
    transport.keep_alive_interval(Some(Duration::from_secs(4)));
    transport.mtu_discovery_config(None);

    let transport = Arc::new(transport);
    let runtime = quinn::default_runtime().context("no async runtime")?;
    let endpoint_config = quinn::EndpointConfig::default();
    let endpoint = quinn::Endpoint::new(endpoint_config, None, socket, runtime)
        .context("failed to create QUIC endpoint")?;

    let ip = tokio::net::lookup_host((host.clone(), port))
        .await
        .context("failed DNS lookup")?
        .next()
        .context("no DNS entries")?;

    let quic: quinn::crypto::rustls::QuicClientConfig = tls.try_into()?;
    let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic));
    client_cfg.transport_config(transport);

    tracing::debug!(%ip, %host, %relay_url, "connecting QUIC");
    let connection = endpoint
        .connect_with(client_cfg, ip, &host)?
        .await
        .context("failed QUIC connect")?;

    let mut request = web_transport_quinn::proto::ConnectRequest::new(relay_url.clone());
    for alpn in moq_lite::ALPNS {
        request = request.with_protocol(alpn.to_string());
    }
    let wt = web_transport_quinn::Session::connect(connection, request)
        .await
        .context("failed WebTransport CONNECT")?;

    let client = moq_lite::Client::new().with_publish(publish);

    match client.connect(wt.clone()).await {
        Ok(session) => {
            tracing::info!("connected to relay (native protocol negotiation)");
            return Ok(session);
        }
        Err(err) => {
            tracing::debug!(err = %err, "native MoQ SETUP failed; trying protocol overrides");
        }
    }

    let attempts: [&str; 4] = ["moqt-16", "moqt-15", "moq-00", ""];
    let mut last_err: Option<anyhow::Error> = None;

    for p in attempts {
        let session = ProtocolOverride {
            inner: wt.clone(),
            protocol: (!p.is_empty()).then(|| p.to_string()),
        };

        match client.connect(session).await {
            Ok(session) => {
                tracing::info!(protocol = %p, "connected to relay");
                return Ok(session);
            }
            Err(err) => {
                last_err = Some(anyhow::Error::new(err));
                tracing::debug!(
                    protocol = %p,
                    err = %last_err.as_ref().unwrap(),
                    "MoQ SETUP failed; retrying"
                );
            }
        }
    }

    Err(last_err.unwrap_or_else(|| anyhow!("failed to connect"))).context("failed MoQ SETUP")
}

async fn open_wt_publish_relay(args: &WtPublishRelayArgs) -> Result<WtPublishRelayState> {
    let relay_url =
        Url::parse(&args.url).with_context(|| format!("invalid relay url: {}", args.url))?;
    let relay_url_str = relay_url.to_string();

    let mut control_stop: Option<oneshot::Sender<()>> = None;
    if args.control_announce {
        let secret = parse_iroh_secret(args.iroh_secret.clone())?;
        let discovery = parse_discovery(args.discovery.as_deref())?;
        let endpoint = ec_iroh::build_endpoint(secret, discovery).await?;
        let gossip_peers = parse_gossip_peers(args.gossip_peer.clone());
        let endpoint_addr_json =
            serde_json::to_string(&endpoint.addr()).unwrap_or_else(|_| endpoint.id().to_string());

        let announcement = build_control_announcement(
            args.name.clone(),
            args.name.clone(),
            vec![StreamTransportDescriptor::RelayMoq {
                url: relay_url_str.clone(),
                broadcast_name: args.name.clone(),
                track_name: "video0.m4s".to_string(),
            }],
            args.control_ttl_ms,
        );

        match spawn_control_announcer_task(
            endpoint.clone(),
            gossip_peers,
            announcement,
            Duration::from_millis(args.control_interval_ms.max(1000)),
        )
        .await
        {
            Ok(stop_tx) => {
                eprintln!("control endpoint id: {}", endpoint.id());
                eprintln!("control endpoint addr: {}", endpoint_addr_json);

                if let Some(path) = args.control_endpoint_addr_out.as_ref() {
                    if let Some(parent) = path.parent() {
                        fs::create_dir_all(parent).with_context(|| {
                            format!(
                                "failed to create control endpoint addr parent dir: {}",
                                parent.display()
                            )
                        })?;
                    }
                    fs::write(path, format!("{endpoint_addr_json}\n")).with_context(|| {
                        format!(
                            "failed to write control endpoint addr file: {}",
                            path.display()
                        )
                    })?;
                    tracing::info!(
                        path = %path.display(),
                        "wrote control endpoint addr file"
                    );
                }

                tracing::info!(
                    endpoint = %endpoint.id(),
                    endpoint_addr = %endpoint_addr_json,
                    stream = %args.name,
                    "control announce enabled"
                );
                control_stop = Some(stop_tx);
            }
            Err(err) => {
                tracing::warn!("failed to start control announce task: {err:#}");
            }
        }
    }

    let origin = moq_lite::Origin::produce();
    let publish = origin.consume();
    let mut broadcast = origin
        .create_broadcast(&args.name)
        .ok_or_else(|| anyhow!("failed to create broadcast: {}", args.name))?;
    let catalog = hang::CatalogProducer::default();
    broadcast.insert_track(catalog.track.clone());

    tracing::info!(url=%relay_url, name=%args.name, "connecting to relay");
    let session = connect_moq_session(&relay_url, publish, args.tls_disable_verify).await?;

    Ok(WtPublishRelayState {
        session,
        broadcast,
        catalog,
        control_stop,
    })
}

async fn wt_archive_serve(args: WtArchiveServeArgs) -> Result<()> {
    let manifest_root = args
        .manifest_dir
        .unwrap_or_else(|| args.output_dir.join("manifests"));
    let cas_root = args.output_dir.join("objects").join("blake3");
    fs::create_dir_all(&manifest_root)
        .with_context(|| format!("failed to create manifest dir {}", manifest_root.display()))?;
    fs::create_dir_all(&cas_root)
        .with_context(|| format!("failed to create CAS dir {}", cas_root.display()))?;

    let listener = TcpListener::bind(&args.listen)
        .await
        .with_context(|| format!("failed to bind archive replay listener {}", args.listen))?;
    let local = listener
        .local_addr()
        .context("failed to read listen addr")?;
    tracing::info!(
        listen = %local,
        manifest_root = %manifest_root.display(),
        cas_root = %cas_root.display(),
        "archive replay server listening"
    );

    let state = ArchiveReplayState {
        cas_root,
        manifest_root,
    };

    loop {
        let (stream, peer) = listener.accept().await.context("accept failed")?;
        let state_clone = state.clone();
        tokio::spawn(async move {
            if let Err(err) = handle_archive_http_connection(stream, state_clone).await {
                tracing::debug!(peer = %peer, err = %err, "archive replay request failed");
            }
        });
    }
}

async fn wt_publish(args: WtPublishArgs) -> Result<()> {
    let mut relay = open_wt_publish_relay(&WtPublishRelayArgs {
        url: args.url.clone(),
        name: args.name.clone(),
        tls_disable_verify: args.tls_disable_verify,
        control_announce: args.control_announce,
        control_ttl_ms: args.control_ttl_ms,
        control_interval_ms: args.control_interval_ms,
        iroh_secret: args.iroh_secret.clone(),
        discovery: args.discovery.clone(),
        gossip_peer: args.gossip_peer.clone(),
        control_endpoint_addr_out: args.control_endpoint_addr_out.clone(),
    })
    .await?;

    // Spawn ffmpeg to generate fMP4 suitable for hang/moq-mux.
    let mut cmd = TokioCommand::new("ffmpeg");
    cmd.arg("-hide_banner")
        .arg("-loglevel")
        .arg("error")
        .arg("-nostats")
        .arg("-fflags")
        .arg("+nobuffer")
        .arg("-flags")
        .arg("low_delay")
        .arg("-i")
        .arg(&args.input);

    if args.transcode {
        cmd.args([
            "-map",
            "0:v:0",
            "-map",
            "0:a:0?",
            "-c:v",
            "libx264",
            "-vf",
            args.video_filter.as_str(),
            "-preset",
            "veryfast",
            "-tune",
            "zerolatency",
            "-pix_fmt",
            "yuv420p",
            "-profile:v",
            "main",
            "-g",
            &args.gop_frames.to_string(),
            "-keyint_min",
            &args.gop_frames.to_string(),
            "-sc_threshold",
            "0",
            "-threads",
            "1",
            "-c:a",
            "aac",
            "-profile:a",
            "aac_low",
            "-b:a",
            "160k",
            "-ac",
            "2",
            "-ar",
            "48000",
            "-af",
            ec_chopper::LIVE_AUDIO_RESAMPLE_FILTER,
            "-max_muxing_queue_size",
            "2048",
        ]);
    } else {
        cmd.args(["-c", "copy"]);
    }

    cmd.args(["-f", "mp4", "-movflags", args.movflags.as_str(), "pipe:1"]);

    cmd.stdout(Stdio::piped());
    cmd.stderr(Stdio::inherit());

    tracing::info!(input=%args.input, "spawning ffmpeg");
    let mut child = cmd.spawn().context("failed to spawn ffmpeg")?;
    let stdout = child
        .stdout
        .take()
        .ok_or_else(|| anyhow!("ffmpeg stdout unavailable"))?;

    let config = moq_mux::import::Fmp4Config {
        passthrough: args.passthrough,
    };
    let mut importer = moq_mux::import::Fmp4::new(relay.broadcast, relay.catalog, config);

    let mut stdout = stdout;
    let decode_fut = importer.decode_from(&mut stdout);
    tokio::pin!(decode_fut);

    tracing::info!("publishing fMP4 -> moq-mux -> relay");
    let outcome = tokio::select! {
        res = &mut decode_fut => {
            let status = child.wait().await.context("failed to wait for ffmpeg")?;
            match res {
                Ok(()) if status.success() => Ok(()),
                Ok(()) => Err(anyhow!("ffmpeg exited with {status}")),
                Err(err) => Err(err).context("fmp4 ingest failed"),
            }
        }
        _ = relay.session.closed() => {
            let _ = child.kill().await;
            Err(anyhow!("relay session closed"))
        }
        _ = tokio::signal::ctrl_c() => {
            tracing::info!("ctrl-c; shutting down");
            let _ = child.kill().await;
            tokio::time::sleep(Duration::from_millis(100)).await;
            Ok(())
        }
    };

    if let Some(stop) = relay.control_stop.take() {
        let _ = stop.send(());
    }

    outcome
}

fn nbc_bootstrap(args: NbcBootstrapArgs) -> Result<()> {
    let chrome_path = resolve_nbc_chrome_path(args.chrome_path.as_deref())?;
    let profile_dir = resolve_nbc_profile_dir(args.profile_dir.as_deref())?;
    let result = bootstrap_nbc_auth(
        chrome_path,
        profile_dir,
        args.source_url,
        args.screenshot_out,
    )?;
    println!(
        "{}",
        serde_json::to_string_pretty(&result).context("failed to encode bootstrap result")?
    );
    Ok(())
}

async fn nbc_wt_publish(args: NbcWtPublishArgs) -> Result<()> {
    let chrome_path = resolve_nbc_chrome_path(args.chrome_path.as_deref())?;
    let profile_dir = resolve_nbc_profile_dir(args.profile_dir.as_deref())?;
    let mut relay = open_wt_publish_relay(&WtPublishRelayArgs {
        url: args.url.clone(),
        name: args.name.clone(),
        tls_disable_verify: args.tls_disable_verify,
        control_announce: args.control_announce,
        control_ttl_ms: args.control_ttl_ms,
        control_interval_ms: args.control_interval_ms,
        iroh_secret: args.iroh_secret.clone(),
        discovery: args.discovery.clone(),
        gossip_peer: args.gossip_peer.clone(),
        control_endpoint_addr_out: args.control_endpoint_addr_out.clone(),
    })
    .await?;

    let mut frame_reader = spawn_nbc_frame_reader(
        chrome_path.clone(),
        profile_dir.clone(),
        args.source_url.clone(),
    )
    .with_context(|| format!("failed to open NBC browser session for {}", args.source_url))?;

    let fps = nbc_capture_fps().max(1);

    let mut cmd = TokioCommand::new("ffmpeg");
    cmd.arg("-hide_banner")
        .arg("-loglevel")
        .arg("error")
        .arg("-nostats")
        .arg("-fflags")
        .arg("+nobuffer")
        .arg("-flags")
        .arg("low_delay")
        .arg("-f")
        .arg("mjpeg")
        .arg("-framerate")
        .arg(fps.to_string())
        .arg("-i")
        .arg("pipe:0")
        .args([
            "-map",
            "0:v:0",
            "-an",
            "-c:v",
            "libx264",
            "-preset",
            "veryfast",
            "-tune",
            "zerolatency",
            "-pix_fmt",
            "yuv420p",
            "-profile:v",
            "main",
            "-g",
        ])
        .arg(args.gop_frames.to_string())
        .arg("-keyint_min")
        .arg(args.gop_frames.to_string())
        .args([
            "-sc_threshold",
            "0",
            "-threads",
            "1",
            "-f",
            "mp4",
            "-movflags",
            args.movflags.as_str(),
            "pipe:1",
        ]);

    cmd.stdin(Stdio::piped());
    cmd.stdout(Stdio::piped());
    cmd.stderr(Stdio::inherit());

    tracing::info!(
        source_url = %args.source_url,
        chrome_path = %chrome_path.display(),
        profile_dir = %profile_dir.display(),
        "spawning ffmpeg for NBC browser capture"
    );
    let mut child = cmd.spawn().context("failed to spawn ffmpeg")?;
    let stdin = child
        .stdin
        .take()
        .ok_or_else(|| anyhow!("ffmpeg stdin unavailable"))?;
    let writer = tokio::task::spawn_blocking(move || -> Result<()> {
        let mut stdin = SyncIoBridge::new(stdin);
        std::io::copy(&mut frame_reader, &mut stdin)
            .context("failed to pipe NBC JPEG frames into ffmpeg")?;
        Ok(())
    });
    let stdout = child
        .stdout
        .take()
        .ok_or_else(|| anyhow!("ffmpeg stdout unavailable"))?;

    let config = moq_mux::import::Fmp4Config {
        passthrough: args.passthrough,
    };
    let mut importer = moq_mux::import::Fmp4::new(relay.broadcast, relay.catalog, config);

    let mut stdout = stdout;
    let decode_fut = importer.decode_from(&mut stdout);
    tokio::pin!(decode_fut);

    tracing::info!("publishing NBC browser capture -> fMP4 -> moq-mux -> relay");
    let outcome = tokio::select! {
        res = &mut decode_fut => {
            let status = child.wait().await.context("failed to wait for ffmpeg")?;
            match writer.await {
                Ok(Ok(())) => {}
                Ok(Err(err)) if !status.success() => {
                    tracing::debug!("NBC frame writer ended after ffmpeg exit: {err:#}");
                }
                Ok(Err(err)) => return Err(err).context("NBC frame writer failed"),
                Err(err) => return Err(anyhow!("NBC frame writer task failed: {err}")),
            }
            match res {
                Ok(()) if status.success() => Ok(()),
                Ok(()) => Err(anyhow!("ffmpeg exited with {status}")),
                Err(err) => Err(err).context("fmp4 ingest failed"),
            }
        }
        _ = relay.session.closed() => {
            let _ = child.kill().await;
            let _ = writer.await;
            Err(anyhow!("relay session closed"))
        }
        _ = tokio::signal::ctrl_c() => {
            tracing::info!("ctrl-c; shutting down");
            let _ = child.kill().await;
            let _ = writer.await;
            tokio::time::sleep(Duration::from_millis(100)).await;
            Ok(())
        }
    };

    if let Some(stop) = relay.control_stop.take() {
        let _ = stop.send(());
    }

    outcome
}
