use anyhow::{Context, Result, bail};
use clap::{ArgAction, Parser};
use include_dir::{Dir, include_dir};
use rand::RngExt;
use serde::Deserialize;
use serde_json::json;
use std::collections::{HashSet, VecDeque};
use std::fs;
use std::io::{self, BufRead, Cursor, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;

static PAIN_AUDIO: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/audio/pain");
static SEXY_AUDIO: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/audio/sexy");
static HALO_AUDIO: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/audio/halo");
static LIZARD_AUDIO: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/audio/lizard");

const DECAY_HALF_LIFE: f64 = 30.0;
const DEFAULT_MIN_AMPLITUDE: f64 = 0.05;
const DEFAULT_COOLDOWN_MS: u64 = 750;
const DEFAULT_SPEED_RATIO: f64 = 1.0;
const DEFAULT_SENSOR_POLL_INTERVAL: Duration = Duration::from_millis(10);
const DEFAULT_MAX_SAMPLE_BATCH: usize = 200;
const SENSOR_STARTUP_DELAY: Duration = Duration::from_millis(100);

#[derive(Parser, Debug)]
#[command(
    name = "spank",
    version,
    about = "Yells 'ow!' when you slap the laptop",
    long_about = "spank reads the Apple Silicon accelerometer directly via IOKit HID and plays audio responses when a slap or hit is detected.\n\nRequires sudo for IOKit HID access to the accelerometer."
)]
struct Cli {
    #[arg(short = 's', long, action = ArgAction::SetTrue)]
    sexy: bool,

    #[arg(short = 'H', long, action = ArgAction::SetTrue)]
    halo: bool,

    #[arg(short = 'l', long, action = ArgAction::SetTrue)]
    lizard: bool,

    #[arg(short = 'c', long, value_name = "DIR")]
    custom: Option<PathBuf>,

    #[arg(long, value_name = "FILE", value_delimiter = ',')]
    custom_files: Vec<PathBuf>,

    #[arg(long, action = ArgAction::SetTrue)]
    fast: bool,

    #[arg(
        long,
        value_name = "FLOAT",
        help = "Minimum amplitude threshold (default: 0.05)"
    )]
    min_amplitude: Option<f64>,

    #[arg(
        long,
        value_name = "MS",
        help = "Cooldown between responses in milliseconds (default: 750)"
    )]
    cooldown: Option<u64>,

    #[arg(long, action = ArgAction::SetTrue)]
    stdio: bool,

    #[arg(long, action = ArgAction::SetTrue)]
    volume_scaling: bool,

    #[arg(
        long,
        value_name = "RATIO",
        help = "Playback speed multiplier (default: 1.0)"
    )]
    speed: Option<f64>,
}

#[derive(Clone, Copy)]
struct RuntimeTuning {
    min_amplitude: f64,
    cooldown: Duration,
    poll_interval: Duration,
    max_batch: usize,
}

impl RuntimeTuning {
    fn default() -> Self {
        Self {
            min_amplitude: DEFAULT_MIN_AMPLITUDE,
            cooldown: Duration::from_millis(DEFAULT_COOLDOWN_MS),
            poll_interval: DEFAULT_SENSOR_POLL_INTERVAL,
            max_batch: DEFAULT_MAX_SAMPLE_BATCH,
        }
    }

    fn apply_fast_overlay(mut self) -> Self {
        self.poll_interval = Duration::from_millis(4);
        self.cooldown = Duration::from_millis(350);
        if self.min_amplitude > 0.18 {
            self.min_amplitude = 0.18;
        }
        if self.max_batch < 320 {
            self.max_batch = 320;
        }
        self
    }
}

#[derive(Clone)]
struct Settings {
    paused: bool,
    min_amplitude: f64,
    cooldown_ms: u64,
    stdio_mode: bool,
    volume_scaling: bool,
    speed_ratio: f64,
}

impl Settings {
    fn new(tuning: RuntimeTuning, cli: &Cli) -> Self {
        Self {
            paused: false,
            min_amplitude: tuning.min_amplitude,
            cooldown_ms: tuning.cooldown.as_millis() as u64,
            stdio_mode: cli.stdio,
            volume_scaling: cli.volume_scaling,
            speed_ratio: cli.speed.unwrap_or(DEFAULT_SPEED_RATIO),
        }
    }
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum PlayMode {
    Random,
    Escalation,
}

#[derive(Clone)]
enum AudioFile {
    Embedded {
        display: String,
        bytes: &'static [u8],
    },
    Custom {
        path: PathBuf,
    },
}

impl AudioFile {
    fn display_path(&self) -> String {
        match self {
            Self::Embedded { display, .. } => display.clone(),
            Self::Custom { path } => path.display().to_string(),
        }
    }

    fn read_bytes(&self) -> Result<Vec<u8>> {
        match self {
            Self::Embedded { bytes, .. } => Ok(bytes.to_vec()),
            Self::Custom { path } => {
                fs::read(path).with_context(|| format!("reading {}", path.display()))
            }
        }
    }
}

struct SoundPack {
    name: &'static str,
    mode: PlayMode,
    files: Vec<AudioFile>,
}

impl SoundPack {
    fn embedded(
        name: &'static str,
        prefix: &'static str,
        dir: &'static Dir<'static>,
        mode: PlayMode,
    ) -> Result<Self> {
        let mut files = dir
            .files()
            .filter(|file| is_mp3(file.path()))
            .map(|file| AudioFile::Embedded {
                display: format!("{}/{}", prefix, file.path().display()),
                bytes: file.contents(),
            })
            .collect::<Vec<_>>();

        files.sort_by_key(AudioFile::display_path);
        if files.is_empty() {
            bail!("no audio files found in {prefix}");
        }

        Ok(Self { name, mode, files })
    }

