import os, sys, uuid, json, tempfile, subprocess, time as _time, threading
import numpy as np
import soundfile as sf
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional, Any
from app.config import settings
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH, apply_preset_to_plugin, render_soundfont_midi_to_audio
from app.core.render_engine import PythonRenderEngine, _find_sf2_path, _find_default_sf2
from app.core.soundfont_inspector import SoundFontInspector
from app.core.soundfont_converter import SoundFontConverter
from app.core.soundfont_scanner import SoundFontAutoScanner
from app.api.v1.auth import get_current_user, enforce_password_changed

router = APIRouter()

UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
os.makedirs(UPLOAD_SF_DIR, exist_ok=True)

SYSTEM_SF_DIR = settings.SOUNDFONT_DIR
SYSTEM_VST_DIR = settings.VST_DIR

# User dirs (Windows/macOS — người dùng chọn qua folder picker trong
# Plugins Manager). File global (không per-user): desktop app 1 user.
PLUGIN_DIRS_FILE = os.path.join(settings.STORAGE_DIR, "plugin_dirs.json")

def _load_plugin_dirs() -> dict:
    if os.path.exists(PLUGIN_DIRS_FILE):
        try:
            with open(PLUGIN_DIRS_FILE, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            pass
    return {}

def _save_plugin_dirs(dirs: dict):
    os.makedirs(settings.STORAGE_DIR, exist_ok=True)
    with open(PLUGIN_DIRS_FILE, "w", encoding="utf-8") as f:
        json.dump(dirs, f, indent=2)

def _effective_dirs() -> dict:
    """Env/.env (Docker) là base; user dirs (file) override nếu khai báo.

    plugin_dirs: list thư mục user thêm trong Plugins Manager (mỗi thư mục
    có thể chứa cả VST lẫn SoundFont — scan tự phân loại). Nếu user chưa
    khai báo → fallback env VST_DIR + SOUNDFONT_DIR.
    """
    user = _load_plugin_dirs()
    plugin_dirs = [d for d in (user.get("plugin_dirs") or []) if d]
    vst_dir = settings.VST_DIR
    soundfont_dir = settings.SOUNDFONT_DIR
    # Backward compat: file cũ lưu vst_dir/soundfont_dir riêng → gộp vào list.
    if not plugin_dirs:
        if user.get("vst_dir"):
            plugin_dirs.append(user["vst_dir"])
        if user.get("soundfont_dir"):
            plugin_dirs.append(user["soundfont_dir"])
    if not plugin_dirs:
        plugin_dirs = [vst_dir, soundfont_dir]
    return {
        "plugin_dirs": plugin_dirs,
        "vst_dir": vst_dir,
        "soundfont_dir": soundfont_dir,
        "plugin_dirs_user_set": bool(user.get("plugin_dirs")),
    }

class DirsRequest(BaseModel):
    vst_dir: Optional[str] = None
    soundfont_dir: Optional[str] = None
    plugin_dirs: Optional[list] = None

@router.get("/dirs")
async def get_plugin_dirs():
    return {"success": True, **(_effective_dirs())}

@router.post("/dirs")
async def save_plugin_dirs(req: DirsRequest, current_user: dict = Depends(get_current_user)):
    enforce_password_changed(current_user)
    user = _load_plugin_dirs()
    if req.plugin_dirs is not None:
        user["plugin_dirs"] = [d.strip() for d in req.plugin_dirs if d and d.strip()]
        # Xóa field cũ (đã gộp vào plugin_dirs) tránh nhầm lẫn
        user.pop("vst_dir", None)
        user.pop("soundfont_dir", None)
    else:
        if req.vst_dir is not None:
            user["vst_dir"] = req.vst_dir.strip()
        if req.soundfont_dir is not None:
            user["soundfont_dir"] = req.soundfont_dir.strip()
    _save_plugin_dirs(user)
    return {"success": True, **(_effective_dirs())}

@router.post("/scan")
async def scan_plugin_dirs(background_tasks: BackgroundTasks = None,
                           current_user: dict = Depends(get_current_user)):
    """Scan các dir hiệu lực (env + user override): cập nhật catalog
    soundfont (inspector/scanner) + liệt kê VST. Trả về danh sách riêng rẽ
    VST (vst_found) + SoundFont (soundfonts) theo từng thư mục user khai báo."""
    enforce_password_changed(current_user)
    dirs = _effective_dirs()
    plugin_dirs = dirs["plugin_dirs"]
    # SoundFont: quét + inspect vào catalog (scan_once dùng dir hiệu lực)
    scanner = SoundFontAutoScanner(system_sf_dirs=plugin_dirs, upload_sf_dir=UPLOAD_SF_DIR)
    if background_tasks:
        background_tasks.add_task(scanner.scan_once)
    else:
        scanner.scan_once()
    catalog = scanner.get_catalog()
    # VST + SoundFont: walk từng thư mục, phân loại riêng rẽ theo extension
    vst_found = []
    sf_found = []
    for d in plugin_dirs:
        if not os.path.isdir(d):
            continue
        for root, dirs, files in os.walk(d):
            # Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong)
            for sub in list(dirs):
                if sub.lower().endswith(".vst3"):
                    vst_found.append({"name": os.path.splitext(sub)[0],
                                      "path": os.path.join(root, sub),
                                      "dir": d,
                                      "type": "VST3"})
            for f in files:
                low = f.lower()
                if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
                    vst_found.append({"name": os.path.splitext(f)[0],
                                      "path": os.path.join(root, f),
                                      "dir": d,
                                      "type": "VST3" if low.endswith(".vst3") else "VST2"})
                elif low.endswith(".sf2") or low.endswith(".sf3"):
                    sf_found.append({"name": os.path.splitext(f)[0],
                                     "path": os.path.join(root, f),
                                     "dir": d})
    return {
        "success": True,
        "plugin_dirs": plugin_dirs,
        "vst_found": vst_found,
        "vst_count": len(vst_found),
        "soundfonts": sf_found,
        "soundfont_count": len(sf_found),
    }

_inspector = None
_scanner = None

def get_inspector():
    global _inspector
    if _inspector is None:
        d = _effective_dirs()
        _inspector = SoundFontInspector(d["plugin_dirs"][0] if d["plugin_dirs"] else settings.SOUNDFONT_DIR,
                                        upload_sf_dir=UPLOAD_SF_DIR)
    return _inspector

def get_scanner():
    global _scanner
    if _scanner is None:
        d = _effective_dirs()
        _scanner = SoundFontAutoScanner(system_sf_dirs=d["plugin_dirs"], upload_sf_dir=UPLOAD_SF_DIR)
        _scanner.scan_once()
    return _scanner


@router.get("/available")
async def list_plugins(current_user: dict = Depends(get_current_user)):
    d = _effective_dirs()
    pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR, extra_vst_dirs=d["plugin_dirs"])
    avail = pm.list_available()
    # Gộp VST từ plugin_dirs user đã scan — list_available() CHỈ quét vst_dir
    # env (mặc định /opt/daw_engine/vst3) → Synth dropdown không thấy VSTi mà
    # Plugin Manager đã scan trong thư mục user chọn (bug: nút Synth rỗng).
    extra = _scan_vst_in_dirs(d["plugin_dirs"])
    by_id = {v["id"]: v for v in avail["vst_instruments"]}
    for v in extra:
        by_id.setdefault(v["id"], v)
    avail["vst_instruments"] = list(by_id.values())
    # Tương tự cho SOUNDFONT: gộp .sf2/.sf3 từ plugin_dirs user — nếu không,
    # nút Synth không liệt kê instrument của soundfont trong thư mục user thêm.
    sf_by_id = {s["id"]: s for s in avail["soundfonts"]}
    for s in _scan_soundfonts_in_dirs(d["plugin_dirs"]):
        sf_by_id.setdefault(s["id"], s)
    avail["soundfonts"] = list(sf_by_id.values())
    return avail


