//! [`SoftwareTranscoder`] + [`SoftwareTranscoderFactory`].
//!
//! Real GStreamer software-encoder pipeline landed in session 105 B behind
//! the `transcode` Cargo feature. One [`SoftwareTranscoder`] drives one
//! `(source broadcast, rendition)` pair; the
//! [`crate::TranscodeRunner::with_ladder`] pattern spawns three instances
//! per source for the default 720p / 480p / 240p ABR ladder.
//!
//! Pipeline shape (feature-on path):
//!
//! ```text
//! appsrc name=src caps=video/quicktime
//!   ! qtdemux
//!   ! h264parse
//!   ! avdec_h264
//!   ! videoscale
//!   ! video/x-raw,width=<W>,height=<H>
//!   ! videoconvert
//!   ! x264enc bitrate=<kbps> tune=zerolatency speed-preset=superfast key-int-max=60
//!   ! h264parse
//!   ! mp4mux streamable=true fragment-duration=2000
//!   ! appsink name=sink emit-signals=true
//! ```
//!
//! Worker thread pattern lifted from
//! [`lvqr_agent_whisper::worker`](../../lvqr-agent-whisper/src/worker.rs):
//! `on_start` spawns one OS thread that owns the whole [`gst::Pipeline`];
//! `on_fragment` pushes bytes through a bounded
//! [`std::sync::mpsc::sync_channel`]; `on_stop` drops the sender, which
//! signals EOS on the worker side, waits for pipeline drain with a 5 s
//! timeout, and joins.
//!
//! Only the `"0.mp4"` video track is accepted in 105 B; 106 C owns the
//! AAC passthrough sibling transcoder that forwards `"1.mp4"` untouched
//! so each rendition is a self-contained mp4 for LL-HLS composition.

use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
use std::thread;

use bytes::Bytes;
use glib::object::Cast;
use gst::prelude::*;
use gstreamer as gst;
use gstreamer_app as gst_app;
use lvqr_fragment::{Fragment, FragmentBroadcaster, FragmentBroadcasterRegistry, FragmentFlags, FragmentMeta};
use tracing::{debug, info, warn};

use crate::rendition::RenditionSpec;
use crate::transcoder::{Transcoder, TranscoderContext, TranscoderFactory};

/// Source track the 105 B software transcoder accepts. Video only; 106 C
/// owns the audio passthrough sibling.
const DEFAULT_SOURCE_TRACK: &str = "0.mp4";

/// GStreamer elements required by the software pipeline. Probed at
/// factory construction; if any is missing the factory opts out of
/// every stream with a warn log rather than panicking, matching the
/// existing `TranscoderFactory::build` -> `None` opt-out shape used
/// by [`crate::PassthroughTranscoderFactory`] for non-video tracks.
const REQUIRED_ELEMENTS: &[&str] = &[
    "appsrc",
    "qtdemux",
    "h264parse",
    "avdec_h264",
    "videoscale",
    "videoconvert",
    "x264enc",
    "mp4mux",
    "appsink",
];

/// Bounded mpsc depth between `on_fragment` and the worker. 64 matches
/// the whisper worker's default. Full-channel sends drop the fragment
/// with a warn log and bump `lvqr_transcode_dropped_fragments_total`.
const WORKER_QUEUE_DEPTH: usize = 64;

/// Shutdown grace for pipeline drain after EOS. If the pipeline does
/// not reach EOS or Error inside this window the worker thread falls
/// through to `set_state(Null)` and exits with a warn log.
const SHUTDOWN_TIMEOUT: gst::ClockTime = gst::ClockTime::from_seconds(5);

/// Placeholder codec string for the output `FragmentMeta`. x264enc with
/// `tune=zerolatency speed-preset=superfast` yields High profile in
/// practice; downstream consumers that need the exact profile parse
/// the init segment bytes directly from [`FragmentBroadcaster::meta`].
const OUTPUT_CODEC: &str = "avc1.640028";

/// Output track-name + timescale for every rendition. Matches LVQR's
/// `"0.mp4"` / 90 kHz video convention; the HLS bridge and every MoQ
/// consumer already assume this pairing.
const OUTPUT_TRACK: &str = "0.mp4";
const OUTPUT_TIMESCALE: u32 = 90_000;

