use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::sync::Arc;

use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, Sample, SampleFormat, SizedSample};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;

use lumotia_core::error::{Error, Result};

const AUDIO_CHANNEL_CAPACITY: usize = 32;

/// Validation window. 350ms is long enough to collect several cpal callback
/// buffers at common 44.1/48kHz rates while keeping Settings/UI device
/// switching perceptibly sub-second.
const DEVICE_VALIDATION_MS: u64 = 350;

/// Below this RMS amplitude (peak ±1.0 scale) the input is treated as
/// silence. Field dogfooding on PipeWire/PulseAudio showed idle monitor
/// sources at exact or near-zero RMS, while connected microphones in quiet
/// rooms stayed around 5e-4+; 1e-5 keeps a 50x safety margin below that.
const SILENCE_RMS_FLOOR: f32 = 1e-5;

/// Absolute floor used even for monitor fallback. Values below this are
/// effectively digital zero on normalized f32 PCM, so accepting them only
/// records silence and hides device-routing failures.
const DEAD_SILENCE_FLOOR: f32 = 1e-7;

/// A chunk of captured audio from the microphone.
pub struct AudioChunk {
    pub samples: Vec<f32>,
    pub sample_rate: u32,
    pub channels: u16,
}

/// Public-facing description of an audio input device.
/// Returned by `list_devices()` and used by the UI device picker.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceInfo {
    /// Device name as reported by cpal/the host.
    pub name: String,
    /// Default sample rate in Hz.
    pub sample_rate: u32,
    /// Default channel count.
    pub channels: u16,
    /// True if the device name matches a known monitor-source pattern
    /// (PulseAudio/PipeWire loopback of speaker output).
    pub is_likely_monitor: bool,
    /// True if cpal reports this as the host's default input device.
    pub is_default: bool,
    /// Human-readable product description, if known (Linux: from
    /// `/proc/asound/cards`). Empty string when unavailable or on
    /// platforms that don't expose one.
    #[serde(default)]
    pub description: String,
}

/// A non-fatal capture-time error emitted by the cpal stream callback after
/// `start()` has already returned. The live session subscribes to these via
/// `error_rx()` so the frontend can show a toast when the mic vanishes
/// mid-recording.
#[derive(Debug, Clone)]
pub struct CaptureRuntimeError {
    pub device_name: String,
    pub message: String,
}

/// Manages microphone capture via cpal.
pub struct MicrophoneCapture {
    stream: Option<cpal::Stream>,
    /// Name of the device that is actually capturing.
    pub device_name: String,
    /// Counter incremented every time the capture callback drops a chunk
    /// because the channel was full. Read via `dropped_chunks()`.
    dropped_chunks: Arc<AtomicU64>,
    /// Receiver for runtime stream errors (device unplugged, audio server
    /// crash, etc.). The live session calls `error_rx()` once and listens.
    error_rx: Option<mpsc::Receiver<CaptureRuntimeError>>,
}

impl MicrophoneCapture {
    /// Number of audio chunks dropped because the downstream channel was full
    /// since this capture started. Should stay at 0 in normal use; non-zero
    /// indicates downstream backpressure or a stuck consumer.
    pub fn dropped_chunks(&self) -> u64 {
        self.dropped_chunks.load(Ordering::Relaxed)
    }

    /// Take the runtime-error receiver. Can be called once per capture; the
    /// caller (live session manager) drains it on its own cadence and surfaces
    /// errors to the frontend. Returns None on the second call.
    pub fn take_error_rx(&mut self) -> Option<mpsc::Receiver<CaptureRuntimeError>> {
        self.error_rx.take()
    }

    /// Enumerate every input device the host knows about, with the metadata
    /// needed by the device-picker UI.
    pub fn list_devices() -> Result<Vec<DeviceInfo>> {
        let host = cpal::default_host();
        let default_name = host
            .default_input_device()
            .and_then(|d| device_display_name(&d))
            .unwrap_or_default();

        let devices = host
            .input_devices()
            .map_err(|e| Error::AudioCaptureFailed(format!("input_devices: {e}")))?;

        // Load ALSA card descriptions once per enumeration. These are the
        // "real" product names (e.g. "Blue Microphones") that cpal's
        // short card name (e.g. "Microphones") alone can't convey. Empty
        // map on non-Linux or if the file is missing.
        let card_descriptions = load_alsa_card_descriptions();

        let mut out = Vec::new();
        for device in devices {
            let name = device_display_name(&device).unwrap_or_else(|| "<unnamed>".to_string());
            let (sample_rate, channels) = match device.default_input_config() {
                Ok(cfg) => (cfg.sample_rate(), cfg.channels()),
                Err(_) => (0, 0),
            };
            let is_likely_monitor = is_monitor_name(&name);
            let is_default = !default_name.is_empty() && name == default_name;
            let description = extract_card_id(&name)
                .and_then(|card| card_descriptions.get(card).cloned())
                .unwrap_or_default();
            out.push(DeviceInfo {
                name,
                sample_rate,
                channels,
                is_likely_monitor,
                is_default,
                description,
            });
        }
        Ok(out)
    }