def _scan_soundfonts_in_dirs(dirs: list) -> list:
    """Walk các thư mục (plugin_dirs user) → danh sách soundfont .sf2/.sf3
    (id = tên file; name từ .meta nếu có — cùng format PluginManager)."""
    found = {}
    for d in dirs:
        if not d or not os.path.isdir(d):
            continue
        for root, dirs2, files in os.walk(d):
            for f in files:
                low = f.lower()
                if not (low.endswith(".sf2") or low.endswith(".sf3")):
                    continue
                base_id = os.path.splitext(f)[0]
                if base_id in found:
                    continue
                meta = {}
                mp = os.path.join(root, os.path.splitext(f)[0] + ".meta")
                if os.path.isfile(mp):
                    try:
                        with open(mp, "r", encoding="utf-8") as mf:
                            meta = json.load(mf)
                    except Exception:
                        meta = {}
                name = meta.get("original_name", f) if meta else f
                found[base_id] = {
                    "id": base_id,
                    "name": name,
                    "file": f,
                    "display": os.path.splitext(name)[0][:40],
                    "source": "user",
                }
            # Không walk sâu thêm (soundfont thường đặt ngay trong thư mục khai báo)
            dirs2[:] = []
    return list(found.values())


def _scan_vst_in_dirs(dirs: list) -> list:
    """Walk các thư mục (plugin_dirs user) → danh sách VST giống /scan:
    file .vst3/.dll/.so + FOLDER tên X.vst3 (Windows VST3 = folder chứa
    X.vst3.dll bên trong)."""
    found = {}
    for d in dirs:
        if not d or not os.path.isdir(d):
            continue
        for root, dirs, files in os.walk(d):
            # Windows: VST3 là FOLDER tên X.vst3
            for sub in list(dirs):
                if sub.lower().endswith(".vst3"):
                    name = os.path.splitext(sub)[0]
                    if name not in found:
                        found[name] = {
                            "id": name, "name": name, "type": "VST3",
                            "path": os.path.join(root, sub),
                        }
            for f in files:
                low = f.lower()
                if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
                    name = os.path.splitext(f)[0]
                    if name not in found:
                        found[name] = {
                            "id": name, "name": name,
                            "type": "VST3" if low.endswith(".vst3") else "VST2",
                            "path": os.path.join(root, f),
                        }
    return list(found.values())


# ── Native folder picker (user yêu cầu: dùng Windows Explorer, không phải
#    nhập tay) ─────────────────────────────────────────────────────────────
PICK_DIR_TIMEOUT = 120  # user có thể mở dialog lâu


def _pick_dir_via_tauri_bridge() -> Optional[str]:
    """Tauri shell (Rust watcher trong lib.rs) mở NATIVE dialog (IFileDialog /
    Explorer) qua file IPC: engine ghi pick_dir.request → Rust mở dialog →
    ghi pick_dir.response. Trả None nếu bridge không tồn tại (chạy standalone)."""
    root = os.environ.get("APPDATA") or os.path.expanduser("~")
    ipc = os.path.join(root, "SonicForgeDAW", "ipc")
    if not os.path.isdir(ipc):
        return None
    # Marker do Rust viết lúc setup — bridge chỉ có trong app Tauri desktop
    if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")):
        return None
    req = os.path.join(ipc, "pick_dir.request")
    resp = os.path.join(ipc, "pick_dir.response")
    try:
        for f in (req, resp):
            if os.path.exists(f):
                os.remove(f)
        with open(req, "w", encoding="utf-8") as fh:
            fh.write("1")
        deadline = _time.time() + PICK_DIR_TIMEOUT
        while _time.time() < deadline:
            if os.path.exists(resp):
                try:
                    with open(resp, "r", encoding="utf-8") as fh:
                        val = fh.read().strip()
                finally:
                    os.remove(resp)
                return val or None
            _time.sleep(0.1)
    except Exception:
        pass
    return None


def _pick_dir_native_engine() -> Optional[str]:
    """Fallback khi không có Tauri bridge: PowerShell FolderBrowserDialog
    (Windows), osascript (macOS), zenity/kdialog (Linux)."""
    if os.name == "nt":
        ps = (
            "Add-Type -AssemblyName System.Windows.Forms; "
            "$f = New-Object System.Windows.Forms.FolderBrowserDialog; "
            "$f.Description = 'Chọn thư mục chứa VST / SoundFont'; "
            "if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }"
        )
        try:
            r = subprocess.run(
                ["powershell", "-NoProfile", "-STA", "-Command", ps],
                capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
            )
            return r.stdout.strip() or None
        except Exception:
            return None
    if sys.platform == "darwin":
        try:
            r = subprocess.run(
                ["osascript", "-e",
                 'POSIX path of (choose folder with prompt "Chọn thư mục plugin")'],
                capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
            )
            return r.stdout.strip() or None
        except Exception:
            return None
    for cmd in (["zenity", "--file-selection", "--directory"],
                ["kdialog", "--getexistingdirectory", os.path.expanduser("~")]):
        try:
            r = subprocess.run(cmd, capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT)
            if r.returncode == 0:
                p = r.stdout.strip()
                if p:
                    return p
        except Exception:
            continue
    return None


@router.post("/pick-dir")
def pick_plugin_directory():
    """Mở NATIVE folder picker. Không cần auth (desktop local, chỉ mở dialog).
    Trả {"path": "<thư mục>"} hoặc {"path": None} (hủy/không có dialog).
    Định nghĩa def (sync) → FastAPI chạy trong threadpool — không block loop
    trong lúc user chọn thư mục (có thể mất phút)."""
    path = _pick_dir_via_tauri_bridge()
    if path is None:
        path = _pick_dir_native_engine()
    return {"path": path}


@router.get("/default-soundfonts")
async def list_default_soundfonts():
    static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
    result = []
    if os.path.isdir(static_sf_dir):
        for f in os.listdir(static_sf_dir):
            if f.endswith(".sf2") or f.endswith(".sf3"):
                result.append({
                    "id": os.path.splitext(f)[0],
                    "name": f,
                    "file": f,
                    "url": f"/soundfonts/{f}"
                })
    return result