    fn custom_dir(path: &Path) -> Result<Self> {
        let entries = fs::read_dir(path).with_context(|| format!("reading {}", path.display()))?;
        let mut files = Vec::new();
        for entry in entries {
            let entry = entry?;
            let file_type = entry.file_type()?;
            if file_type.is_file() && is_mp3(&entry.path()) {
                files.push(AudioFile::Custom { path: entry.path() });
            }
        }

        files.sort_by_key(AudioFile::display_path);
        if files.is_empty() {
            bail!("no MP3 audio files found in {}", path.display());
        }

        Ok(Self {
            name: "custom",
            mode: PlayMode::Random,
            files,
        })
    }

    fn custom_files(paths: &[PathBuf]) -> Result<Self> {
        let mut files = Vec::with_capacity(paths.len());
        for path in paths {
            if !is_mp3(path) {
                bail!("custom file must be MP3: {}", path.display());
            }
            if !path.is_file() {
                bail!("custom file not found: {}", path.display());
            }
            files.push(AudioFile::Custom { path: path.clone() });
        }

        if files.is_empty() {
            bail!("no custom audio files provided");
        }

        files.sort_by_key(AudioFile::display_path);
        Ok(Self {
            name: "custom",
            mode: PlayMode::Random,
            files,
        })
    }
}

struct SlapTracker {
    score: f64,
    last_time: Option<SystemTime>,
    total: u64,
    half_life: f64,
    scale: f64,
}

impl SlapTracker {
    fn new(file_count: usize, cooldown: Duration) -> Self {
        let cooldown_secs = cooldown.as_secs_f64();
        let steady_state_max = 1.0 / (1.0 - 0.5_f64.powf(cooldown_secs / DECAY_HALF_LIFE));
        let scale = (steady_state_max - 1.0) / ((file_count as f64 + 1.0).ln());
        Self {
            score: 0.0,
            last_time: None,
            total: 0,
            half_life: DECAY_HALF_LIFE,
            scale,
        }
    }

    fn record(&mut self, now: SystemTime) -> (u64, f64) {
        if let Some(last) = self.last_time {
            if let Ok(elapsed) = now.duration_since(last) {
                self.score *= 0.5_f64.powf(elapsed.as_secs_f64() / self.half_life);
            }
        }

        self.score += 1.0;
        self.last_time = Some(now);
        self.total += 1;
        (self.total, self.score)
    }