    /// Start capturing from the device whose name matches `device_name` exactly.
    /// If no match is found, returns an error rather than silently falling back.
    ///
    /// The returned tuple is `(capture, replay_buffer, rx)`:
    /// - `replay_buffer` holds chunks observed during the 350ms
    ///   validation pre-roll. Consumers MUST drain it before reading
    ///   from `rx` so the head of the recording isn't lost on hosts
    ///   whose cpal buffer is small enough to overflow the 32-slot
    ///   channel during validation (WASAPI exclusive, low-latency
    ///   ALSA at 256 frames).
    /// - `rx` is the live cpal callback channel.
    pub fn start_with_device(
        device_name: &str,
    ) -> Result<(Self, VecDeque<AudioChunk>, mpsc::Receiver<AudioChunk>)> {
        let host = cpal::default_host();
        let devices = host
            .input_devices()
            .map_err(|e| Error::AudioCaptureFailed(format!("input_devices: {e}")))?;

        for device in devices {
            let name = device_display_name(&device).unwrap_or_default();
            if name == device_name {
                tracing::info!(target: "lumotia_audio", "start_with_device: opening explicit device '{name}'");
                return open_and_validate(device, &name, /* require_audio = */ true);
            }
        }

        Err(Error::AudioCaptureFailed(format!(
            "Selected device '{device_name}' not found in current host enumeration. \
             It may have been disconnected. Open Settings → Audio to pick another."
        )))
    }

    /// Start capturing audio with auto-selection.
    ///
    /// Selection rules:
    /// 1. Try the host default input device first if it exists AND is not a monitor source.
    /// 2. Otherwise, try non-monitor devices in enumeration order.
    /// 3. Validate the chosen device by RMS energy (not just receipt of bytes) over
    ///    a short window — this is what defeats the "silent monitor source wins" bug.
    /// 4. If no non-monitor device produces real audio, fall back to monitor sources
    ///    as a last resort (with a clear log line). Never accept dead silence.
    pub fn start() -> Result<(Self, VecDeque<AudioChunk>, mpsc::Receiver<AudioChunk>)> {
        let host = cpal::default_host();
        let default_name = host
            .default_input_device()
            .and_then(|d| device_display_name(&d))
            .unwrap_or_default();

        let mut all_devices: Vec<cpal::Device> = host
            .input_devices()
            .map_err(|e| Error::AudioCaptureFailed(format!("input_devices: {e}")))?
            .collect();

        // Sort: default first, then non-monitor, then monitor-as-last-resort.
        all_devices.sort_by_key(|d| {
            let n = device_display_name(d).unwrap_or_default();
            let is_default = !default_name.is_empty() && n == default_name;
            let is_monitor = is_monitor_name(&n);
            // Smaller key = tried first.
            match (is_default, is_monitor) {
                (true, false) => 0,  // default, real input
                (false, false) => 1, // any other real input
                (true, true) => 2,   // default but is a monitor (very rare)
                (false, true) => 3,  // monitor source — last resort
            }
        });

        tracing::info!(
            target: "lumotia_audio",
            device_count = all_devices.len(),
            default = %default_name,
            "enumerated input devices"
        );

        // First pass: require real audio energy.
        for device in &all_devices {
            let name = device_display_name(device).unwrap_or_default();
            if is_monitor_name(&name) {
                continue; // Save monitor sources for second pass.
            }
            match open_and_validate(device.clone(), &name, true) {
                Ok(result) => return Ok(result),
                Err(e) => {
                    tracing::warn!(target: "lumotia_audio", device = %name, error = %e, "candidate device rejected");
                }
            }
        }

        // Second pass: accept anything that delivers bytes (monitor sources
        // included). Better to capture from a monitor than fail entirely.
        tracing::warn!(
            target: "lumotia_audio",
            "no non-monitor mic produced audio; falling back to monitor/loopback sources"
        );
        for device in &all_devices {
            let name = device_display_name(device).unwrap_or_default();
            match open_and_validate(device.clone(), &name, false) {
                Ok(result) => {
                    tracing::warn!(
                        target: "lumotia_audio",
                        device = %name,
                        "capturing from likely monitor source; recordings may be silent or contain system audio"
                    );
                    return Ok(result);
                }
                Err(_) => continue,
            }
        }

        Err(Error::AudioCaptureFailed(
            "No working microphone found. Check that an input device is connected, \
             that PulseAudio/PipeWire is running, and that the app has microphone permission. \
             Then open Settings → Audio to pick a device explicitly."
                .into(),
        ))
    }