@router.get("/soundfonts/catalog")
async def soundfont_catalog(current_user: dict = Depends(get_current_user)):
    scanner = get_scanner()
    full_catalog = scanner.get_catalog()
    inspector = get_inspector()
    condensed_catalog = inspector.get_condensed_catalog_summary(full_catalog)
    return {"full_catalog": full_catalog, "condensed_catalog": condensed_catalog}


@router.get("/soundfont-instruments/{sf_id}")
async def list_soundfont_instruments(sf_id: str, current_user: dict = Depends(get_current_user)):
    d = _effective_dirs()
    # extra_vst_dirs = plugin_dirs user (chứa cả VST lẫn SoundFont) → tìm sf2 ở đó
    pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR, extra_vst_dirs=d["plugin_dirs"])
    presets = pm.list_soundfont_instruments(sf_id)
    return {"presets": presets, "count": len(presets)}


@router.post("/upload-soundfont")
async def upload_soundfont(
    file: UploadFile = File(...),
    background_tasks: BackgroundTasks = None,
    current_user: dict = Depends(get_current_user)
):
    enforce_password_changed(current_user)
    if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))):
        raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")

    # Stream upload in chunks with a hard size cap (SGM-class fonts can exceed
    # 500MB; reading the whole body into RAM would OOM the server).
    MAX_SF_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024  # 2 GB
    contents = bytearray()
    while True:
        chunk = await file.read(1024 * 1024)
        if not chunk:
            break
        contents.extend(chunk)
        if len(contents) > MAX_SF_UPLOAD_BYTES:
            raise HTTPException(status_code=413, detail="SoundFont quá lớn (giới hạn 2GB)")

    if not PluginManager.validate_sf2_header(bytes(contents[:4096])):
        raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")

    file_ext = os.path.splitext(file.filename)[1]
    file_uuid = str(uuid.uuid4())
    # Store original name in a sidecar file
    base_name = os.path.splitext(file.filename)[0].replace('/', '_').replace('\\', '_')
    file_id = file_uuid + file_ext
    dest_path = os.path.join(UPLOAD_SF_DIR, file_id)
    with open(dest_path, "wb") as f:
        f.write(contents)
    
    # Save metadata with original name
    meta_path = os.path.join(UPLOAD_SF_DIR, file_uuid + ".meta")
    with open(meta_path, "w", encoding="utf-8") as f:
        import json
        json.dump({"original_name": file.filename, "uuid": file_uuid, "file": file_id}, f)

    scanner = get_scanner()
    if background_tasks:
        background_tasks.add_task(scanner.scan_once)
    else:
        scanner.scan_once()
    return {"id": file_id, "name": file.filename, "path": dest_path, "size_bytes": len(contents)}


@router.delete("/soundfont/{sf_id}")
async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_user)):
    base_id = sf_id.replace("sf_", "")
    deleted = False
    for d in [UPLOAD_SF_DIR, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts"), SYSTEM_SF_DIR]:
        if not os.path.isdir(d):
            continue
        for f in os.listdir(d):
            if os.path.splitext(f)[0] == base_id:
                # Skip system dir — only allow deleting uploads
                if d == SYSTEM_SF_DIR:
                    raise HTTPException(status_code=403, detail="System soundfonts cannot be deleted via this endpoint")
                path = os.path.join(d, f)
                os.remove(path)
                meta_path = os.path.join(d, os.path.splitext(f)[0] + ".meta")
                if os.path.isfile(meta_path):
                    os.remove(meta_path)
                deleted = True
                break
        if deleted:
            break
    if not deleted:
        raise HTTPException(status_code=404, detail="SoundFont not found")
    get_scanner().scan_once()
    return {"deleted": True, "sf_id": sf_id}


@router.get("/soundfonts/download/{sf_id}")
async def download_soundfont_asset(sf_id: str):
    clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
    # Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước
    # đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng)
    # không tải được font → instrument CÂM (browser thường dùng cache nên OK).
    static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
    for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]:
        if not os.path.isdir(base_dir):
            continue
        # Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
        # samples, so any SF3 would play silence in the browser.
        for fname in os.listdir(base_dir):
            fbase, fext = os.path.splitext(fname)
            if fext.lower() == ".sf2" and fbase.lower() == clean_id.lower():
                full = os.path.join(base_dir, fname)
                return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf2")
        # Only an SF3 exists -> decompress it to a playable SF2 on demand (cached)
        for fname in os.listdir(base_dir):
            fbase, fext = os.path.splitext(fname)
            if fext.lower() == ".sf3" and fbase.lower() == clean_id.lower():
                full = os.path.join(base_dir, fname)
                try:
                    from app.core.soundfont_converter import SoundFontConverter
                    sf2_path = os.path.join(UPLOAD_SF_DIR, clean_id + ".sf2")
                    if os.path.exists(sf2_path) and os.path.getmtime(sf2_path) >= os.path.getmtime(full):
                        return FileResponse(sf2_path, media_type="application/octet-stream", filename="soundfont.sf2")
                    result = SoundFontConverter().sf3_to_sf2(full, sf2_path)
                    if result != full and os.path.exists(result):
                        return FileResponse(result, media_type="application/octet-stream", filename="soundfont.sf2")
                except Exception as e:
                    print(f"[soundfont-download] SF3->SF2 conversion failed for {full}: {e}")
                return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf3")
    raise HTTPException(status_code=404, detail="SoundFont asset not found")


class RenderRequest(BaseModel):
    project_json: dict
    output_filename: Optional[str] = "render_output.wav"


class OpenInCarlaRequest(BaseModel):
    """Mở native GUI của VSTi trong Carla (chỉ khả dụng khi runtime=desktop
    và Carla được cài trên cùng máy — tự phát hiện qua runtime profile)."""
    plugin_name: Optional[str] = None
    plugin_path: Optional[str] = None


class PreviewRequest(BaseModel):
    """Quick-render preview: pedalboard render clip ngắn bằng ĐÚNG plugin +
    preset (cùng code path với export) → trả wav để browser phát.
    Âm preview = âm export (khác Preview Synth WASM hiện tại)."""
    instrument_id: str
    notes: list = []
    bpm: float = 120.0
    sample_rate: int = 44100
    soundfont_bank: Optional[int] = 0
    soundfont_program: Optional[int] = 0
    preset_id: Optional[str] = None
    preset_path: Optional[str] = None
    preset_data: Optional[str] = None  # base64 bytes .vstpreset (project nhúng)


class CarlaMidiRequest(BaseModel):
    """Gửi MIDI note từ track ARM → Carla (OSC /Carla/0/note_on|note_off).

    Carla standalone bật OSC UDP mặc định cổng 22752 (source: CarlaEngineOsc,
    CarlaEngineData oscPortUDP=22752; override: env CARLA_OSC_UDP_PORT của
    Carla, hoặc SF_CARLA_OSC_PORT / osc_port trong carla_path.json của app).
    Plugin đầu tiên trong project .carxs do app sinh có pluginId = 0."""
    event: str  # "note_on" | "note_off"
    note: int
    velocity: Optional[int] = 100
    channel: Optional[int] = 0


