import { describe, expect, it } from 'vitest';
import {
  clampTo,
  cloneMix,
  DEFAULT_MIX,
  formatMix,
  MIX_RANGES,
  soloedVoice,
  voiceGain,
  VOICE_NAMES,
  type TrackMix,
  type VoiceName,
} from '../src/audio/mix';

const mix = (over: Partial<TrackMix> = {}): TrackMix => ({ ...cloneMix(DEFAULT_MIX), ...over });

const enabled = (...on: VoiceName[]): Record<VoiceName, boolean> => ({
  kick: on.includes('kick'),
  hat: on.includes('hat'),
  bass: on.includes('bass'),
  pad: on.includes('pad'),
});

describe('DEFAULT_MIX', () => {
  // Tripwire. A retune is a deliberate, reviewed diff — if this goes red because
  // someone pasted an ear-pass dump, update the literals in the same commit.
  it('holds the Phase-1 hardcoded values exactly', () => {
    expect(DEFAULT_MIX).toEqual({
      master: 0.8,
      levels: { kick: 0.9, hat: 0.25, bass: 0.11, pad: 0.05 },
      enabled: { kick: true, hat: true, bass: true, pad: true },
      kick: { startHz: 150, endHz: 50, dropS: 0.06, attackS: 0.008, decayS: 0.34 },
      hat: { hpHz: 7000, decayS: 0.04 },
      bass: { rootHz: 55, filterLoHz: 180, filterHiHz: 900, q: 8, decayS: 0.12, detuneCents: 8 },
      pad: { lpHz: 600, bars: 8 },
      doorQ: 6,
      floorQ: 0.7,
    });
  });

  it('ships every voice unmuted and every level inside its range', () => {
    for (const v of VOICE_NAMES) {
      expect(DEFAULT_MIX.enabled[v]).toBe(true);
      expect(DEFAULT_MIX.levels[v]).toBe(clampTo(DEFAULT_MIX.levels[v], MIX_RANGES.level));
    }
    expect(DEFAULT_MIX.master).toBe(clampTo(DEFAULT_MIX.master, MIX_RANGES.master));
  });
});

describe('cloneMix', () => {
  it('deep-copies every nested branch', () => {
    const src = cloneMix(DEFAULT_MIX);
    const copy = cloneMix(src);

    expect(copy).toEqual(src);
    expect(copy.levels).not.toBe(src.levels);
    expect(copy.enabled).not.toBe(src.enabled);
    expect(copy.kick).not.toBe(src.kick);
    expect(copy.hat).not.toBe(src.hat);
    expect(copy.bass).not.toBe(src.bass);
    expect(copy.pad).not.toBe(src.pad);
  });

  it('leaves the source untouched when every branch of the clone is mutated', () => {
    const src = cloneMix(DEFAULT_MIX);
    const copy = cloneMix(src);

    copy.master = 0.1;
    copy.levels.kick = 0.01;
    copy.levels.hat = 0.02;
    copy.levels.bass = 0.03;
    copy.levels.pad = 0.04;
    copy.enabled.kick = false;
    copy.enabled.hat = false;
    copy.enabled.bass = false;
    copy.enabled.pad = false;
    copy.kick.startHz = 999;
    copy.hat.hpHz = 999;
    copy.bass.rootHz = 999;
    copy.pad.lpHz = 999;
    copy.doorQ = 99;
    copy.floorQ = 99;

    expect(src.master).toBe(0.8);
    expect(src.levels).toEqual({ kick: 0.9, hat: 0.25, bass: 0.11, pad: 0.05 });
    expect(src.enabled).toEqual({ kick: true, hat: true, bass: true, pad: true });
    expect(src.kick.startHz).toBe(150);
    expect(src.hat.hpHz).toBe(7000);
    expect(src.bass.rootHz).toBe(55);
    expect(src.pad.lpHz).toBe(600);
    expect(src.doorQ).toBe(6);
    expect(src.floorQ).toBe(0.7);
  });

  it('does not let a clone of DEFAULT_MIX mutate the module-level default', () => {
    const c = cloneMix(DEFAULT_MIX);
    c.levels.kick = 0;
    c.enabled.pad = false;
    expect(DEFAULT_MIX.levels.kick).toBe(0.9);
    expect(DEFAULT_MIX.enabled.pad).toBe(true);
  });
});