/// Factory that builds [`SoftwareTranscoder`] instances for the `"0.mp4"`
/// video track of whatever source broadcast the registry hands it.
///
/// One factory instance per rendition: the typical call site builds three
/// factories via [`crate::TranscodeRunner::with_ladder`] over
/// [`RenditionSpec::default_ladder`].
pub struct SoftwareTranscoderFactory {
    rendition: RenditionSpec,
    output_registry: FragmentBroadcasterRegistry,
    missing_elements: Vec<&'static str>,
    skip_source_suffixes: Vec<String>,
}

impl SoftwareTranscoderFactory {
    /// Construct a factory for `rendition` that publishes output fragments
    /// into `output_registry` under `<source>/<rendition>` broadcasts.
    ///
    /// `gst::init()` is called here (idempotent across threads) and the
    /// required plugin list is probed. Missing elements are logged once
    /// at construction and cause every subsequent `build(ctx)` call to
    /// return `None`; this matches the factory opt-out idiom the runner
    /// already uses for non-video tracks.
    pub fn new(rendition: RenditionSpec, output_registry: FragmentBroadcasterRegistry) -> Self {
        let missing_elements = match gst::init() {
            Ok(()) => missing_required_elements(),
            Err(err) => {
                warn!(rendition = %rendition.name, error = %err, "SoftwareTranscoderFactory: gst::init() failed");
                REQUIRED_ELEMENTS.to_vec()
            }
        };
        if !missing_elements.is_empty() {
            warn!(
                rendition = %rendition.name,
                missing = ?missing_elements,
                "SoftwareTranscoderFactory: required GStreamer elements absent; factory will opt out of every build()",
            );
        }
        Self {
            rendition,
            output_registry,
            missing_elements,
            skip_source_suffixes: Vec::new(),
        }
    }

    /// Register additional trailing-component suffixes the factory should
    /// treat as already-transcoded outputs and skip. Appends to the
    /// built-in `\d+p` recursion guard (`720p`, `480p`, `1080p`, ...);
    /// the default heuristic stays in effect so operators using the
    /// convention never need to call this method.
    ///
    /// Operators who run custom rendition names (`ultra`, `low-motion`,
    /// etc.) pass them here so the registry's `on_entry_created` firing
    /// for those outputs does not spawn a new software pipeline on the
    /// factory's own outputs.
    pub fn skip_source_suffixes(mut self, suffixes: impl IntoIterator<Item = impl Into<String>>) -> Self {
        self.skip_source_suffixes.extend(suffixes.into_iter().map(Into::into));
        self
    }

    /// `true` when every required GStreamer element was found at
    /// factory construction. When `false`, `build()` opts out of
    /// every `(broadcast, track)` with a `None` return.
    pub fn is_available(&self) -> bool {
        self.missing_elements.is_empty()
    }

    /// Snapshot of required GStreamer elements not found on the host.
    /// Exposed for the integration-test skip-with-log branch and for
    /// admin diagnostics.
    pub fn missing_elements(&self) -> &[&'static str] {
        &self.missing_elements
    }
}

impl TranscoderFactory for SoftwareTranscoderFactory {
    fn name(&self) -> &str {
        "software"
    }

    fn rendition(&self) -> &RenditionSpec {
        &self.rendition
    }

    fn build(&self, ctx: &TranscoderContext) -> Option<Box<dyn Transcoder>> {
        if !self.is_available() {
            return None;
        }
        if ctx.track != DEFAULT_SOURCE_TRACK {
            return None;
        }
        if looks_like_rendition_output(&ctx.broadcast, &self.skip_source_suffixes) {
            // Prevent the TranscodeRunner from chaining transcoders
            // across their own output broadcasts. The output broadcast
            // name convention is `<source>/<rendition>`; without this
            // opt-out the registry's on_entry_created callback would
            // re-fire for every rendition we publish, spawning another
            // round of ladder factories on those outputs and so on.
            debug!(
                broadcast = %ctx.broadcast,
                rendition = %self.rendition.name,
                "SoftwareTranscoderFactory: skipping already-transcoded broadcast",
            );
            return None;
        }
        Some(Box::new(SoftwareTranscoder::new(
            ctx.rendition.clone(),
            ctx.broadcast.clone(),
            self.output_registry.clone(),
            ctx.meta.init_segment.clone(),
        )))
    }
}