    fn file_index(&self, mode: PlayMode, file_count: usize, score: f64) -> usize {
        if mode == PlayMode::Random {
            return rand::rng().random_range(0..file_count);
        }

        let max_idx = file_count - 1;
        let idx = (file_count as f64 * (1.0 - (-(score - 1.0) / self.scale).exp())) as usize;
        idx.min(max_idx)
    }
}

fn main() {
    if let Err(err) = real_main() {
        eprintln!("spank: {err:#}");
        std::process::exit(1);
    }
}

fn real_main() -> Result<()> {
    let cli = Cli::parse();
    let mut tuning = RuntimeTuning::default();
    if cli.fast {
        tuning = tuning.apply_fast_overlay();
    }
    if let Some(min_amplitude) = cli.min_amplitude {
        tuning.min_amplitude = min_amplitude;
    }
    if let Some(cooldown_ms) = cli.cooldown {
        tuning.cooldown = Duration::from_millis(cooldown_ms);
    }

    run(cli, tuning)
}

fn run(cli: Cli, tuning: RuntimeTuning) -> Result<()> {
    sensor::ensure_supported()?;

    if !is_root() {
        bail!("spank requires root privileges for accelerometer access, run with: sudo spank");
    }

    validate_cli(&cli, tuning)?;
    let pack = select_sound_pack(&cli)?;
    let settings = Arc::new(RwLock::new(Settings::new(tuning, &cli)));
    let mut sensor = sensor::SensorRuntime::start()?;

    let running = Arc::new(AtomicBool::new(true));
    {
        let running = Arc::clone(&running);
        ctrlc::set_handler(move || {
            running.store(false, Ordering::SeqCst);
        })
        .context("installing signal handler")?;
    }

    if cli.stdio {
        let settings = Arc::clone(&settings);
        thread::spawn(move || {
            let stdin = io::stdin();
            let stdout = io::stdout();
            let mut out = stdout.lock();
            if let Err(err) = process_commands(stdin.lock(), &mut out, settings) {
                eprintln!("spank: stdin command reader failed: {err}");
            }
        });
    }

    thread::sleep(SENSOR_STARTUP_DELAY);
    listen_for_slaps(&mut sensor, &pack, tuning, settings, running, cli.fast)
}

fn validate_cli(cli: &Cli, tuning: RuntimeTuning) -> Result<()> {
    let mut mode_count = 0;
    if cli.sexy {
        mode_count += 1;
    }
    if cli.halo {
        mode_count += 1;
    }
    if cli.lizard {
        mode_count += 1;
    }
    if cli.custom.is_some() || !cli.custom_files.is_empty() {
        mode_count += 1;
    }
    if mode_count > 1 {
        bail!(
            "--sexy, --halo, --lizard, and --custom/--custom-files are mutually exclusive; pick one"
        );
    }
    if !(0.0..=1.0).contains(&tuning.min_amplitude) {
        bail!("--min-amplitude must be between 0.0 and 1.0");
    }
    if tuning.cooldown.is_zero() {
        bail!("--cooldown must be greater than 0");
    }
    if cli.speed.unwrap_or(DEFAULT_SPEED_RATIO) <= 0.0 {
        bail!("--speed must be greater than 0");
    }
    Ok(())
}

fn select_sound_pack(cli: &Cli) -> Result<SoundPack> {
    if !cli.custom_files.is_empty() {
        return SoundPack::custom_files(&cli.custom_files);
    }
    if let Some(path) = &cli.custom {
        return SoundPack::custom_dir(path);
    }
    if cli.sexy {
        return SoundPack::embedded("sexy", "audio/sexy", &SEXY_AUDIO, PlayMode::Escalation);
    }
    if cli.halo {
        return SoundPack::embedded("halo", "audio/halo", &HALO_AUDIO, PlayMode::Random);
    }
    if cli.lizard {
        return SoundPack::embedded(
            "lizard",
            "audio/lizard",
            &LIZARD_AUDIO,
            PlayMode::Escalation,
        );
    }
    SoundPack::embedded("pain", "audio/pain", &PAIN_AUDIO, PlayMode::Random)
}

fn listen_for_slaps(
    sensor: &mut sensor::SensorRuntime,
    pack: &SoundPack,
    tuning: RuntimeTuning,
    settings: Arc<RwLock<Settings>>,
    running: Arc<AtomicBool>,
    fast_mode: bool,
) -> Result<()> {
    let mut tracker = SlapTracker::new(pack.files.len(), tuning.cooldown);
    let mut detector = Detector::new();
    let mut last_event_time = 0.0;
    let mut last_yell: Option<SystemTime> = None;
    let preset_label = if fast_mode { "fast" } else { "default" };

    println!(
        "spank: listening for slaps in {} mode with {} tuning... (ctrl+c to quit)",
        pack.name, preset_label
    );
    if settings.read().expect("settings lock poisoned").stdio_mode {
        println!("{}", json!({ "status": "ready" }));
    }

    while running.load(Ordering::SeqCst) {
        let settings_snapshot = settings.read().expect("settings lock poisoned").clone();
        let samples = sensor.poll(tuning.poll_interval);
        if settings_snapshot.paused {
            continue;
        }
        if samples.is_empty() {
            continue;
        }

        let mut samples = samples;
        if samples.len() > tuning.max_batch {
            samples = samples.split_off(samples.len() - tuning.max_batch);
        }

        let now_secs = unix_now_secs();
        let n_samples = samples.len();
        for (idx, sample) in samples.iter().enumerate() {
            let t_sample = now_secs - (n_samples - idx - 1) as f64 / detector.fs as f64;
            detector.process(sample.x, sample.y, sample.z, t_sample);
        }

        let Some(event) = detector.events.last().cloned() else {
            continue;
        };
        if (event.time - last_event_time).abs() < f64::EPSILON {
            continue;
        }
        last_event_time = event.time;

        let now = SystemTime::now();
        if let Some(last) = last_yell {
            let elapsed = now.duration_since(last).unwrap_or_default();
            if elapsed <= Duration::from_millis(settings_snapshot.cooldown_ms) {
                continue;
            }
        }
        if event.amplitude < settings_snapshot.min_amplitude {
            continue;
        }

        last_yell = Some(now);
        let (num, score) = tracker.record(now);
        let idx = tracker.file_index(pack.mode, pack.files.len(), score);
        let file = pack.files[idx].clone();
        let display_path = file.display_path();

        if settings_snapshot.stdio_mode {
            println!(
                "{}",
                json!({
                    "timestamp": rfc3339_now(),
                    "slapNumber": num,
                    "amplitude": event.amplitude,
                    "severity": event.severity,
                    "file": display_path,
                })
            );
        } else {
            println!(
                "slap #{} [{} amp={:.5}g] -> {}",
                num, event.severity, event.amplitude, display_path
            );
        }

        let settings = Arc::clone(&settings);
        thread::spawn(move || {
            if let Err(err) = play_audio(file, event.amplitude, settings) {
                eprintln!("spank: {err:#}");
            }
        });
    }

    println!("\nbye!");
    Ok(())
}

fn play_audio(file: AudioFile, amplitude: f64, settings: Arc<RwLock<Settings>>) -> Result<()> {
    let bytes = file.read_bytes()?;
    let cursor = Cursor::new(bytes);
    let output =
        rodio::DeviceSinkBuilder::open_default_sink().context("opening default audio output")?;
    let player = rodio::play(output.mixer(), cursor)
        .with_context(|| format!("playing {}", file.display_path()))?;
    let settings = settings.read().expect("settings lock poisoned").clone();

    if settings.volume_scaling {
        player.set_volume(amplitude_to_gain(amplitude) as f32);
    }
    if settings.speed_ratio > 0.0 {
        player.set_speed(settings.speed_ratio as f32);
    }

    player.sleep_until_end();
    Ok(())
}

fn amplitude_to_volume(amplitude: f64) -> f64 {
    const MIN_AMP: f64 = 0.05;
    const MAX_AMP: f64 = 0.80;
    const MIN_VOL: f64 = -3.0;
    const MAX_VOL: f64 = 0.0;

    if amplitude <= MIN_AMP {
        return MIN_VOL;
    }
    if amplitude >= MAX_AMP {
        return MAX_VOL;
    }

    let mut t = (amplitude - MIN_AMP) / (MAX_AMP - MIN_AMP);
    t = (1.0 + t * 99.0).ln() / 100.0_f64.ln();
    MIN_VOL + t * (MAX_VOL - MIN_VOL)
}

fn amplitude_to_gain(amplitude: f64) -> f64 {
    2.0_f64.powf(amplitude_to_volume(amplitude))
}

#[derive(Deserialize)]
struct StdinCommand {
    cmd: String,
    amplitude: Option<f64>,
    cooldown: Option<u64>,
    speed: Option<f64>,
}

fn process_commands<R, W>(
    reader: R,
    writer: &mut W,
    settings: Arc<RwLock<Settings>>,
) -> io::Result<()>
where
    R: BufRead,
    W: Write,
{
    for line in reader.lines() {
        let line = line?;
        let line = line.trim();
        if line.is_empty() {
            continue;
        }

        let command = match serde_json::from_str::<StdinCommand>(line) {
            Ok(command) => command,
            Err(err) => {
                if settings.read().expect("settings lock poisoned").stdio_mode {
                    writeln!(
                        writer,
                        "{}",
                        json!({ "error": format!("invalid command: {err}") })
                    )?;
                }
                continue;
            }
        };

        match command.cmd.as_str() {
            "pause" => {
                settings.write().expect("settings lock poisoned").paused = true;
                if settings.read().expect("settings lock poisoned").stdio_mode {
                    writeln!(writer, "{}", json!({ "status": "paused" }))?;
                }
            }
            "resume" => {
                settings.write().expect("settings lock poisoned").paused = false;
                if settings.read().expect("settings lock poisoned").stdio_mode {
                    writeln!(writer, "{}", json!({ "status": "resumed" }))?;
                }
            }
            "set" => {
                let snapshot = {
                    let mut settings = settings.write().expect("settings lock poisoned");
                    if let Some(amplitude) = command.amplitude {
                        if amplitude > 0.0 && amplitude <= 1.0 {
                            settings.min_amplitude = amplitude;
                        }
                    }
                    if let Some(cooldown) = command.cooldown {
                        if cooldown > 0 {
                            settings.cooldown_ms = cooldown;
                        }
                    }
                    if let Some(speed) = command.speed {
                        if speed > 0.0 {
                            settings.speed_ratio = speed;
                        }
                    }
                    settings.clone()
                };
                if snapshot.stdio_mode {
                    writeln!(
                        writer,
                        "{}",
                        json!({
                            "status": "settings_updated",
                            "amplitude": snapshot.min_amplitude,
                            "cooldown": snapshot.cooldown_ms,
                            "speed": snapshot.speed_ratio,
                        })
                    )?;
                }
            }
            "volume-scaling" => {
                let snapshot = {
                    let mut settings = settings.write().expect("settings lock poisoned");
                    settings.volume_scaling = !settings.volume_scaling;
                    settings.clone()
                };
                if snapshot.stdio_mode {
                    writeln!(
                        writer,
                        "{}",
                        json!({
                            "status": "volume_scaling_toggled",
                            "volume_scaling": snapshot.volume_scaling,
                        })
                    )?;
                }
            }
            "status" => {
                let snapshot = settings.read().expect("settings lock poisoned").clone();
                if snapshot.stdio_mode {
                    writeln!(
                        writer,
                        "{}",
                        json!({
                            "status": "ok",
                            "paused": snapshot.paused,
                            "amplitude": snapshot.min_amplitude,
                            "cooldown": snapshot.cooldown_ms,
                            "volume_scaling": snapshot.volume_scaling,
                            "speed": snapshot.speed_ratio,
                        })
                    )?;
                }
            }
            other => {
                if settings.read().expect("settings lock poisoned").stdio_mode {
                    writeln!(
                        writer,
                        "{}",
                        json!({ "error": format!("unknown command: {other}") })
                    )?;
                }
            }
        }
    }

    Ok(())
}

fn is_mp3(path: &Path) -> bool {
    path.extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.eq_ignore_ascii_case("mp3"))
        .unwrap_or(false)
}

