"""Tests for the spectral-features module.

Each feature is pinned to a property with a known closed form: a single bin, a
symmetric pair, a flat spectrum, identical frames, and so on, so the numbers are
derived from the inputs rather than transcribed.
"""

import numpy as np
import pytest

from spectral_features import (
    spectral_centroid,
    spectral_spread,
    spectral_rolloff,
    spectral_flux,
    spectral_flatness,
    frame_features,
)


# A simple frequency axis reused across tests.
FREQS = np.linspace(0.0, 8000.0, 257)   # 257 one-sided bins, 0..8 kHz


def _delta(nfreq, k):
    """A magnitude spectrum that is all energy in bin k."""
    m = np.zeros(nfreq)
    m[k] = 1.0
    return m


# --- centroid ---------------------------------------------------------------

def test_centroid_of_single_bin_is_that_frequency():
    k = 40
    c = spectral_centroid(FREQS, _delta(len(FREQS), k))
    assert np.isclose(c, FREQS[k])


def test_centroid_of_equal_pair_is_their_mean():
    m = np.zeros(len(FREQS))
    a, b = 30, 90
    m[a] = m[b] = 1.0
    c = spectral_centroid(FREQS, m)
    assert np.isclose(c, 0.5 * (FREQS[a] + FREQS[b]))


def test_centroid_weights_toward_the_louder_bin():
    m = np.zeros(len(FREQS))
    a, b = 30, 90
    m[a], m[b] = 3.0, 1.0                     # 3:1 toward the low bin
    c = spectral_centroid(FREQS, m)
    expected = (3 * FREQS[a] + 1 * FREQS[b]) / 4
    assert np.isclose(c, expected)


def test_centroid_zero_energy_frame_returns_zero():
    c = spectral_centroid(FREQS, np.zeros(len(FREQS)))
    assert c == 0.0


def test_centroid_runs_per_frame_on_a_spectrogram():
    M = np.stack([_delta(len(FREQS), k) for k in (20, 50, 100)], axis=1)
    c = spectral_centroid(FREQS, M)
    assert c.shape == (3,)
    assert np.allclose(c, FREQS[[20, 50, 100]])


# --- spread -----------------------------------------------------------------

def test_spread_of_single_bin_is_zero():
    s = spectral_spread(FREQS, _delta(len(FREQS), 60))
    assert np.isclose(s, 0.0)


def test_spread_of_symmetric_pair_is_half_the_gap():
    # two equal bins symmetric about a centre bin: spread = distance to centre
    m = np.zeros(len(FREQS))
    centre, off = 100, 25
    m[centre - off] = m[centre + off] = 1.0
    s = spectral_spread(FREQS, m)
    df = FREQS[1] - FREQS[0]
    assert np.isclose(s, off * df)


def test_spread_zero_energy_frame_returns_zero():
    assert spectral_spread(FREQS, np.zeros(len(FREQS))) == 0.0
    # a silent column in a spectrogram returns 0 for that frame
    M = np.stack([np.ones(len(FREQS)), np.zeros(len(FREQS))], axis=1)
    assert spectral_spread(FREQS, M)[1] == 0.0


# --- rolloff ----------------------------------------------------------------

def test_rolloff_of_flat_spectrum_tracks_the_fraction():
    # flat magnitude: cumulative sum is linear, so rolloff at fraction p sits at
    # about p of the way up the axis.
    m = np.ones(len(FREQS))
    r85 = spectral_rolloff(FREQS, m, 0.85)
    assert np.isclose(r85, FREQS[int(np.ceil(0.85 * len(FREQS))) - 1])


def test_rolloff_satisfies_its_threshold_property():
    rng = np.random.default_rng(0)
    m = rng.random(len(FREQS))
    p = 0.85
    r = spectral_rolloff(FREQS, m, p)
    k = int(np.where(FREQS == r)[0][0])
    total = m.sum()
    assert m[: k + 1].sum() >= p * total          # cumulative up to rolloff meets it
    assert m[:k].sum() < p * total                # the bin before does not


def test_rolloff_low_when_energy_is_low_frequency():
    m = np.zeros(len(FREQS))
    m[:10] = 1.0                                   # all energy in the lowest bins
    r = spectral_rolloff(FREQS, m, 0.85)
    assert r <= FREQS[10]


def test_rolloff_rejects_bad_percent():
    with pytest.raises(ValueError):
        spectral_rolloff(FREQS, np.ones(len(FREQS)), 1.5)


def test_rolloff_zero_energy_frame_returns_zero():
    assert spectral_rolloff(FREQS, np.zeros(len(FREQS))) == 0.0
    # silent frame returns 0 even on a sub-band axis that does not start at DC
    subband = np.linspace(2000.0, 4000.0, 65)
    assert spectral_rolloff(subband, np.zeros(65)) == 0.0


# --- flux -------------------------------------------------------------------

def test_flux_of_constant_spectrogram_is_zero():
    M = np.tile(_delta(len(FREQS), 40)[:, None], (1, 5))
    f = spectral_flux(M)
    assert f.shape == (4,)
    assert np.allclose(f, 0.0)


def test_flux_is_positive_when_the_spectrum_moves():
    M = np.stack([_delta(len(FREQS), 40), _delta(len(FREQS), 120)], axis=1)
    f = spectral_flux(M, normalize=False)
    # two orthonormal unit spectra differ by sqrt(2)
    assert np.isclose(f[0], np.sqrt(2.0))


def test_flux_normalize_ignores_a_pure_level_change():
    base = np.abs(np.random.default_rng(1).standard_normal(len(FREQS)))
    M = np.stack([base, 5.0 * base], axis=1)       # same shape, louder
    assert np.isclose(spectral_flux(M, normalize=True)[0], 0.0, atol=1e-12)
    assert spectral_flux(M, normalize=False)[0] > 0.0


def test_flux_needs_a_2d_spectrogram():
    with pytest.raises(ValueError):
        spectral_flux(np.ones(len(FREQS)))


# --- flatness ---------------------------------------------------------------

def test_flatness_of_flat_spectrum_is_one():
    m = np.full(len(FREQS), 3.0)                   # every bin equal
    assert np.isclose(spectral_flatness(m), 1.0)


def test_flatness_of_a_tone_is_near_zero():
    m = _delta(len(FREQS), 50) + 1e-6             # one big bin, tiny floor
    assert spectral_flatness(m) < 0.01


def test_flatness_noise_above_tone():
    rng = np.random.default_rng(2)
    noise = np.abs(rng.standard_normal(len(FREQS))) + 0.5
    tone = _delta(len(FREQS), 50) + 1e-3
    assert spectral_flatness(noise) > spectral_flatness(tone)


def test_flatness_per_frame_shape():
    M = np.stack([np.ones(len(FREQS)), _delta(len(FREQS), 10) + 1e-6], axis=1)
    fl = spectral_flatness(M)
    assert fl.shape == (2,)
    assert np.isclose(fl[0], 1.0) and fl[1] < 0.01


# --- bundle -----------------------------------------------------------------

def test_frame_features_bundle_matches_individual_calls():
    rng = np.random.default_rng(3)
    m = np.abs(rng.standard_normal(len(FREQS)))
    feats = frame_features(FREQS, m)
    assert np.isclose(feats["centroid"], spectral_centroid(FREQS, m))
    assert np.isclose(feats["spread"], spectral_spread(FREQS, m))
    assert np.isclose(feats["rolloff"], spectral_rolloff(FREQS, m))
    assert np.isclose(feats["flatness"], spectral_flatness(m))
