///|
const BENCH_SAMPLE_RATE : Double = 48000.0

///|
/// WHY three sizes: 64 stresses per-block overhead, 128 is the production
/// AudioWorklet quantum, 256 is common for higher-latency backends.
let bench_block_sizes : FixedArray[Int] = [64, 128, 256]

///|
/// Passthrough: constant → output (2 nodes).
/// WHY: establishes the absolute floor for process() overhead — any time above
/// this is attributable to actual DSP math, not graph traversal bookkeeping.
fn build_bench_passthrough() -> GraphBuilder {
  let signal : GraphBuilder = GraphBuilder::constant(0.5)
  GraphBuilder::output(signal)
}

///|
/// Minimal voice: osc → gain → output (~4 nodes).
fn build_bench_minimal_voice() -> GraphBuilder {
  let freq : GraphBuilder = GraphBuilder::constant(440.0)
  let osc = freq.oscillator(Waveform::Saw)
  let gained = osc.gain(0.5)
  GraphBuilder::output(gained)
}

///|
/// FM voice: LFO → range → carrier → LPF → gain → output (12 nodes).
/// WHY exit_deliverable(): reuses the Phase 2 exit deliverable directly so
/// benchmark results stay comparable to the canonical "does it sound right?" graph.
fn build_bench_fm_voice() -> GraphBuilder {
  exit_deliverable()
}

///|
/// Full voice: osc + noise → ADSR → filter → gain → delay → clip → output.
fn build_bench_full_voice() -> GraphBuilder {
  let freq : GraphBuilder = GraphBuilder::constant(220.0)
  let osc = freq.oscillator(Waveform::Saw)
  let noise : GraphBuilder = GraphBuilder::noise(42U)
  let noise_gained = noise.gain(0.2)
  let mixed = osc.mix(noise_gained)
  let env : GraphBuilder = GraphBuilder::adsr(5.0, 10.0, 0.6, 50.0)
  let modulated = mixed.mul(env)
  let filtered = modulated.biquad(BiquadMode::LowPass, 1200.0, 0.707)
  let gained = filtered.gain(0.7)
  let delayed = gained.delay(4800, 2400, 0.3)
  let clipped = delayed.clip(0.9)
  GraphBuilder::output(clipped)
}

///|
/// Feedback voice (7 nodes): osc → filter → mix(filter, gain) → delay → gain → output.
/// WHY raw DspNode array: GraphBuilder cannot express feedback cycles. The mix
/// node at index 3 references the gain node at index 5 (which is downstream),
/// creating a z^-1 back-edge that the compiler detects and routes through the
/// per-sample feedback path (process_feedback_graph) instead of the per-buffer path.
/// WHY feedback=0.0 on delay: the recirculating feedback comes from the
/// explicit back-edge in mix(2,5), not from the delay's internal feedback tap.
fn build_bench_feedback_voice_nodes() -> Array[DspNode] {
  [
    DspNode::constant(440.0), // 0: freq
    DspNode::oscillator(Waveform::Saw, 440.0), // 1: osc (fixed freq)
    DspNode::biquad(
      input=1,
      mode=BiquadMode::LowPass,
      cutoff_hz=1000.0,
      q=0.707,
    ), // 2: filter
    DspNode::mix(2, 5), // 3: mix (filter + feedback from gain) — back-edge
    DspNode::delay(
      input=3,
      max_delay_samples=4800,
      delay_samples=2400,
      feedback=0.0,
    ), // 4: delay
    DspNode::gain(4, 0.5), // 5: gain (feedback source)
    DspNode::output(5), // 6: output
  ]
}

///|
/// Stereo chain: full voice → pan → stereo filter → stereo delay →
/// stereo gain → stereo clip → stereo output.
fn build_bench_stereo_chain() -> GraphBuilder {
  let freq : GraphBuilder = GraphBuilder::constant(220.0)
  let osc = freq.oscillator(Waveform::Saw)
  let noise : GraphBuilder = GraphBuilder::noise(42U)
  let noise_gained = noise.gain(0.2)
  let mixed = osc.mix(noise_gained)
  let env : GraphBuilder = GraphBuilder::adsr(5.0, 10.0, 0.6, 50.0)
  let modulated = mixed.mul(env)
  let filtered = modulated.biquad(BiquadMode::LowPass, 1200.0, 0.707)
  let gained = filtered.gain(0.7)
  let panned = gained.pan(0.0)
  let stereo_filtered = panned.stereo_biquad(BiquadMode::LowPass, 2000.0, 0.707)
  let stereo_delayed = stereo_filtered.stereo_delay(4800, 2400, 0.3)
  let stereo_gained = stereo_delayed.stereo_gain(0.8)
  let stereo_clipped = stereo_gained.stereo_clip(0.9)
  GraphBuilder::stereo_output(stereo_clipped)
}