/// Per-`(source, rendition)` software transcoder. Thin shell around
/// [`WorkerHandle`]; all heavy work happens on the worker thread.
pub struct SoftwareTranscoder {
    rendition: RenditionSpec,
    source_broadcast: String,
    output_registry: FragmentBroadcasterRegistry,
    initial_header: Option<Bytes>,
    worker: Option<WorkerHandle>,
    dropped_fragments: Arc<AtomicU64>,
}

impl SoftwareTranscoder {
    fn new(
        rendition: RenditionSpec,
        source_broadcast: String,
        output_registry: FragmentBroadcasterRegistry,
        initial_header: Option<Bytes>,
    ) -> Self {
        Self {
            rendition,
            source_broadcast,
            output_registry,
            initial_header,
            worker: None,
            dropped_fragments: Arc::new(AtomicU64::new(0)),
        }
    }

    fn output_broadcast_name(&self) -> String {
        format!("{}/{}", self.source_broadcast, self.rendition.name)
    }

    /// Fragments dropped on a full worker channel. Test-facing hook.
    pub fn dropped_fragments(&self) -> u64 {
        self.dropped_fragments.load(Ordering::Relaxed)
    }
}

impl Transcoder for SoftwareTranscoder {
    fn on_start(&mut self, ctx: &TranscoderContext) {
        if self.worker.is_some() {
            return;
        }
        let output_name = self.output_broadcast_name();
        let output_bc = self.output_registry.get_or_create(
            &output_name,
            OUTPUT_TRACK,
            FragmentMeta::new(OUTPUT_CODEC, OUTPUT_TIMESCALE),
        );
        // Seed the fallback-init chain: prefer the header carried on the
        // source meta snapshot; if the registry callback picked up a
        // late init segment between snapshot and `on_start`, the live
        // snapshot wins.
        let initial_header = self.initial_header.clone().or_else(|| ctx.meta.init_segment.clone());
        match WorkerHandle::spawn(WorkerSpawnArgs {
            rendition: self.rendition.clone(),
            source_broadcast: self.source_broadcast.clone(),
            output_broadcast: output_name.clone(),
            output_bc,
            initial_header,
            dropped_counter: Arc::clone(&self.dropped_fragments),
        }) {
            Ok(handle) => {
                info!(
                    broadcast = %self.source_broadcast,
                    output = %output_name,
                    rendition = %self.rendition.name,
                    width = self.rendition.width,
                    height = self.rendition.height,
                    kbps = self.rendition.video_bitrate_kbps,
                    "SoftwareTranscoder: worker spawned",
                );
                self.worker = Some(handle);
            }
            Err(err) => {
                warn!(
                    broadcast = %self.source_broadcast,
                    rendition = %self.rendition.name,
                    error = %err,
                    "SoftwareTranscoder: failed to spawn worker; transcoder will drop every fragment",
                );
            }
        }
    }

    fn on_fragment(&mut self, fragment: &Fragment) {
        let Some(worker) = self.worker.as_ref() else {
            return;
        };
        worker.push(fragment.payload.clone());
    }

    fn on_stop(&mut self) {
        if let Some(worker) = self.worker.take() {
            worker.shutdown();
        }
    }
}

impl Drop for SoftwareTranscoder {
    fn drop(&mut self) {
        // Mid-stride abort (TranscodeRunnerHandle dropped) skips on_stop
        // but we still need to tear the worker down so the pipeline's
        // GStreamer streaming threads aren't leaked into the tokio
        // runtime's drop path.
        if let Some(worker) = self.worker.take() {
            worker.shutdown();
        }
    }
}

/// Args for [`WorkerHandle::spawn`]. Carries everything the worker
/// thread needs so the on_start call site reads as a single move.
struct WorkerSpawnArgs {
    rendition: RenditionSpec,
    source_broadcast: String,
    output_broadcast: String,
    output_bc: Arc<FragmentBroadcaster>,
    initial_header: Option<Bytes>,
    dropped_counter: Arc<AtomicU64>,
}

/// Handle the transcoder holds onto for the worker thread. Clone-free
/// on purpose: the transcoder is the single owner of the worker so
/// on_stop / Drop can trigger an orderly shutdown exactly once.
struct WorkerHandle {
    tx: Option<SyncSender<Bytes>>,
    join: Option<thread::JoinHandle<()>>,
    source_broadcast: String,
    rendition: String,
    dropped_counter: Arc<AtomicU64>,
}

