# SonicForge Studio VST / VSTi Engine Service
import os
import numpy as np
import functools
from ctypes import c_int, c_char_p, c_void_p

def midi_note_to_freq(note_number: int) -> float:
    return 440.0 * (2.0 ** ((note_number - 69) / 12.0))

def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray:
    beat_duration_sec = 60.0 / max(30.0, bpm)
    max_duration_sec = 2.0
    for event in midi_events:
        start_beat = event.get('start_beat', 0.0)
        dur_beats = event.get('duration_beats', 1.0)
        end_sec = (start_beat + dur_beats) * beat_duration_sec
        if end_sec > max_duration_sec:
            max_duration_sec = end_sec
    total_samples = int((max_duration_sec + 0.5) * sr)
    out_l = np.zeros(total_samples, dtype=np.float32)
    out_r = np.zeros(total_samples, dtype=np.float32)
    for event in midi_events:
        note = event.get('note', 60)
        velocity = event.get('velocity', 100) / 127.0
        start_beat = event.get('start_beat', 0.0)
        dur_beats = event.get('duration_beats', 1.0)
        start_sample = int(start_beat * beat_duration_sec * sr)
        dur_samples = int(dur_beats * beat_duration_sec * sr)
        end_sample = min(total_samples, start_sample + dur_samples)
        actual_len = end_sample - start_sample
        if actual_len <= 0 or start_sample >= total_samples:
            continue
        freq = midi_note_to_freq(note)
        t = np.arange(actual_len) / float(sr)
        tone = 0.6 * np.sin(2 * np.pi * freq * t) + 0.3 * np.sin(2 * np.pi * freq * 2 * t) + 0.1 * np.sin(2 * np.pi * freq * 3 * t)
        attack = min(int(0.01 * sr), actual_len // 4)
        release = min(int(0.05 * sr), actual_len // 4)
        env = np.ones(actual_len, dtype=np.float32)
        if attack > 0:
            env[:attack] = np.linspace(0.0, 1.0, attack)
        if release > 0:
            env[-release:] = np.linspace(1.0, 0.0, release)
        signal = tone * env * velocity
        out_l[start_sample:end_sample] += signal
        out_r[start_sample:end_sample] += signal
    max_peak = max(np.max(np.abs(out_l)), np.max(np.abs(out_r)))
    if max_peak > 1.0:
        out_l /= max_peak
        out_r /= max_peak
    return np.vstack([out_l, out_r])

def check_pedalboard_safe():
    import subprocess, sys
    try:
        res = subprocess.run(
            [sys.executable, "-c", "import pedalboard"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
        )
        return res.returncode == 0
    except Exception:
        return False

def check_pyfluidsynth_safe():
    import subprocess, sys
    try:
        res = subprocess.run(
            [sys.executable, "-c", "import fluidsynth"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
        )
        return res.returncode == 0
    except Exception:
        return False

HAS_PEDALBOARD = check_pedalboard_safe()
HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe()

def ensure_pyfluidsynth():
    global HAS_PYFLUIDSYNTH
    if not HAS_PYFLUIDSYNTH:
        HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe()
    return HAS_PYFLUIDSYNTH

if HAS_PEDALBOARD:
    try:
        from pedalboard import VST3Plugin, Pedalboard, Gain, MidiMessage
    except Exception:
        HAS_PEDALBOARD = False

if HAS_PYFLUIDSYNTH:
    try:
        import fluidsynth
    except Exception:
        HAS_PYFLUIDSYNTH = False


# ── Module-level caches ──
_FLUID_CACHE = {}  # path → (fluidsynth.FluidSynth, refcount)
_PLUGIN_MANAGER_INSTANCE = None
_PLUGIN_MANAGER_ARGS = None
_SF_INSTRUMENTS_CACHE = {}  # sf_id → list[presets]

def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None) -> "PluginManager":
    """Singleton: reuse PluginManager when args match, else create new."""
    global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS
    args = (vst_dir, sf_dir, upload_sf_dir)
    if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args:
        return _PLUGIN_MANAGER_INSTANCE
    _PLUGIN_MANAGER_ARGS = args
    _PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir)
    return _PLUGIN_MANAGER_INSTANCE

def load_soundfont_cached(path: str):
    """Return a cached FluidSynth instance for path, incrementing refcount."""
    global _FLUID_CACHE
    if not HAS_PYFLUIDSYNTH:
        return None
    if path in _FLUID_CACHE:
        fl, ref = _FLUID_CACHE[path]
        _FLUID_CACHE[path] = (fl, ref + 1)
        return fl
    try:
        import fluidsynth
        fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5)
        font_id = fl.sfload(path)
        fl.program_select(0, font_id, 0, 0)
        _FLUID_CACHE[path] = (fl, 1)
        return fl
    except Exception:
        return None

def release_soundfont(path: str):
    """Decrement refcount; delete FluidSynth when count reaches 0."""
    global _FLUID_CACHE
    if path not in _FLUID_CACHE:
        return
    fl, ref = _FLUID_CACHE[path]
    if ref <= 1:
        try:
            fl.delete()
        except Exception:
            pass
        del _FLUID_CACHE[path]
    else:
        _FLUID_CACHE[path] = (fl, ref - 1)

class PluginManager:
    def __init__(self, vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None):
        self.vst_dir = vst_dir
        self.sf_dir = sf_dir
        self.upload_sf_dir = upload_sf_dir
        self._sf_scan_cache = None  # cache for _scan_soundfonts()

    def _scan_plugins(self) -> dict:
        plugins = {}
        if not os.path.isdir(self.vst_dir):
            return plugins
        for root, dirs, files in os.walk(self.vst_dir):
            for file in files:
                if file.endswith(".vst3") or file.endswith(".so"):
                    plugin_path = os.path.join(root, file)
                    plugin_name = os.path.splitext(file)[0]
                    plugins[plugin_name] = plugin_path
        return plugins

    def _scan_soundfonts(self) -> list:
        sfonts = []
        dirs = [self.sf_dir]
        if self.upload_sf_dir and self.upload_sf_dir != self.sf_dir:
            dirs.append(self.upload_sf_dir)
        
        # Load metadata cache for upload soundfonts
        meta_cache = {}
        if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
            for f in os.listdir(self.upload_sf_dir):
                if f.endswith(".meta"):
                    try:
                        import json
                        with open(os.path.join(self.upload_sf_dir, f), "r") as mf:
                            meta_cache[os.path.splitext(f)[0]] = json.load(mf)
                    except Exception:
                        pass
        
        for d in dirs:
            if not os.path.isdir(d):
                continue
            for f in os.listdir(d):
                if f.endswith(".sf2") or f.endswith(".sf3"):
                    base_id = os.path.splitext(f)[0]
                    meta = meta_cache.get(base_id, None)
                    if meta:
                        display_name = meta.get("original_name", f)
                    else:
                        # Generate a friendly name from UUID: truncate to first 8 chars
                        short_id = base_id[:8] if len(base_id) > 8 else base_id
                        display_name = f"SoundFont_{short_id}"
                    sfonts.append({"id": base_id, "name": display_name, "file": f, "display": os.path.splitext(display_name)[0][:40]})
        return sfonts

    def load_vst(self, plugin_name: str, preset_data: dict = None):
        if not HAS_PEDALBOARD:
            return None
        plugins = self._scan_plugins()
        if plugin_name not in plugins:
            return None
        path = plugins[plugin_name]
        vst = VST3Plugin(path)
        if preset_data:
            for k, v in preset_data.items():
                try:
                    setattr(vst, k, v)
                except Exception:
                    pass
        return vst

    def _scan_soundfonts_cached(self):
        if self._sf_scan_cache is not None:
            return self._sf_scan_cache
        self._sf_scan_cache = self._scan_soundfonts()
        return self._sf_scan_cache

    def load_soundfont(self, path: str):
        return load_soundfont_cached(path)

    def list_soundfont_instruments(self, sf_id: str):
        if not ensure_pyfluidsynth():
            return []
        if sf_id in _SF_INSTRUMENTS_CACHE:
            return _SF_INSTRUMENTS_CACHE[sf_id]
        search_dirs = []
        if os.path.isdir(self.sf_dir):
            search_dirs.append(self.sf_dir)
        if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir) and self.upload_sf_dir != self.sf_dir:
            search_dirs.append(self.upload_sf_dir)
        for d in search_dirs:
            for f in os.listdir(d):
                if not (f.endswith(".sf2") or f.endswith(".sf3")):
                    continue
                base = os.path.splitext(f)[0]
                if base == sf_id or base == sf_id.replace("sf_", ""):
                    path = os.path.join(d, f)
                    try:
                        import fluidsynth
                        fl = fluidsynth.Synth()
                        fid = fl.sfload(path)
                        if fid < 0:
                            fl.delete()
                            continue
                        presets = []
                        _fl = fluidsynth._fl
                        _fl.fluid_synth_get_sfont_by_id.restype = c_void_p
                        _fl.fluid_preset_get_name.restype = c_char_p
                        _fl.fluid_sfont_get_preset.restype = c_void_p
                        sfont_ptr = _fl.fluid_synth_get_sfont_by_id(c_void_p(fl.synth), c_int(fid))
                        if sfont_ptr:
                            for bank in range(0, 2):
                                for prog_num in range(0, 128):
                                    try:
                                        preset = fluidsynth.fluid_sfont_get_preset(sfont_ptr, c_int(bank), c_int(prog_num))
                                    except Exception:
                                        break
                                    if preset:
                                        name_ptr = fluidsynth.fluid_preset_get_name(preset)
                                        if name_ptr:
                                            name_val = c_char_p(name_ptr).value
                                            if name_val:
                                                presets.append({
                                                    "bank": bank,
                                                    "program": prog_num,
                                                    "name": name_val.decode("utf-8", errors="replace")
                                                })
                        fl.delete()
                        _SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
                        return presets[:256]
                    except Exception:
                        import traceback; traceback.print_exc()
        _SF_INSTRUMENTS_CACHE[sf_id] = []
        return []

    def list_available(self) -> dict:
        return {
            "vst_instruments": [
                {"id": k, "name": k, "type": "VST3", "has_native_support": HAS_PEDALBOARD}
                for k in self._scan_plugins().keys()
            ],
            "soundfonts": self._scan_soundfonts()
        }

    @staticmethod
    def midi_events_to_messages(midi_events: list, bpm: float, sr: int) -> list:
        if not HAS_PEDALBOARD:
            return []
        beat_duration_sec = 60.0 / max(30.0, bpm)
        messages = []
        for ev in midi_events:
            note = ev.get("note", 60)
            velocity = ev.get("velocity", 100)
            start_beat = ev.get("start_beat", 0.0)
            dur_beats = ev.get("duration_beats", 1.0)
            start_sec = start_beat * beat_duration_sec
            dur_sec = dur_beats * beat_duration_sec
            sample_offset = int(start_sec * sr)
            end_sample_offset = int((start_sec + dur_sec) * sr)
            messages.append(MidiMessage(note_on=note, velocity=velocity, sample_offset=sample_offset))
            messages.append(MidiMessage(note_off=note, velocity=0, sample_offset=end_sample_offset))
        return messages

    @staticmethod
    def validate_sf2_header(data: bytes) -> bool:
        if len(data) < 12:
            return False
        if data[0:4] != b'RIFF':
            return False
        if data[8:12] != b'sfbk':
            return False
        return True