// ---- Validation tests ----
// WHY: a failed .unwrap() inside a benchmark closure gives no diagnostic.
// These guard-rail tests catch broken graph builders before the benchmark
// runner sees them, producing clear assertion failures instead of silent aborts.

///|
test "bench graph: passthrough compiles" {
  let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size=128)
  let compiled = CompiledDsp::compile(
    CompiledTemplate::analyze(build_bench_passthrough().nodes()),
    ctx,
  )
  assert_true(compiled is Some(_))
}

///|
test "bench graph: minimal voice compiles" {
  let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size=128)
  let compiled = CompiledDsp::compile(
    CompiledTemplate::analyze(build_bench_minimal_voice().nodes()),
    ctx,
  )
  assert_true(compiled is Some(_))
}

///|
test "bench graph: fm voice compiles" {
  let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size=128)
  let compiled = CompiledDsp::compile(
    CompiledTemplate::analyze(build_bench_fm_voice().nodes()),
    ctx,
  )
  assert_true(compiled is Some(_))
}

///|
test "bench graph: full voice compiles" {
  let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size=128)
  let compiled = CompiledDsp::compile(
    CompiledTemplate::analyze(build_bench_full_voice().nodes()),
    ctx,
  )
  assert_true(compiled is Some(_))
}

///|
test "bench graph: feedback voice compiles" {
  let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size=128)
  let compiled = CompiledDsp::compile(
    CompiledTemplate::analyze(build_bench_feedback_voice_nodes()),
    ctx,
  )
  assert_true(compiled is Some(_))
}

///|
test "bench graph: stereo chain compiles" {
  let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size=128)
  let compiled = CompiledStereoDsp::compile(
    CompiledTemplate::analyze(build_bench_stereo_chain().nodes()),
    ctx,
  )
  assert_true(compiled is Some(_))
}

///|
test "bench graph: stereo chain hotswap compiles" {
  let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size=128)
  let compiled = CompiledStereoDsp::compile(
    CompiledTemplate::analyze(build_bench_stereo_chain().nodes()),
    ctx,
  )
  assert_true(compiled is Some(_))
  let hs = CompiledStereoDspHotSwap::from_graph(
    compiled.unwrap(),
    crossfade_samples=128,
  )
  let left = AudioBuffer::filled(128)
  let right = AudioBuffer::filled(128)
  hs.process(ctx, left, right)
}

// ---- Process benchmarks ----
// WHY separate compile per block_size: the compiled graph's internal buffer
// capacity is tied to the context's block_size (see compiled_block_size in
// graph.mbt). Reusing a graph compiled at 128 with a 64-sample context would
// hit the capacity check and zero the output.

///|
test "bench/process/passthrough" (b : @bench.T) {
  let nodes = build_bench_passthrough().nodes()
  for block_size in bench_block_sizes {
    let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size~)
    let compiled = CompiledDsp::compile(CompiledTemplate::analyze(nodes), ctx).unwrap()
    let output = AudioBuffer::filled(block_size)
    b.bench(name="passthrough/\{block_size}", fn() {
      compiled.process(ctx, output)
      b.keep(output)
    })
  }
}

///|
test "bench/process/minimal_voice" (b : @bench.T) {
  let nodes = build_bench_minimal_voice().nodes()
  for block_size in bench_block_sizes {
    let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size~)
    let compiled = CompiledDsp::compile(CompiledTemplate::analyze(nodes), ctx).unwrap()
    let output = AudioBuffer::filled(block_size)
    b.bench(name="minimal_voice/\{block_size}", fn() {
      compiled.process(ctx, output)
      b.keep(output)
    })
  }
}

