//! Lightify Audio Engine — native Spotify Connect device via librespot.
//!
//! Registers as a Spotify Connect endpoint named "Lightify", decodes audio to
//! the chosen OS output device, performs FFT on the PCM stream for visuals,
//! and exchanges JSON-lines with the Tauri host over stdin/stdout.
//!
//! CLI:
//!   lightify-audio [--device <output-name>] [<cache_dir>]
//!
//! The Spotify access token is NOT passed on the CLI — it is sent as the
//! first line on stdin to avoid leaking via process listings. After connect,
//! librespot persists reusable credentials and refreshes auth internally.

use std::io::{self, BufRead, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};

use librespot_core::authentication::Credentials;
use librespot_core::cache::Cache;
use librespot_core::config::SessionConfig;
use librespot_core::session::Session;
use librespot_connect::{ConnectConfig, Spirc};
use librespot_playback::audio_backend;
use librespot_playback::audio_backend::{Sink, SinkResult};
use librespot_playback::config::{AudioFormat, PlayerConfig};
use librespot_playback::convert::Converter;
use librespot_playback::decoder::AudioPacket;
use librespot_playback::mixer::{self, MixerConfig};
use librespot_playback::player::Player;
use librespot_metadata::audio::item::UniqueFields;

use rustfft::{num_complex::Complex, FftPlanner};
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
#[serde(tag = "event")]
enum OutEvent {
    #[serde(rename = "ready")]    Ready { device_id: String },
    #[serde(rename = "state")]    State { playing: bool },
    #[serde(rename = "freq")]     Freq { bands: Vec<u8> },
    #[serde(rename = "volume")]   Volume { percent: u16 },
    #[serde(rename = "track")]    Track { id: String, name: String, artists: String, duration_ms: u64 },
    #[serde(rename = "position")] Position { ms: u64 },
    #[serde(rename = "error")]    Error { msg: String },
}

#[derive(Deserialize)]
#[serde(tag = "cmd")]
enum InCommand {
    #[serde(rename = "token")]    Token { access_token: String },
    #[serde(rename = "play")]     Play,
    #[serde(rename = "pause")]    Pause,
    #[serde(rename = "next")]     Next,
    #[serde(rename = "prev")]     Prev,
    #[serde(rename = "seek")]     Seek { position_ms: u32 },
    #[serde(rename = "volume")]   Volume { percent: u16 },
    #[serde(rename = "shuffle")]  Shuffle { enabled: bool },
    #[serde(rename = "repeat")]   Repeat { enabled: bool },
    #[serde(rename = "stop")]     Stop,
    #[serde(rename = "quit")]     Quit,
}

const DEVICE_NAME: &str = "Lightify";
const NUM_FFT_BANDS: usize = 24;
const FFT_SIZE: usize = 2048;

fn emit(event: &OutEvent) {
    let mut stdout = io::stdout().lock();
    if let Ok(json) = serde_json::to_string(event) {
        let _ = writeln!(stdout, "{}", json);
        let _ = stdout.flush();
    }
}

struct FftAnalyser {
    fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
    scratch: Vec<Complex<f32>>,
    buffer: Vec<Complex<f32>>,
    window: Vec<f32>,
    bands: Vec<u8>,
    sample_count: usize,
}

