"""Signal building blocks + their endpoints (#40) — onsets, waveform, analyze.

Torch-free, real DSP on synth clips. conftest sets paths + the dev fallback.

    cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/test_signal.py -q
"""
import io
import sys
import tempfile
from pathlib import Path

import numpy as np
import soundfile as sf
from fastapi.testclient import TestClient

import app as APP
from engines import ears, signal

client = TestClient(APP.app)
SR = 22050


def _click_track(bpm=120, secs=2.0):
    """A simple pulse train so onset detection has real hits to find."""
    n = int(SR * secs)
    y = np.zeros(n, dtype="float32")
    step = int(SR * 60 / bpm)
    for i in range(0, n, step):
        y[i:i + 200] += np.hanning(min(200, n - i)).astype("float32")
    return y


def _wav_bytes(y, sr=SR):
    buf = io.BytesIO()
    sf.write(buf, y, sr, format="WAV")
    return buf.getvalue()


# ── engine ──────────────────────────────────────────────────────────────────
def test_onsets_finds_pulses():
    p = Path(tempfile.mktemp(suffix=".wav"))
    sf.write(str(p), _click_track(bpm=120), SR)
    r = signal.onsets(str(p))
    assert r["n_onsets"] >= 3 and r["tempo_bpm"] > 0
    assert all(0 <= t <= r["duration_s"] + 0.1 for t in r["onsets"])
    assert r["engine"] == "light"


def test_waveform_shape_and_range():
    p = Path(tempfile.mktemp(suffix=".wav"))
    t = np.linspace(0, 1.5, int(SR * 1.5), endpoint=False)
    sf.write(str(p), (0.5 * np.sin(2 * np.pi * 220 * t)).astype("float32"), SR)
    r = signal.waveform(str(p), bins=256)
    assert r["bins"] == 256 and len(r["peaks"]) == 256 and len(r["rms"]) == 256
    assert all(-1.0 <= lo <= hi <= 1.0 for lo, hi in r["peaks"])
    assert max(r["rms"]) <= 1.0 and min(r["rms"]) >= 0.0


def test_signal_pulls_no_torch():
    p = Path(tempfile.mktemp(suffix=".wav"))
    sf.write(str(p), _click_track(), SR)
    signal.onsets(str(p)); signal.waveform(str(p), bins=64)
    assert "torch" not in sys.modules


# ── endpoints ─────────────────────────────────────────────────────────────────
GW = lambda scope="*": {"X-Tenant": "hexa", "X-Scopes": "api:audio:*"}


def test_onsets_endpoint_and_cache():
    files = {"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")}
    r1 = client.post("/onsets", files=files, headers=GW("onsets"))
    assert r1.status_code == 200 and r1.headers["X-Douanier-Cache"] == "miss"
    assert "onsets" in r1.json() and r1.json()["n_onsets"] >= 3
    r2 = client.post("/onsets", files={"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")},
                     headers=GW("onsets"))
    assert r2.headers["X-Douanier-Cache"] == "hit"


def test_waveform_endpoint():
    files = {"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")}
    r = client.post("/waveform?bins=128", files=files, headers=GW("waveform"))
    assert r.status_code == 200
    body = r.json()
    assert body["bins"] == 128 and len(body["peaks"]) == 128


def test_analyze_composite(monkeypatch):
    # stub the (clap) emotion engine; light features/sample run for real
    monkeypatch.setattr(ears, "emotion_read",
                        lambda p: {"valence": 0.1, "arousal": 0.2, "engine": "light"})
    files = {"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")}
    r = client.post("/analyze", files=files, headers=GW("analyze"))
    assert r.status_code == 200
    body = r.json()
    assert "emotion" in body and "features" in body and "sample" in body
    assert "spectral_centroid" in body["features"]


def test_signal_scope_enforced():
    # api:audio:features doesn't cover /onsets → 403 (path-derived scope).
    r = client.post("/onsets", files={"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")},
                    headers={"X-Tenant": "hexa", "X-Scopes": "api:audio:features"})
    assert r.status_code == 403