///|
test "bench/process/fm_voice" (b : @bench.T) {
  let nodes = build_bench_fm_voice().nodes()
  for block_size in bench_block_sizes {
    let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size~)
    let compiled = CompiledDsp::compile(CompiledTemplate::analyze(nodes), ctx).unwrap()
    let output = AudioBuffer::filled(block_size)
    b.bench(name="fm_voice/\{block_size}", fn() {
      compiled.process(ctx, output)
      b.keep(output)
    })
  }
}

///|
test "bench/process/full_voice" (b : @bench.T) {
  let nodes = build_bench_full_voice().nodes()
  for block_size in bench_block_sizes {
    let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size~)
    let compiled = CompiledDsp::compile(CompiledTemplate::analyze(nodes), ctx).unwrap()
    let output = AudioBuffer::filled(block_size)
    b.bench(name="full_voice/\{block_size}", fn() {
      compiled.process(ctx, output)
      b.keep(output)
    })
  }
}

///|
test "bench/process/feedback_voice" (b : @bench.T) {
  let nodes = build_bench_feedback_voice_nodes()
  for block_size in bench_block_sizes {
    let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size~)
    let compiled = CompiledDsp::compile(CompiledTemplate::analyze(nodes), ctx).unwrap()
    let output = AudioBuffer::filled(block_size)
    b.bench(name="feedback_voice/\{block_size}", fn() {
      compiled.process(ctx, output)
      b.keep(output)
    })
  }
}

///|
test "bench/process/stereo_chain" (b : @bench.T) {
  let nodes = build_bench_stereo_chain().nodes()
  for block_size in bench_block_sizes {
    let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size~)
    let compiled = CompiledStereoDsp::compile(
      CompiledTemplate::analyze(nodes),
      ctx,
    ).unwrap()
    let left = AudioBuffer::filled(block_size)
    let right = AudioBuffer::filled(block_size)
    b.bench(name="stereo_chain/\{block_size}", fn() {
      compiled.process(ctx, left, right)
      b.keep(left)
      b.keep(right)
    })
  }
}

///|
test "bench/compile/passthrough" (b : @bench.T) {
  let nodes = build_bench_passthrough().nodes()
  let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size=128)
  b.bench(name="passthrough", fn() {
    let compiled = CompiledDsp::compile(CompiledTemplate::analyze(nodes), ctx)
    b.keep(compiled)
  })
}

///|
test "bench/compile/minimal_voice" (b : @bench.T) {
  let nodes = build_bench_minimal_voice().nodes()
  let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size=128)
  b.bench(name="minimal_voice", fn() {
    let compiled = CompiledDsp::compile(CompiledTemplate::analyze(nodes), ctx)
    b.keep(compiled)
  })
}

///|
test "bench/compile/fm_voice" (b : @bench.T) {
  let nodes = build_bench_fm_voice().nodes()
  let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size=128)
  b.bench(name="fm_voice", fn() {
    let compiled = CompiledDsp::compile(CompiledTemplate::analyze(nodes), ctx)
    b.keep(compiled)
  })
}

///|
test "bench/compile/full_voice" (b : @bench.T) {
  let nodes = build_bench_full_voice().nodes()
  let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size=128)
  b.bench(name="full_voice", fn() {
    let compiled = CompiledDsp::compile(CompiledTemplate::analyze(nodes), ctx)
    b.keep(compiled)
  })
}

///|
test "bench/compile/feedback_voice" (b : @bench.T) {
  let nodes = build_bench_feedback_voice_nodes()
  let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size=128)
  b.bench(name="feedback_voice", fn() {
    let compiled = CompiledDsp::compile(CompiledTemplate::analyze(nodes), ctx)
    b.keep(compiled)
  })
}

///|
test "bench/compile/stereo_chain" (b : @bench.T) {
  let nodes = build_bench_stereo_chain().nodes()
  let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size=128)
  b.bench(name="stereo_chain", fn() {
    let compiled = CompiledStereoDsp::compile(
      CompiledTemplate::analyze(nodes),
      ctx,
    )
    b.keep(compiled)
  })
}

///|
/// WHY a distinct graph: hot-swap crossfade blends old and new graphs sample
/// by sample. Using a structurally different topology (Sine vs Saw, different
/// freq/gain) ensures the crossfade codepath is exercised with genuinely
/// different sample values rather than blending a graph with itself.
fn build_bench_minimal_voice_alt() -> GraphBuilder {
  let freq : GraphBuilder = GraphBuilder::constant(880.0)
  let osc = freq.oscillator(Waveform::Sine)
  let gained = osc.gain(0.3)
  GraphBuilder::output(gained)
}

