// moonaudio — pure MoonBit audio decoding and DSP toolkit.
// Copyright 2026 Sakuraqwq1
// Licensed under the Apache License, Version 2.0.

///|
/// Text printed when the CLI is invoked without a recognised command.
fn usage() -> Unit {
  println("moonaudio \{@moonaudio.version()} — pure MoonBit audio toolkit")
  println("")
  println("usage:")
  println(
    "  moonaudio demo                          self check with a Markdown report",
  )
  println("  moonaudio version                       print the module version")
  println(
    "  moonaudio info                  container, format and metadata summary",
  )
  println(
    "  moonaudio stats                 peak/RMS/crest/DC/zero crossings",
  )
  println(
    "  moonaudio spectrum  [options]   dominant frequency and spectral peak",
  )
  println("      --fft            FFT size, power of two (default: 1024)")
  println("  moonaudio spectrogram  [options]")
  println("      --fft  --hop  --window  --svg")
  println("      prints CSV (default) or a grayscale SVG heat map")
  println(
    "  moonaudio convert  [options]    decode, optionally resample, re-encode",
  )
  println("      --rate          target sample rate (default: keep)")
  println("      --channels       target channel count (default: keep)")
  println("      --format      u8|s16|s24|s32|f32|f64 (default: s16)")
  println("      --container   wav|aiff|au (default: wav)")
  println(
    "  moonaudio tone [options]                synthesise a sine wave as WAV",
  )
  println("      --freq  --rate  --ms  --format ")
  println("")
  println(
    "Binary payloads travel as base64 on the command line, because MoonBit",
  )
  println(
    "core has no portable filesystem API. Decode the output to get a file:",
  )
  println("  moon run cmd/main -- tone --freq 440 > tone.b64")
  println("  export AUDIO_B64=$(cat tone.b64)")
  println("  moon run cmd/main -- stats @AUDIO_B64")
}

///|
/// Entry point.
fn main {
  let argv = @env.args()
  let args = drop_program_name(argv)
  if args.length() == 0 {
    usage()
    return
  }
  let command = args[0]
  let rest = if args.length() > 1 { args[1:].to_owned() } else { [] }
  match command {
    "version" | "--version" | "-V" =>
      println("moonaudio \{@moonaudio.version()}")
    "help" | "--help" | "-h" => usage()
    "demo" => run_demo()
    "info" =>
      if rest.length() == 0 {
        println("error: `info` needs a base64 payload")
      } else {
        run_info(rest[0])
      }
    "stats" =>
      if rest.length() == 0 {
        println("error: `stats` needs a base64 payload")
      } else {
        run_stats(rest[0])
      }
    "spectrum" =>
      if rest.length() == 0 {
        println("error: `spectrum` needs a base64 payload")
      } else {
        run_spectrum(rest[0], rest[1:].to_owned())
      }
    "spectrogram" =>
      if rest.length() == 0 {
        println("error: `spectrogram` needs a base64 payload")
      } else {
        run_spectrogram(rest[0], rest[1:].to_owned())
      }
    "convert" =>
      if rest.length() == 0 {
        println("error: `convert` needs a base64 payload")
      } else {
        run_convert(rest[0], rest[1:].to_owned())
      }
    "tone" => run_tone(rest)
    _ => {
      println("error: unknown command \"\{command}\"")
      usage()
    }
  }
}

///|
/// Drop the leading program path that some hosts include in `args()`.
fn drop_program_name(argv : Array[String]) -> Array[String] {
  if argv.length() == 0 {
    return argv
  }
  let first = argv[0]
  if first.has_suffix("main") ||
    first.has_suffix("main.exe") ||
    first.has_suffix("main.wasm") ||
    first.has_suffix("moonaudio") {
    argv[1:].to_owned()
  } else {
    argv
  }
}