impl FftAnalyser {
    fn new() -> Self {
        let window: Vec<f32> = (0..FFT_SIZE)
            .map(|i| 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos()))
            .collect();
        let mut planner = FftPlanner::new();
        let fft = planner.plan_fft_forward(FFT_SIZE);
        let scratch = vec![Complex::new(0.0, 0.0); fft.get_inplace_scratch_len()];
        Self {
            fft,
            scratch,
            buffer: vec![Complex::new(0.0, 0.0); FFT_SIZE],
            window,
            bands: vec![0u8; NUM_FFT_BANDS],
            sample_count: 0,
        }
    }

    fn push_samples(&mut self, samples: &[f64]) {
        let mut i = 0;
        while i + 1 < samples.len() {
            if self.sample_count >= FFT_SIZE { break; }
            let mono = ((samples[i] + samples[i + 1]) * 0.5) as f32;
            self.buffer[self.sample_count] = Complex::new(mono * self.window[self.sample_count], 0.0);
            self.sample_count += 1;
            i += 2;
        }
    }

    fn analyse(&mut self) -> bool {
        if self.sample_count < FFT_SIZE { return false; }
        let fft = Arc::clone(&self.fft);
        fft.process_with_scratch(&mut self.buffer, &mut self.scratch);

        let nyquist = FFT_SIZE / 2;
        let log_min = 20.0f32.ln();
        let log_max = 20000.0f32.ln();

        for band_idx in 0..NUM_FFT_BANDS {
            let freq_lo = ((log_min + (log_max - log_min) * band_idx as f32 / NUM_FFT_BANDS as f32).exp()) as usize;
            let freq_hi = ((log_min + (log_max - log_min) * (band_idx + 1) as f32 / NUM_FFT_BANDS as f32).exp()) as usize;
            let bin_lo = (freq_lo * FFT_SIZE / 44100).max(1).min(nyquist - 1);
            let bin_hi = (freq_hi * FFT_SIZE / 44100).max(bin_lo + 1).min(nyquist);

            let mut sum = 0.0f32;
            let count = (bin_hi - bin_lo).max(1) as f32;
            for bin in bin_lo..bin_hi { sum += self.buffer[bin].norm(); }
            let avg = sum / count;
            let db = 20.0 * (avg + 1e-10).log10();
            let normalized = ((db + 60.0) / 60.0).clamp(0.0, 1.0);
            self.bands[band_idx] = (normalized * 255.0) as u8;
        }
        self.sample_count = 0;
        true
    }

    fn get_bands(&self) -> &[u8] { &self.bands }
}

struct SharedState {
    running: AtomicBool,
    analyser: Mutex<FftAnalyser>,
}

impl SharedState {
    fn new() -> Arc<Self> {
        Arc::new(Self {
            running: AtomicBool::new(true),
            analyser: Mutex::new(FftAnalyser::new()),
        })
    }
}

struct TappedSink {
    inner: Box<dyn Sink>,
    state: Arc<SharedState>,
    seen_packets: u64,
}

// SAFETY: The concrete CPAL audio backend returned by audio_backend::find() is Send.
// The Sink trait object erases this bound; this impl is sound given that constraint.
unsafe impl Send for TappedSink {}

impl Sink for TappedSink {
    fn write(&mut self, packet: AudioPacket, converter: &mut Converter) -> SinkResult<()> {
        if let AudioPacket::Samples(ref samples) = packet {
            if let Ok(mut a) = self.state.analyser.try_lock() {
                a.push_samples(samples);
            }
            // Diagnostic: log on first packet AND every 1000 packets so we can
            // confirm audio frames are actually being delivered to rodio.
            // A silent backend with WASAPI open but zero packets means librespot
            // never received play / never decoded the track.
            if self.seen_packets == 0 || self.seen_packets % 1000 == 0 {
                eprintln!("[lightify-audio] sink.write packet #{} samples={}", self.seen_packets, samples.len());
            }
            self.seen_packets += 1;
        }
        self.inner.write(packet, converter)
    }

    fn start(&mut self) -> SinkResult<()> {
        eprintln!("[lightify-audio] sink.start()");
        self.inner.start()
    }

    fn stop(&mut self) -> SinkResult<()> {
        eprintln!("[lightify-audio] sink.stop()");
        self.inner.stop()
    }
}

fn stdin_command_loop(state: Arc<SharedState>, spirc: Arc<Spirc>) {
    let stdin = io::stdin();
    for line in stdin.lock().lines() {
        let line = match line { Ok(l) => l, Err(_) => break };
        if line.trim().is_empty() { continue; }
        match serde_json::from_str::<InCommand>(&line) {
            Ok(InCommand::Token { .. }) => {
                eprintln!("[lightify-audio] Token command ignored post-auth; session refreshes via stored credentials");
            }
            Ok(InCommand::Play)         => { let _ = spirc.play(); }
            Ok(InCommand::Pause)        => { let _ = spirc.pause(); }
            Ok(InCommand::Next)         => { let _ = spirc.next(); }
            Ok(InCommand::Prev)         => { let _ = spirc.prev(); }
            Ok(InCommand::Seek { position_ms }) => { let _ = spirc.set_position_ms(position_ms); }
            Ok(InCommand::Volume { percent }) => {
                let pct = percent.min(100) as u32;
                let v = ((pct * u16::MAX as u32) / 100) as u16;
                let _ = spirc.set_volume(v);
            }
            Ok(InCommand::Shuffle { enabled }) => { let _ = spirc.shuffle(enabled); }
            Ok(InCommand::Repeat  { enabled }) => { let _ = spirc.repeat(enabled); }
            Ok(InCommand::Stop) => { let _ = spirc.pause(); }
            Ok(InCommand::Quit) => {
                state.running.store(false, Ordering::Relaxed);
                let _ = spirc.shutdown();
                break;
            }
            Err(e) => { eprintln!("[lightify-audio] Bad command: {}: {}", line, e); }
        }
    }
    state.running.store(false, Ordering::Relaxed);
}

