import { describe, expect, it } from 'vitest';
import {
  CLOSE_SWEEP_MS,
  DOOR_CUTOFF_HZ,
  DOOR_GAIN,
  FLOOR_CUTOFF_HZ,
  FLOOR_GAIN,
  OPEN_SWEEP_MS,
  cutoffAt,
  cutoffCurve,
  cutoffFor,
  gainAt,
  gainFor,
  sweepMsFor,
} from '../src/audio/filterCurve';

describe('location constants', () => {
  it('maps each location to its cutoff and gain', () => {
    expect(cutoffFor('door')).toBe(DOOR_CUTOFF_HZ);
    expect(cutoffFor('floor')).toBe(FLOOR_CUTOFF_HZ);
    expect(gainFor('door')).toBe(DOOR_GAIN);
    expect(gainFor('floor')).toBe(FLOOR_GAIN);
  });

  it('keeps the door muffled and quieter than the floor', () => {
    expect(cutoffFor('door')).toBeLessThan(cutoffFor('floor'));
    expect(gainFor('door')).toBeLessThan(gainFor('floor'));
  });

  it('opens faster than it closes', () => {
    expect(sweepMsFor('floor')).toBe(OPEN_SWEEP_MS);
    expect(sweepMsFor('door')).toBe(CLOSE_SWEEP_MS);
    expect(sweepMsFor('floor')).toBeLessThan(sweepMsFor('door'));
  });
});