///|
/// Value of `--name value`, or the fallback when absent.
fn option(args : Array[String], name : String, fallback : String) -> String {
  let mut index = 0
  while index + 1 < args.length() {
    if args[index] == name {
      return args[index + 1]
    }
    index = index + 1
  }
  fallback
}

///|
/// `true` when the flag `--name` appears in `args`.
fn flag(args : Array[String], name : String) -> Bool {
  for argument in args {
    if argument == name {
      return true
    }
  }
  false
}

///|
/// Report a raised error and stop.
fn report_error(error : @audio.AudioError) -> Unit {
  println("error: \{error.message()}")
}

///|
/// Decode a base64 payload into bytes.
///
/// A payload of the form `@NAME` is read from the environment variable `NAME`,
/// which sidesteps the command line length limit for anything larger than a few
/// kilobytes.
fn decode_payload(payload : String) -> Bytes? {
  let text = if payload.has_prefix("@") {
    let name = payload[1:].to_owned()
    match @env.get_env_var(name) {
      Some(value) => value
      None => {
        println("error: environment variable \{name} is not set")
        return None
      }
    }
  } else {
    payload
  }
  let decoded = @base64.decode(text) catch {
    (_ : @base64.Malformed) => {
      println("error: payload is not valid base64")
      return None
    }
  }
  Some(decoded)
}

///|
/// Load a payload and decode it as audio, reporting any error.
fn load_audio(payload : String) -> @audio.AudioBuffer? {
  let bytes = match decode_payload(payload) {
    Some(bytes) => bytes
    None => return None
  }
  let audio = @moonaudio.decode(bytes) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return None
    }
  }
  Some(audio)
}

///|
/// `info` command: container and metadata summary.
fn run_info(payload : String) -> Unit {
  let bytes = match decode_payload(payload) {
    Some(bytes) => bytes
    None => return
  }
  let kind = match @moonaudio.sniff(bytes) {
    Some(kind) => kind.to_name()
    None => "unknown"
  }
  println("container: \{kind}")
  let description = @moonaudio.describe(bytes) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  println("summary:   \{description}")
}

///|
/// `stats` command: decode and print measured levels.
fn run_stats(payload : String) -> Unit {
  let audio = match load_audio(payload) {
    Some(audio) => audio
    None => return
  }
  println(@dsp.analyse(audio).to_lines())
}

///|
/// `spectrum` command: dominant frequency of the loudest FFT frame.
fn run_spectrum(payload : String, options : Array[String]) -> Unit {
  let audio = match load_audio(payload) {
    Some(audio) => audio
    None => return
  }
  let fft_size = int_option(options, "--fft", 1024)
  let peak = dominant_frequency(audio, fft_size)
  println("fft size:  \{fft_size}")
  println("dominant:  \{format_double(peak)} Hz")
  println("expected:  \{format_double(440.0)} Hz for the CLI tone command")
}

///|
/// `spectrogram` command: CSV or SVG rendering.
fn run_spectrogram(payload : String, options : Array[String]) -> Unit {
  let audio = match load_audio(payload) {
    Some(audio) => audio
    None => return
  }
  let fft_size = int_option(options, "--fft", 1024)
  let hop = int_option(options, "--hop", fft_size / 4)
  let window_name = option(options, "--window", "hann")
  let window = match window_name {
    "rect" | "rectangular" => @dsp.WindowKind::Rectangular
    "hamming" => @dsp.WindowKind::Hamming
    "blackman" => @dsp.WindowKind::Blackman
    "blackman-harris" => @dsp.WindowKind::BlackmanHarris
    "kaiser" => @dsp.WindowKind::Kaiser
    _ => @dsp.WindowKind::Hann
  }
  let spectrogram = @dsp.spectrogram(audio, fft_size~, hop~, window~) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  if flag(options, "--svg") {
    println(spectrogram.to_svg())
  } else {
    println(spectrogram.to_csv())
  }
}