///|
/// WHY simpler than the main stereo chain: intentionally fewer nodes so the
/// hot-swap crossfade blends graphs of different sizes, which is the common
/// real-world case (user edits simplify or expand the graph).
fn build_bench_stereo_chain_alt() -> GraphBuilder {
  let freq : GraphBuilder = GraphBuilder::constant(330.0)
  let osc = freq.oscillator(Waveform::Sine)
  let gained = osc.gain(0.5)
  let panned = gained.pan(0.3)
  let stereo_gained = panned.stereo_gain(0.7)
  GraphBuilder::stereo_output(stereo_gained)
}

// ---- Hot-swap benchmarks ----
// WHY alternating: after process() completes a crossfade, the pending graph
// becomes active. Without alternation, every iteration after the first would
// queue_swap the same graph that is already active — crossfading a graph with
// itself. Alternating ensures each iteration swaps between genuinely different
// compiled graphs, exercising the real crossfade codepath.

///|
test "bench/hotswap/minimal_voice" (b : @bench.T) {
  let nodes_a = build_bench_minimal_voice().nodes()
  let nodes_b = build_bench_minimal_voice_alt().nodes()
  for block_size in bench_block_sizes {
    let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size~)
    let compiled_a = CompiledDsp::compile(
      CompiledTemplate::analyze(nodes_a),
      ctx,
    ).unwrap()
    let compiled_b = CompiledDsp::compile(
      CompiledTemplate::analyze(nodes_b),
      ctx,
    ).unwrap()
    let hs = CompiledDspHotSwap::from_graph(
      compiled_a,
      crossfade_samples=block_size,
    )
    let output = AudioBuffer::filled(block_size)
    let use_b = @ref.new(true)
    b.bench(name="minimal_voice/\{block_size}", fn() {
      let next = if use_b.val { compiled_b } else { compiled_a }
      use_b.val = !use_b.val
      hs.queue_swap(next).unwrap()
      hs.process(ctx, output)
      b.keep(output)
    })
  }
}

///|
test "bench/hotswap/fm_voice" (b : @bench.T) {
  let nodes_a = build_bench_fm_voice().nodes()
  let nodes_b = build_bench_minimal_voice().nodes()
  for block_size in bench_block_sizes {
    let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size~)
    let compiled_a = CompiledDsp::compile(
      CompiledTemplate::analyze(nodes_a),
      ctx,
    ).unwrap()
    let compiled_b = CompiledDsp::compile(
      CompiledTemplate::analyze(nodes_b),
      ctx,
    ).unwrap()
    let hs = CompiledDspHotSwap::from_graph(
      compiled_a,
      crossfade_samples=block_size,
    )
    let output = AudioBuffer::filled(block_size)
    let use_b = @ref.new(true)
    b.bench(name="fm_voice/\{block_size}", fn() {
      let next = if use_b.val { compiled_b } else { compiled_a }
      use_b.val = !use_b.val
      hs.queue_swap(next).unwrap()
      hs.process(ctx, output)
      b.keep(output)
    })
  }
}

///|
test "bench/hotswap/full_voice" (b : @bench.T) {
  let nodes_a = build_bench_full_voice().nodes()
  let nodes_b = build_bench_fm_voice().nodes()
  for block_size in bench_block_sizes {
    let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size~)
    let compiled_a = CompiledDsp::compile(
      CompiledTemplate::analyze(nodes_a),
      ctx,
    ).unwrap()
    let compiled_b = CompiledDsp::compile(
      CompiledTemplate::analyze(nodes_b),
      ctx,
    ).unwrap()
    let hs = CompiledDspHotSwap::from_graph(
      compiled_a,
      crossfade_samples=block_size,
    )
    let output = AudioBuffer::filled(block_size)
    let use_b = @ref.new(true)
    b.bench(name="full_voice/\{block_size}", fn() {
      let next = if use_b.val { compiled_b } else { compiled_a }
      use_b.val = !use_b.val
      hs.queue_swap(next).unwrap()
      hs.process(ctx, output)
      b.keep(output)
    })
  }
}