fn unix_now_secs() -> f64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs_f64()
}

fn rfc3339_now() -> String {
    OffsetDateTime::now_utc()
        .format(&Rfc3339)
        .unwrap_or_else(|_| unix_now_secs().to_string())
}

#[cfg(unix)]
fn is_root() -> bool {
    (unsafe { libc::geteuid() }) == 0
}

#[cfg(not(unix))]
fn is_root() -> bool {
    false
}

#[derive(Clone, Copy)]
struct Sample {
    x: f64,
    y: f64,
    z: f64,
}

#[derive(Clone)]
struct Event {
    time: f64,
    severity: &'static str,
    amplitude: f64,
}

struct Detector {
    sample_count: usize,
    fs: usize,
    latest_raw: [f64; 3],
    latest_mag: f64,
    hp_alpha: f64,
    hp_prev_raw: [f64; 3],
    hp_prev_out: [f64; 3],
    hp_ready: bool,
    sta: [f64; 3],
    lta: [f64; 3],
    sta_n: [usize; 3],
    lta_n: [usize; 3],
    sta_lta_on: [f64; 3],
    sta_lta_off: [f64; 3],
    sta_lta_active: [bool; 3],
    cusum_pos: f64,
    cusum_neg: f64,
    cusum_mu: f64,
    cusum_k: f64,
    cusum_h: f64,
    kurt_buf: RingFloat,
    kurtosis: f64,
    peak_buf: RingFloat,
    rms: f64,
    peak: f64,
    crest: f64,
    mad_sigma: f64,
    rms_window: RingFloat,
    events: Vec<Event>,
    last_event_t: f64,
    kurt_dec: usize,
    rms_dec: usize,
}

impl Detector {
    fn new() -> Self {
        let fs = 100;
        Self {
            sample_count: 0,
            fs,
            latest_raw: [0.0; 3],
            latest_mag: 0.0,
            hp_alpha: 0.95,
            hp_prev_raw: [0.0; 3],
            hp_prev_out: [0.0; 3],
            hp_ready: false,
            sta: [0.0; 3],
            lta: [1e-10; 3],
            sta_n: [3, 15, 50],
            lta_n: [100, 500, 2000],
            sta_lta_on: [3.0, 2.5, 2.0],
            sta_lta_off: [1.5, 1.3, 1.2],
            sta_lta_active: [false; 3],
            cusum_pos: 0.0,
            cusum_neg: 0.0,
            cusum_mu: 0.0,
            cusum_k: 0.0005,
            cusum_h: 0.01,
            kurt_buf: RingFloat::new(100),
            kurtosis: 3.0,
            peak_buf: RingFloat::new(200),
            rms: 0.0,
            peak: 0.0,
            crest: 1.0,
            mad_sigma: 0.0,
            rms_window: RingFloat::new(fs),
            events: Vec::new(),
            last_event_t: 0.0,
            kurt_dec: 0,
            rms_dec: 0,
        }
    }

