// client/src/input/microphone.rs
use anyhow::{Context, Result};
use gst::prelude::*;
use gstreamer as gst;
use gstreamer_app as gst_app;
use lesavka_common::lesavka::AudioPacket;
use shell_escape::unix::escape;
#[cfg(not(coverage))]
use std::sync::atomic::{AtomicU64, Ordering};
use std::{
    path::{Path as StdPath, PathBuf},
    sync::{
        Arc,
        atomic::{AtomicBool, Ordering as AtomicOrdering},
    },
    thread,
    time::Duration,
};
use tracing::{debug, warn};
#[cfg(not(coverage))]
use tracing::{error, info, trace};

#[cfg(any(coverage, test))]
fn shared_capture_pts_us() -> u64 {
    use std::sync::OnceLock;
    use std::time::Instant;

    static CAPTURE_ORIGIN: OnceLock<Instant> = OnceLock::new();
    CAPTURE_ORIGIN
        .get_or_init(Instant::now)
        .elapsed()
        .as_micros()
        .min(u64::MAX as u128) as u64
}

#[cfg(not(any(coverage, test)))]
fn shared_capture_pts_us() -> u64 {
    crate::live_capture_clock::capture_pts_us()
}

const MIC_GAIN_ENV: &str = "LESAVKA_MIC_GAIN";
const MIC_GAIN_CONTROL_ENV: &str = "LESAVKA_MIC_GAIN_CONTROL";
const MIC_LEVEL_TAP_ENV: &str = "LESAVKA_UPLINK_MIC_LEVEL";

pub struct MicrophoneCapture {
    #[allow(dead_code)] // kept alive to hold PLAYING state
    pipeline: gst::Pipeline,
    sink: gst_app::AppSink,
    level_tap_running: Option<Arc<AtomicBool>>,
}

impl MicrophoneCapture {
    pub fn new() -> Result<Self> {
        gst::init().ok(); // idempotent

        /* preferred path: pipewiresrc; fallback: pulsesrc ----------------*/
        let source_desc = match std::env::var("LESAVKA_MIC_SOURCE") {
            Ok(s) if !s.is_empty() => match Self::resolve_source_desc(&s) {
                Some(desc) => desc,
                None => {
                    warn!("🎤 requested mic '{s}' not found; using default");
                    Self::default_source_desc()
                }
            },
            _ => Self::default_source_desc(),
        };
        debug!("🎤 source: {source_desc}");
        let aac = ["avenc_aac", "fdkaacenc", "faac", "opusenc"]
            .into_iter()
            .find(|e| gst::ElementFactory::find(e).is_some())
            .unwrap_or("opusenc");
        let parser = parser_for_encoder(aac);
        let gain = mic_gain_from_env();
        let level_tap_path = mic_level_tap_path();
        let desc =
            microphone_pipeline_desc(&source_desc, aac, parser, gain, level_tap_path.is_some());

        let pipeline: gst::Pipeline = gst::parse::launch(&desc)?.downcast().expect("pipeline");
        let sink: gst_app::AppSink = pipeline.by_name("asink").unwrap().downcast().unwrap();
        let volume = pipeline
            .by_name("mic_input_gain")
            .context("missing mic_input_gain volume")?;

        #[cfg(not(coverage))]
        {
            /* ─── bus for diagnostics ───────────────────────────────────────*/
            let bus = pipeline.bus().unwrap();
            std::thread::spawn(move || {
                use gst::MessageView::*;
                for msg in bus.iter_timed(gst::ClockTime::NONE) {
                    match msg.view() {
                        StateChanged(s)
                            if s.current() == gst::State::Playing
                                && msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) =>
                        {
                            info!("🎤 mic pipeline ▶️")
                        }
                        Error(e) => error!(
                            "🎤💥 mic: {} ({})",
                            e.error(),
                            e.debug().unwrap_or_default()
                        ),
                        Warning(w) => warn!(
                            "🎤⚠️ mic: {} ({})",
                            w.error(),
                            w.debug().unwrap_or_default()
                        ),
                        _ => {}
                    }
                }
            });
        }

        if let Err(err) = pipeline.set_state(gst::State::Playing) {
            let _ = pipeline.set_state(gst::State::Null);
            return Err(err).context("start mic pipeline");
        }
        maybe_spawn_mic_gain_control(volume);
        let level_tap_running = if let Some(path) = level_tap_path {
            let level_sink = pipeline
                .by_name("level_sink")
                .context("missing microphone level tap appsink")?
                .downcast::<gst_app::AppSink>()
                .expect("microphone level tap appsink");
            Some(spawn_mic_level_tap(level_sink, path))
        } else {
            None
        };

