//! 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_oauth::OAuthClientBuilder;
use librespot_core::session::Session;
use librespot_connect::{ConnectConfig, LoadRequest, LoadRequestOptions, 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 = "trackend")] TrackEnd { id: String },
    #[serde(rename = "position")] Position { ms: u64 },
    #[serde(rename = "needauth")] NeedAuth { msg: String },
    #[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 },
    // mode: "off" | "context" | "track". librespot's spirc drives track advancement,
    // so repeat must be applied natively here (the Web-API repeat flag alone doesn't
    // reliably reach/loop the local device). "context" loops the current context,
    // "track" loops the current track.
    #[serde(rename = "repeat")]   Repeat { #[serde(default)] mode: String },
    #[serde(rename = "stop")]     Stop,
    // Blip gate: sanctioned base62 track ids for the local queue. Empty ⇒ fail open.
    #[serde(rename = "expect")]   Expect { #[serde(default)] ids: Vec<String> },
    // #5 station: toggle spirc autoplay (Spotify's algorithmic radio continuation).
    #[serde(rename = "autoplay")] Autoplay { enabled: bool },
    // #5 station: start Spotify's native radio for a seed context (song/album/artist).
    #[serde(rename = "station")]  Station { context_uri: String },
    #[serde(rename = "quit")]     Quit,
}

const DEVICE_NAME: &str = "Lightify";
const NUM_FFT_BANDS: usize = 24;
const FFT_SIZE: usize = 2048;
// Blip-gate fail-safe: never keep a track muted longer than this. Guards against a
// false positive (a legitimate track missing from the host's set) causing permanent
// silence — after this window the gate gives up and lets the audio through.
const GATE_MAX_MUTE_MS: u64 = 1500;

// Streaming auth (librespot / Spotify Connect protocol).
//
// Spotify's streaming stack (clienttoken + login5) only works with a first-party
// "keymaster" client id and credentials minted under it. The Lightify Web-API
// OAuth token is minted for the app's own developer client id, which login5
// rejects (`INVALID_CREDENTIALS`), so it cannot drive playback. We therefore run
// a dedicated, one-time OAuth against the keymaster client to obtain a
// streaming-capable token; librespot then persists reusable credentials in the
// cache so subsequent launches authenticate silently. This is librespot's own
// documented `librespot oauth` flow (see the librespot-oauth crate example).
const STREAM_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd";
const STREAM_REDIRECT_URI: &str = "http://127.0.0.1:8898/login";
// Marker written after a successful keymaster login. Its presence means the
// cached credentials.json was minted by the streaming OAuth (login5-valid) and
// can be trusted; its absence means any credentials.json on disk predates this
// flow (e.g. bound to the Web-API client) and must be discarded before login.
const STREAM_AUTH_MARKER: &str = ".lightify_stream_oauth_v1";
// Give the user a generous window to complete the browser sign-in before the
// engine gives up, rather than hanging as a zombie process forever.
const STREAM_AUTH_TIMEOUT_SECS: u64 = 180;

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();
    }
}