    fn process(&mut self, ax: f64, ay: f64, az: f64, t_now: f64) -> f64 {
        self.sample_count += 1;
        self.latest_raw = [ax, ay, az];
        self.latest_mag = (ax * ax + ay * ay + az * az).sqrt();

        if !self.hp_ready {
            self.hp_prev_raw = [ax, ay, az];
            self.hp_ready = true;
            return 0.0;
        }

        let a = self.hp_alpha;
        let hx = a * (self.hp_prev_out[0] + ax - self.hp_prev_raw[0]);
        let hy = a * (self.hp_prev_out[1] + ay - self.hp_prev_raw[1]);
        let hz = a * (self.hp_prev_out[2] + az - self.hp_prev_raw[2]);
        self.hp_prev_raw = [ax, ay, az];
        self.hp_prev_out = [hx, hy, hz];
        let mag = (hx * hx + hy * hy + hz * hz).sqrt();

        self.rms_window.push(mag);
        self.rms_dec += 1;
        if self.rms_dec >= self.fs.saturating_div(10).max(1) {
            self.rms_dec = 0;
            let vals = self.rms_window.slice();
            if !vals.is_empty() {
                let sum = vals.iter().map(|v| v * v).sum::<f64>();
                self.rms = (sum / vals.len() as f64).sqrt();
            }
        }

        let mut detections = Vec::new();
        let energy = mag * mag;
        for i in 0..3 {
            self.sta[i] += (energy - self.sta[i]) / self.sta_n[i] as f64;
            self.lta[i] += (energy - self.lta[i]) / self.lta_n[i] as f64;
            let ratio = self.sta[i] / (self.lta[i] + 1e-30);
            let was_active = self.sta_lta_active[i];
            if ratio > self.sta_lta_on[i] && !was_active {
                self.sta_lta_active[i] = true;
                detections.push("STA/LTA");
            } else if ratio < self.sta_lta_off[i] {
                self.sta_lta_active[i] = false;
            }
        }

        self.cusum_mu += 0.0001 * (mag - self.cusum_mu);
        self.cusum_pos = 0.0_f64.max(self.cusum_pos + mag - self.cusum_mu - self.cusum_k);
        self.cusum_neg = 0.0_f64.max(self.cusum_neg - mag + self.cusum_mu - self.cusum_k);
        if self.cusum_pos > self.cusum_h {
            detections.push("CUSUM");
            self.cusum_pos = 0.0;
        }
        if self.cusum_neg > self.cusum_h {
            detections.push("CUSUM");
            self.cusum_neg = 0.0;
        }

        self.kurt_buf.push(mag);
        self.kurt_dec += 1;
        if self.kurt_dec >= 10 && self.kurt_buf.len() >= 50 {
            self.kurt_dec = 0;
            let buf = self.kurt_buf.slice();
            let n = buf.len() as f64;
            let mean = buf.iter().sum::<f64>() / n;
            let mut m2 = 0.0;
            let mut m4 = 0.0;
            for value in &buf {
                let diff = value - mean;
                let d2 = diff * diff;
                m2 += d2;
                m4 += d2 * d2;
            }
            m2 /= n;
            m4 /= n;
            self.kurtosis = m4 / (m2 * m2 + 1e-30);
            if self.kurtosis > 6.0 {
                detections.push("KURTOSIS");
            }
        }

        self.peak_buf.push(mag);
        if self.peak_buf.len() >= 50 && self.sample_count % 10 == 0 {
            let buf = self.peak_buf.slice();
            let mut sorted = buf.clone();
            insertion_sort(&mut sorted);
            let n = sorted.len();
            let median = sorted[n / 2];

            let mut devs = sorted
                .iter()
                .map(|value| (value - median).abs())
                .collect::<Vec<_>>();
            insertion_sort(&mut devs);
            let mad = devs[n / 2];
            let sigma = 1.4826 * mad + 1e-30;
            self.mad_sigma = sigma;

            let mut sum = 0.0;
            let mut peak = 0.0_f64;
            for value in &buf {
                sum += value * value;
                peak = peak.max(value.abs());
            }
            self.rms = (sum / n as f64).sqrt();
            self.peak = peak;
            self.crest = peak / (self.rms + 1e-30);

            let dev = (mag - median).abs() / sigma;
            if dev > 2.0 {
                detections.push("PEAK");
            }
        }

        if !detections.is_empty() && (t_now - self.last_event_t) > 0.01 {
            self.last_event_t = t_now;
            self.classify(&detections, t_now, mag);
        }

        mag
    }

    fn classify(&mut self, detections: &[&'static str], t: f64, amplitude: f64) {
        let sources = detections.iter().copied().collect::<HashSet<_>>();
        let source_count = sources.len();
        let severity = if source_count >= 4 && amplitude > 0.05 {
            "CHOC_MAJEUR"
        } else if source_count >= 3 && amplitude > 0.02 {
            "CHOC_MOYEN"
        } else if sources.contains("PEAK") && amplitude > 0.005 {
            "MICRO_CHOC"
        } else if (sources.contains("STA/LTA") || sources.contains("CUSUM")) && amplitude > 0.003 {
            "VIBRATION"
        } else if amplitude > 0.001 {
            "VIB_LEGERE"
        } else {
            "MICRO_VIB"
        };

        self.events.push(Event {
            time: t,
            severity,
            amplitude,
        });
        if self.events.len() > 500 {
            let drop_count = self.events.len() - 500;
            self.events.drain(0..drop_count);
        }
    }
}

struct RingFloat {
    data: Vec<f64>,
    pos: usize,
    full: bool,
}

impl RingFloat {
    fn new(capacity: usize) -> Self {
        Self {
            data: vec![0.0; capacity],
            pos: 0,
            full: false,
        }
    }