        Ok(Self {
            pipeline,
            sink,
            level_tap_running,
        })
    }

    /// Blocking pull; call from an async wrapper
    pub fn pull(&self) -> Option<AudioPacket> {
        match self.sink.pull_sample() {
            Ok(sample) => {
                let buf = sample.buffer().unwrap();
                let map = buf.map_readable().unwrap();
                let pts = shared_capture_pts_us();
                #[cfg(not(coverage))]
                {
                    static CNT: AtomicU64 = AtomicU64::new(0);
                    let n = CNT.fetch_add(1, Ordering::Relaxed);
                    if n < 10 || n.is_multiple_of(300) {
                        trace!("🎤⇧ cli  pkt#{n}  {} bytes", map.len());
                    }
                }
                Some(AudioPacket {
                    id: 0,
                    pts,
                    data: map.as_slice().to_vec(),
                })
            }
            Err(_) => None,
        }
    }

    /// Resolve launcher-selected mic names while preserving Pulse catalog routing.
    fn resolve_source_desc(fragment: &str) -> Option<String> {
        if looks_like_pulse_source_name(fragment)
            && let Some(full) = Self::pulse_source_by_substr(fragment)
        {
            return Some(Self::pulse_source_desc(Some(&full)));
        }
        if Self::pipewire_source_available()
            && let Some(full) = Self::pipewire_source_by_substr(fragment)
        {
            return Some(Self::pipewire_source_desc(Some(&full)));
        }
        Self::pulse_source_by_substr(fragment).map(|full| Self::pulse_source_desc(Some(&full)))
    }

    fn pipewire_source_available() -> bool {
        #[cfg(coverage)]
        if std::env::var("LESAVKA_MIC_DISABLE_PIPEWIRE").is_ok() {
            return false;
        }
        gst::ElementFactory::find("pipewiresrc").is_some()
    }

    fn pipewire_source_desc(source: Option<&str>) -> String {
        match source {
            Some(source) if !source.trim().is_empty() => {
                format!(
                    "pipewiresrc target-object={} do-timestamp=true",
                    escape(source.to_string().into())
                )
            }
            _ => "pipewiresrc do-timestamp=true".to_string(),
        }
    }

    fn pulse_source_desc(source: Option<&str>) -> String {
        match source {
            Some(source) if !source.trim().is_empty() => {
                format!(
                    "pulsesrc device={} do-timestamp=true",
                    escape(source.to_string().into())
                )
            }
            _ => "pulsesrc do-timestamp=true".to_string(),
        }
    }

    fn pipewire_source_by_substr(fragment: &str) -> Option<String> {
        let out = std::process::Command::new("pw-dump").output().ok()?;
        let list = serde_json::from_slice::<serde_json::Value>(&out.stdout).ok()?;
        let objects = list.as_array()?;
        objects.iter().find_map(|object| {
            let props = object.get("info")?.get("props")?.as_object()?;
            if props.get("media.class")?.as_str()? != "Audio/Source" {
                return None;
            }
            let name = props
                .get("node.name")
                .or_else(|| props.get("node.nick"))?
                .as_str()?;
            if name.contains(fragment) && !name.ends_with(".monitor") {
                Some(name.to_owned())
            } else {
                None
            }
        })
    }

    fn pulse_source_by_substr(fragment: &str) -> Option<String> {
        use std::process::Command;
        let out = Command::new("pactl")
            .args(["list", "short", "sources"])
            .output()
            .ok()?;
        let list = String::from_utf8_lossy(&out.stdout);
        list.lines().find_map(|ln| {
            let mut cols = ln.split_whitespace();
            let _id = cols.next()?;
            let name = cols.next()?; // column #1
            if name.contains(fragment) {
                Some(name.to_owned())
            } else {
                None
            }
        })
    }

    fn default_source_desc() -> String {
        #[cfg(coverage)]
        if let Ok(source) = std::env::var("LESAVKA_MIC_TEST_SOURCE_DESC")
            && !source.trim().is_empty()
        {
            return source;
        }
        if Self::pipewire_source_available() {
            return Self::pipewire_source_desc(None);
        }
        Self::pulse_source_desc(None)
    }
}

fn mic_level_tap_path() -> Option<PathBuf> {
    std::env::var(MIC_LEVEL_TAP_ENV)
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
}

fn parser_for_encoder(aac: &str) -> &'static str {
    if aac.contains("opus") {
        "capsfilter caps=audio/x-opus,rate=48000,channels=2"
    } else {
        "aacparse ! capsfilter caps=audio/mpeg,stream-format=adts,rate=48000,channels=2"
    }
}