/// Report a fatal startup failure to the host as a structured event, then exit.
/// Mirrors the token/auth error handling so the frontend always gets a diagnostic
/// instead of a bare Rust panic backtrace on stderr.
fn fail(msg: String) -> ! {
    emit(&OutEvent::Error { msg });
    std::process::exit(1);
}

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>,
    // ── Blip gate ──
    // The host supplies the set of sanctioned track ids for the arbitrary local
    // ('lightify') queue via the `expect` command. Any `TrackChanged` whose id is
    // absent is a stale interloper (e.g. a leftover Spotify user-queue item that
    // clips in between tracks) and its audio is muted to silence until the host
    // redirects playback. `None` = no expectation set → fail open (never gate).
    gate_expected: Mutex<Option<std::collections::HashSet<String>>>,
    gate_muted: AtomicBool,
    gate_muted_since: Mutex<Option<std::time::Instant>>,
}

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

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<()> {
        // Blip gate: while muted (an unsanctioned track is decoding), emit silence to
        // the device instead of the wrong track's audio. Fail-safe: never stay muted
        // past GATE_MAX_MUTE_MS so a false positive can't cause permanent silence.
        let muted = if self.state.gate_muted.load(Ordering::Relaxed) {
            let expired = self
                .state
                .gate_muted_since
                .lock()
                .ok()
                .and_then(|g| *g)
                .map(|since| since.elapsed() > std::time::Duration::from_millis(GATE_MAX_MUTE_MS))
                .unwrap_or(false);
            if expired {
                self.state.gate_muted.store(false, Ordering::Relaxed);
                eprintln!("[lightify-audio] gate: mute fail-safe expired, letting audio through");
                false
            } else {
                true
            }
        } else {
            false
        };

        if muted {
            // Feed silence (keeps stream timing/gapless alive) but don't leak the
            // interloper's audio or pollute the FFT visualiser with it.
            if let AudioPacket::Samples(samples) = packet {
                return self.inner.write(AudioPacket::Samples(vec![0.0f64; samples.len()]), converter);
            }
            return self.inner.write(packet, converter);
        }

        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>, session: Session) {
    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  { mode }) => {
                match mode.as_str() {
                    "track" => { let _ = spirc.repeat_track(true); let _ = spirc.repeat(false); }
                    "context" => { let _ = spirc.repeat(true); let _ = spirc.repeat_track(false); }
                    _ => { let _ = spirc.repeat(false); let _ = spirc.repeat_track(false); }
                }
            }
            Ok(InCommand::Stop) => { let _ = spirc.pause(); }
            Ok(InCommand::Expect { ids }) => {
                let set: std::collections::HashSet<String> = ids
                    .into_iter()
                    .map(|s| s.trim().to_string())
                    .filter(|s| !s.is_empty())
                    .collect();
                let fail_open = set.is_empty();
                *state.gate_expected.lock().unwrap() = if fail_open { None } else { Some(set) };
                if fail_open {
                    // Gating turned off → never leave playback stranded silent.
                    state.gate_muted.store(false, Ordering::Relaxed);
                }
            }
            Ok(InCommand::Autoplay { enabled }) => {
                // Live toggle (config.autoplay is None → session.autoplay() reads this).
                session.set_user_attribute("autoplay", if enabled { "1" } else { "0" });
                eprintln!("[lightify-audio] autoplay set to {}", enabled);
            }
            Ok(InCommand::Station { context_uri }) => {
                // Native Spotify radio, loaded as a real *station context*.
                //
                // Spotify exposes a seed's radio as `spotify:station:<type>:<id>` (e.g.
                // spotify:station:track:<id> — verified: it resolves to a 50-track
                // station of similar songs, not the seed's own album/artist tracks).
                // We load THAT as the normal (Default) context.
                //
                // Note: `LoadContextOptions::Autoplay` looks like the obvious choice but
                // only *continues* an already-loaded context — it fills the Autoplay
                // context and leaves Default empty, so the load then fails with
                // "context is not available. type: Default" and nothing plays.
                //
                // disconnect(pause) first: librespot's `clear_next_tracks` deliberately
                // KEEPS user-queued tracks, so leftovers from earlier Lightify plays
                // would survive a plain load and reappear as duplicates / a skip-back
                // loop. Going inactive resets the connect state (queue emptied,
                // queue_count = 0); load() then re-activates. `handle_load` also calls
                // reset_options(), clearing repeat/shuffle — repeat-context would
                // otherwise keep the context "full" and loop it forever.
                let station_uri = match context_uri.strip_prefix("spotify:") {
                    Some(rest) if !rest.starts_with("station:") => format!("spotify:station:{rest}"),
                    _ => context_uri.clone(),
                };
                eprintln!("[lightify-audio] station: loading {}", station_uri);
                if let Err(e) = spirc.disconnect(true) {
                    eprintln!("[lightify-audio] station: disconnect failed: {e}");
                }
                // Re-activate before loading: spirc drops Load (and every other command)
                // while the device is inactive, which is exactly what disconnect above
                // makes it — and what it already is on a cold start.
                if let Err(e) = spirc.activate() {
                    eprintln!("[lightify-audio] station: activate failed: {e}");
                }
                let request = LoadRequest::from_context_uri(
                    station_uri,
                    LoadRequestOptions {
                        start_playing: true,
                        ..Default::default()
                    },
                );
                if let Err(e) = spirc.load(request) {
                    emit(&OutEvent::Error { msg: format!("Station failed: {e}") });
                }
            }
            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()) }
}