describe('clampTo', () => {
  const range: readonly [number, number] = [0.5, 20];

  it('passes values inside the range through unchanged', () => {
    expect(clampTo(0.5, range)).toBe(0.5);
    expect(clampTo(6, range)).toBe(6);
    expect(clampTo(20, range)).toBe(20);
  });

  it('clamps both ends', () => {
    expect(clampTo(-100, range)).toBe(0.5);
    expect(clampTo(1e9, range)).toBe(20);
    expect(clampTo(20.0001, range)).toBe(20);
    expect(clampTo(0.4999, range)).toBe(0.5);
  });

  // A NaN gain reaching WebAudio silently kills a voice for the rest of the
  // night, so a bad input must land on the minimum, never propagate. Infinity
  // takes the same branch: non-finite reads as a broken value, not a loud one.
  it('maps every non-finite input to the range minimum, not NaN and not the max', () => {
    for (const bad of [NaN, Infinity, -Infinity]) {
      expect(clampTo(bad, range)).toBe(0.5);
      expect(clampTo(bad, MIX_RANGES.master)).toBe(0);
      expect(Number.isNaN(clampTo(bad, range))).toBe(false);
    }
  });

  it('keeps every declared range within its own bounds', () => {
    for (const [lo, hi] of Object.values(MIX_RANGES)) {
      expect(lo).toBeLessThan(hi);
      expect(clampTo(lo - 1, [lo, hi])).toBe(lo);
      expect(clampTo(hi + 1, [lo, hi])).toBe(hi);
    }
  });
});

describe('voiceGain', () => {
  it('multiplies level by the arc scale', () => {
    const m = mix();
    expect(voiceGain(m, 'kick', 1)).toBeCloseTo(0.9, 10);
    expect(voiceGain(m, 'kick', 0.5)).toBeCloseTo(0.45, 10);
    expect(voiceGain(m, 'bass', 0.5)).toBeCloseTo(0.055, 10);
  });

  it('defaults the arc scale to 1', () => {
    expect(voiceGain(mix(), 'hat')).toBe(mix().levels.hat);
  });

  it('returns 0 for a disabled voice regardless of level or arc scale', () => {
    const m = mix({ enabled: enabled('hat'), levels: { kick: 1.5, hat: 0.25, bass: 1, pad: 1 } });
    expect(voiceGain(m, 'kick', 1)).toBe(0);
    expect(voiceGain(m, 'kick', 10)).toBe(0);
    expect(voiceGain(m, 'bass', 1)).toBe(0);
    expect(voiceGain(m, 'pad', 0.5)).toBe(0);
    expect(voiceGain(m, 'hat', 1)).toBe(0.25);
  });

  it('yields exactly 0 at a 0 arc scale', () => {
    for (const v of VOICE_NAMES) expect(voiceGain(mix(), v, 0)).toBe(0);
  });

  it('never returns a negative gain', () => {
    const m = mix();
    for (const v of VOICE_NAMES) {
      expect(voiceGain(m, v, -1)).toBe(0);
      expect(voiceGain(m, v, -1e6)).toBe(0);
      expect(voiceGain(m, v, 2)).toBeGreaterThanOrEqual(0);
    }
    expect(voiceGain(mix({ levels: { kick: -1, hat: 0, bass: 0, pad: 0 } }), 'kick')).toBe(0);
  });
});