///|
test "bench/hotswap/stereo_chain" (b : @bench.T) {
  let nodes_a = build_bench_stereo_chain().nodes()
  let nodes_b = build_bench_stereo_chain_alt().nodes()
  for block_size in bench_block_sizes {
    let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size~)
    let compiled_a = CompiledStereoDsp::compile(
      CompiledTemplate::analyze(nodes_a),
      ctx,
    ).unwrap()
    let compiled_b = CompiledStereoDsp::compile(
      CompiledTemplate::analyze(nodes_b),
      ctx,
    ).unwrap()
    let hs = CompiledStereoDspHotSwap::from_graph(
      compiled_a,
      crossfade_samples=block_size,
    )
    let left = AudioBuffer::filled(block_size)
    let right = AudioBuffer::filled(block_size)
    let use_b = @ref.new(true)
    b.bench(name="stereo_chain/\{block_size}", fn() {
      let next = if use_b.val { compiled_b } else { compiled_a }
      use_b.val = !use_b.val
      hs.queue_swap(next).unwrap()
      hs.process(ctx, left, right)
      b.keep(left)
      b.keep(right)
    })
  }
}

// ---- Topology edit benchmarks ----
// WHY topology edits trigger full recompilation internally: the topology
// controller maintains authoring_nodes (the user's view) and a compiled graph.
// Each edit mutates authoring_nodes, recompiles, and crossfades to the new graph.
// This makes topology edits much more expensive than hot-swap (which only
// swaps pre-compiled graphs) or replace_node (which substitutes in-place).

///|
/// WHY replace_node is stable across iterations: replace mutates the node
/// in-place without changing the graph's node count or topology, so the
/// authoring array stays the same size every iteration.
test "bench/topology/replace_node" (b : @bench.T) {
  let nodes = build_bench_fm_voice().nodes()
  for block_size in bench_block_sizes {
    let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size~)
    let tc = CompiledDspTopologyController::from_nodes(
      nodes,
      ctx,
      crossfade_samples=block_size,
    ).unwrap()
    let output = AudioBuffer::filled(block_size)
    let edit = GraphTopologyEdit::replace_node(0, DspNode::constant(3.0))
    b.bench(name="replace_node/\{block_size}", fn() {
      tc.queue_topology_edit(edit).unwrap()
      tc.process(ctx, output)
      b.keep(output)
    })
  }
}

///|
/// WHY insert+delete roundtrip: a bare insert_node benchmark would grow the
/// authoring_nodes array by one node per iteration, causing unbounded O(N)
/// growth and making later iterations measure a larger graph than earlier ones.
/// By following each insert with a delete (and vice versa), the graph returns
/// to its original size every two process() calls, keeping measurements stable.
/// The cost measured is one insert recompile + one delete recompile per iteration.
test "bench/topology/insert_delete_roundtrip" (b : @bench.T) {
  let nodes = build_bench_fm_voice().nodes()
  for block_size in bench_block_sizes {
    let ctx = DspContext::new(sample_rate=BENCH_SAMPLE_RATE, block_size~)
    let tc = CompiledDspTopologyController::from_nodes(
      nodes,
      ctx,
      crossfade_samples=block_size,
    ).unwrap()
    let output = AudioBuffer::filled(block_size)
    let last_index = nodes.length() - 1
    let insert_edit = GraphTopologyEdit::insert_node(
      last_index,
      GraphTopologyInputSlot::Input0,
      DspNode::gain(-1, 0.9),
    )
    // WHY delete targets last_index + 1: after insert, the new gain node is
    // appended at index last_index + 1. The output node (at last_index) was
    // rewired to read from the new gain. Deleting the gain (last_index + 1)
    // and retargeting output (last_index) back to its original source restores
    // the graph to its pre-insert state.
    let delete_edit = GraphTopologyEdit::delete_node(
      last_index + 1,
      last_index,
      GraphTopologyInputSlot::Input0,
      last_index + 1,
    )
    b.bench(name="insert_delete_roundtrip/\{block_size}", fn() {
      tc.queue_topology_edit(insert_edit).unwrap()
      tc.process(ctx, output)
      tc.queue_topology_edit(delete_edit).unwrap()
      tc.process(ctx, output)
      b.keep(output)
    })
  }
}