fn read_stdin_line() -> Option<String> {
    let mut buf = String::new();
    let stdin = io::stdin();
    let mut locked = stdin.lock();
    match locked.read_line(&mut buf) {
        Ok(0) => None,
        Ok(_) => Some(buf),
        Err(_) => None,
    }
}

struct CliArgs { device: Option<String>, cache_dir: String }

fn parse_cli() -> CliArgs {
    let mut device: Option<String> = None;
    let mut cache_dir: Option<String> = None;
    let args: Vec<String> = std::env::args().skip(1).collect();
    let mut i = 0;
    while i < args.len() {
        match args[i].as_str() {
            "--device" => {
                if i + 1 < args.len() {
                    let name = args[i + 1].trim().to_string();
                    if !name.is_empty() && name.to_ascii_lowercase() != "default" {
                        device = Some(name);
                    }
                    i += 2;
                } else { i += 1; }
            }
            other => {
                if cache_dir.is_none() && !other.starts_with("--") {
                    cache_dir = Some(other.to_string());
                }
                i += 1;
            }
        }
    }
    CliArgs { device, cache_dir: cache_dir.unwrap_or_else(|| ".lightify_cache".to_string()) }
}

#[tokio::main]
async fn main() {
    env_logger::init();
    let cli = parse_cli();
    let state = SharedState::new();

    let first_line = match read_stdin_line() {
        Some(l) => l,
        None => {
            emit(&OutEvent::Error { msg: "lightify-audio: no token received on stdin".into() });
            std::process::exit(1);
        }
    };

    let access_token = match serde_json::from_str::<InCommand>(first_line.trim()) {
        Ok(InCommand::Token { access_token, .. }) => access_token,
        Ok(_) => {
            emit(&OutEvent::Error { msg: "lightify-audio: first stdin message must be token".into() });
            std::process::exit(1);
        }
        Err(e) => {
            emit(&OutEvent::Error { msg: format!("lightify-audio: malformed token line: {}", e) });
            std::process::exit(1);
        }
    };

    let session_config = SessionConfig::default();
    eprintln!("[lightify-audio] session client_id={}", session_config.client_id);
    let cache = Cache::new(Some(&cli.cache_dir), Some(&cli.cache_dir), None, None)
        .expect("Failed to create cache directory");

    // Always enter librespot through the current OAuth token. The cache is still
    // passed to Session so librespot can persist reusable AP credentials after
    // login, but preferring an old credentials.json here can bind playback to
    // stale account/product metadata and produce tracks with no playable files.
    let credentials = Credentials::with_access_token(&access_token);
    drop(access_token);

    let session = Session::new(session_config, Some(cache));

    let player_config = PlayerConfig::default();
    let audio_format = AudioFormat::S16;
    eprintln!(
        "[lightify-audio] requested output device={}",
        cli.device.as_deref().unwrap_or("System Default")
    );

    // Force the rodio backend (Cargo.toml enables `rodio-backend` feature).
    // Passing None would let librespot return the pipe/stdout backend and
    // silently write PCM to stdout instead of the OS audio device.
    let backend = audio_backend::find(Some("rodio".into())).expect("rodio audio backend missing");
    let mixer_config = MixerConfig::default();
    let mixer_ctor = mixer::find(None).expect("No mixer available");
    let mixer_instance = (mixer_ctor)(mixer_config).expect("Failed to create mixer");

    let device_arg = cli.device.clone();
    let state_for_sink = Arc::clone(&state);
    let player = Player::new(
        player_config,
        session.clone(),
        mixer_instance.get_soft_volume(),
        move || Box::new(TappedSink {
            inner: backend(device_arg.clone(), audio_format),
            state: Arc::clone(&state_for_sink),
            seen_packets: 0,
        }),
    );
    let mut player_events = player.get_player_event_channel();

    let connect_config = ConnectConfig {
        name: DEVICE_NAME.into(),
        // librespot's Default sets initial_volume: Some(50) — but the Spirc API
        // treats this as a u16 in the 0..=65535 range, so 50 ≈ 0.076%. The
        // device joins effectively muted. Force ~70% so playback is audible
        // immediately on first Connect, regardless of whether the UI has sent
        // a Volume command yet.
        initial_volume: (u16::MAX as u32 * 70 / 100) as u16,
        ..Default::default()
    };

    let (spirc, spirc_task) = match Spirc::new(
        connect_config,
        session.clone(),
        credentials,
        player.clone(),
        mixer_instance,
    )
    .await
    {
        Ok(result) => result,
        Err(e) => {
            emit(&OutEvent::Error { msg: format!("Auth failed: {}", e) });
            std::process::exit(1);
        }
    };

    let spirc_handle = tokio::spawn(spirc_task);
    let spirc = Arc::new(spirc);

    let ready_deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(8);
    loop {
        let user_data = session.user_data();
        let account_type = user_data.attributes.get("type").cloned().unwrap_or_default();
        let catalogue = user_data.attributes.get("catalogue").cloned().unwrap_or_default();
        if !user_data.country.is_empty() && (!account_type.is_empty() || !catalogue.is_empty()) {
            eprintln!(
                "[lightify-audio] session ready country={} type={} catalogue={}",
                user_data.country, account_type, catalogue
            );
            break;
        }
        if tokio::time::Instant::now() >= ready_deadline {
            eprintln!(
                "[lightify-audio] session readiness timeout country={} type={} catalogue={}",
                user_data.country, account_type, catalogue
            );
            break;
        }
        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
    }

    let device_id = session.device_id().to_string();
    emit(&OutEvent::Ready { device_id });

    let stdin_state = Arc::clone(&state);
    let stdin_spirc = Arc::clone(&spirc);
    std::thread::spawn(move || stdin_command_loop(stdin_state, stdin_spirc));

    let state_freq = Arc::clone(&state);
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(33));
        while state_freq.running.load(Ordering::Relaxed) {
            interval.tick().await;
            if let Ok(mut analyser) = state_freq.analyser.lock() {
                if analyser.analyse() {
                    emit(&OutEvent::Freq { bands: analyser.get_bands().to_vec() });
                }
            }
        }
    });

    let state_events = Arc::clone(&state);
    while state_events.running.load(Ordering::Relaxed) {
        tokio::select! {
            Some(event) = player_events.recv() => {
                use librespot_playback::player::PlayerEvent;
                match event {
                    PlayerEvent::Playing { position_ms, .. } => {
                        emit(&OutEvent::State { playing: true });
                        emit(&OutEvent::Position { ms: position_ms as u64 });
                    }
                    PlayerEvent::Paused { position_ms, .. } => {
                        emit(&OutEvent::State { playing: false });
                        emit(&OutEvent::Position { ms: position_ms as u64 });
                    }
                    PlayerEvent::Stopped { .. } => {
                        emit(&OutEvent::State { playing: false });
                    }
                    PlayerEvent::Seeked { position_ms, .. } => {
                        emit(&OutEvent::Position { ms: position_ms as u64 });
                    }
                    PlayerEvent::PositionCorrection { position_ms, .. } => {
                        emit(&OutEvent::Position { ms: position_ms as u64 });
                    }
                    PlayerEvent::VolumeChanged { volume } => {
                        let percent = ((volume as u32 * 100) / u16::MAX as u32) as u16;
                        emit(&OutEvent::Volume { percent });
                    }
                    PlayerEvent::TrackChanged { audio_item } => {
                        let artists = match &audio_item.unique_fields {
                            UniqueFields::Track { artists, .. } => artists.0.iter()
                                .map(|a| a.name.clone())
                                .collect::<Vec<String>>()
                                .join(", "),
                            _ => String::new(),
                        };
                        emit(&OutEvent::Track {
                            id: audio_item.track_id.to_string(),
                            name: audio_item.name.clone(),
                            artists,
                            duration_ms: audio_item.duration_ms as u64,
                        });
                    }
                    _ => {}
                }
            }
            _ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => {}
        }
    }

    let _ = spirc.shutdown();
    spirc_handle.abort();
    emit(&OutEvent::State { playing: false });
}
