#[cfg(not(coverage))]
fn preview_startup_condition(err: &tonic::Status) -> bool {
    let message = err.message().to_ascii_lowercase();
    err.code() == tonic::Code::Internal
        && (message.contains("starting video pipeline")
            || message.contains("failed to change its state")
            || message.contains("resource busy")
            || message.contains("device or resource busy")
            || message.contains("no signal")
            || message.contains("was not ready")
            || message.contains("no such file or directory"))
}

#[cfg(not(coverage))]
fn preview_retry_delay(current: Duration, message: Option<&str>) -> Duration {
    let current_ms = current.as_millis() as u64;
    let mut next_ms = if current_ms < 1_500 {
        1_500
    } else {
        current_ms.saturating_mul(2)
    };
    if let Some(message) = message {
        let message = message.to_ascii_lowercase();
        if message.contains("too many open files")
            || message.contains("failed to change its state")
            || message.contains("resource busy")
            || message.contains("device or resource busy")
        {
            next_ms = next_ms.max(6_000);
        }
    }
    Duration::from_millis(next_ms.min(30_000))
}

#[cfg(not(coverage))]
fn set_shared_status(
    shared: &Arc<Mutex<SharedPreviewState>>,
    log_sink: &Arc<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
    monitor_id: u32,
    status: impl Into<String>,
    clear: bool,
) {
    let status = status.into();
    let should_log = if let Ok(mut slot) = shared.lock() {
        let should_log = slot.last_logged_status.as_deref() != Some(status.as_str());
        if should_log {
            slot.last_logged_status = Some(status.clone());
        }
        slot.set_status(status.clone(), clear);
        should_log
    } else {
        false
    };
    if should_log {
        log_preview_status(log_sink, monitor_id, &status);
    }
}

#[cfg(not(coverage))]
fn log_preview_issue(
    shared: &Arc<Mutex<SharedPreviewState>>,
    log_sink: &Arc<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
    monitor_id: u32,
    message: &str,
) {
    let should_log = if let Ok(mut slot) = shared.lock() {
        if slot.last_logged_error.as_deref() == Some(message) {
            false
        } else {
            slot.last_logged_error = Some(message.to_string());
            true
        }
    } else {
        false
    };
    if !should_log {
        return;
    }
    if let Ok(slot) = log_sink.lock()
        && let Some(tx) = slot.as_ref()
    {
        let _ = tx.send(format!(
            "[preview:{}] {message}",
            preview_eye_label(monitor_id)
        ));
    }
}

#[cfg(not(coverage))]
fn log_preview_status(
    log_sink: &Arc<Mutex<Option<std::sync::mpsc::Sender<String>>>>,
    monitor_id: u32,
    status: &str,
) {
    if status == PREVIEW_IDLE_STATUS {
        return;
    }
    let eye = preview_eye_label(monitor_id);
    let message = match status {
        "Waking relay preview..." => {
            format!(
                "🪄 waking {eye} eye preview - «У лукоморья дуб зелёный; златая цепь на дубе том…»"
            )
        }
        "Connecting relay preview..." => {
            format!(
                "🛰️ connecting {eye} eye feed - «Там чудеса: там леший бродит, русалка на ветвях сидит…»"
            )
        }
        "Waiting for stream..." => {
            format!(
                "👁️ {eye} eye connected; waiting for first frame - «Подымите мне веки: не вижу!»"
            )
        }
        "Preview stream ended." => {
            format!("🌙 {eye} eye stream ended - «Там лес и дол видений полны…»")
        }
        "Preview host is unavailable." => {
            format!(
                "💔 {eye} eye cannot reach preview host - «Там царь Кащей над златом чахнет; там русский дух… там Русью пахнет!»"
            )
        }
        "Preview address is unavailable." => {
            format!(
                "🧭 {eye} eye has no preview address - «Идёт направо — песнь заводит, налево — сказку говорит…»"
            )
        }
        "Preview address is invalid." => {
            format!(
                "🧭 {eye} eye got an invalid preview address - «Там на неведомых дорожках следы невиданных зверей…»"
            )
        }
        "Waiting for capture pipeline..." => {
            format!(
                "⏳ {eye} eye waiting for capture pipeline - «Избушка, избушка! Встань к лесу задом, ко мне передом.»"
            )
        }
        "Preview stream error. See session log." => {
            format!("💥 {eye} eye preview stream error - «Фу-фу! Русским духом пахнет!»")
        }
        "Preview RPC failed. See session log." => {
            format!(
                "💥 {eye} eye preview RPC failed - «Дела давно минувших дней, преданья старины глубокой…»"
            )
        }
        other => format!("🎥 {eye} eye: {other}"),
    };
    if let Ok(slot) = log_sink.lock()
        && let Some(tx) = slot.as_ref()
    {
        let _ = tx.send(format!(
            "[preview:{}] {message}",
            preview_eye_label(monitor_id)
        ));
    }
}