/// Resolve librespot credentials for the streaming session.
///
/// Preference order:
/// 1. Reusable credentials cached from a previous streaming login — the silent
///    fast path, trusted only when the streaming-auth marker is present.
/// 2. A fresh interactive OAuth against the keymaster client. This opens the
///    user's browser once; librespot persists reusable credentials on connect,
///    so it does not recur on later launches.
async fn resolve_streaming_credentials(cache: &Cache, cache_dir: &std::path::Path) -> Credentials {
    let marker = cache_dir.join(STREAM_AUTH_MARKER);
    if marker.exists() {
        if let Some(c) = cache.credentials() {
            eprintln!("[lightify-audio] using cached streaming credentials");
            return c;
        }
    } else {
        // Any credentials.json present without the marker predates this flow (e.g.
        // it was minted by the Web-API client). Those authenticate with the access
        // point but are denied by login5, which freezes playback — drop the file so
        // the keymaster login below writes a clean, login5-valid credential.
        let legacy = cache_dir.join("credentials.json");
        if legacy.exists() {
            let _ = std::fs::remove_file(&legacy);
            eprintln!("[lightify-audio] discarded legacy credentials (pre-streaming-oauth)");
        }
    }

    emit(&OutEvent::NeedAuth {
        msg: "Complete the one-time Spotify sign-in in your browser to enable playback.".into(),
    });
    eprintln!("[lightify-audio] starting keymaster OAuth flow (redirect {STREAM_REDIRECT_URI})");

    let client = OAuthClientBuilder::new(STREAM_CLIENT_ID, STREAM_REDIRECT_URI, vec!["streaming"])
        .open_in_browser()
        .build()
        .unwrap_or_else(|e| fail(format!("Failed to build streaming OAuth client: {e}")));

    match tokio::time::timeout(
        std::time::Duration::from_secs(STREAM_AUTH_TIMEOUT_SECS),
        client.get_access_token_async(),
    )
    .await
    {
        Ok(Ok(token)) => Credentials::with_access_token(token.access_token),
        Ok(Err(e)) => fail(format!("Streaming sign-in failed: {e}")),
        Err(_) => fail("Streaming sign-in timed out — restart playback to try again.".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);
        }
    };

    // The host still sends the Web-API OAuth token as the first line for protocol
    // compatibility, but it is NOT used for streaming auth (login5 rejects it — see
    // STREAM_CLIENT_ID above). Streaming credentials come from the keymaster OAuth /
    // cache instead. Validate the handshake line, then discard the token.
    match serde_json::from_str::<InCommand>(first_line.trim()) {
        Ok(InCommand::Token { access_token }) => drop(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);
        }
    }

    // Autoplay is a RUNTIME toggle here, not a fixed setting. `Some(false)` would pin
    // it off forever (no radio ever); `None` makes `session.autoplay()` read the live
    // "autoplay" user attribute instead, which the host flips via the `autoplay`
    // command. We force the attribute to "0" once the session is ready (below) so it
    // starts OFF — Lightify owns what plays next during normal playback, and the blip
    // gate mutes any stray radio track there. The host only turns it ON while a
    // station is active, so spirc can resolve Spotify's real algorithmic radio (#5).
    //
    // client_id is left at librespot's keymaster default: streaming auth only works
    // with the keymaster client, and STREAM_CLIENT_ID matches it.
    let session_config = SessionConfig {
        autoplay: None,
        ..SessionConfig::default()
    };
    eprintln!("[lightify-audio] session client_id={}", session_config.client_id);
    let cache_dir = std::path::PathBuf::from(&cli.cache_dir);
    let cache = Cache::new(Some(&cli.cache_dir), Some(&cli.cache_dir), None, None)
        .unwrap_or_else(|e| fail(format!("Failed to create cache directory: {}", e)));

    let credentials = resolve_streaming_credentials(&cache, &cache_dir).await;

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

    // Enable periodic position reporting. Without this librespot never emits
    // PositionChanged, so the frontend's progress bar can only update from the
    // 3s Spotify poll or the one-shot Track event — a missed Track leaves it stuck.
    let player_config = PlayerConfig {
        position_update_interval: Some(std::time::Duration::from_millis(500)),
        ..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()))
        .unwrap_or_else(|| fail("rodio audio backend missing".to_string()));
    let mixer_config = MixerConfig::default();
    let mixer_ctor = mixer::find(None)
        .unwrap_or_else(|| fail("No mixer available".to_string()));
    let mixer_instance = (mixer_ctor)(mixer_config)
        .unwrap_or_else(|e| fail(format!("Failed to create mixer: {}", e)));

    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) => {
            // Login (including login5) failed. If cached credentials went stale,
            // drop them plus the marker so the next launch runs a fresh keymaster
            // sign-in instead of failing here again.
            let _ = std::fs::remove_file(cache_dir.join("credentials.json"));
            let _ = std::fs::remove_file(cache_dir.join(STREAM_AUTH_MARKER));
            emit(&OutEvent::Error { msg: format!("Auth failed: {}", e) });
            std::process::exit(1);
        }
    };

    // Login (and login5) succeeded — librespot has saved reusable credentials to
    // the cache. Mark them as streaming-OAuth-minted so later launches trust them
    // and skip the browser.
    if let Err(e) = std::fs::write(cache_dir.join(STREAM_AUTH_MARKER), b"1") {
        eprintln!("[lightify-audio] warning: could not write streaming-auth marker: {e}");
    }

    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 });

    // Force autoplay OFF by default now that the session is up (config.autoplay is
    // None → session.autoplay() reads this attribute). The host turns it on only for
    // stations. Without this, a Spotify-account autoplay=on setting would leak radio.
    session.set_user_attribute("autoplay", "0");

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

    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! {
            maybe_event = player_events.recv() => {
                use librespot_playback::player::PlayerEvent;
                // None means the player event channel closed (player dropped / fatal
                // error). Without handling this the select! branch silently stops
                // matching and the loop spins on the timer, frozen on the last state.
                let Some(event) = maybe_event else {
                    emit(&OutEvent::State { playing: false });
                    break;
                };
                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 });
                        // Clear stale progress so the UI doesn't show the old track
                        // frozen at its last position after the player tears down.
                        emit(&OutEvent::Position { ms: 0 });
                    }
                    PlayerEvent::EndOfTrack { track_id, .. } => {
                        // Natural end of a track. On mid-queue transitions this is
                        // followed by TrackChanged+Playing; on the final track it is
                        // the only signal, so surface it for prompt UI reconciliation.
                        emit(&OutEvent::TrackEnd { id: track_id.to_string() });
                    }
                    PlayerEvent::Unavailable { track_id, .. } => {
                        // Track could not be loaded (region lock, no file, stale
                        // account metadata). librespot skips it; tell the host so it
                        // isn't left waiting on a track that will never play.
                        emit(&OutEvent::Error { msg: format!("track unavailable: {}", track_id) });
                    }
                    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::PositionChanged { 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 tid = audio_item.track_id.to_string(); // base62, matches Web-API ids
                        // Blip gate: mute this track if the host provided a sanctioned
                        // set and this id isn't in it (a stale interloper). Fail open
                        // when the set is None/empty.
                        {
                            let allow = match &*state_events.gate_expected.lock().unwrap() {
                                None => true,
                                Some(set) => set.is_empty() || set.contains(&tid),
                            };
                            if allow {
                                state_events.gate_muted.store(false, Ordering::Relaxed);
                            } else {
                                state_events.gate_muted.store(true, Ordering::Relaxed);
                                *state_events.gate_muted_since.lock().unwrap() = Some(std::time::Instant::now());
                                eprintln!("[lightify-audio] gate: muting unsanctioned track {}", tid);
                            }
                        }
                        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: tid,
                            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 });
}
