#!/usr/bin/env python3
"""
Beat detector — vendored music-beat-detector (librosa-based, high accuracy)
when available, pure-Python stdlib as graceful fallback.

Usage:
    python3 scripts/beat-detector.py -i input.wav -o output.json [--fps 30] [--pretty]
"""
import argparse
import json
import math
import os
import struct
import subprocess
import sys
import wave

VENDOR_DIR = os.path.join(os.path.dirname(__file__), '..', 'vendor', 'music-beat-detector')


def detect_with_librosa(input_file, fps=30):
    """Use vendored music-beat-detector (requires librosa/numpy)."""
    env = os.environ.copy()
    env['PYTHONPATH'] = VENDOR_DIR + ':' + env.get('PYTHONPATH', '')
    try:
        proc = subprocess.run(
            [sys.executable, '-m', 'beat_detector.cli', input_file,
             '--fps', str(fps), '--pretty'],
            capture_output=True, text=True, env=env, timeout=60, cwd=VENDOR_DIR
        )
        if proc.returncode == 0 and proc.stdout.strip():
            return json.loads(proc.stdout.strip())
    except Exception:
        pass
    return None


def detect_pure_python(input_file, fps=30):
    """Pure-Python fallback: energy onset + autocorrelation. Less accurate BPM, heuristic structure."""
    with wave.open(input_file, 'rb') as wf:
        nchannels = wf.getnchannels()
        sampwidth = wf.getsampwidth()
        sr = wf.getframerate()
        nframes = wf.getnframes()
        frames = wf.readframes(nframes)

    if sampwidth == 2:
        fmt = f'<{nframes * nchannels}h'
        samples = struct.unpack(fmt, frames)
    else:
        raise ValueError(f"Unsupported sample width: {sampwidth}")

    if nchannels == 2:
        mono = [(samples[i] + samples[i + 1]) / 2.0 for i in range(0, len(samples), 2)]
    else:
        mono = list(samples)

    duration_ms = int(len(mono) / sr * 1000)

    # Compute onset envelope
    frame_size = sr // 50
    n_frames = len(mono) // frame_size
    envelope = []
    for i in range(n_frames):
        start = i * frame_size
        chunk = mono[start:start + frame_size]
        if not chunk:
            break
        energy = math.sqrt(sum(x * x for x in chunk) / len(chunk))
        envelope.append(energy)

    onset = [0.0] * len(envelope)
    for i in range(1, len(envelope)):
        diff = envelope[i] - envelope[i - 1]
        onset[i] = max(0.0, diff)

    smoothed = onset[:]
    smooth_window = 4
    for i in range(smooth_window, len(onset) - smooth_window):
        smoothed[i] = sum(onset[i - smooth_window:i + smooth_window]) / (2 * smooth_window)

    # BPM via autocorrelation
    best_bpm, best_score = 120, 0
    for bpm in range(60, 201):
        beat_interval_frames = int((60.0 / bpm) * sr / frame_size)
        if beat_interval_frames < 1:
            continue
        score = 0
        check_frames = min(n_frames, sr * 4 // frame_size)
        for i in range(beat_interval_frames, check_frames):
            score += smoothed[i] * (smoothed[i - beat_interval_frames] if i - beat_interval_frames >= 0 else 0)
        if score > best_score:
            best_score = score
            best_bpm = bpm

    bpm = float(best_bpm)

    # Beat timestamps
    beat_interval_ms = 60000.0 / bpm
    beats = []
    t_ms = 0.0
    beat_idx = 0
    while t_ms < duration_ms:
        beat_in_bar = (beat_idx % 4) + 1
        beats.append({
            "time_ms": int(t_ms),
            "frame": int(t_ms / 1000 * fps),
            "beat_in_bar": beat_in_bar,
            "energy_level": "high" if beat_in_bar == 1 else ("medium" if beat_in_bar == 3 else "low")
        })
        t_ms += beat_interval_ms
        beat_idx += 1

    # Heuristic structure
    quarter = duration_ms / 4
    segments = [
        {"type": "intro", "start_ms": 0, "end_ms": int(quarter)},
        {"type": "verse", "start_ms": int(quarter), "end_ms": int(quarter * 2)},
        {"type": "chorus", "start_ms": int(quarter * 2), "end_ms": int(quarter * 3)},
        {"type": "outro", "start_ms": int(quarter * 3), "end_ms": duration_ms},
    ]
    energy_peaks = [{"time_ms": b["time_ms"], "energy": 0.85} for i, b in enumerate(beats) if i % 16 == 0]
    silence_regions = [{"start_ms": s["end_ms"] - 500, "end_ms": s["end_ms"] + 500} for s in segments]

    return {
        "meta": {"file": input_file, "duration_ms": duration_ms, "sample_rate": sr,
                 "bpm": round(bpm, 1), "time_signature": "4/4"},
        "beats": beats,
        "structure": {"segments": segments, "energy_peaks": energy_peaks, "silence_regions": silence_regions}
    }


def main():
    parser = argparse.ArgumentParser(description="Beat detector (vendored librosa or pure-Python fallback)")
    parser.add_argument("-i", "--input", required=True, help="Input WAV file")
    parser.add_argument("-o", "--output", required=True, help="Output JSON file")
    parser.add_argument("--fps", type=int, default=30, help="Frame rate")
    parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
    args = parser.parse_args()

    if not os.path.exists(args.input):
        print(f"File not found: {args.input}", file=sys.stderr)
        sys.exit(1)

    result = detect_with_librosa(args.input, fps=args.fps)

    if result is None:
        print("librosa not installed — using pure-Python fallback (less accurate BPM, heuristic structure)")
        result = detect_pure_python(args.input, fps=args.fps)
    else:
        print("Using vendored music-beat-detector (librosa-based, high accuracy)")

    os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
    indent = 2 if args.pretty else None
    with open(args.output, 'w', encoding='utf-8') as f:
        json.dump(result, f, indent=indent, ensure_ascii=False)

    bpm = result["meta"]["bpm"]
    nbeats = len(result["beats"])
    print(f"Beat detection: {bpm} BPM, {nbeats} beats -> {args.output}")


if __name__ == "__main__":
    main()
