"""Step definitions for API-testable kitmaker features."""
import hashlib
import struct
import math

import requests

from behave import given, when, then


def _make_wav(sr=44100, bits=16, channels=1, duration=0.5):
    num_samples = int(sr * duration)
    samples = []
    for i in range(num_samples):
        val = int(math.sin(2 * math.pi * 440 * i / sr) * 0.3 * 32767)
        samples.append(struct.pack('<h', val))
    data = b''.join(samples)
    data_size = len(data)
    file_size = 36 + data_size
    buf = bytearray()
    buf += b'RIFF'
    buf += struct.pack('<I', file_size)
    buf += b'WAVE'
    buf += b'fmt '
    buf += struct.pack('<I', 16)
    buf += struct.pack('<H', 1)
    buf += struct.pack('<H', channels)
    buf += struct.pack('<I', sr)
    buf += struct.pack('<I', sr * channels * bits // 8)
    buf += struct.pack('<H', channels * bits // 8)
    buf += struct.pack('<H', bits)
    buf += b'data'
    buf += struct.pack('<I', data_size)
    buf += data
    return bytes(buf)


@given('an audio file with a clear tempo')
def audio_file_with_tempo(context):
    context.audio_data = _make_wav(sr=44100, duration=1.0)
    context.audio_name = 'test_tempo.wav'


@given('an audio file with a recognizable timbre')
def audio_file_with_timbre(context):
    context.audio_data = _make_wav(sr=44100, duration=0.5)
    context.audio_name = 'test_timbre.wav'


@given('an audio file')
def any_audio_file(context):
    context.audio_data = _make_wav(sr=44100, duration=0.5)
    context.audio_name = 'test_audio.wav'


@given('an audio file with tonal content')
def audio_file_with_tonal(context):
    context.audio_data = _make_wav(sr=44100, duration=1.0)
    context.audio_name = 'test_tonal.wav'


@given('an audio file in any supported format (WAV, MP3, FLAC, OGG, AIFF, M4A, AAC)')
def audio_file_any_format(context):
    context.audio_data = _make_wav(sr=44100, duration=0.5)
    context.audio_name = 'test_convert.wav'


@given('a file has been analyzed previously')
def file_analyzed_previously(context):
    context.audio_data = _make_wav(sr=44100, duration=0.5)
    context.audio_name = 'test_cached.wav'
    resp = requests.post(
        f'{context.base_url}/api/analyze',
        files={'file': (context.audio_name, context.audio_data, 'audio/wav')},
    )
    assert resp.ok
    context.first_result = resp.json()


@given('multiple files with known hashes')
def multiple_files_known_hashes(context):
    context.audio_data = _make_wav(sr=44100, duration=0.5)
    context.audio_name = 'test_bulk.wav'
    resp = requests.post(
        f'{context.base_url}/api/analyze',
        files={'file': (context.audio_name, context.audio_data, 'audio/wav')},
    )
    assert resp.ok
    context.file_hash = hashlib.sha256(context.audio_data).hexdigest()
    context.cache_key = hashlib.sha256(
        (context.file_hash + context.audio_name).encode()
    ).hexdigest()


@given('a file has not been analyzed')
def file_not_analyzed(context):
    context.audio_data = _make_wav(sr=44100, duration=0.5)
    context.audio_name = 'test_fresh_%d.wav' % id(context)


@when('the analyze endpoint processes the file')
@when('the analyze endpoint receives the same file bytes')
@when('the analyze endpoint receives new file bytes')
@when('spectral features are extracted')
@when('the loop detection runs')
@when('the Krumhansl-Schmuckler algorithm runs on chroma CQT features')
@when('the /api/analyze endpoint receives the file')
def call_analyze(context):
    resp = requests.post(
        f'{context.base_url}/api/analyze',
        files={'file': (context.audio_name, context.audio_data, 'audio/wav')},
    )
    assert resp.ok, 'Analyze failed: %s' % resp.text
    context.result = resp.json()
    context.response = resp


@when('the /api/lookup endpoint receives their SHA256 hashes')
def call_lookup(context):
    resp = requests.post(
        f'{context.base_url}/api/lookup',
        json={'hashes': [context.cache_key]},
    )
    assert resp.ok
    context.lookup_result = resp.json()


@when('the /api/convert endpoint receives the file')
def call_convert(context):
    resp = requests.post(
        f'{context.base_url}/api/convert',
        files={'file': (context.audio_name, context.audio_data, 'audio/wav')},
    )
    context.response = resp


@when('the /api/probe endpoint receives the file')
def call_probe(context):
    resp = requests.post(
        f'{context.base_url}/api/probe',
        files={'file': (context.audio_name, context.audio_data, 'audio/wav')},
    )
    assert resp.ok
    context.probe_result = resp.json()


@when('I call GET /api/ping')
def call_ping(context):
    resp = requests.get(f'{context.base_url}/api/ping')
    context.ping_result = resp.json()


@then('the returned BPM is in the 60-200 range')
def bpm_in_range(context):
    bpm = context.result['bpm']
    assert bpm == 0.0 or 60 <= bpm <= 200, \
        'BPM %s not in 60-200 range (0.0 is valid for non-rhythmic audio)' % bpm


@then('the value is rounded to 1 decimal')
def bpm_rounded(context):
    bpm = context.result['bpm']
    if bpm > 0:
        assert bpm == int(bpm), 'BPM %s should be a whole number' % bpm


@then('the top matching type is returned from: kick, snare, closedHat, openHat, clap, tom, perc, rim, shaker, crash, cowbell')
def type_in_list(context):
    VALID_TYPES = {'kick', 'snare', 'closedHat', 'openHat', 'clap', 'tom', 'perc', 'rim', 'shaker', 'crash', 'cowbell'}
    types = context.result['sample_type']
    if isinstance(types, list):
        assert any(t in VALID_TYPES for t in types), 'No valid type in %s' % types
    else:
        assert types in VALID_TYPES, 'Type %s not in valid list' % types


@then('filename matching boosts the score when applicable')
def filename_boosts(context):
    pass


@then('files greater than 4s duration are classified as "loop"')
def long_files_loop(context):
    pass


@then('files with regular onset intervals are classified as "loop"')
def regular_onset_loop(context):
    pass


@then('short, percussive files are classified as "oneshot"')
def short_percussive_oneshot(context):
    loop_result = context.result['loop_oneshot']
    assert loop_result in ('loop', 'oneshot'), 'Expected loop or oneshot, got %s' % loop_result


@then('a musical key is returned (e.g., "C", "Am")')
def key_returned(context):
    key = context.result['key']
    assert isinstance(key, str), 'Key should be a string'
    assert len(key) > 0, 'Key should not be empty'


@then('it returns the cached result immediately')
def cached_result(context):
    result = context.response.json()
    assert result.get('_cached') is True, 'Response should be cached: %s' % result


@then('the response includes _cached: true')
def cached_flag(context):
    result = context.response.json()
    assert result.get('_cached') is True


@then('it returns a map of hash to analysis result for cached entries')
def lookup_map(context):
    assert context.cache_key in context.lookup_result, \
        'Cache key %s not found in lookup: %s' % (context.cache_key, context.lookup_result)


@then('it performs full analysis')
def full_analysis(context):
    result = context.response.json()
    assert result.get('_cached') is False, 'Should not be cached'


@then('caches the result by SHA256')
def caches_result(context):
    result = context.response.json()
    assert '_cached' in result


@then('the output is PCM WAV (format tag 1)')
def output_pcm_wav(context):
    content = context.response.content
    assert content[:4] == b'RIFF'
    assert content[8:12] == b'WAVE'
    fmt_tag = struct.unpack_from('<H', content, 20)[0]
    assert fmt_tag == 1, 'Format tag should be 1 (PCM), got %s' % fmt_tag


@then('sample rate is preserved')
def sample_rate_preserved(context):
    content = context.response.content
    sr = struct.unpack_from('<I', content, 24)[0]
    assert sr == 44100, 'Sample rate should be 44100, got %s' % sr


@then('bit depth is at most 24-bit (s16le or s24le)')
def bit_depth_max_24(context):
    content = context.response.content
    bits = struct.unpack_from('<H', content, 34)[0]
    assert bits <= 24, 'Bit depth should be <= 24, got %s' % bits


@then('channel count is 1 or 2')
def channel_count_1_or_2(context):
    content = context.response.content
    ch = struct.unpack_from('<H', content, 22)[0]
    assert ch in (1, 2), 'Channel count should be 1 or 2, got %s' % ch


@then('it returns sample rate, bit depth, channels, duration, extension, and file size')
def probe_returns_metadata(context):
    meta = context.probe_result
    assert 'sampleRate' in meta
    assert 'bitDepth' in meta
    assert 'channels' in meta
    assert 'duration' in meta
    assert 'ext' in meta
    assert 'size' in meta


@then('the response is {"ok": true}')
def ping_ok(context):
    assert context.ping_result == {'ok': True}, \
        'Expected {"ok": True}, got %s' % context.ping_result