///|
/// `convert` command: decode, resample, remix, re-encode.
fn run_convert(payload : String, options : Array[String]) -> Unit {
  let audio = match load_audio(payload) {
    Some(audio) => audio
    None => return
  }
  let rate = int_option(options, "--rate", 0)
  let target = if rate > 0 { rate } else { audio.sample_rate }
  let resampled = if target == audio.sample_rate {
    audio
  } else {
    @resample.resample(audio, target) catch {
      (error : @audio.AudioError) => {
        report_error(error)
        return
      }
    }
  }
  let channels = int_option(options, "--channels", 0)
  let mixed = if channels > 0 && channels != resampled.channels {
    resampled.remix(channels) catch {
      (error : @audio.AudioError) => {
        report_error(error)
        return
      }
    }
  } else {
    resampled
  }
  let format = @audio.SampleFormat::parse(option(options, "--format", "s16")) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  let container = option(options, "--container", "wav")
  let encoded = if container == "wav" {
    @wav.encode(mixed, format~) catch {
      (error : @audio.AudioError) => {
        report_error(error)
        return
      }
    }
  } else if container == "aiff" {
    @aiff.encode(mixed, format~) catch {
      (error : @audio.AudioError) => {
        report_error(error)
        return
      }
    }
  } else if container == "au" {
    @au.encode(mixed, format~) catch {
      (error : @audio.AudioError) => {
        report_error(error)
        return
      }
    }
  } else {
    println("error: unknown container \"\{container}\"")
    return
  }
  println(@base64.encode(encoded[:]))
}

///|
/// `tone` command: synthesise a sine wave and print it as a WAV file.
fn run_tone(options : Array[String]) -> Unit {
  let frequency = double_option(options, "--freq", 440.0)
  let rate = int_option(options, "--rate", 44100)
  let milliseconds = int_option(options, "--ms", 500)
  let format = @audio.SampleFormat::parse(option(options, "--format", "s16")) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  let frames = if milliseconds > 0 { rate * milliseconds / 1000 } else { 0 }
  let audio = @dsp.tone(frequency, rate, frames, amplitude=0.8) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  let encoded = @wav.encode(audio, format~) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  println(@base64.encode(encoded[:]))
}

///|
/// Frequency of the strongest FFT bin of the whole buffer.
fn dominant_frequency(audio : @audio.AudioBuffer, fft_size : Int32) -> Double {
  let size = if fft_size < 2 { 2 } else { fft_size }
  let samples = audio.data
  let count = if samples.length() < size { samples.length() } else { size }
  let slice : Array[Double] = Array::make(size, 0.0)
  for index = 0; index < count; index = index + 1 {
    slice[index] = samples[index]
  }
  let spectrum = @dsp.real_fft(slice)
  let magnitudes = @dsp.magnitude(spectrum, n=size)
  let mut best_index = 0
  let mut best = 0.0
  for index = 0; index < magnitudes.length(); index = index + 1 {
    if magnitudes[index] > best {
      best = magnitudes[index]
      best_index = index
    }
  }
  best_index.to_double() * audio.sample_rate.to_double() / size.to_double()
}

///|
/// Integer command line option.
fn int_option(args : Array[String], name : String, fallback : Int32) -> Int32 {
  @string.parse_int(option(args, name, "")) catch {
    (_ : Error) => fallback
  }
}

///|
/// Floating point command line option.
fn double_option(
  args : Array[String],
  name : String,
  fallback : Double,
) -> Double {
  let text = option(args, name, "")
  if text.length() == 0 {
    fallback
  } else {
    @string.parse_double(text) catch {
      (_ : Error) => fallback
    }
  }
}

///|
/// Three decimal places rendering of a level.
fn format_double(value : Double) -> String {
  let scaled = (value * 1000.0).round().to_int()
  let sign = if scaled < 0 { "-" } else { "" }
  let magnitude = if scaled < 0 { -scaled } else { scaled }
  let whole = magnitude / 1000
  let fraction = magnitude % 1000
  "\{sign}\{whole}.\{pad3(fraction)}"
}