    /// Stop capturing audio.
    pub fn stop(&mut self) {
        if let Some(stream) = self.stream.take() {
            let _ = stream.pause();
        }
    }
}

impl Drop for MicrophoneCapture {
    fn drop(&mut self) {
        self.stop();
    }
}

/// Heuristic: identify a PulseAudio/PipeWire monitor source by name.
/// Common patterns:
///   - ".monitor" suffix (PulseAudio convention)
///   - "Monitor of " prefix (longer human-readable name)
///   - "Loopback" anywhere (some PipeWire configurations)
fn is_monitor_name(name: &str) -> bool {
    let lower = name.to_lowercase();
    lower.ends_with(".monitor")
        || lower.starts_with("monitor of ")
        || lower.contains("monitor of ")
        || lower.contains("loopback")
}

fn device_display_name(device: &cpal::Device) -> Option<String> {
    device
        .description()
        .ok()
        .map(|description| description.name().to_string())
}

/// Pull the CARD= value from an ALSA device string.
///
/// `sysdefault:CARD=Microphones` → `Some("Microphones")`
/// `hw:CARD=C920,DEV=0`          → `Some("C920")`
/// `pipewire` / `default`        → `None`
fn extract_card_id(name: &str) -> Option<&str> {
    let rest = name.split("CARD=").nth(1)?;
    Some(rest.split([',', ';']).next().unwrap_or(rest))
}

/// Read `/proc/asound/cards` and return a map from ALSA card short name
/// (e.g. "Microphones") to the richer product string (e.g. "Blue
/// Microphones"). Empty map on non-Linux or if the file is missing.
///
/// Format of `/proc/asound/cards`:
/// ```text
///  2 [Microphones    ]: USB-Audio - Blue Microphones
///                       Blue Microphones at usb-...
///  3 [C920           ]: USB-Audio - HD Pro Webcam C920
///                       HD Pro Webcam C920 at usb-...
/// ```
/// The bracket contains the short name that cpal reports; the text
/// after the colon on that same line is the description we want. The
/// next indented line is a longer location string we ignore.
fn load_alsa_card_descriptions() -> std::collections::HashMap<String, String> {
    #[cfg(target_os = "linux")]
    {
        let Ok(contents) = std::fs::read_to_string("/proc/asound/cards") else {
            return std::collections::HashMap::new();
        };
        parse_alsa_card_descriptions(&contents)
    }

    #[cfg(not(target_os = "linux"))]
    {
        std::collections::HashMap::new()
    }
}

fn parse_alsa_card_descriptions(contents: &str) -> std::collections::HashMap<String, String> {
    use std::collections::HashMap;

    static CARD_LINE: OnceLock<Regex> = OnceLock::new();
    let card_line = CARD_LINE.get_or_init(|| {
        Regex::new(r"^\s*\d+\s+\[([^\]]+)\]\s*:\s*(.+?)\s*$").expect("valid ALSA card-line regex")
    });

    let mut map = HashMap::new();
    for line in contents.lines() {
        let Some(captures) = card_line.captures(line) else {
            continue;
        };
        let Some(short_name) = captures.get(1).map(|m| m.as_str().trim()) else {
            continue;
        };
        if short_name.is_empty() {
            continue;
        }
        let raw = captures
            .get(2)
            .map(|m| m.as_str().trim())
            .unwrap_or_default();
        let description = raw
            .split_once(" - ")
            .map(|(_, product)| product.trim())
            .unwrap_or(raw);
        if !description.is_empty() {
            map.insert(short_name.to_string(), description.to_string());
        }
    }
    map
}