    fn push(&mut self, value: f64) {
        self.data[self.pos] = value;
        self.pos += 1;
        if self.pos >= self.data.len() {
            self.pos = 0;
            self.full = true;
        }
    }

    fn len(&self) -> usize {
        if self.full { self.data.len() } else { self.pos }
    }

    fn slice(&self) -> Vec<f64> {
        let len = self.len();
        if !self.full {
            return self.data[..len].to_vec();
        }

        let mut out = Vec::with_capacity(len);
        out.extend_from_slice(&self.data[self.pos..]);
        out.extend_from_slice(&self.data[..self.pos]);
        out
    }
}

fn insertion_sort(values: &mut [f64]) {
    for i in 1..values.len() {
        let key = values[i];
        let mut j = i;
        while j > 0 && values[j - 1] > key {
            values[j] = values[j - 1];
            j -= 1;
        }
        values[j] = key;
    }
}

#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
mod sensor {
    use super::{Sample, VecDeque};
    use anyhow::{Context, Result, bail};
    use std::ffi::CString;
    use std::os::raw::{c_char, c_double, c_int, c_void};
    use std::ptr;
    use std::sync::{Mutex, OnceLock};
    use std::time::Duration;

    const PAGE_VENDOR: i64 = 0xFF00;
    const USAGE_ACCEL: i64 = 3;
    const IMU_REPORT_LEN: usize = 22;
    const IMU_DECIMATION: usize = 8;
    const IMU_DATA_OFFSET: usize = 6;
    const REPORT_BUF_SIZE: usize = 4096;
    const REPORT_INTERVAL_US: i32 = 1000;
    const ACCEL_SCALE: f64 = 65536.0;
    const RING_CAP: usize = 8000;
    const CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;
    const CF_NUMBER_SINT32_TYPE: i32 = 3;
    const CF_NUMBER_SINT64_TYPE: i32 = 4;

    type KernReturn = i32;
    type IOReturn = i32;
    type IOOptionBits = u32;
    type IOObject = u32;
    type IOIterator = IOObject;
    type IORegistryEntry = IOObject;
    type IOService = IOObject;
    type MachPort = u32;
    type CFIndex = isize;
    type CFTypeRef = *const c_void;
    type CFStringRef = *const c_void;
    type CFNumberRef = *const c_void;
    type CFDictionaryRef = *const c_void;
    type CFMutableDictionaryRef = *mut c_void;
    type CFAllocatorRef = *const c_void;
    type CFRunLoopRef = *mut c_void;
    type IOHIDDeviceRef = *mut c_void;
    type IOHIDReportType = i32;
    type Boolean = u8;

    type IOHIDReportCallback = extern "C" fn(
        context: *mut c_void,
        result: IOReturn,
        sender: *mut c_void,
        report_type: IOHIDReportType,
        report_id: u32,
        report: *mut u8,
        report_length: CFIndex,
    );

    #[link(name = "IOKit", kind = "framework")]
    unsafe extern "C" {
        fn IOServiceMatching(name: *const c_char) -> CFMutableDictionaryRef;
        fn IOServiceGetMatchingServices(
            main_port: MachPort,
            matching: CFDictionaryRef,
            existing: *mut IOIterator,
        ) -> KernReturn;
        fn IOIteratorNext(iterator: IOIterator) -> IOObject;
        fn IOObjectRelease(object: IOObject) -> KernReturn;
        fn IORegistryEntryCreateCFProperty(
            entry: IORegistryEntry,
            key: CFStringRef,
            allocator: CFAllocatorRef,
            options: IOOptionBits,
        ) -> CFTypeRef;
        fn IORegistryEntrySetCFProperty(
            entry: IORegistryEntry,
            property_name: CFStringRef,
            property: CFTypeRef,
        ) -> KernReturn;
        fn IOHIDDeviceCreate(allocator: CFAllocatorRef, service: IOService) -> IOHIDDeviceRef;
        fn IOHIDDeviceOpen(device: IOHIDDeviceRef, options: IOOptionBits) -> IOReturn;
        fn IOHIDDeviceRegisterInputReportCallback(
            device: IOHIDDeviceRef,
            report: *mut u8,
            report_length: CFIndex,
            callback: IOHIDReportCallback,
            context: *mut c_void,
        );
        fn IOHIDDeviceScheduleWithRunLoop(
            device: IOHIDDeviceRef,
            run_loop: CFRunLoopRef,
            run_loop_mode: CFStringRef,
        );
    }

    #[link(name = "CoreFoundation", kind = "framework")]
    unsafe extern "C" {
        static kCFRunLoopDefaultMode: CFStringRef;

        fn CFStringCreateWithCString(
            allocator: CFAllocatorRef,
            c_str: *const c_char,
            encoding: u32,
        ) -> CFStringRef;
        fn CFNumberCreate(
            allocator: CFAllocatorRef,
            the_type: c_int,
            value_ptr: *const c_void,
        ) -> CFNumberRef;
        fn CFNumberGetValue(
            number: CFNumberRef,
            the_type: c_int,
            value_ptr: *mut c_void,
        ) -> Boolean;
        fn CFRunLoopGetCurrent() -> CFRunLoopRef;
        fn CFRunLoopRunInMode(
            mode: CFStringRef,
            seconds: c_double,
            return_after_source_handled: Boolean,
        ) -> i32;
        fn CFRelease(cf: CFTypeRef);
    }