describe('soloedVoice', () => {
  it('is null when every voice is on', () => {
    expect(soloedVoice(mix())).toBeNull();
  });

  it('is null when no voice is on', () => {
    expect(soloedVoice(mix({ enabled: enabled() }))).toBeNull();
  });

  it('is null with two voices on', () => {
    expect(soloedVoice(mix({ enabled: enabled('kick', 'bass') }))).toBeNull();
    expect(soloedVoice(mix({ enabled: enabled('hat', 'pad') }))).toBeNull();
  });

  it('is null with three voices on', () => {
    expect(soloedVoice(mix({ enabled: enabled('kick', 'hat', 'bass') }))).toBeNull();
  });

  it('names the voice when exactly one is on', () => {
    for (const v of VOICE_NAMES) {
      expect(soloedVoice(mix({ enabled: enabled(v) }))).toBe(v);
    }
  });
});

describe('formatMix', () => {
  // The dump is how a tuning session becomes a diff; parse it back and compare
  // field by field so a silently dropped knob goes red.
  const parseDump = (s: string): unknown =>
    JSON.parse(
      s
        .replace(/([{,]\s*)([A-Za-z][A-Za-z0-9]*)\s*:/g, '$1"$2":')
        .replace(/,(\s*})/g, '$1'),
    );

  const tuned: TrackMix = {
    master: 0.95,
    levels: { kick: 1.05, hat: 0.3125, bass: 0.123456789, pad: 0.07 },
    enabled: enabled('bass'),
    kick: { startHz: 165, endHz: 44, dropS: 0.075, attackS: 0.003, decayS: 0.41 },
    hat: { hpHz: 8200, decayS: 0.055 },
    bass: { rootHz: 41, filterLoHz: 220, filterHiHz: 1400, q: 11.5, decayS: 0.19, detuneCents: 14 },
    pad: { lpHz: 780, bars: 4 },
    doorQ: 7.25,
    floorQ: 0.85,
  };

  it('round-trips every numeric field, rounded to 4 decimal places', () => {
    expect(parseDump(formatMix(tuned))).toEqual({
      master: 0.95,
      levels: { kick: 1.05, hat: 0.3125, bass: 0.1235, pad: 0.07 },
      enabled: { kick: true, hat: true, bass: true, pad: true },
      kick: { startHz: 165, endHz: 44, dropS: 0.075, attackS: 0.003, decayS: 0.41 },
      hat: { hpHz: 8200, decayS: 0.055 },
      bass: {
        rootHz: 41,
        filterLoHz: 220,
        filterHiHz: 1400,
        q: 11.5,
        decayS: 0.19,
        detuneCents: 14,
      },
      pad: { lpHz: 780, bars: 4 },
      doorQ: 7.25,
      floorQ: 0.85,
    });
  });

  it('round-trips DEFAULT_MIX back to itself', () => {
    expect(parseDump(formatMix(DEFAULT_MIX))).toEqual(DEFAULT_MIX);
  });

  it('emits every key of every branch', () => {
    const out = parseDump(formatMix(tuned)) as Record<string, Record<string, unknown>>;
    expect(Object.keys(out).sort()).toEqual(Object.keys(tuned).sort());
    for (const branch of ['levels', 'enabled', 'kick', 'hat', 'bass', 'pad'] as const) {
      expect(Object.keys(out[branch] ?? {}).sort()).toEqual(Object.keys(tuned[branch]).sort());
    }
  });

  // Mutes are an ear-pass tool, not a shipped state: the dump deliberately
  // un-mutes everything so a solo pass cannot ship as silence.
  it('always emits enabled: all true, even from a soloed mix', () => {
    expect(soloedVoice(tuned)).toBe('bass');
    const out = parseDump(formatMix(tuned)) as { enabled: Record<string, boolean> };
    expect(out.enabled).toEqual({ kick: true, hat: true, bass: true, pad: true });
  });

  it('emits integers without a decimal point', () => {
    const dump = formatMix(DEFAULT_MIX);
    expect(dump).toContain('startHz: 150');
    expect(dump).toContain('hpHz: 7000');
    expect(dump).toContain('doorQ: 6');
    expect(dump).not.toMatch(/\d\.0000/);
  });
});