describe('cutoffAt', () => {
  it('hits the endpoints exactly', () => {
    expect(cutoffAt(0, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6);
    expect(cutoffAt(1, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6);
    expect(cutoffAt(0, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6);
    expect(cutoffAt(1, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6);
  });

  it('clamps t outside 0..1 so callers can pass raw elapsed/duration', () => {
    expect(cutoffAt(-3, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6);
    expect(cutoffAt(-0.001, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6);
    expect(cutoffAt(1.5, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6);
    expect(cutoffAt(9000, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6);
  });

  it('sits at the geometric mean halfway, not the arithmetic one', () => {
    // The whole point: a linear ramp would already be at ~9kHz by the midpoint and
    // the door would sound like someone nudged a fader.
    const geometric = Math.sqrt(DOOR_CUTOFF_HZ * FLOOR_CUTOFF_HZ); // ~2121Hz
    const arithmetic = (DOOR_CUTOFF_HZ + FLOOR_CUTOFF_HZ) / 2; // ~9125Hz
    const mid = cutoffAt(0.5, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);

    expect(mid).toBeCloseTo(geometric, 3);
    expect(mid).toBeCloseTo(2121.32, 1);
    expect(mid).toBeLessThan(arithmetic / 4);
  });

  it('keeps the brightness late — three quarters through is still under half the range', () => {
    const oneQuarter = cutoffAt(0.25, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
    const threeQuarters = cutoffAt(0.75, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);

    // Anchored to measured values, not to the formula — otherwise any curve of the
    // same algebraic shape passes and a regression in the clamp goes unnoticed.
    expect(oneQuarter).toBeCloseTo(728.24, 1);
    expect(threeQuarters).toBeCloseTo(6179.3, 1);
    expect(threeQuarters).toBeLessThan(FLOOR_CUTOFF_HZ / 2);
  });

  it('covers equal ratios over equal steps of t', () => {
    // The defining property of the sweep: every quarter multiplies the cutoff by the
    // same factor, so the reveal accelerates in Hz while sounding even to the ear.
    const at = (t: number) => cutoffAt(t, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
    const step = Math.pow(FLOOR_CUTOFF_HZ / DOOR_CUTOFF_HZ, 0.25);

    for (const t of [0, 0.25, 0.5, 0.75]) {
      expect(at(t + 0.25) / at(t)).toBeCloseTo(step, 6);
    }
    // Equivalently: each sample is the geometric mean of its neighbours.
    expect(at(0.75)).toBeCloseTo(Math.sqrt(at(0.5) * FLOOR_CUTOFF_HZ), 3);
  });

  it('rises monotonically while opening', () => {
    let prev = -Infinity;
    for (let i = 0; i <= 200; i++) {
      const hz = cutoffAt(i / 200, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
      expect(hz).toBeGreaterThan(prev);
      prev = hz;
    }
  });

  it('falls monotonically while closing', () => {
    let prev = Infinity;
    for (let i = 0; i <= 200; i++) {
      const hz = cutoffAt(i / 200, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ);
      expect(hz).toBeLessThan(prev);
      prev = hz;
    }
  });

  it('stays inside the sweep range at every t', () => {
    for (let i = 0; i <= 100; i++) {
      const hz = cutoffAt(i / 100, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
      expect(hz).toBeGreaterThanOrEqual(DOOR_CUTOFF_HZ);
      expect(hz).toBeLessThanOrEqual(FLOOR_CUTOFF_HZ);
    }
  });

  it('is symmetric: closing retraces the opening sweep backwards', () => {
    for (let i = 0; i <= 20; i++) {
      const t = i / 20;
      expect(cutoffAt(t, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ)).toBeCloseTo(
        cutoffAt(1 - t, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ),
        6,
      );
    }
  });
});

describe('gainAt', () => {
  it('interpolates linearly between the endpoints', () => {
    expect(gainAt(0, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(DOOR_GAIN, 6);
    expect(gainAt(1, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(FLOOR_GAIN, 6);
    expect(gainAt(0.5, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo((DOOR_GAIN + FLOOR_GAIN) / 2, 6);
    expect(gainAt(0.25, 0, 1)).toBeCloseTo(0.25, 6);
    expect(gainAt(0.25, 1, 0)).toBeCloseTo(0.75, 6);
  });

  it('clamps t outside 0..1', () => {
    expect(gainAt(-2, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(DOOR_GAIN, 6);
    expect(gainAt(4, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(FLOOR_GAIN, 6);
  });
});

describe('cutoffCurve', () => {
  it('defaults to 32 sampled points spanning the full sweep', () => {
    const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
    expect(curve).toBeInstanceOf(Float32Array);
    expect(curve.length).toBe(32);
    expect(curve[0]!).toBeCloseTo(DOOR_CUTOFF_HZ, 3);
    expect(curve[31]!).toBeCloseTo(FLOOR_CUTOFF_HZ, 0);
  });

  it('honours a custom point count', () => {
    for (const points of [2, 8, 64, 129]) {
      const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, points);
      expect(curve.length).toBe(points);
      expect(curve[0]!).toBeCloseTo(DOOR_CUTOFF_HZ, 3);
      expect(curve[points - 1]!).toBeCloseTo(FLOOR_CUTOFF_HZ, 0);
    }
  });

  it('degrades to a single valid point instead of NaN, empty, or a throw', () => {
    // A NaN here would reach setValueCurveAtTime (throws) or AudioParam.value
    // (silently mutes the filter), so degenerate counts must stay finite.
    for (const points of [1, 0, -1, -100]) {
      const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, points);
      expect(curve.length).toBe(1);
      expect(Number.isFinite(curve[0]!)).toBe(true);
      expect(curve[0]!).toBeCloseTo(DOOR_CUTOFF_HZ, 3);
    }
  });

  it('never emits a non-finite sample for any point count', () => {
    for (const points of [-5, 0, 1, 2, 3, 32, 128]) {
      const curve = cutoffCurve(FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ, points);
      expect(curve.length).toBeGreaterThan(0);
      for (const hz of curve) expect(Number.isFinite(hz)).toBe(true);
    }
  });

  it('rises monotonically opening and falls monotonically closing', () => {
    const up = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, 64);
    for (let i = 1; i < up.length; i++) expect(up[i]!).toBeGreaterThan(up[i - 1]!);

    const down = cutoffCurve(FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ, 64);
    for (let i = 1; i < down.length; i++) expect(down[i]!).toBeLessThan(down[i - 1]!);
  });

  it('samples the same curve cutoffAt describes', () => {
    const points = 16;
    const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, points);
    for (let i = 0; i < points; i++) {
      const expected = cutoffAt(i / (points - 1), DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ);
      // Float32 storage costs precision, so compare relatively.
      expect(curve[i]! / expected).toBeCloseTo(1, 5);
    }
  });

  it('keeps its midpoint well below the linear halfway mark', () => {
    const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, 33);
    expect(curve[16]!).toBeCloseTo(Math.sqrt(DOOR_CUTOFF_HZ * FLOOR_CUTOFF_HZ), 1);
    expect(curve[16]!).toBeLessThan((DOOR_CUTOFF_HZ + FLOOR_CUTOFF_HZ) / 4);
  });
});
