import os, logging, math
import numpy as np
import soundfile as sf
# scipy.signal import LAZY (chi dung trong ham) — giam thoi gian khoi dong
# engine (khong nap scipy+OpenBLAS ~70MB luc boot)
from app.config import settings
from app.core.vst_engine import (
    render_midi_events_to_audio,
    PluginManager,
    DecentSamplerManager,
    HAS_PEDALBOARD,
    HAS_PYFLUIDSYNTH,
    apply_preset_to_plugin,
)

logger = logging.getLogger(__name__)

UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts"

SYS_SOUNDFONTS = [
    ("GeneralUser_GS.sf2", "GeneralUser GS"),
    ("SGM_v2.01.sf2", "SGM v2.01"),
    ("SGM-V2.01.sf2", "SGM v2.01"),
]

def _find_sf2_path(sf_id: str) -> str:
    clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
    clean_lower = clean_id.lower()
    sf_lower = sf_id.lower()
    for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
        if not os.path.isdir(base_dir):
            continue
        for fname in os.listdir(base_dir):
            fbase, fext = os.path.splitext(fname)
            if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower):
                return os.path.join(base_dir, fname)
    # Thư mục user thêm qua Plugin Manager (plugin_dirs — Add Directory):
    # soundfont trong thư mục user phải render được (Windows thường dùng cách này)
    try:
        from app.core.vst_engine import _load_user_plugin_dirs
        for base_dir in _load_user_plugin_dirs():
            if not os.path.isdir(base_dir):
                continue
            for root, dirs, files in os.walk(base_dir):
                for fname in files:
                    fbase, fext = os.path.splitext(fname)
                    if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower):
                        return os.path.join(root, fname)
                dirs[:] = []  # không walk sâu
    except Exception:
        pass
    static_dir = os.path.join(settings.APP_DIR, "static", "soundfonts")
    if os.path.isdir(static_dir):
        for fname in os.listdir(static_dir):
            fbase, fext = os.path.splitext(fname)
            if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower):
                return os.path.join(static_dir, fname)
    return ""

def _find_default_sf2() -> str:
    for sf_name, _ in SYS_SOUNDFONTS:
        for base_dir in [SYSTEM_SF_DIR, UPLOAD_SF_DIR]:
            p = os.path.join(base_dir, sf_name)
            if os.path.exists(p):
                return p
    return ""

if HAS_PEDALBOARD:
    try:
        from pedalboard import Pedalboard, Gain, Chorus, Reverb
    except Exception:
        HAS_PEDALBOARD = False