@router.post("/carla-midi")
async def carla_midi(req: CarlaMidiRequest):
    """MIDI keyboard (piano roll / keybed) → Carla để preview VSTi realtime."""
    if req.event not in ("note_on", "note_off"):
        raise HTTPException(status_code=400, detail="event phải là note_on hoặc note_off")
    ok = _send_carla_osc(req.event, req.note, req.velocity if req.event == "note_on" else 0, req.channel)
    if not ok:
        raise HTTPException(
            status_code=502,
            detail="Không gửi được OSC tới Carla — Carla đã mở chưa? (cổng OSC UDP mặc định 22752; "
                   "nếu đổi cổng trong Carla, đặt SF_CARLA_OSC_PORT hoặc osc_port trong carla_path.json)",
        )
    return {"success": True, "event": req.event, "note": req.note, "channel": req.channel}


def _carla_osc_port() -> int:
    """Cổng OSC UDP của Carla: SF_CARLA_OSC_PORT env → osc_port trong
    storage/carla_path.json → mặc định 22752 (CarlaEngineData)."""
    try:
        p = os.environ.get("SF_CARLA_OSC_PORT")
        if p:
            return int(p)
    except Exception:
        pass
    try:
        from app.core.runtime import _carla_config_path
        with open(_carla_config_path(), "r", encoding="utf-8") as f:
            data = json.load(f)
        p = data.get("osc_port")
        if p:
            return int(p)
    except Exception:
        pass
    return 22752


def _carla_osc_ready() -> bool:
    """Carla OSC engine đang lắng nghe cổng UDP chưa — thử bind socket vào đúng
    cổng. Bind THÀNH CÔNG → cổng trống (Carla chưa mở / OSC chưa sẵn sàng);
    EADDRINUSE → Carla đang giữ cổng (kể cả mở thủ công ngoài app, hoặc app
    spawn đang boot tới giai đoạn OSC). KHÔNG set SO_REUSEADDR (Linux/Win cho
    phép 2 UDP socket cùng port → mất tác dụng phát hiện)."""
    try:
        import socket
        port = _carla_osc_port()
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.bind(("127.0.0.1", port))
        s.close()
        return False
    except OSError:
        return True
    except Exception:
        return False


def _send_carla_osc(event: str, note: int, velocity: int, channel: int) -> bool:
    """Gửi OSC UDP tới `/Carla/0/{event}` (plugin đầu tiên = plugin auto-load).

    OSC message: path + typetag + int args, mỗi phần pad '\0' tới bội số 4.
    Đã xác minh từ source Carla: handleMsgNoteOn/NoteOff nhận `iii`/`ii` và
    tên client mặc định của app standalone là "Carla" (carla_host.py
    fClientName = CARLA_CLIENT_NAME or "Carla")."""
    try:
        import socket
        import struct
        port = _carla_osc_port()
        path = f"/Carla/0/{event}".encode("utf-8")
        typetag = b",iii" if event == "note_on" else b",ii"
        vals = [int(channel), int(note), int(velocity)] if event == "note_on" else [int(channel), int(note)]

        def _pad(b: bytes) -> bytes:
            rem = len(b) % 4
            return b + b"\x00" * (4 - rem) if rem else b

        msg = _pad(path) + _pad(typetag) + b"".join(struct.pack(">i", v) for v in vals)
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.settimeout(0.5)
        s.sendto(msg, ("127.0.0.1", port))
        s.close()
        return True
    except Exception:
        return False