/// Open the given device and validate it produces non-silent audio.
/// If `require_audio` is false, accept any data (used for monitor fallback).
fn open_and_validate(
    device: cpal::Device,
    name: &str,
    require_audio: bool,
) -> Result<(
    MicrophoneCapture,
    VecDeque<AudioChunk>,
    mpsc::Receiver<AudioChunk>,
)> {
    let config = device
        .default_input_config()
        .map_err(|e| Error::AudioCaptureFailed(format!("default_input_config: {e}")))?;
    let sample_rate = config.sample_rate();
    let channels = config.channels();
    let format = config.sample_format();

    tracing::info!(
        target: "lumotia_audio",
        device = %name,
        sample_rate,
        channels,
        format = ?format,
        "trying audio input device"
    );

    let (tx, rx) = mpsc::sync_channel::<AudioChunk>(AUDIO_CHANNEL_CAPACITY);
    let dropped_chunks = Arc::new(AtomicU64::new(0));
    // Bounded channel for runtime stream errors. Capacity 32 = plenty for
    // the rare error case; if it ever fills, drops are reported via stderr
    // and counted in `dropped_errors` so the symptom is visible in the
    // diagnostic bundle even when the listener has gone away. Errors
    // beyond the cap are by definition redundant noise in a stream that
    // is already failing.
    let (err_tx, err_rx) = mpsc::sync_channel::<CaptureRuntimeError>(32);
    let dropped_errors = Arc::new(AtomicU64::new(0));

    let stream = match format {
        SampleFormat::F32 => build_input_stream::<f32>(
            &device,
            &config,
            sample_rate,
            channels,
            tx,
            dropped_chunks.clone(),
            err_tx.clone(),
            dropped_errors.clone(),
            name.to_string(),
        ),
        SampleFormat::I16 => build_input_stream::<i16>(
            &device,
            &config,
            sample_rate,
            channels,
            tx,
            dropped_chunks.clone(),
            err_tx.clone(),
            dropped_errors.clone(),
            name.to_string(),
        ),
        SampleFormat::U16 => build_input_stream::<u16>(
            &device,
            &config,
            sample_rate,
            channels,
            tx,
            dropped_chunks.clone(),
            err_tx.clone(),
            dropped_errors.clone(),
            name.to_string(),
        ),
        other => {
            return Err(Error::AudioCaptureFailed(format!(
                "unsupported sample format {other:?}"
            )))
        }
    }
    .map_err(|e| Error::AudioCaptureFailed(format!("build_input_stream: {e}")))?;

    stream
        .play()
        .map_err(|e| Error::AudioCaptureFailed(format!("stream.play: {e}")))?;

    // Validation window: collect chunks for DEVICE_VALIDATION_MS, compute RMS.
    let deadline =
        std::time::Instant::now() + std::time::Duration::from_millis(DEVICE_VALIDATION_MS);
    let mut collected: Vec<AudioChunk> = Vec::new();
    let mut total_samples = 0_usize;
    let mut sum_sq: f64 = 0.0;

    while std::time::Instant::now() < deadline {
        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
        if remaining.is_zero() {
            break;
        }
        match rx.recv_timeout(remaining) {
            Ok(chunk) => {
                sum_sq += chunk
                    .samples
                    .iter()
                    .map(|&s| (s as f64).powi(2))
                    .sum::<f64>();
                total_samples += chunk.samples.len();
                collected.push(chunk);
            }
            Err(_) => break,
        }
    }

    if total_samples == 0 {
        return Err(Error::AudioCaptureFailed(
            "device delivered zero samples in validation window".into(),
        ));
    }

    let rms = (sum_sq / total_samples as f64).sqrt() as f32;
    tracing::info!(
        target: "lumotia_audio",
        device = %name,
        samples = total_samples,
        rms,
        "audio input validation complete"
    );

    if require_audio && rms < SILENCE_RMS_FLOOR {
        return Err(Error::AudioCaptureFailed(format!(
            "device produced silence (rms={rms:.6} below floor {SILENCE_RMS_FLOOR:.6})"
        )));
    }

    // Even in the fallback pass (require_audio=false), reject completely
    // dead-zero audio. PulseAudio/PipeWire will sometimes happily emit a
    // long stream of f32 zeros from a borked device — that is worse than
    // failing fast.
    if rms < DEAD_SILENCE_FLOOR {
        return Err(Error::AudioCaptureFailed(format!(
            "device produced dead silence (rms={rms:.6e} below absolute floor {DEAD_SILENCE_FLOOR:.6e})"
        )));
    }

    // Hand the validation pre-roll back to the consumer as a separate
    // VecDeque rather than try_send-requeuing into the 32-slot channel.
    // On small-buffer audio hosts (WASAPI exclusive at ~256 frames /
    // low-latency ALSA) the 350ms window collects ~65 chunks; the old
    // requeue path silently dropped roughly half of them, losing ~150ms
    // from the head of every recording. The consumer-side drain
    // bypasses the channel cap entirely.
    let replay_buffer: VecDeque<AudioChunk> = collected.into_iter().collect();

    tracing::info!(
        target: "lumotia_audio",
        device = %name,
        replay_chunks = replay_buffer.len(),
        "selected microphone"
    );
    Ok((
        MicrophoneCapture {
            stream: Some(stream),
            device_name: name.to_string(),
            dropped_chunks,
            error_rx: Some(err_rx),
        },
        replay_buffer,
        rx,
    ))
}