class PythonRenderEngine:
    def __init__(self, sample_rate=44100):
        self.sample_rate = sample_rate

    def bars_to_samples(self, bars: float, bpm: float, time_sig_num: int) -> int:
        seconds_per_beat = 60.0 / max(20.0, bpm)
        seconds_per_bar = seconds_per_beat * time_sig_num
        return int(bars * seconds_per_bar * self.sample_rate)

    def resolve_file_path(self, url_or_id: str) -> str:
        if not url_or_id:
            return ""
        base = os.path.basename(url_or_id)
        # Check uploads directory
        p_uploads = os.path.join(settings.UPLOADS_DIR, base)
        if os.path.exists(p_uploads):
            return p_uploads
        # Check processed directory
        p_processed = os.path.join(settings.PROCESSED_DIR, base)
        if os.path.exists(p_processed):
            return p_processed
        # Check general storage directory
        p_storage = os.path.join(settings.STORAGE_DIR, base)
        if os.path.exists(p_storage):
            return p_storage
        # Direct check
        if os.path.exists(url_or_id):
            return url_or_id
        return url_or_id

    def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int, _cache: dict = None) -> np.ndarray:
        session_buffer = np.zeros((2, total_samples), dtype=np.float32)

        # Solo semantics: when any track is soloed, only soloed tracks sound.
        tracks = session.get("tracks", [])
        solo_ids = {t.get("id") for t in tracks if t.get("solo")}

        _channel_counter = 0

        for track in tracks:
            if solo_ids and track.get("id") not in solo_ids:
                continue
            track_type = track.get("type", "AUDIO")
            track_buffer = np.zeros((2, total_samples), dtype=np.float32)

            # Parse synth_engine struct (Task C) — fall back to flat fields
            se = track.get("synth_engine", {}) or {}
            instrument_id = se.get("plugin_id") or track.get("instrument_id", "") or track.get("instrument", "")
            instrument_source = se.get("type") or track.get("instrument_source", "soundfont")
            soundfont_bank = se.get("soundfont_bank") if se.get("soundfont_bank") is not None else track.get("soundfont_bank", 0)
            soundfont_program = se.get("soundfont_program") if se.get("soundfont_program") is not None else track.get("soundfont_program", 0)
            soundfont_id = se.get("soundfont_id") or track.get("soundfont_id", "")
            is_percussion = track.get("is_percussion", False) or (soundfont_bank == 128)
            midi_channel = 9 if is_percussion else (_channel_counter % 9)
            if not is_percussion:
                _channel_counter += 1

            for item in track.get("items", []):
                start_sample = self.bars_to_samples(item["start_bar"], bpm, time_sig_num)
                dur_samples = self.bars_to_samples(item["duration_bars"], bpm, time_sig_num)
                offset_sample = self.bars_to_samples(item["clip_start_offset_bars"], bpm, time_sig_num)

                item_type = item.get("type")
                if item_type == "AUDIO_ITEM":
                    source_data = item.get("source_data", {})
                    audio_url = source_data.get("audio_file_url", "")
                    resolved_path = self.resolve_file_path(audio_url)

                    if resolved_path and os.path.exists(resolved_path):
                        try:
                            audio_data, sr = sf.read(resolved_path, dtype='float32')
                            if sr != self.sample_rate:
                                # Proper resampling: previously a silent no-op that
                                # played 48kHz audio at the wrong speed/pitch.
                                from scipy.signal import resample_poly
                                g = math.gcd(sr, self.sample_rate)
                                audio_data = resample_poly(
                                    audio_data,
                                    up=self.sample_rate // g,
                                    down=sr // g,
                                    axis=-1,
                                )
                                sr = self.sample_rate
                            
                            # Handle channel mapping (Mono/Stereo)
                            if len(audio_data.shape) == 1:
                                audio_data = np.vstack([audio_data, audio_data])
                            else:
                                audio_data = audio_data.T # Shape: (channels, samples)
                            
                            # Trim source offset & duration
                            src_len = audio_data.shape[1]
                            if offset_sample < src_len:
                                actual_dur = min(dur_samples, src_len - offset_sample)
                                sliced_audio = audio_data[:, offset_sample : offset_sample + actual_dur]
                                
                                # Apply gain
                                gain_val = source_data.get("gain", 1.0)
                                sliced_audio = sliced_audio * gain_val

                                # Write to track buffer with boundaries
                                write_end = min(start_sample + sliced_audio.shape[1], total_samples)
                                actual_len = write_end - start_sample
                                if actual_len > 0:
                                    track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
                        except Exception as e:
                            logger.warning("[RenderEngine] Error reading audio file %s: %s", resolved_path, e)

                elif item_type == "MIDI_ITEM":
                    source_data = item.get("source_data", {})
                    notes = source_data.get("notes", [])
                    
                    # Convert to midi events required by vst_engine
                    midi_events = []
                    for note in notes:
                        note_start_bar = note["start_beat"] / time_sig_num
                        # Filter notes within the non-destructive visible window
                        offset_bar = item["clip_start_offset_bars"]
                        dur_bar = item["duration_bars"]
                        if note_start_bar >= offset_bar and note_start_bar < (offset_bar + dur_bar):
                            rel_bar_in_item = note_start_bar - offset_bar
                            target_global_bar = item["start_bar"] + rel_bar_in_item
                            midi_events.append({
                                "note": note["pitch"],
                                "start_beat": target_global_bar * time_sig_num,
                                "duration_beats": note["duration_beats"],
                                "velocity": int(note.get("velocity", 0.8) * 127)
                            })
                    
                    if midi_events:
                        try:
                            plugin_mgr = PluginManager()
                            vst = plugin_mgr.load_vst(instrument_id) if instrument_id else None

                            if instrument_source == "pianobook":
                                dspreset_path = track.get("dspreset_path", "")
                                if dspreset_path and os.path.exists(dspreset_path) and HAS_PEDALBOARD:
                                    from pedalboard import Pedalboard
                                    ds_manager = DecentSamplerManager()
                                    try:
                                        vst = ds_manager.create_decent_sampler_instance(dspreset_path)
                                        midi_messages = PluginManager.midi_events_to_messages(
                                            midi_events, bpm, self.sample_rate
                                        )
                                        total_needed = 0
                                        for ev in midi_events:
                                            end_sec = (ev.get("start_beat", 0) + ev.get("duration_beats", 1)) * (60.0 / bpm)
                                            dur_samples = int(end_sec * self.sample_rate)
                                            if dur_samples > total_needed:
                                                total_needed = dur_samples
                                        total_needed = max(total_needed, 1024)
                                        silent = np.zeros((2, total_needed), dtype=np.float32)
                                        # pedalboard >= 0.9: instrument KHÔNG vào Pedalboard container
                                        synth_buffer = vst(midi_messages, sample_rate=self.sample_rate,
                                                           duration=total_needed / float(self.sample_rate), num_channels=2)
                                    except Exception as e:
                                        logger.warning(f"[RenderEngine] DecentSampler/Pianobook error: {e}")
                                        synth_buffer = render_midi_events_to_audio(
                                            midi_events=midi_events, sr=self.sample_rate, bpm=bpm, instrument='synth'
                                        )
                                else:
                                    synth_buffer = render_midi_events_to_audio(
                                        midi_events=midi_events, sr=self.sample_rate, bpm=bpm, instrument='synth'
                                    )
                            elif vst and HAS_PEDALBOARD:
                                from pedalboard import Pedalboard
                                # Preset bridge Carla → pedalboard: preset_id
                                # (thư viện storage/presets), preset_path (file)
                                # hoặc preset_data (base64 .vstpreset nhúng trong
                                # project). Cùng sample rate → âm render = âm đã
                                # chỉnh trong Carla.
                                try:
                                    se2 = track.get("synth_engine", {}) or {}
                                    apply_preset_to_plugin(
                                        vst,
                                        preset_id=se2.get("preset_id") or track.get("preset_id"),
                                        preset_path=se2.get("preset_path") or track.get("preset_path"),
                                        preset_data_b64=se2.get("preset_data") or track.get("preset_data"),
                                    )
                                except Exception as e:
                                    logger.warning("[RenderEngine] Preset apply failed: %s", e)
                                midi_messages = PluginManager.midi_events_to_messages(
                                    midi_events, bpm, self.sample_rate,
                                    bank=soundfont_bank, program=soundfont_program
                                )
                                total_needed = 0
                                for ev in midi_events:
                                    end_sec = (ev.get("start_beat", 0) + ev.get("duration_beats", 1)) * (60.0 / bpm)
                                    dur_samples = int(end_sec * self.sample_rate)
                                    if dur_samples > total_needed:
                                        total_needed = dur_samples
                                total_needed = max(total_needed, 1024)
                                silent = np.zeros((2, total_needed), dtype=np.float32)
                                # pedalboard >= 0.9: instrument KHÔNG vào Pedalboard container
                                synth_buffer = vst(midi_messages, sample_rate=self.sample_rate,
                                                   duration=total_needed / float(self.sample_rate), num_channels=2)
                            elif soundfont_id or (instrument_id and instrument_id.startswith("sf_")):
                                sf_path = _find_sf2_path(soundfont_id or instrument_id)
                                # 3-level fallback: selected SF → default SF → oscillator synth
                                if not sf_path or not os.path.exists(sf_path) or not HAS_PYFLUIDSYNTH:
                                    if not sf_path or not os.path.exists(sf_path):
                                        logger.warning(f"[RenderEngine] SoundFont not found for {soundfont_id or instrument_id}, trying default")
                                    sf_path = _find_default_sf2() if HAS_PYFLUIDSYNTH else ""

                                if sf_path and os.path.exists(sf_path) and HAS_PYFLUIDSYNTH:
                                    import fluidsynth as _fs
                                    _settings = _fs.new_fluid_settings()
                                    _fs.fluid_settings_setnum(_settings, b'synth.sample-rate', float(self.sample_rate))
                                    _fl = _fs.new_fluid_synth(_settings)
                                    _fid = _fs.fluid_synth_sfload(_fl, sf_path.encode("utf-8"), 1)
                                    _fs.fluid_synth_program_select(_fl, midi_channel, _fid, soundfont_bank, soundfont_program)
                                    beat_sec = 60.0 / bpm
                                    total_sec = 0
                                    for ev in midi_events:
                                        end_sec = (ev.get("start_beat", 0) + ev.get("duration_beats", 1)) * beat_sec
                                        if end_sec > total_sec:
                                            total_sec = end_sec
                                    sf_total_samples = int((total_sec + 1.0) * self.sample_rate)
                                    midi_data = np.zeros((2, sf_total_samples), dtype=np.float32)
                                    _cursor = 0
                                    for ev in sorted(midi_events, key=lambda e: e.get("start_beat", 0)):
                                        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_sec
                                        dur_sec = dur_beats * beat_sec
                                        start_s = int(start_sec * self.sample_rate)
                                        dur_s = int(dur_sec * self.sample_rate)
                                        # Advance synth time by rendering silence
                                        if start_s > _cursor:
                                            gap = start_s - _cursor
                                            _fs.fluid_synth_write_s16_stereo(_fl, gap)
                                            _cursor = start_s
                                        # Start note
                                        _fs.fluid_synth_noteon(_fl, midi_channel, note, min(velocity, 127))
                                        block_s16 = _fs.fluid_synth_write_s16_stereo(_fl, dur_s)
                                        _fs.fluid_synth_noteoff(_fl, midi_channel, note)
                                        block = block_s16.astype(np.float32).reshape(-1, 2).T / 32768.0
                                        end_s = min(_cursor + block.shape[1], sf_total_samples)
                                        actual = end_s - _cursor
                                        if actual > 0 and block.shape[1] > 0:
                                            midi_data[:, _cursor:end_s] += block[:, :actual]
                                        _cursor = end_s
                                    synth_buffer = midi_data
                                    _fs.delete_fluid_synth(_fl)
                                else:
                                    if not HAS_PYFLUIDSYNTH:
                                        logger.warning("[RenderEngine] pyfluidsynth not available, falling back to oscillator synth")
                                    synth_buffer = render_midi_events_to_audio(
                                        midi_events=midi_events, sr=self.sample_rate, bpm=bpm, instrument='synth'
                                    )
                            else:
                                synth_buffer = render_midi_events_to_audio(
                                    midi_events=midi_events, sr=self.sample_rate, bpm=bpm, instrument='synth'
                                )
                            actual_len = min(synth_buffer.shape[1], total_samples)
                            track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
                        except Exception as e:
                            logger.warning("[RenderEngine] Error rendering MIDI: %s", e)

                elif item_type == "SECTION_ITEM":
                    source_data = item.get("source_data", {})
                    sec_id = source_data.get("referenced_section_id", "")
                    if sec_id and sec_id in section_store:
                        # Render nested section recursively, cached per section id
                        # so repeated section instances don't re-render every time.
                        cache = _cache if _cache is not None else {}
                        if sec_id in cache:
                            sec_buffer = cache[sec_id]
                        else:
                            sec_buffer = self.render_session_container(
                                session=section_store[sec_id],
                                section_store=section_store,
                                bpm=bpm,
                                time_sig_num=time_sig_num,
                                total_samples=total_samples,
                                _cache=cache,
                            )
                            cache[sec_id] = sec_buffer
                        
                        # Apply non-destructive crop/slicing on section buffer
                        if offset_sample < total_samples:
                            actual_dur = min(dur_samples, total_samples - offset_sample)
                            sliced_sec = sec_buffer[:, offset_sample : offset_sample + actual_dur]

                            # Write to track buffer
                            write_end = min(start_sample + sliced_sec.shape[1], total_samples)
                            actual_len = write_end - start_sample
                            if actual_len > 0:
                                track_buffer[:, start_sample:write_end] += sliced_sec[:, :actual_len]

            # Apply Track Gain (via Pedalboard or fallback)
            vol_db = track.get("volume_db", 0.0)
            pan = track.get("pan", 0.0)
            mute = track.get("mute", False)

            if mute:
                continue

            # Apply Track FX (Chorus or Reverb)
            fx_type = track.get("fx_type")
            if fx_type == "chorus":
                if HAS_PEDALBOARD:
                    try:
                        board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
                        track_buffer = board(track_buffer, sample_rate=self.sample_rate)
                    except Exception as e:
                        logger.warning("[RenderEngine] Pedalboard Chorus failed: %s", e)
                else:
                    # Fallback chorus using simple LFO delay modulation in scipy/numpy
                    try:
                        # 1.5 Hz sine LFO, modulating delay time between 15ms and 25ms (average 20ms)
                        lfo = 0.020 + 0.005 * np.sin(2 * np.pi * 1.5 * np.arange(total_samples) / self.sample_rate)
                        dry = track_buffer * 0.6
                        wet = np.zeros_like(track_buffer)
                        for ch in range(2):
                            indices = np.arange(total_samples) - (lfo * self.sample_rate)
                            indices = np.clip(indices, 0, total_samples - 1).astype(np.int32)
                            wet[ch, :] = track_buffer[ch, indices]
                        track_buffer = dry + wet * 0.5
                    except Exception as e:
                        logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
            elif fx_type == "reverb":
                if HAS_PEDALBOARD:
                    try:
                        board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)])
                        track_buffer = board(track_buffer, sample_rate=self.sample_rate)
                    except Exception as e:
                        logger.warning("[RenderEngine] Pedalboard Reverb failed: %s", e)
                else:
                    # Fallback reverb using exponentially decaying noise room impulse response
                    try:
                        # Generate impulse response (decaying noise)
                        len_ir = int(self.sample_rate * 2.0)
                        t_ir = np.arange(len_ir) / self.sample_rate
                        decay = np.exp(-t_ir / 0.5)
                        ir_l = (np.random.rand(len_ir) * 2 - 1) * decay
                        ir_r = (np.random.rand(len_ir) * 2 - 1) * decay
                        
                        dry = track_buffer * 0.6
                        wet = np.zeros_like(track_buffer)
                        for ch in range(2):
                            ir = ir_l if ch == 0 else ir_r
                            # Convolve
                            from scipy.signal import convolve
                            conv = convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
                            wet[ch, :] = conv
                        track_buffer = dry + wet * 0.4
                    except Exception as e:
                        logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)

            # Process track volume
            if HAS_PEDALBOARD:
                try:
                    board = Pedalboard([Gain(gain_db=vol_db)])
                    processed_track = board(track_buffer, sample_rate=self.sample_rate)
                except Exception:
                    gain_linear = 10 ** (vol_db / 20.0)
                    processed_track = track_buffer * gain_linear
            else:
                gain_linear = 10 ** (vol_db / 20.0)
                processed_track = track_buffer * gain_linear

            # Apply Track Pan
            if pan != 0.0:
                # Constant power panning
                theta = ((np.clip(pan, -1.0, 1.0) + 1.0) / 2.0) * (np.pi / 2.0)
                processed_track[0, :] *= np.cos(theta)
                processed_track[1, :] *= np.sin(theta)

            # Mix track to session
            session_buffer += processed_track

        return session_buffer

    def render_project(self, project_json: dict, output_filepath: str):
        bpm = project_json["metadata"]["bpm"]
        time_sig_num = project_json["metadata"].get("time_signature_numerator", 4)
        main_session = project_json["main_session"]
        section_store = project_json.get("section_store", {})

        # Compute total project samples
        total_bars = main_session.get("length_bars", 16.0)
        total_samples = self.bars_to_samples(total_bars, bpm, time_sig_num)
        
        # Render main session
        master_buffer = self.render_session_container(
            session=main_session,
            section_store=section_store,
            bpm=bpm,
            time_sig_num=time_sig_num,
            total_samples=total_samples,
            _cache={},
        )

        # Normalization to prevent clipping
        max_peak = np.max(np.abs(master_buffer))
        if max_peak > 1.0:
            master_buffer /= max_peak

        # Write final output file
        sf.write(output_filepath, master_buffer.T, self.sample_rate)
        return output_filepath