@router.post("/open-in-carla")
async def open_in_carla(req: OpenInCarlaRequest, current_user: dict = Depends(get_current_user)):
    """Mở Carla với VSTi đã chọn — TỰ ĐỘNG load plugin (native GUI + keyboard).

    Cơ chế: sinh file project .carxs (định dạng XML chính thức của Carla —
    `carla.exe [FILE]` nhận project file) chứa node <Plugin><Info><Type>VST3
    </Type><Binary>...</Binary></Info> → Carla mở lên là plugin đã load sẵn,
    kèm on-screen MIDI keyboard (PixmapKeyboard) để preview realtime.

    Carla là app ngoài do user tự giải nén (GPL-2.0+ → không bundle/nhúng);
    app chỉ spawn tiến trình + trao đổi file preset."""
    enforce_password_changed(current_user)
    from app.core.runtime import find_carla
    carla = find_carla()
    if not carla:
        raise HTTPException(
            status_code=409,
            detail="Không tìm thấy Carla trên máy này. Hãy giải nén bản Carla "
                   "portable (zip, miễn phí) từ https://github.com/falkTX/Carla/releases, "
                   "rồi vào Plugin Manager → Carla Bridge → Định vị Carla... để chọn "
                   "thư mục chứa carla.exe (bản portable không dùng PATH).",
        )
    plugin_path = req.plugin_path or ""
    if not plugin_path and req.plugin_name:
        try:
            pm = PluginManager()
            plugins = pm._scan_plugins()
            if req.plugin_name in plugins:
                plugin_path = plugins[req.plugin_name]
        except Exception:
            plugin_path = ""
    carxs_path = ""
    if plugin_path:
        carxs_path = _write_carla_project(req.plugin_name or os.path.basename(plugin_path), plugin_path)
    # ⚠️ FIX (Bug 2): Carla ĐÃ chạy (mở thủ công / app spawn từ trước, hoặc
    # đang boot) → KHÔNG spawn bản 2 (trùng OSC port 22752 + audio → bridge
    # hỏng; Carla là single-instance). Trả already_running — frontend poll
    # carla-status chờ OSC ready rồi mới gửi note.
    if _carla_osc_ready() or len(_CARLA_PROCESSES) > 0:
        return {
            "success": True,
            "started": False,
            "already_running": True,
            "carla_path": carla,
            "plugin_path": plugin_path,
            "project_file": carxs_path,
            "cmd": [],
        }
    cmd = [carla]
    if carxs_path:
        cmd.append(carxs_path)
    try:
        cwd = os.path.dirname(carla) or None
        proc = subprocess.Popen(cmd, cwd=cwd, close_fds=os.name != "nt")
        _register_carla_process(proc)
        return {
            "success": True,
            "started": True,
            "carla_path": carla,
            "plugin_path": plugin_path,
            "project_file": carxs_path,
            "cmd": cmd,
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Không mở được Carla: {e}")


# ── Carla bridge lifecycle ─────────────────────────────────────────────────
# App spawn Carla (open_in_carla) và PHẢI có khả năng dừng nó khi track chuyển
# sang instrument KHÔNG phải VST (soundfont/GM) — nếu không, Carla vẫn chạy và
# MIDI vẫn vào VSTi cũ → âm sai instrument + âm kẹt không dừng được.
_CARLA_PROCESSES = []  # list[subprocess.Popen]


def _register_carla_process(proc):
    """Lưu handle tiến trình Carla do app spawn (để carla-stop terminate được)."""
    global _CARLA_PROCESSES
    _prune_carla_processes()
    _CARLA_PROCESSES.append(proc)


def _prune_carla_processes():
    """Bỏ các handle đã thoát (poll() trả code) — tránh list rác."""
    global _CARLA_PROCESSES
    _CARLA_PROCESSES = [p for p in _CARLA_PROCESSES if p is not None and p.poll() is None]


def _send_carla_all_notes_off() -> bool:
    """Gửi note_off TẤT CẢ pitch (0-127) trên mọi channel (0-15) tới Carla.

    Dừng mọi âm đang ngân trong Carla (kể cả sustain) — dùng ngay trước khi
    terminate để không còn tiếng kẹt khi bridge bị unload."""
    ok = False
    try:
        for ch in range(16):
            for note in range(128):
                if _send_carla_osc("note_off", note, 0, ch):
                    ok = True
    except Exception:
        pass
    return ok


@router.get("/carla-status")
async def carla_status():
    """Kiểm tra Carla bridge còn sống không (OSC engine đang nghe cổng UDP —
    phát hiện CẢ Carla mở thủ công ngoài app, không chỉ app-spawn; chính xác
    hơn _CARLA_PROCESSES vì OSC bind khi Carla boot xong engine).

    Frontend dùng để quyết định: route MIDI item EXCLUSIVE qua Carla (chỉ khi
    Carla đang chạy) hay fallback FluidSynth (luôn có âm) — tránh câm toàn
    phần khi Carla bị đóng. running=OSC ready → gửi note là tới được Carla."""
    _prune_carla_processes()
    return {
        "success": True,
        "running": _carla_osc_ready(),
        "app_spawned": len(_CARLA_PROCESSES) > 0,
        "osc_port": _carla_osc_port(),
    }


@router.post("/carla-stop")
async def carla_stop(authorization: Optional[str] = Header(None)):
    """Unload Carla bridge: tắt mọi note đang ngân + terminate tiến trình Carla
    do app spawn. Gọi khi track chuyển từ VSTi sang instrument khác (soundfont)
    để âm KHÔNG còn play qua Carla bridge."""
    # Auth là optional (desktop app có thể chưa login) — chỉ cần decode nếu có
    try:
        if authorization and authorization.startswith("Bearer "):
            from app.core.auth import decode_token
            decode_token(authorization.split(" ")[1])
    except Exception:
        pass
    # 1. Tắt hết âm đang ngân trong Carla (trước khi giết tiến trình)
    try:
        _send_carla_all_notes_off()
    except Exception:
        pass
    # 2. Terminate tiến trình Carla đã spawn
    killed = 0
    _prune_carla_processes()
    for proc in list(_CARLA_PROCESSES):
        try:
            proc.terminate()
        except Exception:
            pass
    # Chờ tiến trình thoát (tối đa ~2s) rồi kill mạnh nếu còn sống
    try:
        import time as _t
        deadline = _t.time() + 2.0
        for proc in list(_CARLA_PROCESSES):
            while proc.poll() is None and _t.time() < deadline:
                _t.sleep(0.05)
            if proc.poll() is None:
                try:
                    proc.kill()
                except Exception:
                    pass
            if proc.poll() is not None:
                killed += 1
    except Exception:
        pass
    _CARLA_PROCESSES.clear()
    return {
        "success": True,
        "stopped": True,
        "killed": killed,
        "all_notes_off": True,
    }



def _write_carla_project(plugin_name: str, plugin_path: str) -> str:
    """Sinh file project .carxs cho Carla load sẵn VSTi (chỉ VST3 — VST2 cần
    uniqueID không đoán được → mở Carla trống để user tự Add Plugin).

    Định dạng theo source Carla (CarlaEngine::saveProjectInternal +
    CarlaStateSave::dumpToMemoryStream): root <CARLA-PROJECT VERSION='2.5'>,
    <Plugin><Info><Type>VST3</Type><Binary>path</Binary><Label>..</Label>
    </Info><Data><Active>Yes</Active><ControlChannel>1</ControlChannel>
    <Options>0x0</Options></Data></Plugin>."""
    if not plugin_path or not os.path.exists(plugin_path):
        return ""
    low = plugin_path.lower()
    is_vst3 = low.endswith(".vst3") or low.endswith(".vst3/") or "\\" in plugin_path and plugin_path.rstrip("\\/").lower().endswith(".vst3")
    if not is_vst3:
        # VST2 (.dll/.so ngoài .vst3): không auto-load được tin cậy → Carla trống
        return ""
    from xml.sax.saxutils import escape
    name = escape(plugin_name or os.path.splitext(os.path.basename(plugin_path))[0])
    binary = escape(plugin_path)
    # Patchbay: kết nối TỰ ĐỘNG MIDI input + audio output → default speaker.
    # Mặc định Carla KHÔNG connect khi load project (chỉ restore connection có
    # trong file) → lần đầu mở không có âm, không nhận MIDI (phải unload/load
    # lại mới auto-connect như thêm plugin thủ công). Connection nào trỏ tới
    # port không tồn tại sẽ bị Carla bỏ qua im lặng → thêm nhiều biến thể tên.
    pb_name = name
    patchbay = (
        " <Patchbay>\n"
        # Audio out plugin → loa mặc định (system:playback_1/2)
        f"  <Connection><Source>{pb_name}:audio_out1</Source><Target>system:playback_1</Target></Connection>\n"
        f"  <Connection><Source>{pb_name}:audio_out2</Source><Target>system:playback_2</Target></Connection>\n"
        # MIDI input → plugin midi_in (biến thể tên client: Carla / carla / system)
        f"  <Connection><Source>Carla:midi_in</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
        f"  <Connection><Source>carla:midi_in</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
        f"  <Connection><Source>system:midi_capture_1</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
        " </Patchbay>\n"
    )
    xml = (
        "<?xml version='1.0' encoding='UTF-8'?>\n"
        "<!DOCTYPE CARLA-PROJECT>\n"
        f"<CARLA-PROJECT VERSION='2.5'>\n"
        " <EngineSettings>\n"
        "  <ForceStereo>false</ForceStereo>\n"
        "  <PreferPluginBridges>false</PreferPluginBridges>\n"
        "  <PreferUiBridges>false</PreferUiBridges>\n"
        "  <UIsAlwaysOnTop>false</UIsAlwaysOnTop>\n"
        "  <MaxParameters>100</MaxParameters>\n"
        "  <UIBridgesTimeout>10000</UIBridgesTimeout>\n"
        " </EngineSettings>\n"
        " <Plugin>\n"
        "  <Info>\n"
        f"   <Type>VST3</Type>\n"
        f"   <Name>{name}</Name>\n"
        f"   <Binary>{binary}</Binary>\n"
        f"   <Label>{name}</Label>\n"
        "  </Info>\n"
        "  <Data>\n"
        "   <Active>Yes</Active>\n"
        "   <ControlChannel>1</ControlChannel>\n"
        "   <Options>0x0</Options>\n"
        "  </Data>\n"
        " </Plugin>\n"
        + patchbay +
        "</CARLA-PROJECT>\n"
    )
    try:
        proj_dir = os.path.join(settings.STORAGE_DIR, "carla_projects")
        os.makedirs(proj_dir, exist_ok=True)
        # Dọn project cũ (quá 1 ngày) — tránh rác
        try:
            now = _time.time()
            for f in os.listdir(proj_dir):
                fp = os.path.join(proj_dir, f)
                try:
                    if os.path.isfile(fp) and now - os.path.getmtime(fp) > 86400:
                        os.remove(fp)
                except Exception:
                    pass
        except Exception:
            pass
        safe = "".join(c for c in plugin_name if c.isalnum() or c in " _-")[:40].strip() or "plugin"
        carxs = os.path.join(proj_dir, f"{safe}_{uuid.uuid4().hex[:8]}.carxs")
        with open(carxs, "w", encoding="utf-8") as fh:
            fh.write(xml)
        return carxs
    except Exception:
        return ""


@router.post("/preview")
async def preview_instrument(req: PreviewRequest):
    """Quick-render preview VSTi (âm thật, cùng code path với export)."""
    if not HAS_PEDALBOARD:
        raise HTTPException(status_code=501, detail="pedalboard không khả dụng trên máy này")
    if not req.notes:
        raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để preview")
    try:
        out_path, duration_sec = _render_midi_notes_pedalboard(
            instrument_id=req.instrument_id,
            notes=req.notes,
            bpm=req.bpm,
            sample_rate=req.sample_rate,
            preset_id=req.preset_id,
            preset_path=req.preset_path,
            preset_data_b64=req.preset_data,
            soundfont_bank=req.soundfont_bank,
            soundfont_program=req.soundfont_program,
        )
        fname = os.path.basename(out_path)
        return {
            "success": True,
            "url": f"/static/audio/processed/{fname}",
            "path": out_path,
            "duration_sec": round(duration_sec, 3),
        }
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Preview thất bại: {e}")


class MidiRenderRequest(BaseModel):
    """Render MIDI notes → audio qua VSTi (âm thật, cùng code path với export).

    instrument_id = plugin_id của track synth_engine (khớp key scan của
    PluginManager). Đây là cầu nối Carla → pedalboard: preset chỉnh trong
    Carla (.vstpreset) được áp vào pedalboard trước khi render."""
    instrument_id: str
    notes: list = []
    bpm: float = 120.0
    sample_rate: int = 44100
    preset_id: Optional[str] = None
    preset_path: Optional[str] = None
    preset_data: Optional[str] = None  # base64 bytes .vstpreset (project nhúng)


@router.post("/midi-render")
async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_current_user)):
    """Export MIDI notes → WAV với âm của VSTi instrument (lưu vào processed).

    Preview/export MIDI notes với âm VSTi trước đây KHÔNG thực hiện được:
    - preview realtime chỉ phát qua loa Carla (không vào audio graph DAW)
    - clientSideExport chỉ render soundfont (midiCache = FluidSynth WASM)
    - /plugins/preview có sẵn nhưng frontend không gọi
    Endpoint này render offline bằng pedalboard (đúng plugin + preset như
    export) → trả file_id để UI preview / gán clip vào project / download."""
    enforce_password_changed(current_user)
    if not req.notes:
        raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để render")
    user_id = current_user["user_id"]
    out_name = f"user_{user_id}_midi_{uuid.uuid4().hex[:10]}.wav"
    out_path = os.path.join(settings.PROCESSED_DIR, out_name)
    try:
        out_path, duration_sec = _render_midi_notes_pedalboard(
            instrument_id=req.instrument_id,
            notes=req.notes,
            bpm=req.bpm,
            sample_rate=req.sample_rate,
            preset_id=req.preset_id,
            preset_path=req.preset_path,
            preset_data_b64=req.preset_data,
        )
        return {
            "success": True,
            "file_id": os.path.basename(out_path),
            "url": f"/static/audio/processed/{os.path.basename(out_path)}",
            "path": out_path,
            "duration_sec": round(duration_sec, 3),
            "render_mode": "pedalboard",
        }
    except HTTPException:
        # Không để lại file rác nếu thất bại giữa chừng
        try:
            if os.path.exists(out_path):
                os.remove(out_path)
        except Exception:
            pass
        raise
    except Exception as e:
        try:
            if os.path.exists(out_path):
                os.remove(out_path)
        except Exception:
            pass
        raise HTTPException(
            status_code=500,
            detail=f"Render MIDI thất bại: {e}. Nếu plugin là VST2 hoặc pedalboard "
                   "không load được, hãy mở Carla Bridge (chọn VSTi, chỉnh âm, "
                   "Save preset .vstpreset) rồi Upload preset vào track — render "
                   "sẽ dùng đúng âm đã chỉnh.",
        )