fn microphone_pipeline_desc(
    source_desc: &str,
    encoder: &str,
    parser: &str,
    gain: f64,
    level_tap_enabled: bool,
) -> String {
    let gain = format_mic_gain_for_gst(gain);
    if level_tap_enabled {
        format!(
            "{source_desc} ! \
             audioconvert ! audioresample ! \
             audio/x-raw,format=S16LE,channels=2,rate=48000 ! \
             volume name=mic_input_gain volume={gain} ! \
             tee name=t \
             t. ! queue max-size-buffers=100 leaky=downstream ! \
                  audioconvert ! audioresample ! \
                  audio/x-raw,channels=2,rate=48000 ! \
                  {encoder} bitrate=128000 ! \
                  {parser} ! \
                  appsink name=asink emit-signals=true max-buffers=50 drop=true \
             t. ! queue max-size-buffers=8 leaky=downstream ! \
                  audio/x-raw,format=S16LE,channels=2,rate=48000 ! \
                  appsink name=level_sink emit-signals=false sync=false max-buffers=8 drop=true"
        )
    } else {
        format!(
            "{source_desc} ! \
             audioconvert ! audioresample ! \
             audio/x-raw,channels=2,rate=48000 ! \
             volume name=mic_input_gain volume={gain} ! \
             audioconvert ! audioresample ! \
             audio/x-raw,channels=2,rate=48000 ! \
             {encoder} bitrate=128000 ! \
             {parser} ! \
             queue max-size-buffers=100 leaky=downstream ! \
             appsink name=asink emit-signals=true max-buffers=50 drop=true"
        )
    }
}

/// Detect launcher catalog names that should be opened through Pulse directly.
fn looks_like_pulse_source_name(source: &str) -> bool {
    let source = source.trim();
    source.starts_with("alsa_input.")
        || source.starts_with("bluez_input.")
        || source.starts_with("input.")
}

fn mic_gain_from_env() -> f64 {
    std::env::var(MIC_GAIN_ENV)
        .ok()
        .and_then(|raw| parse_mic_gain(&raw))
        .unwrap_or(1.0)
}

fn parse_mic_gain(raw: &str) -> Option<f64> {
    let value = raw.split_ascii_whitespace().next()?.parse::<f64>().ok()?;
    value.is_finite().then_some(clamp_mic_gain(value))
}

fn clamp_mic_gain(value: f64) -> f64 {
    value.clamp(0.0, 4.0)
}

fn format_mic_gain_for_gst(gain: f64) -> String {
    format!("{:.3}", clamp_mic_gain(gain))
}

fn maybe_spawn_mic_gain_control(volume: gst::Element) {
    let Ok(path) = std::env::var(MIC_GAIN_CONTROL_ENV) else {
        return;
    };
    let path = std::path::PathBuf::from(path);
    thread::spawn(move || {
        let mut last_gain = None;
        loop {
            if let Some(gain) = read_mic_gain_control(&path)
                && last_gain != Some(gain)
            {
                volume.set_property("volume", gain);
                last_gain = Some(gain);
                tracing::info!("🎤 mic gain set to {gain:.2}x");
            }
            thread::sleep(Duration::from_millis(100));
        }
    });
}

fn spawn_mic_level_tap(sink: gst_app::AppSink, path: PathBuf) -> Arc<AtomicBool> {
    let running = Arc::new(AtomicBool::new(true));
    let thread_running = Arc::clone(&running);
    thread::spawn(move || {
        while thread_running.load(AtomicOrdering::Acquire) {
            if let Some(sample) = sink.try_pull_sample(gst::ClockTime::from_mseconds(250))
                && let Some(buffer) = sample.buffer()
                && let Ok(map) = buffer.map_readable()
            {
                let level = pcm_peak_fraction(map.as_slice());
                if let Err(err) = write_mic_level_tap(&path, level) {
                    tracing::debug!("🎤 local uplink level tap write failed: {err:#}");
                }
            }
        }
    });
    running
}

fn pcm_peak_fraction(bytes: &[u8]) -> f64 {
    let peak = bytes
        .chunks_exact(2)
        .map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]).unsigned_abs() as f64)
        .fold(0.0, f64::max);
    (peak / i16::MAX as f64).clamp(0.0, 1.0)
}

fn write_mic_level_tap(path: &StdPath, level: f64) -> Result<()> {
    let tmp_path = path.with_extension("tmp");
    std::fs::write(&tmp_path, format!("{level:.6}\n"))
        .with_context(|| format!("writing {}", tmp_path.display()))?;
    std::fs::rename(&tmp_path, path).with_context(|| format!("publishing {}", path.display()))?;
    Ok(())
}

fn read_mic_gain_control(path: &StdPath) -> Option<f64> {
    std::fs::read_to_string(path)
        .ok()
        .and_then(|raw| parse_mic_gain(&raw))
}

impl Drop for MicrophoneCapture {
    fn drop(&mut self) {
        if let Some(running) = &self.level_tap_running {
            running.store(false, AtomicOrdering::Release);
        }
        let _ = self.pipeline.set_state(gst::State::Null);
    }
}