    #[derive(Default)]
    struct SensorState {
        samples: VecDeque<Sample>,
        accel_decimation: usize,
    }

    static SENSOR_STATE: OnceLock<Mutex<SensorState>> = OnceLock::new();

    pub struct SensorRuntime {
        report_buffers: Vec<Box<[u8; REPORT_BUF_SIZE]>>,
        devices: Vec<IOHIDDeviceRef>,
    }

    impl SensorRuntime {
        pub fn start() -> Result<Self> {
            SENSOR_STATE.get_or_init(|| Mutex::new(SensorState::default()));
            wake_spu_drivers().context("waking SPU drivers")?;

            let mut runtime = Self {
                report_buffers: Vec::new(),
                devices: Vec::new(),
            };
            runtime
                .register_hid_devices()
                .context("registering HID devices")?;

            if runtime.devices.is_empty() {
                bail!("no AppleSPUHIDDevice accelerometer found");
            }

            Ok(runtime)
        }

        pub fn poll(&mut self, timeout: Duration) -> Vec<Sample> {
            unsafe {
                CFRunLoopRunInMode(kCFRunLoopDefaultMode, timeout.as_secs_f64(), 0);
            }

            let state = SENSOR_STATE.get().expect("sensor state initialized");
            let mut state = state.lock().expect("sensor state lock poisoned");
            state.samples.drain(..).collect()
        }

        fn register_hid_devices(&mut self) -> Result<()> {
            let matching = io_service_matching("AppleSPUHIDDevice")?;
            let mut iterator: IOIterator = 0;
            let kr = unsafe {
                IOServiceGetMatchingServices(0, matching as CFDictionaryRef, &mut iterator)
            };
            if kr != 0 {
                bail!("IOServiceGetMatchingServices returned {kr}");
            }

            loop {
                let service = unsafe { IOIteratorNext(iterator) };
                if service == 0 {
                    break;
                }

                let result = self.maybe_register_accelerometer(service);
                unsafe {
                    IOObjectRelease(service);
                }
                result?;
            }

            if iterator != 0 {
                unsafe {
                    IOObjectRelease(iterator);
                }
            }

            Ok(())
        }

        fn maybe_register_accelerometer(&mut self, service: IOService) -> Result<()> {
            let usage_page = prop_int(service, "PrimaryUsagePage").unwrap_or_default();
            let usage = prop_int(service, "PrimaryUsage").unwrap_or_default();
            if usage_page != PAGE_VENDOR || usage != USAGE_ACCEL {
                return Ok(());
            }

            let hid = unsafe { IOHIDDeviceCreate(ptr::null(), service) };
            if hid.is_null() {
                return Ok(());
            }

            let kr = unsafe { IOHIDDeviceOpen(hid, 0) };
            if kr != 0 {
                return Ok(());
            }

            let mut report_buf = Box::new([0_u8; REPORT_BUF_SIZE]);
            let report_ptr = report_buf.as_mut_ptr();
            unsafe {
                IOHIDDeviceRegisterInputReportCallback(
                    hid,
                    report_ptr,
                    REPORT_BUF_SIZE as CFIndex,
                    accel_callback,
                    ptr::null_mut(),
                );
                IOHIDDeviceScheduleWithRunLoop(hid, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
            }

            self.report_buffers.push(report_buf);
            self.devices.push(hid);
            Ok(())
        }
    }

    impl Drop for SensorRuntime {
        fn drop(&mut self) {
            for device in self.devices.drain(..) {
                if !device.is_null() {
                    unsafe {
                        CFRelease(device as CFTypeRef);
                    }
                }
            }
        }
    }

    pub fn ensure_supported() -> Result<()> {
        Ok(())
    }

    extern "C" fn accel_callback(
        _context: *mut c_void,
        _result: IOReturn,
        _sender: *mut c_void,
        _report_type: IOHIDReportType,
        _report_id: u32,
        report: *mut u8,
        report_length: CFIndex,
    ) {
        if report.is_null() || report_length as usize != IMU_REPORT_LEN {
            return;
        }

        let Some(state) = SENSOR_STATE.get() else {
            return;
        };
        let mut state = state.lock().expect("sensor state lock poisoned");
        state.accel_decimation += 1;
        if state.accel_decimation < IMU_DECIMATION {
            return;
        }
        state.accel_decimation = 0;

        let report = unsafe { std::slice::from_raw_parts(report, report_length as usize) };
        let x = read_i32_le(report, IMU_DATA_OFFSET) as f64 / ACCEL_SCALE;
        let y = read_i32_le(report, IMU_DATA_OFFSET + 4) as f64 / ACCEL_SCALE;
        let z = read_i32_le(report, IMU_DATA_OFFSET + 8) as f64 / ACCEL_SCALE;

        if state.samples.len() >= RING_CAP {
            state.samples.pop_front();
        }
        state.samples.push_back(Sample { x, y, z });
    }

    fn wake_spu_drivers() -> Result<()> {
        let matching = io_service_matching("AppleSPUHIDDriver")?;
        let mut iterator: IOIterator = 0;
        let kr =
            unsafe { IOServiceGetMatchingServices(0, matching as CFDictionaryRef, &mut iterator) };
        if kr != 0 {
            bail!("IOServiceGetMatchingServices returned {kr}");
        }

        loop {
            let service = unsafe { IOIteratorNext(iterator) };
            if service == 0 {
                break;
            }

            set_int_property(service, "SensorPropertyReportingState", 1);
            set_int_property(service, "SensorPropertyPowerState", 1);
            set_int_property(service, "ReportInterval", REPORT_INTERVAL_US);
            unsafe {
                IOObjectRelease(service);
            }
        }

        if iterator != 0 {
            unsafe {
                IOObjectRelease(iterator);
            }
        }

        Ok(())
    }