def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
                                  sample_rate: int, preset_id=None, preset_path=None,
                                  preset_data_b64=None, soundfont_bank=None,
                                  soundfont_program=None) -> tuple:
    """Render MIDI notes qua pedalboard (VSTi + preset) → WAV trong PROCESSED_DIR.

    Trả (out_path, duration_sec). Ném HTTPException khi plugin không load được."""
    if not HAS_PEDALBOARD:
        raise HTTPException(status_code=501, detail="pedalboard không khả dụng trên máy này")
    pm = PluginManager()
    vst = pm.load_vst(instrument_id)
    if vst is None:
        raise HTTPException(
            status_code=404,
            detail=f"Không tìm thấy VSTi: {instrument_id} — chưa scan thấy plugin này. "
                   "Kiểm tra Plugins Manager → Scan, hoặc mở Carla để load VST2 "
                   "(pedalboard chỉ render được VST3).",
        )
    apply_preset_to_plugin(
        vst,
        preset_id=preset_id,
        preset_path=preset_path,
        preset_data_b64=preset_data_b64,
    )
    from pedalboard import Pedalboard
    midi_events = []
    for n in notes:
        midi_events.append({
            "note": int(n.get("pitch", 60)),
            "start_beat": float(n.get("start_beat", 0)),
            "duration_beats": float(n.get("duration_beats", 1)),
            "velocity": int(float(n.get("velocity", 0.8)) * 127),
        })
    midi_messages = PluginManager.midi_events_to_messages(
        midi_events, bpm, sample_rate,
        bank=soundfont_bank, program=soundfont_program,
    )
    total_needed = 0
    beat_sec = 60.0 / max(30.0, bpm)
    for ev in midi_events:
        end_sec = (ev["start_beat"] + ev["duration_beats"]) * beat_sec
        if int(end_sec * sample_rate) > total_needed:
            total_needed = int(end_sec * sample_rate)
    total_needed = max(total_needed, 1024)
    # pedalboard >= 0.9: Pedalboard container KHÔNG chứa instrument — gọi
    # thẳng overload MIDI của plugin (overload 2: midi_messages + duration).
    buf = vst(midi_messages, sample_rate=sample_rate,
              duration=total_needed / float(sample_rate), num_channels=2)
    out_path = os.path.join(settings.PROCESSED_DIR, f"preview_{uuid.uuid4().hex[:10]}.wav")
    sf.write(out_path, buf.T, sample_rate)
    return out_path, buf.shape[1] / float(sample_rate)