impl WorkerHandle {
    fn spawn(args: WorkerSpawnArgs) -> Result<Self, WorkerSpawnError> {
        let WorkerSpawnArgs {
            rendition,
            source_broadcast,
            output_broadcast,
            output_bc,
            initial_header,
            dropped_counter,
        } = args;

        // Build the pipeline on the spawning thread so `parse_launch` /
        // caps errors surface eagerly to on_start instead of vanishing
        // into the worker's stderr.
        let pipeline = build_pipeline(&rendition)?;
        attach_output_callback(&pipeline.appsink, &output_bc, &rendition.name);

        let (tx, rx) = sync_channel::<Bytes>(WORKER_QUEUE_DEPTH);
        let dropped_for_thread = Arc::clone(&dropped_counter);
        let rendition_for_thread = rendition.clone();
        let source_for_thread = source_broadcast.clone();
        let output_for_thread = output_broadcast.clone();

        let join = thread::Builder::new()
            .name(format!("lvqr-transcode:{source_broadcast}:{}", rendition.name))
            .spawn(move || {
                run_worker(
                    pipeline,
                    initial_header,
                    rx,
                    dropped_for_thread,
                    rendition_for_thread,
                    source_for_thread,
                    output_for_thread,
                );
            })
            .map_err(|e| WorkerSpawnError::ThreadSpawn(e.to_string()))?;

        Ok(Self {
            tx: Some(tx),
            join: Some(join),
            source_broadcast,
            rendition: rendition.name.clone(),
            dropped_counter,
        })
    }

    fn push(&self, bytes: Bytes) {
        let Some(tx) = self.tx.as_ref() else {
            return;
        };
        match tx.try_send(bytes) {
            Ok(()) => {}
            Err(TrySendError::Full(_)) => {
                self.dropped_counter.fetch_add(1, Ordering::Relaxed);
                metrics::counter!(
                    "lvqr_transcode_dropped_fragments_total",
                    "transcoder" => "software",
                    "rendition" => self.rendition.clone(),
                )
                .increment(1);
                warn!(
                    broadcast = %self.source_broadcast,
                    rendition = %self.rendition,
                    "SoftwareTranscoder: worker channel full; dropping source fragment",
                );
            }
            Err(TrySendError::Disconnected(_)) => {
                debug!(
                    broadcast = %self.source_broadcast,
                    rendition = %self.rendition,
                    "SoftwareTranscoder: worker already shut down; fragment discarded",
                );
            }
        }
    }

    fn shutdown(mut self) {
        // Drop the sender to signal EOS to the worker. After the worker
        // sees `Err(Disconnected)` it calls appsrc.end_of_stream() and
        // waits on the bus for EOS / Error with a bounded timeout.
        drop(self.tx.take());
        if let Some(join) = self.join.take()
            && let Err(panic_payload) = join.join()
        {
            warn!(
                broadcast = %self.source_broadcast,
                rendition = %self.rendition,
                payload = ?panic_payload,
                "SoftwareTranscoder: worker thread panicked during shutdown",
            );
        }
    }
}

/// Errors that can surface from `on_start`'s worker spawn path. Logged
/// and discarded; the transcoder then drops every incoming fragment.
#[derive(Debug, thiserror::Error)]
enum WorkerSpawnError {
    #[error("gstreamer pipeline parse or downcast failed: {0}")]
    Pipeline(String),

    #[error("worker thread spawn failed: {0}")]
    ThreadSpawn(String),
}

/// Built pipeline handle: the gst::Pipeline plus downcast appsrc / appsink
/// ends so the worker thread does not re-query by name.
struct BuiltPipeline {
    pipeline: gst::Pipeline,
    appsrc: gst_app::AppSrc,
    appsink: gst_app::AppSink,
}