    fn prop_int(service: IOService, key: &str) -> Option<i64> {
        let key = cf_string(key).ok()?;
        let value = unsafe { IORegistryEntryCreateCFProperty(service, key, ptr::null(), 0) };
        unsafe {
            CFRelease(key as CFTypeRef);
        }
        if value.is_null() {
            return None;
        }

        let mut out64 = 0_i64;
        let ok64 = unsafe {
            CFNumberGetValue(
                value as CFNumberRef,
                CF_NUMBER_SINT64_TYPE,
                &mut out64 as *mut _ as *mut c_void,
            )
        } != 0;
        if ok64 {
            unsafe {
                CFRelease(value);
            }
            return Some(out64);
        }

        let mut out32 = 0_i32;
        let ok32 = unsafe {
            CFNumberGetValue(
                value as CFNumberRef,
                CF_NUMBER_SINT32_TYPE,
                &mut out32 as *mut _ as *mut c_void,
            )
        } != 0;
        unsafe {
            CFRelease(value);
        }
        ok32.then_some(out32 as i64)
    }

    fn set_int_property(service: IOService, key: &str, value: i32) {
        let Ok(key) = cf_string(key) else {
            return;
        };
        let number = unsafe {
            CFNumberCreate(
                ptr::null(),
                CF_NUMBER_SINT32_TYPE,
                &value as *const _ as *const c_void,
            )
        };
        if !number.is_null() {
            unsafe {
                IORegistryEntrySetCFProperty(service, key, number);
                CFRelease(number as CFTypeRef);
            }
        }
        unsafe {
            CFRelease(key as CFTypeRef);
        }
    }

    fn cf_string(value: &str) -> Result<CFStringRef> {
        let c_string = CString::new(value)?;
        let cf = unsafe {
            CFStringCreateWithCString(ptr::null(), c_string.as_ptr(), CF_STRING_ENCODING_UTF8)
        };
        if cf.is_null() {
            bail!("CFStringCreateWithCString failed for {value}");
        }
        Ok(cf)
    }

    fn io_service_matching(name: &str) -> Result<CFMutableDictionaryRef> {
        let name = CString::new(name)?;
        let matching = unsafe { IOServiceMatching(name.as_ptr()) };
        if matching.is_null() {
            bail!("IOServiceMatching returned null");
        }
        Ok(matching)
    }

    fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
        let mut raw = [0_u8; 4];
        raw.copy_from_slice(&bytes[offset..offset + 4]);
        i32::from_le_bytes(raw)
    }
}

#[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
mod sensor {
    use super::Sample;
    use anyhow::{Result, bail};
    use std::time::Duration;

    pub struct SensorRuntime;

    impl SensorRuntime {
        pub fn start() -> Result<Self> {
            bail!("spank only supports macOS on Apple Silicon");
        }

        pub fn poll(&mut self, _timeout: Duration) -> Vec<Sample> {
            Vec::new()
        }
    }

    pub fn ensure_supported() -> Result<()> {
        bail!("spank only supports macOS on Apple Silicon");
    }
}

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

    fn test_settings(stdio_mode: bool) -> Arc<RwLock<Settings>> {
        Arc::new(RwLock::new(Settings {
            paused: false,
            min_amplitude: DEFAULT_MIN_AMPLITUDE,
            cooldown_ms: DEFAULT_COOLDOWN_MS,
            stdio_mode,
            volume_scaling: false,
            speed_ratio: DEFAULT_SPEED_RATIO,
        }))
    }

    #[test]
    fn pause_command_updates_state() {
        let settings = test_settings(true);
        let mut output = Vec::new();
        process_commands(
            io::Cursor::new(r#"{"cmd":"pause"}"#),
            &mut output,
            Arc::clone(&settings),
        )
        .unwrap();

        assert!(settings.read().unwrap().paused);
        let response: serde_json::Value = serde_json::from_slice(&output).unwrap();
        assert_eq!(response["status"], "paused");
    }

    #[test]
    fn set_command_updates_values() {
        let settings = test_settings(true);
        let mut output = Vec::new();
        process_commands(
            io::Cursor::new(r#"{"cmd":"set","amplitude":0.2,"cooldown":1000,"speed":0.8}"#),
            &mut output,
            Arc::clone(&settings),
        )
        .unwrap();

        let snapshot = settings.read().unwrap();
        assert_eq!(snapshot.min_amplitude, 0.2);
        assert_eq!(snapshot.cooldown_ms, 1000);
        assert_eq!(snapshot.speed_ratio, 0.8);
    }

    #[test]
    fn no_output_when_stdio_disabled() {
        let settings = test_settings(false);
        let mut output = Vec::new();
        process_commands(
            io::Cursor::new(r#"{"cmd":"pause"}"#),
            &mut output,
            Arc::clone(&settings),
        )
        .unwrap();

        assert!(settings.read().unwrap().paused);
        assert!(output.is_empty());
    }

    #[test]
    fn amplitude_to_volume_is_monotonic() {
        let mut prev = amplitude_to_volume(0.05);
        for step in 1..=15 {
            let amp = 0.05 + step as f64 * 0.05;
            let current = amplitude_to_volume(amp);
            assert!(current >= prev - 1e-9);
            prev = current;
        }
        assert_eq!(amplitude_to_volume(0.01), -3.0);
        assert_eq!(amplitude_to_volume(0.80), 0.0);
    }

    #[test]
    fn detector_detects_impulse() {
        let mut detector = Detector::new();
        for i in 0..200 {
            detector.process(0.0, 0.0, -1.0, i as f64 * 0.01);
        }
        detector.process(0.5, 0.5, -1.0, 2.01);
        assert!(!detector.events.is_empty());
    }
}