class SoundfontRenderRequest(BaseModel):
    """Render MIDI notes → WAV bằng soundfont THẬT qua native FluidSynth
    (pyfluidsynth) — luồng âm instrument cho môi trường STANDALONE (xử lí
    trực tiếp trên OS; docker dùng FluidSynthWASM ở client)."""
    soundfont_id: Optional[str] = None
    soundfont_path: Optional[str] = None
    bank: int = 0
    program: int = 0
    notes: list = []
    bpm: float = 120.0
    sample_rate: int = 44100


@router.post("/soundfont-render")
async def soundfont_render(req: SoundfontRenderRequest, current_user: dict = Depends(get_current_user)):
    """Preview/play MIDI notes bằng âm soundfont thật — native FluidSynth trên OS.

    Khác FluidSynthWASM (docker): âm render server-side bằng pyfluidsynth, trả
    WAV để client play. Dùng cho: preview/keyboard, play MIDI item khi chạy
    transport ở môi trường standalone."""
    enforce_password_changed(current_user)
    if not req.notes:
        raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để render")
    if not HAS_PYFLUIDSYNTH:
        raise HTTPException(status_code=501, detail="pyfluidsynth không khả dụng — không render được soundfont native")
    # Resolve path: soundfont_path → soundfont_id (upload/user dirs) → default
    sf_path = (req.soundfont_path or "").strip()
    if not sf_path and req.soundfont_id:
        sf_path = _find_sf2_path(req.soundfont_id)
    if not sf_path or not os.path.exists(sf_path):
        sf_path = _find_default_sf2()
    if not sf_path or not os.path.exists(sf_path):
        raise HTTPException(status_code=404, detail="SoundFont không tìm thấy — hãy thêm soundfont vào Plugin Manager")
    midi_events = [{
        "note": int(n.get("pitch", 60)),
        "start_beat": float(n.get("start_beat", 0)),
        "duration_beats": float(n.get("duration_beats", 1)),
        "velocity": int(float(n.get("velocity", 0.8)) * 127),
    } for n in req.notes]
    out_path = os.path.join(settings.PROCESSED_DIR, f"sf_{uuid.uuid4().hex[:10]}.wav")
    try:
        audio = render_soundfont_midi_to_audio(
            midi_events, sf_path,
            bank=req.bank, program=req.program,
            sr=req.sample_rate, bpm=req.bpm,
        )
        sf.write(out_path, audio.T, req.sample_rate)
        return {
            "success": True,
            "file_id": os.path.basename(out_path),
            "url": f"/static/audio/processed/{os.path.basename(out_path)}",
            "path": out_path,
            "duration_sec": round(audio.shape[1] / float(req.sample_rate), 3),
            "render_mode": "native-fluidsynth",
            "soundfont": os.path.basename(sf_path),
        }
    except HTTPException:
        raise
    except Exception as e:
        try:
            if os.path.exists(out_path):
                os.remove(out_path)
        except Exception:
            pass
        raise HTTPException(status_code=500, detail=f"Render soundfont thất bại: {e}")


class CarlaPlayNotesRequest(BaseModel):
    """Phát dãy MIDI notes qua Carla bridge (OSC, realtime) — preview khi
    pedalboard không render được plugin (VD VST2). Cần Carla đang chạy với
    plugin đã load (open-in-carla)."""
    notes: list = []
    bpm: float = 120.0
    channel: Optional[int] = 0


@router.post("/carla-play-notes")
async def carla_play_notes(req: CarlaPlayNotesRequest, current_user: dict = Depends(get_current_user)):
    """Phát toàn bộ dãy MIDI notes vào Carla (note_on/note_off đúng thời điểm).

    Preview realtime qua đúng VSTi đang mở trong Carla — dùng khi pedalboard
    không load được plugin (VST2, plugin cần state GUI). Âm phát ra loa hệ
    thống (Carla), KHÔNG thu vào file — muốn file audio dùng /midi-render."""
    enforce_password_changed(current_user)
    if not req.notes:
        raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để phát")
    if not find_carla_local():
        raise HTTPException(status_code=409, detail="Carla chưa được định vị (Plugin Manager → Carla Bridge → Định vị Carla...)")
    if not _carla_bridge_running():
        raise HTTPException(status_code=409, detail="Carla chưa mở. Hãy mở Carla Bridge (nút Synth → VSTi) trước khi preview qua Carla.")
    beat_sec = 60.0 / max(30.0, req.bpm)
    channel = int(req.channel or 0)
    events = []
    for n in req.notes:
        pitch = int(n.get("pitch", 60))
        vel = int(float(n.get("velocity", 0.8)) * 127)
        start = float(n.get("start_beat", 0)) * beat_sec
        dur = float(n.get("duration_beats", 1)) * beat_sec
        events.append((start, "note_on", pitch, max(1, min(127, vel))))
        events.append((start + dur, "note_off", pitch, 0))
    events.sort(key=lambda e: (e[0], 0 if e[1] == "note_off" else 1))
    duration_sec = max((e[0] for e in events), default=0) + 0.3
    # Phát trong thread nền — endpoint trả ngay, không block tới hết bản nhạc
    def _player():
        import time as _t
        t0 = _t.monotonic()
        for ev_time, ev_type, pitch, vel in events:
            wait = (t0 + ev_time) - _t.monotonic()
            if wait > 0:
                _t.sleep(wait)
            _send_carla_osc(ev_type, pitch, vel, channel)
    threading.Thread(target=_player, daemon=True).start()
    return {"success": True, "mode": "carla_realtime", "duration_sec": round(duration_sec, 3), "events": len(events)}


def find_carla_local() -> str:
    from app.core.runtime import find_carla as _fc
    try:
        return _fc() or ""
    except Exception:
        return ""


def _carla_bridge_running() -> bool:
    try:
        _prune_carla_processes()
        # OSC ready (mở thủ công / app spawn đã boot) HOẶC tiến trình app-spawn
        # còn sống (đang boot) — preview chấp nhận gửi sớm một chút.
        return _carla_osc_ready() or len(_CARLA_PROCESSES) > 0
    except Exception:
        return False


@router.post("/render")
async def render_project(
    req: RenderRequest,
    current_user: dict = Depends(get_current_user)
):
    enforce_password_changed(current_user)
    engine = PythonRenderEngine()
    # Prevent path traversal: strip any directory components and force .wav.
    safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/"))
    if not safe_name.lower().endswith(".wav"):
        safe_name += ".wav"
    output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
    try:
        result_path = engine.render_project(req.project_json, output_path)
        return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}")