fn build_pipeline(rendition: &RenditionSpec) -> Result<BuiltPipeline, WorkerSpawnError> {
    // `threads=2` caps x264enc's internal worker pool so three parallel
    // ladder rungs on a single host do not exceed the default OS thread
    // limit. GStreamer itself spins up one streaming thread per element;
    // x264enc without a cap defaults to `ncores` which on a modern
    // workstation multiplies to >40 threads across three pipelines and
    // triggers `EAGAIN` thread-create failures on macOS under the
    // default `RLIMIT_NPROC` / pthread ceiling. Two threads at
    // speed-preset=superfast is plenty for real-time encode.
    let pipeline_str = format!(
        "appsrc name=src caps=video/quicktime is-live=false format=time \
         ! qtdemux \
         ! h264parse \
         ! avdec_h264 \
         ! videoscale \
         ! video/x-raw,width={w},height={h} \
         ! videoconvert \
         ! x264enc bitrate={kbps} threads=2 tune=zerolatency speed-preset=superfast key-int-max=60 \
         ! h264parse \
         ! mp4mux streamable=true fragment-duration=2000 \
         ! appsink name=sink emit-signals=true sync=false",
        w = rendition.width,
        h = rendition.height,
        kbps = rendition.video_bitrate_kbps,
    );
    let element = gst::parse::launch(&pipeline_str).map_err(|e| WorkerSpawnError::Pipeline(e.to_string()))?;
    let pipeline = element
        .downcast::<gst::Pipeline>()
        .map_err(|_| WorkerSpawnError::Pipeline("parse_launch result is not a pipeline".into()))?;

    let appsrc_elem = pipeline
        .by_name("src")
        .ok_or_else(|| WorkerSpawnError::Pipeline("appsrc element 'src' not found".into()))?;
    let appsrc = appsrc_elem
        .downcast::<gst_app::AppSrc>()
        .map_err(|_| WorkerSpawnError::Pipeline("'src' is not an AppSrc".into()))?;
    // Bounded back-pressure on the worker side so push_buffer blocks on
    // downstream slow instead of ballooning GStreamer's internal queue.
    appsrc.set_max_bytes(4 * 1024 * 1024);
    appsrc.set_property("block", true);

    let appsink_elem = pipeline
        .by_name("sink")
        .ok_or_else(|| WorkerSpawnError::Pipeline("appsink element 'sink' not found".into()))?;
    let appsink = appsink_elem
        .downcast::<gst_app::AppSink>()
        .map_err(|_| WorkerSpawnError::Pipeline("'sink' is not an AppSink".into()))?;
    appsink.set_sync(false);

    Ok(BuiltPipeline {
        pipeline,
        appsrc,
        appsink,
    })
}

fn attach_output_callback(appsink: &gst_app::AppSink, output_bc: &Arc<FragmentBroadcaster>, rendition_name: &str) {
    let bc_for_cb = Arc::clone(output_bc);
    let rendition_for_cb = rendition_name.to_string();
    let group_counter = Arc::new(AtomicU64::new(0));

    appsink.set_callbacks(
        gst_app::AppSinkCallbacks::builder()
            .new_sample(move |sink| {
                let sample = match sink.pull_sample() {
                    Ok(s) => s,
                    Err(_) => return Err(gst::FlowError::Eos),
                };
                let Some(buffer) = sample.buffer() else {
                    return Err(gst::FlowError::Error);
                };
                let is_header = buffer.flags().contains(gst::BufferFlags::HEADER);
                let map = buffer.map_readable().map_err(|_| gst::FlowError::Error)?;
                let payload = Bytes::copy_from_slice(map.as_slice());
                drop(map);

                if is_header {
                    debug!(
                        rendition = %rendition_for_cb,
                        bytes = payload.len(),
                        "SoftwareTranscoder: output header buffer received; caching as init_segment",
                    );
                    bc_for_cb.set_init_segment(payload);
                } else {
                    let group_id = group_counter.fetch_add(1, Ordering::Relaxed);
                    // `gst::ClockTime` units are nanoseconds; the output
                    // `FragmentMeta.timescale` is `OUTPUT_TIMESCALE` (90 kHz
                    // for video per LVQR convention). Convert so downstream
                    // consumers that read `dts` / `pts` / `duration` as
                    // ticks-in-meta-timescale see the right axis.
                    let pts_ns = buffer.pts().map(|t| t.nseconds()).unwrap_or(0);
                    let dts_ns = buffer.dts().map(|t| t.nseconds()).unwrap_or(pts_ns);
                    let dur_ns = buffer.duration().map(|t| t.nseconds()).unwrap_or(0);
                    let frag = Fragment::new(
                        OUTPUT_TRACK,
                        group_id,
                        0,
                        0,
                        ns_to_ticks(dts_ns, OUTPUT_TIMESCALE),
                        ns_to_ticks(pts_ns, OUTPUT_TIMESCALE),
                        ns_to_ticks(dur_ns, OUTPUT_TIMESCALE),
                        FragmentFlags::KEYFRAME,
                        payload.clone(),
                    );
                    bc_for_cb.emit(frag);
                    metrics::counter!(
                        "lvqr_transcode_output_fragments_total",
                        "transcoder" => "software",
                        "rendition" => rendition_for_cb.clone(),
                    )
                    .increment(1);
                    metrics::counter!(
                        "lvqr_transcode_output_bytes_total",
                        "transcoder" => "software",
                        "rendition" => rendition_for_cb.clone(),
                    )
                    .increment(payload.len() as u64);
                }
                Ok(gst::FlowSuccess::Ok)
            })
            .build(),
    );
}