///|
/// Zero padded three digit number.
fn pad3(value : Int32) -> String {
  if value < 10 {
    "00\{value}"
  } else if value < 100 {
    "0\{value}"
  } else {
    value.to_string()
  }
}

///|
/// dBFS rendering that keeps `-inf` readable.
fn format_dbfs(value : Double) -> String {
  if value.is_inf() && value < 0.0 {
    "-inf dBFS"
  } else if value.is_nan() {
    "nan dBFS"
  } else {
    "\{format_double(value)} dBFS"
  }
}

///|
/// `demo` command: build, encode, decode, analyse and resample in one run and
/// print a Markdown report. This is the runnable example the README points at.
fn run_demo() -> Unit {
  println("# moonaudio demo")
  println("")
  println("- module version: `\{@moonaudio.version()}`")
  let tone = @dsp.tone(440.0, 44100, 44100, amplitude=0.8) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  println(
    "- generated: 1.000 s of 440 Hz at 44.1 kHz, peak \{format_double(tone.peak())}",
  )
  let wav_bytes = @wav.encode(tone) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  let kind = match @moonaudio.sniff(wav_bytes) {
    Some(kind) => kind.to_name()
    None => "unknown"
  }
  println("- encoded: \{wav_bytes.length()} byte WAV, sniffed as \{kind}")
  let description = @moonaudio.describe(wav_bytes) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  println("- header: \{description}")
  let decoded = @moonaudio.decode(wav_bytes) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  let stats = @dsp.analyse(decoded)
  println(
    "- roundtrip: \{decoded.frames()} frames, peak \{format_dbfs(stats.peak_dbfs)}, rms \{format_dbfs(stats.rms_dbfs)}, crest \{format_double(stats.crest_factor)}",
  )
  let aiff_bytes = @aiff.encode(tone, format=@audio.I24) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  let from_aiff = @moonaudio.decode(aiff_bytes) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  println(
    "- AIFF 24-bit: \{aiff_bytes.length()} bytes, decoded peak \{format_double(from_aiff.peak())}",
  )
  let au_bytes = @au.encode(tone, format=@audio.F64) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  let from_au = @moonaudio.decode(au_bytes) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  println(
    "- AU float64: \{au_bytes.length()} bytes, decoded peak \{format_double(from_au.peak())}",
  )
  let upsampled = @resample.resample(tone, 48000) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  println(
    "- resampled 44.1 kHz -> 48 kHz: \{upsampled.frames()} frames, peak \{format_double(upsampled.peak())}",
  )
  let dominant = dominant_frequency(tone, 4096)
  println(
    "- FFT peak: \{format_double(dominant)} Hz for the 440 Hz tone (\{format_double(44100.0 / 4096.0)} Hz per bin)",
  )
  let spectrogram = @dsp.spectrogram(
    tone,
    fft_size=1024,
    hop=256,
    window=@dsp.WindowKind::Hann,
  ) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  let (frames, bins) = spectrogram.size()
  println(
    "- spectrogram: \{frames} frames x \{bins} bins, bin width \{format_double(spectrogram.bin_hz())} Hz, frame 0 peak \{format_double(spectrogram.peak_frequency(0))} Hz",
  )
  let high_pass = @dsp.Biquad::new(@dsp.HighPass, 44100, 200.0) catch {
    (error : @audio.AudioError) => {
      report_error(error)
      return
    }
  }
  let filtered = high_pass.apply_buffer(tone)
  println(
    "- high pass 200 Hz: peak \{format_double(filtered.peak())}, rms \{format_double(filtered.rms())}",
  )
  println("")
  println(
    "Every decoder produced the same normalised `AudioBuffer`, which is what lets",
  )
  println("the CLI pipe any container through any analysis stage.")
}