
/// Open the UAC sink with retry logic.
///
/// Inputs: the ALSA device string that should receive microphone audio.
/// Outputs: a ready-to-use `Voice` sink.
/// Why: the USB audio gadget can appear after the RPC stream has already been
/// negotiated, so the server retries briefly before declaring the sink broken.
#[cfg(coverage)]
pub async fn open_voice_with_retry(uac_dev: &str) -> anyhow::Result<audio::Voice> {
    audio::Voice::new(uac_dev).await
}

#[cfg(not(coverage))]
pub async fn open_voice_with_retry(uac_dev: &str) -> anyhow::Result<audio::Voice> {
    let candidates = preferred_uac_device_candidates(uac_dev);
    let (attempts, delay_ms) = audio_init_retry_policy();
    let mut last_error: Option<anyhow::Error> = None;

    for attempt in 1..=attempts {
        for candidate in &candidates {
            match audio::Voice::new(candidate).await {
                Ok(voice) => {
                    if attempt > 1 || candidate != uac_dev {
                        info!(
                            requested = %uac_dev,
                            resolved = %candidate,
                            attempt,
                            "🎤 microphone sink recovered"
                        );
                    }
                    return Ok(voice);
                }
                Err(error) => {
                    warn!(
                        requested = %uac_dev,
                        candidate = %candidate,
                        attempt,
                        "⚠️  microphone sink init failed: {error:#}"
                    );
                    last_error = Some(error);
                }
            }
        }
        tokio::time::sleep(Duration::from_millis(delay_ms)).await;
    }

    Err(last_error.unwrap_or_else(|| anyhow::anyhow!("microphone sink init failed")))
}

/// Open the UAC capture source with retry logic.
///
/// Inputs: the preferred ALSA device string plus the logical stream id.
/// Outputs: a ready-to-stream AAC capture pipeline.
/// Why: the USB gadget card name is not always stable, so the server should
/// retry both the preferred name and any discovered aliases before failing.
#[cfg(coverage)]
pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result<audio::AudioStream> {
    audio::ear(alsa_dev, id).await
}

#[cfg(not(coverage))]
pub async fn open_ear_with_retry(alsa_dev: &str, id: u32) -> anyhow::Result<audio::AudioStream> {
    let candidates = preferred_uac_device_candidates(alsa_dev);
    let (attempts, delay_ms) = audio_init_retry_policy();
    let mut last_error: Option<anyhow::Error> = None;

    for attempt in 1..=attempts {
        for candidate in &candidates {
            match audio::ear(candidate, id).await {
                Ok(stream) => {
                    if attempt > 1 || candidate != alsa_dev {
                        info!(
                            requested = %alsa_dev,
                            resolved = %candidate,
                            attempt,
                            "🔊 audio source recovered"
                        );
                    }
                    return Ok(stream);
                }
                Err(error) => {
                    warn!(
                        requested = %alsa_dev,
                        candidate = %candidate,
                        attempt,
                        "⚠️  audio source init failed: {error:#}"
                    );
                    last_error = Some(error);
                }
            }
        }
        tokio::time::sleep(Duration::from_millis(delay_ms)).await;
    }

    Err(last_error.unwrap_or_else(|| anyhow::anyhow!("audio source init failed")))
}

#[cfg(not(coverage))]
fn audio_init_retry_policy() -> (u32, u64) {
    let attempts = std::env::var("LESAVKA_AUDIO_INIT_ATTEMPTS")
        .ok()
        .and_then(|value| value.parse::<u32>().ok())
        .or_else(|| {
            std::env::var("LESAVKA_MIC_INIT_ATTEMPTS")
                .ok()
                .and_then(|value| value.parse::<u32>().ok())
        })
        .unwrap_or(20)
        .max(1);
    let delay_ms = std::env::var("LESAVKA_AUDIO_INIT_DELAY_MS")
        .ok()
        .and_then(|value| value.parse::<u64>().ok())
        .or_else(|| {
            std::env::var("LESAVKA_MIC_INIT_DELAY_MS")
                .ok()
                .and_then(|value| value.parse::<u64>().ok())
        })
        .unwrap_or(250);
    (attempts, delay_ms)
}

fn preferred_uac_device_candidates(preferred: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut seen = BTreeSet::new();
    let auto_family = [
        "hw:UAC2Gadget,0",
        "hw:UAC2_Gadget,0",
        "hw:Composite,0",
        "hw:Lesavka,0",
    ];
    let allow_aliases = auto_family.contains(&preferred);
    if allow_aliases {
        for detected in detect_uac_card_candidates() {
            push_audio_candidate_family(&mut out, &mut seen, &detected);
        }
        for alias in auto_family {
            if alias != preferred {
                push_audio_candidate_family(&mut out, &mut seen, alias);
            }
        }
    }
    push_audio_candidate_family(&mut out, &mut seen, preferred);
    out
}