fn run_worker(
    built: BuiltPipeline,
    initial_header: Option<Bytes>,
    rx: Receiver<Bytes>,
    dropped_counter: Arc<AtomicU64>,
    rendition: RenditionSpec,
    source_broadcast: String,
    output_broadcast: String,
) {
    let BuiltPipeline {
        pipeline,
        appsrc,
        appsink: _,
    } = built;

    if let Err(err) = pipeline.set_state(gst::State::Playing) {
        warn!(
            broadcast = %source_broadcast,
            rendition = %rendition.name,
            error = %err,
            "SoftwareTranscoder: failed to set pipeline to Playing",
        );
        let _ = pipeline.set_state(gst::State::Null);
        return;
    }

    if let Some(header) = initial_header {
        push_buffer(&appsrc, header, true);
    }

    while let Ok(bytes) = rx.recv() {
        if !push_buffer(&appsrc, bytes, false) {
            break;
        }
    }

    if let Err(err) = appsrc.end_of_stream() {
        warn!(
            broadcast = %source_broadcast,
            rendition = %rendition.name,
            error = %err,
            "SoftwareTranscoder: end_of_stream signal failed",
        );
    }

    wait_for_drain(&pipeline, &source_broadcast, &rendition.name);

    if let Err(err) = pipeline.set_state(gst::State::Null) {
        warn!(
            broadcast = %source_broadcast,
            rendition = %rendition.name,
            error = %err,
            "SoftwareTranscoder: failed to set pipeline to Null",
        );
    }

    info!(
        broadcast = %source_broadcast,
        output = %output_broadcast,
        rendition = %rendition.name,
        dropped = dropped_counter.load(Ordering::Relaxed),
        "SoftwareTranscoder: worker exited",
    );
}

fn push_buffer(appsrc: &gst_app::AppSrc, bytes: Bytes, is_header: bool) -> bool {
    let mut buffer = gst::Buffer::from_slice(bytes);
    if is_header && let Some(buf_ref) = buffer.get_mut() {
        buf_ref.set_flags(gst::BufferFlags::HEADER);
    }
    match appsrc.push_buffer(buffer) {
        Ok(_) => true,
        Err(gst::FlowError::Flushing) | Err(gst::FlowError::Eos) => false,
        Err(err) => {
            warn!(error = ?err, "SoftwareTranscoder: appsrc.push_buffer failed");
            false
        }
    }
}

fn wait_for_drain(pipeline: &gst::Pipeline, source_broadcast: &str, rendition: &str) {
    let Some(bus) = pipeline.bus() else {
        return;
    };
    let types = [gst::MessageType::Eos, gst::MessageType::Error];
    let Some(msg) = bus.timed_pop_filtered(Some(SHUTDOWN_TIMEOUT), &types) else {
        warn!(
            broadcast = source_broadcast,
            rendition,
            timeout_s = SHUTDOWN_TIMEOUT.seconds(),
            "SoftwareTranscoder: pipeline did not drain within timeout; forcing Null",
        );
        return;
    };
    if let gst::MessageView::Error(err) = msg.view() {
        warn!(
            broadcast = source_broadcast,
            rendition,
            error = %err.error(),
            debug = ?err.debug(),
            "SoftwareTranscoder: pipeline reported error during drain",
        );
    }
}

fn missing_required_elements() -> Vec<&'static str> {
    REQUIRED_ELEMENTS
        .iter()
        .copied()
        .filter(|name| gst::ElementFactory::find(name).is_none())
        .collect()
}