#[allow(clippy::too_many_arguments)]
fn build_input_stream<T>(
    device: &cpal::Device,
    supported_config: &cpal::SupportedStreamConfig,
    sample_rate: u32,
    channels: u16,
    tx: mpsc::SyncSender<AudioChunk>,
    dropped_chunks: Arc<AtomicU64>,
    err_tx: mpsc::SyncSender<CaptureRuntimeError>,
    dropped_errors: Arc<AtomicU64>,
    device_name: String,
) -> std::result::Result<cpal::Stream, cpal::BuildStreamError>
where
    T: Sample + SizedSample,
    f32: FromSample<T>,
{
    let config: cpal::StreamConfig = supported_config.clone().into();
    let err_device_name = device_name.clone();
    device.build_input_stream(
        &config,
        move |data: &[T], _| {
            let samples: Vec<f32> = data.iter().copied().map(f32::from_sample).collect();
            let chunk = AudioChunk {
                samples,
                sample_rate,
                channels,
            };
            // try_send fails if the channel is full. Track that explicitly;
            // otherwise backpressure looks like clean transcription silence.
            if tx.try_send(chunk).is_err() {
                dropped_chunks.fetch_add(1, Ordering::Relaxed);
            }
        },
        move |err| {
            // Surface stream errors to the live session via err_tx so the
            // frontend can show a toast.
            tracing::error!(target: "lumotia_audio", error = %err, "capture stream error");
            if err_tx
                .try_send(CaptureRuntimeError {
                    device_name: err_device_name.clone(),
                    message: err.to_string(),
                })
                .is_err()
            {
                // Channel full — listener has stalled or detached. Keep a
                // counter so the diagnostic bundle still shows the symptom
                // even if the frontend never received the typed event.
                let prior = dropped_errors.fetch_add(1, Ordering::Relaxed);
                tracing::warn!(
                    target: "lumotia_audio",
                    device = %err_device_name,
                    dropped_error = prior + 1,
                    "capture error channel full; dropping runtime error"
                );
            }
        },
        None,
    )
}

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

    #[test]
    fn monitor_pattern_detection() {
        for name in [
            "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor",
            "Monitor of Built-in Audio Analog Stereo",
            "PipeWire Loopback Source",
            "Built-in Audio Monitor of Analog Stereo",
        ] {
            assert!(is_monitor_name(name), "expected monitor source: {name}");
        }

        for name in [
            "Built-in Audio Analog Stereo",
            "Blue Microphones",
            "HD Pro Webcam C920",
            "sysdefault:CARD=Microphones",
        ] {
            assert!(!is_monitor_name(name), "expected physical input: {name}");
        }
    }

    #[test]
    fn parses_alsa_cards_with_regex() {
        let contents = r#"
 2 [Microphones    ]: USB-Audio - Blue Microphones
                      Blue Microphones at usb-0000:04:00.3-2.1, full speed
 3 [C920           ]: USB-Audio - HD Pro Webcam C920: With Colon
                      HD Pro Webcam C920 at usb-0000:04:00.3-2.2, high speed
"#;

        let parsed = parse_alsa_card_descriptions(contents);

        assert_eq!(
            parsed.get("Microphones").map(String::as_str),
            Some("Blue Microphones")
        );
        assert_eq!(
            parsed.get("C920").map(String::as_str),
            Some("HD Pro Webcam C920: With Colon")
        );
    }
}