fn push_audio_candidate_family(
    out: &mut Vec<String>,
    seen: &mut BTreeSet<String>,
    candidate: &str,
) {
    let trimmed = candidate.trim();
    if trimmed.is_empty() {
        return;
    }
    push_audio_candidate(out, seen, trimmed);
    if let Some(rest) = trimmed.strip_prefix("hw:") {
        push_audio_candidate(out, seen, &format!("plughw:{rest}"));
    } else if let Some(rest) = trimmed.strip_prefix("plughw:") {
        push_audio_candidate(out, seen, &format!("hw:{rest}"));
    }
}

fn push_audio_candidate(out: &mut Vec<String>, seen: &mut BTreeSet<String>, candidate: &str) {
    let trimmed = candidate.trim();
    if trimmed.is_empty() {
        return;
    }
    if seen.insert(trimmed.to_string()) {
        out.push(trimmed.to_string());
    }
}

fn detect_uac_card_candidates() -> Vec<String> {
    let mut out = Vec::new();
    let mut seen = BTreeSet::new();
    let card_data = asound_cards_snapshot();
    let numeric_card_ids = card_data
        .as_deref()
        .map(parse_uac_numeric_card_ids)
        .unwrap_or_default();

    if let Some(pcm) = asound_pcm_snapshot() {
        for candidate in parse_uac_pcm_candidates(&pcm, &numeric_card_ids) {
            push_audio_candidate(&mut out, &mut seen, &candidate);
        }
    }
    if let Some(cards) = card_data.as_deref() {
        for candidate in parse_uac_named_card_candidates(cards) {
            push_audio_candidate(&mut out, &mut seen, &candidate);
        }
    }
    out
}

#[cfg(coverage)]
fn asound_cards_snapshot() -> Option<String> {
    std::env::var("LESAVKA_TEST_ASOUND_CARDS")
        .ok()
        .or_else(|| fs::read_to_string("/proc/asound/cards").ok())
}

#[cfg(not(coverage))]
fn asound_cards_snapshot() -> Option<String> {
    fs::read_to_string("/proc/asound/cards").ok()
}

#[cfg(coverage)]
fn asound_pcm_snapshot() -> Option<String> {
    std::env::var("LESAVKA_TEST_ASOUND_PCM")
        .ok()
        .or_else(|| fs::read_to_string("/proc/asound/pcm").ok())
}

#[cfg(not(coverage))]
fn asound_pcm_snapshot() -> Option<String> {
    fs::read_to_string("/proc/asound/pcm").ok()
}

fn parse_uac_named_card_candidates(cards: &str) -> Vec<String> {
    cards
        .lines()
        .filter_map(|line| {
            let lower = line.to_ascii_lowercase();
            if !(lower.contains("uac2")
                || lower.contains("gadget")
                || lower.contains("composite")
                || lower.contains("lesavka"))
            {
                return None;
            }
            let start = line.find('[')?;
            let end = line[start + 1..].find(']')?;
            let card_id = line[start + 1..start + 1 + end].trim();
            (!card_id.is_empty()).then(|| format!("hw:{card_id},0"))
        })
        .collect()
}

fn parse_uac_numeric_card_ids(cards: &str) -> BTreeSet<String> {
    cards
        .lines()
        .filter_map(|line| {
            let lower = line.to_ascii_lowercase();
            if !(lower.contains("uac2")
                || lower.contains("gadget")
                || lower.contains("composite")
                || lower.contains("lesavka"))
            {
                return None;
            }
            line.split_whitespace()
                .next()
                .filter(|candidate| candidate.chars().all(|ch| ch.is_ascii_digit()))
                .map(|candidate| candidate.to_string())
        })
        .collect()
}

fn parse_uac_pcm_candidates(pcm: &str, numeric_card_ids: &BTreeSet<String>) -> Vec<String> {
    pcm.lines()
        .filter_map(|line| {
            let (prefix, _) = line.split_once(':')?;
            let (card_id, device_id) = prefix.split_once('-')?;
            let normalized_card = card_id.trim_start_matches('0');
            let normalized_card = if normalized_card.is_empty() {
                "0"
            } else {
                normalized_card
            };
            let normalized_device = device_id.trim_start_matches('0');
            let normalized_device = if normalized_device.is_empty() {
                "0"
            } else {
                normalized_device
            };
            numeric_card_ids
                .contains(normalized_card)
                .then(|| format!("hw:{normalized_card},{normalized_device}"))
        })
        .collect()
}