#[cfg(not(coverage))]
fn preview_eye_label(monitor_id: u32) -> &'static str {
    match monitor_id {
        0 => "left",
        1 => "right",
        _ => "eye",
    }
}

#[cfg(not(coverage))]
fn looks_like_preview_problem(status: &str) -> bool {
    let lower = status.to_ascii_lowercase();
    lower.contains("unavailable")
        || lower.contains("invalid")
        || lower.contains("failed")
        || lower.contains("waiting for capture pipeline")
        || lower.contains("error")
}

#[cfg(not(coverage))]
fn build_preview_pipeline(
    profile: PreviewProfile,
    decoder_name: &str,
) -> Result<(gst::Pipeline, gst_app::AppSrc, gst_app::AppSink, String)> {
    let _display_bounds = (profile.display_width, profile.display_height);
    let _source_mode = eye_source_mode_for_request(
        profile.requested_width.max(2) as u32,
        profile.requested_height.max(2) as u32,
    );
    let decoder_fragment = h264_decoder_launch_fragment(decoder_name);
    let desc = format!(
        "appsrc name=src is-live=true format=time do-timestamp=true block=false ! \
         queue max-size-buffers=6 max-size-time=0 max-size-bytes=0 leaky=downstream ! \
         h264parse name=preview_parse disable-passthrough=true ! {decoder_fragment} ! videoconvert ! \
         video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \
         appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true",
    );
    let pipeline = gst::parse::launch(&desc)?
        .downcast::<gst::Pipeline>()
        .expect("preview pipeline");

    let appsrc = pipeline
        .by_name("src")
        .context("missing preview appsrc")?
        .downcast::<gst_app::AppSrc>()
        .expect("preview appsrc");
    appsrc.set_caps(Some(
        &gst::Caps::builder("video/x-h264")
            .field("stream-format", "byte-stream")
            .field("alignment", "au")
            .build(),
    ));
    appsrc.set_format(gst::Format::Time);

    let appsink = pipeline
        .by_name("sink")
        .context("missing preview appsink")?
        .downcast::<gst_app::AppSink>()
        .expect("preview appsink");
    appsink.set_caps(Some(
        &gst::Caps::builder("video/x-raw")
            .field("format", "RGBA")
            .field("pixel-aspect-ratio", gst::Fraction::new(1, 1))
            .build(),
    ));

    Ok((pipeline, appsrc, appsink, decoder_name.to_string()))
}

#[cfg(not(coverage))]
fn preview_decoder_candidates() -> Vec<String> {
    let mut candidates = Vec::new();
    let preferred = require_h264_decoder().ok();
    if let Some(name) = preferred.as_ref() {
        candidates.push(name.clone());
    }
    for name in h264_decoder_preference_order() {
        if gst::ElementFactory::find(name).is_some() {
            candidates.push(name.to_string());
        }
    }
    if software_video_fallback_allowed() {
        candidates.push("decodebin".to_string());
    }
    candidates.sort();
    candidates.dedup();
    if let Some(preferred) = preferred
        && let Some(pos) = candidates.iter().position(|name| name == &preferred)
    {
        let decoder = candidates.remove(pos);
        candidates.insert(0, decoder);
    }
    candidates
}