# ── E1/E2: NATIVE HOST BRIDGE (daw_vst_bridge) ──────────────────────────────
# IPC dir giống _pick_dir_via_tauri_bridge: %APPDATA%/SonicForgeDAW/ipc — Rust
# (src-tauri) tạo, engine chỉ đọc/ghi file request/response.
BRIDGE_IPC_DIR = os.path.join(os.environ.get("APPDATA") or os.path.expanduser("~"), "SonicForgeDAW", "ipc")

def _bridge_ipc_dir() -> Optional[str]:
    if os.path.isdir(BRIDGE_IPC_DIR):
        return BRIDGE_IPC_DIR
    return None

@router.get("/bridge/status")
async def bridge_status(current_user: dict = Depends(get_current_user)):
    """E1: trạng thái native bridge. Rust ghi bridge_status (JSON) khi spawn/
    health-check; nếu chưa có → probe tail bridge.log (dòng started/exists)."""
    ipc = _bridge_ipc_dir()
    if not ipc:
        return {"connected": False, "reason": "no_ipc_dir"}
    st = os.path.join(ipc, "bridge_status")
    if os.path.exists(st):
        try:
            with open(st, "r", encoding="utf-8") as fh:
                raw = fh.read() or "{}"
            try:
                data = json.loads(raw)
            except Exception:
                data = {}
            data.setdefault("connected", True)
            return data
        except Exception:
            pass
    log = os.path.normpath(os.path.join(ipc, "..", "logs", "bridge.log"))
    if os.path.exists(log):
        try:
            with open(log, "r", encoding="utf-8", errors="ignore") as fh:
                tail = fh.read().splitlines()[-5:]
            joined = "\n".join(tail).lower()
            return {"connected": ("started" in joined or "exists=true" in joined), "log_tail": tail}
        except Exception:
            pass
    return {"connected": False, "reason": "no_status_no_log"}

class BridgeLoadRequest(BaseModel):
    name: str                    # tên asset hiển thị (sf_xxx / Vital.vst3 / ...)
    path: Optional[str] = None   # path tuyệt đối nếu đã biết
    instrumentType: str = "SF2"  # 'VST3'|'VST2'|'SF2'|'SF3'|'SFZ'
    channel: Optional[int] = None

def _resolve_bridge_asset(name: str, instrument_type: str, path: Optional[str]) -> Optional[str]:
    if path and os.path.exists(path):
        return path
    base = os.path.basename((path or name).replace("\\", "/"))
    base_noext = os.path.splitext(base)[0].lower()
    # sf2/sf3/sfz: storage/soundfonts + system + user plugin_dirs
    sf_dirs = [UPLOAD_SF_DIR, SYSTEM_SF_DIR]
    try:
        from app.core.vst_engine import _load_user_plugin_dirs
        sf_dirs += _load_user_plugin_dirs() or []
    except Exception:
        pass
    for base_dir in dict.fromkeys(d for d in sf_dirs if d):
        if not os.path.isdir(base_dir):
            continue
        for root, _, files in os.walk(base_dir):
            for fname in files:
                if fname.lower().endswith((".sf2", ".sf3", ".sfz")) and \
                   (fname.lower() == base.lower() or os.path.splitext(fname)[0].lower() == base_noext):
                    return os.path.join(root, fname)
    ext = (".vst3" if instrument_type.upper() == "VST3" else
           ".vst2" if instrument_type.upper() == "VST2" else
           ".sfz" if instrument_type.upper() == "SFZ" else None)
    if ext:
        try:
            from app.core.vst_engine import _load_user_plugin_dirs
            dirs = _load_user_plugin_dirs() or []
        except Exception:
            dirs = []
        # Luon gop VST_DIR/SOUNDFONT_DIR: neu chi dung user plugin_dirs,
        # VST trong C:/Program Files/Common Files/VST3 khong resolve
        # duoc -> bridge load fail -> VSTi cam / khong mo GUI.
        for d in (settings.VST_DIR, settings.SOUNDFONT_DIR):
            if d and d not in dirs:
                dirs.append(d)
        for base_dir in dirs:
            if not os.path.isdir(base_dir):
                continue
            for root, _, files in os.walk(base_dir):
                for fn in files:
                    if fn.lower().endswith(ext) and \
                       (fn.lower() == base.lower() or os.path.splitext(fn)[0].lower() == base_noext):
                        return os.path.join(root, fn)
            # V9 bug 8: Windows VST3 = FOLDER "X.vst3" (chua X.vst3.dll) —
            # khong la file nen file-walk tren khong thay -> resolve null ->
            # bridge load fail -> VSTi khong auto-load khi mo project. Scan
            # sub-folder trung ten: "x.vst3" == base.lower() hoac "x" ==
            # base_noext.
            for sub in os.listdir(root):
                sp = os.path.join(root, sub)
                if os.path.isdir(sp) and sub.lower().endswith(ext) and \
                   (sub.lower() == base.lower() or os.path.splitext(sub)[0].lower() == base_noext):
                    return sp
    return None

@router.post("/bridge/load")
async def bridge_load(req: BridgeLoadRequest, current_user: dict = Depends(get_current_user)):
    """E2: resolve path thật của asset rồi (a) ghi bridge_load.request cho Rust
    watcher (nếu có) và (b) trả path để JS gọi invoke load_native_instrument
    trực tiếp — pipeline hoạt động ngay, không chờ watcher."""
    resolved = _resolve_bridge_asset(req.name, req.instrumentType, req.path)
    if not resolved:
        raise HTTPException(status_code=404, detail=f"Không tìm thấy asset: {req.name}")
    wrote_request = False
    ipc = _bridge_ipc_dir()
    if ipc:
        try:
            req_file = os.path.join(ipc, "bridge_load.request")
            if os.path.exists(req_file):
                os.remove(req_file)
            with open(req_file, "w", encoding="utf-8") as fh:
                json.dump({"path": resolved, "type": req.instrumentType, "channel": req.channel}, fh)
            wrote_request = True
        except Exception:
            pass
    return {"success": True, "path": resolved, "type": req.instrumentType, "ipc_request_written": wrote_request}

@router.get("/bridge/log")
async def bridge_log(lines: int = Query(100, ge=1, le=2000), current_user: dict = Depends(get_current_user)):
    """E5: tail bridge.log cho UI debug. Log Rust ghi tại %APPDATA%/SonicForgeDAW/logs/bridge.log."""
    log = os.path.normpath(os.path.join(BRIDGE_IPC_DIR, "..", "logs", "bridge.log"))
    if not os.path.exists(log):
        return {"lines": [], "path": log, "exists": False}
    try:
        with open(log, "r", encoding="utf-8", errors="ignore") as fh:
            all_lines = fh.read().splitlines()
        return {"lines": all_lines[-lines:], "path": log, "exists": True}
    except Exception as e:
        return {"lines": [], "path": log, "exists": False, "error": str(e)}