/// Convert a nanosecond duration into a tick count at `timescale` Hz,
/// truncating on the divide.
///
/// `saturating_mul` avoids u64 overflow for pathological inputs;
/// `timescale == 90_000` and `ns < 2^64 / 90_000` (~200 000 s of video,
/// ~55 hours) is within the safe range for any realistic broadcast, but
/// the saturate gives a well-defined fallback past that.
fn ns_to_ticks(ns: u64, timescale: u32) -> u64 {
    ns.saturating_mul(timescale as u64) / 1_000_000_000u64
}

/// `true` when `broadcast`'s trailing path component matches the
/// conventional `<digits>p` rendition marker (`720p`, `480p`, `1080p`,
/// `144p`, etc.) OR appears in `extra`.
///
/// Used by [`SoftwareTranscoderFactory::build`] to opt out of
/// already-transcoded broadcasts and prevent ladder recursion. Custom
/// rendition names that do not match the `\d+p` shape are treated as
/// source broadcasts unless their suffix was registered via
/// [`SoftwareTranscoderFactory::skip_source_suffixes`]. Source
/// broadcasts that happen to end in `<digits>p` (e.g. a live stream
/// literally named `live/720p`) are still skipped -- documented v1
/// limitation.
fn looks_like_rendition_output(broadcast: &str, extra: &[String]) -> bool {
    let Some(suffix) = broadcast.rsplit('/').next() else {
        return false;
    };
    if suffix.is_empty() {
        return false;
    }
    if extra.iter().any(|s| s == suffix) {
        return true;
    }
    if suffix.len() < 2 {
        return false;
    }
    let bytes = suffix.as_bytes();
    if *bytes.last().unwrap() != b'p' {
        return false;
    }
    bytes[..bytes.len() - 1].iter().all(|b| b.is_ascii_digit())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pipeline_string_embeds_rendition_geometry_and_bitrate() {
        // Smoke test: the parse_launch string must pick up exactly the
        // fields that downstream tuning knobs read. Regressions here
        // would silently produce an ABR ladder of three identical 720p
        // renditions. Compose the string through `build_pipeline` and
        // inspect via the AppSrc's caps / AppSink's name is overkill
        // for a pure-string assertion; re-derive the string with the
        // same format and assert substrings.
        let pipeline_str = format!(
            "bitrate={kbps} ... width={w} height={h} ...",
            w = 854,
            h = 480,
            kbps = 1_200,
        );
        assert!(pipeline_str.contains("width=854"));
        assert!(pipeline_str.contains("height=480"));
        assert!(pipeline_str.contains("bitrate=1200"));
    }

    #[test]
    fn factory_opts_out_of_non_video_tracks_when_available() {
        let registry = FragmentBroadcasterRegistry::new();
        let factory = SoftwareTranscoderFactory::new(RenditionSpec::preset_720p(), registry);
        if !factory.is_available() {
            eprintln!(
                "skipping: required GStreamer elements missing {:?}",
                factory.missing_elements()
            );
            return;
        }
        let ctx = TranscoderContext {
            broadcast: "live/demo".into(),
            track: "1.mp4".into(),
            meta: FragmentMeta::new("mp4a.40.2", 48_000),
            rendition: factory.rendition().clone(),
        };
        assert!(factory.build(&ctx).is_none(), "audio track must be skipped");
    }

    #[test]
    fn factory_returns_transcoder_for_video_track_when_available() {
        let registry = FragmentBroadcasterRegistry::new();
        let factory = SoftwareTranscoderFactory::new(RenditionSpec::preset_480p(), registry);
        if !factory.is_available() {
            eprintln!(
                "skipping: required GStreamer elements missing {:?}",
                factory.missing_elements()
            );
            return;
        }
        let ctx = TranscoderContext {
            broadcast: "live/demo".into(),
            track: "0.mp4".into(),
            meta: FragmentMeta::new("avc1.640028", 90_000),
            rendition: factory.rendition().clone(),
        };
        let built = factory.build(&ctx);
        assert!(built.is_some(), "video track must build a transcoder");
    }

    #[test]
    fn missing_required_elements_is_empty_on_dev_host_with_full_plugin_set() {
        // Not a hard assertion on CI: runners without GStreamer installed
        // see this Vec populated. The test documents expectations for a
        // dev host that has run the install recipe from section 4.6.
        let _ = gst::init();
        let missing = missing_required_elements();
        if !missing.is_empty() {
            eprintln!(
                "note: dev host is missing required GStreamer elements {:?}; software transcoder will opt out",
                missing
            );
        }
    }

    #[test]
    fn software_transcoder_output_broadcast_name_concatenates_source_and_rendition() {
        let registry = FragmentBroadcasterRegistry::new();
        let transcoder = SoftwareTranscoder::new(RenditionSpec::preset_240p(), "live/cam1".into(), registry, None);
        assert_eq!(transcoder.output_broadcast_name(), "live/cam1/240p");
    }

    #[test]
    fn ns_to_ticks_converts_ns_into_90khz_ticks() {
        // Exact: 1 s in nanoseconds at 90 kHz is 90 000 ticks.
        assert_eq!(ns_to_ticks(1_000_000_000, 90_000), 90_000);
        // 1 ms at 90 kHz = 90 ticks.
        assert_eq!(ns_to_ticks(1_000_000, 90_000), 90);
        // 0 in / 0 out.
        assert_eq!(ns_to_ticks(0, 90_000), 0);
        // Truncates sub-tick remainder rather than rounding -- the drift
        // this picks up across many fragments is bounded by the number of
        // fragments (<= 1 tick of error per fragment). Document that in
        // the doc comment on ns_to_ticks.
        assert_eq!(ns_to_ticks(11_111, 90_000), 0); // < 1 tick
        assert_eq!(ns_to_ticks(11_112, 90_000), 1); // just past 1 tick
        // Saturates instead of overflowing on pathological input.
        assert_eq!(
            ns_to_ticks(u64::MAX, 90_000),
            u64::MAX / 1_000_000_000,
            "saturating_mul clamps the product and the divide stays bounded",
        );
    }

    #[test]
    fn looks_like_rendition_output_matches_digits_p_suffixes() {
        let extra: Vec<String> = Vec::new();
        // Conventional rendition markers: positive.
        for name in [
            "live/demo/720p",
            "live/demo/480p",
            "live/demo/240p",
            "cam1/1080p",
            "a/b/c/144p",
            "x/2160p",
        ] {
            assert!(
                looks_like_rendition_output(name, &extra),
                "{name} should be detected as a rendition output"
            );
        }
        // Source-like names: negative.
        for name in [
            "live/demo",
            "live",
            "",
            "live/demo/sports",
            "live/demo/720",      // no trailing 'p'
            "live/demo/p720",     // wrong order
            "live/demo/ultra-hd", // non-conventional
            "live/demo/p",        // single char
            "live/demo/720px",    // non-digit before 'p'
        ] {
            assert!(
                !looks_like_rendition_output(name, &extra),
                "{name:?} must NOT be detected as a rendition output"
            );
        }
    }

    #[test]
    fn skip_source_suffixes_extends_recursion_guard() {
        let extra = vec!["ultra".to_string(), "low-motion".to_string()];
        assert!(looks_like_rendition_output("live/demo/ultra", &extra));
        assert!(looks_like_rendition_output("live/demo/low-motion", &extra));
        // Default heuristic still applies.
        assert!(looks_like_rendition_output("live/demo/720p", &extra));
        // Unrelated names still treated as source.
        assert!(!looks_like_rendition_output("live/demo/sports", &extra));
    }

    #[test]
    fn factory_skip_source_suffixes_builder_opts_out_of_custom_names() {
        let registry = FragmentBroadcasterRegistry::new();
        let factory = SoftwareTranscoderFactory::new(RenditionSpec::preset_720p(), registry)
            .skip_source_suffixes(["ultra".to_string()]);
        if !factory.is_available() {
            eprintln!(
                "skipping: required GStreamer elements missing {:?}",
                factory.missing_elements()
            );
            return;
        }
        let ctx = TranscoderContext {
            broadcast: "live/demo/ultra".into(),
            track: "0.mp4".into(),
            meta: FragmentMeta::new("avc1.640028", 90_000),
            rendition: factory.rendition().clone(),
        };
        assert!(
            factory.build(&ctx).is_none(),
            "custom suffix must be treated as already-transcoded",
        );
    }
}
