"""
score_renderer.py — Translates a SongScore into Ableton Live SESSION VIEW operations via TCP.

Architecture:
  - Each SectionDef in score.structure -> one Ableton Scene
  - Each TrackDef in score.tracks -> one Ableton track
  - Each ClipDef in a track -> clip slot at (track_index, scene_index)

Session View mapping:
  section "Verse"   -> scene index 1
  section "Chorus"  -> scene index 2
  ...

Clip placement (Session View only):
  - MIDI tracks: create_clip + add_notes_to_clip
  - Audio tracks: load_sample_to_clip (loads .wav into a clip slot)

Pattern generators (all computed on server side -- no Ableton logic needed):
  MIDI drums:   dembow_minimal, dembow_standard, dembow_double
  MIDI bass:    bass_sub, bass_pluck, bass_octaves, bass_sustained
  MIDI harmony: chords_verse, chords_chorus, melody_simple

Sample selection uses SampleMetadataStore for BPM/key-aware picking.
Auto-warp: samples whose BPM differs from project tempo get warp_clip_to_bpm.
"""

import json
import logging
import os
import sqlite3
import socket
from pathlib import Path
from typing import Dict, List, Optional, Tuple

from score_engine import SongScore, TrackDef, ClipDef

logger = logging.getLogger("ScoreRenderer")

DB_PATH = Path(
    r"C:\ProgramData\Ableton\Live 12 Suite\Resources\MIDI Remote Scripts"
    r"\libreria\reggaeton\sample_metadata.db"
)

# ------------------------------------------------------------------
# Ableton TCP transport (self-contained — no FastMCP dependency)
# ------------------------------------------------------------------

ABLETON_HOST = "127.0.0.1"
ABLETON_PORT = 9877
_TERMINATOR  = b"\n"


def _send(cmd_type: str, params: dict, timeout: float = 30.0) -> dict:
    """Send a command to Ableton via TCP and return the parsed response."""
    sock = None
    try:
        sock = socket.create_connection((ABLETON_HOST, ABLETON_PORT), timeout=timeout)
        sock.settimeout(timeout)
        msg = json.dumps({"type": cmd_type, "params": params}) + "\n"
        sock.sendall(msg.encode("utf-8"))

        buf = b""
        while True:
            chunk = sock.recv(65536)
            if not chunk:
                break
            buf += chunk
            if _TERMINATOR in buf:
                raw, _, _ = buf.partition(_TERMINATOR)
                return json.loads(raw.decode("utf-8"))

        return {"status": "error", "message": "No response terminator received"}
    except socket.timeout:
        return {"status": "error", "message": "Timeout after %.0fs on '%s'" % (timeout, cmd_type)}
    except ConnectionRefusedError:
        return {"status": "error",
                "message": "Cannot connect to Ableton on %s:%d" % (ABLETON_HOST, ABLETON_PORT)}
    except Exception as exc:
        return {"status": "error", "message": str(exc)}
    finally:
        if sock:
            try:
                sock.close()
            except Exception:
                pass


# ------------------------------------------------------------------
# Sample resolution — "kick/auto" or "kick/kick_01.wav" → absolute path
# ------------------------------------------------------------------

# Keyword mapping: invented filenames or short refs -> category/auto paths
# These map to BOTH folder names (main library) and DB categories (all libraries)
_SAMPLE_KEYWORD_MAP = {
    "kick": "kick/auto",
    "snare": "snare/auto",
    "hihat": "hihat/auto",
    "hi-hat": "hihat/auto",
    "hat": "hihat/auto",
    "clap": "snare/auto",
    "drumloop": "drumloops/auto",
    "drum_loop": "drumloops/auto",
    "drum": "drumloops/auto",
    "perc": "perc/auto",
    "bass": "bass/auto",
    "fx": "fx/auto",
    "transition": "fx/auto",
    "transicion": "fx/auto",
    "riser": "fx/auto",
    "impact": "impact/auto",
    "oneshot": "oneshots/auto",
    "synth": "synth/auto",
    "lead": "synth/auto",
    "pad": "pad/auto",
    "keys": "keys/auto",
    "vocal": "vocals/auto",
    "guitar": "guitar/auto",
    "melody": "melody/auto",
    "fill": "fill/auto",
    "chord": "chords/auto",
    "bell": "bells/auto",
    "brass": "brass/auto",
    "music_loop": "music_loop/auto",
}

# Map from folder-based auto path prefix to DB category name
_FOLDER_TO_CATEGORY = {
    "kick": "kick",
    "snare": "snare",
    "hihat": "hihat",
    "hi-hat (para percs normalmente)": "hihat",
    "drumloops": "drumloops",
    "perc loop": "perc",
    "bass": "bass",
    "fx": "fx",
    "oneshots": "oneshots",
    "synth": "synth",
    "pad": "pad",
    "keys": "keys",
    "vocals": "vocals",
    "guitar": "guitar",
    "melody": "melody",
    "fill": "fill",
    "chords": "chords",
    "bells": "bells",
    "brass": "brass",
    "impact": "impact",
    "music_loop": "music_loop",
    "perc": "perc",
}

# Valid MIDI pattern names
_VALID_PATTERNS = {
    "dembow_minimal", "dembow_standard", "dembow_double",
    "bass_sub", "bass_pluck", "bass_octaves", "bass_sustained",
    "chords_verse", "chords_chorus", "melody_simple",
}


def _sanitize_sample_ref(sample_ref: str) -> str:
    """Map invented filenames to correct folder/auto paths.

    Handles cases where LLMs generate names like:
      "kick 1.wav" → "kick/auto"
      "snare 3.wav" → "snare/auto"
      "hi-hat 1.wav" → "hi-hat (para percs normalmente)/auto"
    """
    if not sample_ref:
        return sample_ref
    if "/" in sample_ref:
        return sample_ref  # already has folder structure

    # Already an "auto" path without folder
    if sample_ref.lower() == "auto":
        return "kick/auto"

    # Strip common suffixes: .wav, .mp3, .aif, numbers, spaces
    name = sample_ref.lower()
    name = os.path.splitext(name)[0]  # remove .wav etc
    # Remove trailing numbers: "kick 1" → "kick", "bass_sub 2" → "bass_sub"
    name = name.strip()
    name_parts = name.rsplit(None, 1)
    if len(name_parts) == 2 and name_parts[1].isdigit():
        name = name_parts[0]

    # Keyword match
    for keyword, path in _SAMPLE_KEYWORD_MAP.items():
        if keyword in name.lower():
            return path

    return sample_ref  # no match, return as-is


def _sanitize_pattern_name(pattern: str) -> str:
    """Map invented pattern names to valid pattern names."""
    if not pattern:
        return "dembow_standard"
    if pattern in _VALID_PATTERNS:
        return pattern

    pat = pattern.lower().strip()
    # Remove file extensions
    pat = os.path.splitext(pat)[0]
    # Remove trailing numbers
    parts = pat.rsplit(None, 1)
    if len(parts) == 2 and parts[1].isdigit():
        pat = parts[0]

    # Keyword matching
    if "dembow" in pat:
        return "dembow_standard"
    if "bass" in pat:
        if "sub" in pat:
            return "bass_sub"
        if "pluck" in pat:
            return "bass_pluck"
        if "octave" in pat:
            return "bass_octaves"
        return "bass_sub"
    if "chord" in pat:
        if "chorus" in pat:
            return "chords_chorus"
        return "chords_verse"
    if "melody" in pat or "lead" in pat:
        return "melody_simple"
    if "snare" in pat or "perc" in pat or "hat" in pat or "hihat" in pat:
        return "dembow_standard"

    return "dembow_standard"  # default fallback


def _resolve_sample(sample_ref: str, lib_root: str, tempo: float = 95.0,
                    key: str = "") -> Tuple[Optional[str], Optional[float]]:
    """Resolve a sample reference to an absolute filesystem path and its native BPM.

    Formats accepted:
      "kick/auto"                -> best WAV from <lib_root>/kick/ (BPM/key matched)
      "kick/kick 1.wav"          -> exact file <lib_root>/kick/kick 1.wav
      "kick 1.wav"               -> sanitized to "kick/auto"
      "/C:/absolute/path.wav"    -> passthrough

    Returns: (absolute_path, sample_native_bpm_or_None)
    """
    if not sample_ref:
        return None, None

    # Sanitize invented filenames
    sample_ref = _sanitize_sample_ref(sample_ref)

    # Already absolute
    if os.path.isabs(sample_ref):
        if os.path.isfile(sample_ref):
            sample_bpm = _lookup_sample_bpm(sample_ref, lib_root)
            return sample_ref, sample_bpm
        return None, None

    parts = sample_ref.replace("\\", "/").split("/")

    if parts[-1].lower() == "auto":
        folder = os.path.join(lib_root, *parts[:-1])
        return _pick_best(folder, tempo, key)
    else:
        # Exact relative path
        path = os.path.join(lib_root, *parts)
        if os.path.isfile(path):
            sample_bpm = _lookup_sample_bpm(path, lib_root)
            return path, sample_bpm
        # Fallback: auto-select from the folder
        folder = os.path.join(lib_root, *parts[:-1]) if len(parts) > 1 else lib_root
        best, bpm = _pick_best(folder, tempo, key)
        if best:
            return best, bpm
        # Last resort: try keyword mapping on the whole ref
        sanitized = _sanitize_sample_ref(sample_ref)
        if sanitized != sample_ref:
            return _resolve_sample(sanitized, lib_root, tempo, key)
        return None, None


def _lookup_sample_bpm(abs_path: str, lib_root: str) -> Optional[float]:
    """Look up the native BPM of a sample from the metadata DB."""
    if not DB_PATH.exists():
        return None

    try:
        rel = os.path.relpath(abs_path, lib_root).replace("\\", "/")
    except ValueError:
        return None

    try:
        conn = sqlite3.connect(str(DB_PATH))
        conn.row_factory = sqlite3.Row
        c = conn.cursor()
        c.execute("SELECT bpm FROM samples WHERE path = ?", (rel,))
        row = c.fetchone()
        conn.close()
        if row and row["bpm"] and row["bpm"] > 0:
            return row["bpm"]
    except Exception:
        pass

    from engines.bpm_key_parser import parse_bpm
    parsed = parse_bpm(os.path.basename(abs_path))
    return parsed


def _pick_best(folder: str, tempo: float = 95.0, key: str = "") -> Tuple[Optional[str], Optional[float]]:
    """Pick the best audio file from a folder, using metadata DB when available.

    Strategy (with DB):
      1. Query SampleMetadataStore for samples in the folder's category
         that match BPM (within +-5) and key (if specified).
      2. Score candidates: BPM proximity + key match.
      3. Prefer tempo-matched samples for loops; oneshots need no BPM match.

    Strategy (without DB / fallback):
      1. Parse filenames for BPM numbers close to project tempo.
      2. Return first file alphabetically if no BPM info.

    Returns: (absolute_path, sample_bpm_or_None)
    """
    candidates = _pick_best_db(folder, tempo, key)
    if candidates:
        return candidates

    if not os.path.isdir(folder):
        return None, None

    files = sorted([
        os.path.join(folder, f)
        for f in os.listdir(folder)
        if f.lower().endswith((".wav", ".aif", ".aiff", ".mp3"))
    ])

    if not files:
        return None, None

    def bpm_score(fpath: str) -> float:
        fname = os.path.basename(fpath).replace("-", " ").replace("_", " ")
        for tok in fname.split():
            try:
                bpm = float(tok)
                if 60 < bpm < 220:
                    return abs(bpm - tempo)
            except ValueError:
                pass
        return 999.0

    scores = [(bpm_score(f), f) for f in files]
    best = min(scores, key=lambda x: x[0])
    best_path = best[1] if best[0] < 15.0 else files[0]

    sample_bpm = None
    if best[0] < 999.0:
        sample_bpm = tempo - best[0]

    return best_path, sample_bpm


def _pick_best_db(folder: str, tempo: float, key: str) -> Optional[Tuple[str, Optional[float]]]:
    """Try to pick best sample using the metadata DB with clean categories.

    Searches across ALL libraries (main, SentimientoLatino2025, reggaeton 3)
    for samples matching the requested category, BPM, and key.
    """
    if not DB_PATH.exists():
        return None

    folder_name = os.path.basename(folder).lower()
    category = _FOLDER_TO_CATEGORY.get(folder_name, folder_name)

    # Oneshot categories (no BPM matching needed)
    is_oneshot = category in ("kick", "snare", "hihat", "clap", "oneshots", "impact", "fill")

    lib_root = str(DB_PATH.parent)

    try:
        conn = sqlite3.connect(str(DB_PATH))
        conn.row_factory = sqlite3.Row
        c = conn.cursor()

        if is_oneshot:
            c.execute(
                """SELECT s.path, s.bpm, s.key FROM samples s
                   INNER JOIN sample_categories sc ON s.path = sc.path
                   WHERE sc.category = ?""",
                (category,)
            )
        else:
            bpm_min = tempo - 5
            bpm_max = tempo + 5
            c.execute(
                """SELECT s.path, s.bpm, s.key FROM samples s
                   INNER JOIN sample_categories sc ON s.path = sc.path
                   WHERE sc.category = ?
                   AND ((s.bpm >= ? AND s.bpm <= ?) OR s.bpm = 0 OR s.bpm IS NULL)""",
                (category, bpm_min, bpm_max)
            )

        rows = c.fetchall()
        conn.close()

        if not rows:
            return None

        candidates = []
        for row in rows:
            rel_path = row["path"]
            abs_path = os.path.join(lib_root, rel_path)
            if not os.path.isfile(abs_path):
                continue

            # Audio tracks: only return WAV/AIF/MP3, not MIDI files
            ext = os.path.splitext(rel_path)[1].lower()
            if ext in ('.mid', '.midi'):
                continue

            sample_bpm = row["bpm"] if row["bpm"] else None
            sample_key = row["key"] if row["key"] else ""

            score = 0.0

            # BPM proximity scoring (higher weight than key)
            if sample_bpm and sample_bpm > 0:
                bpm_diff = abs(sample_bpm - tempo)
                if bpm_diff <= 0.5:
                    score -= 20.0
                elif bpm_diff <= 1.5:
                    score -= 15.0
                elif bpm_diff <= 3:
                    score -= 10.0
                elif bpm_diff <= 5:
                    score -= 5.0
                elif bpm_diff <= 10:
                    score -= 1.0

            # Key matching scoring
            if key and sample_key:
                key_lower = key.lower().replace("maj", "").replace("min", "m").strip()
                sk_lower = sample_key.lower().replace("maj", "").replace("min", "m").strip()
                if key_lower == sk_lower:
                    score -= 8.0
                elif key_lower.replace("m", "") == sk_lower.replace("m", ""):
                    score -= 3.0

            # Prefer main library samples slightly (more tested)
            if "sentimientolatino" in rel_path.lower() or "reggaeton 3" in rel_path.lower():
                score += 0.5

            candidates.append((score, abs_path, sample_bpm))

        if not candidates:
            return None

        candidates.sort(key=lambda x: x[0])
        best = candidates[0]

        return (best[1], best[2])

    except Exception as exc:
        logger.warning("DB lookup failed for category '%s': %s", category, exc)
        return None


# ------------------------------------------------------------------
# MIDI pattern generators — use engines.pattern_library (Fase 4)
# ------------------------------------------------------------------

try:
    from engines.pattern_library import (
        BassPatterns, ChordProgressions, MelodyGenerator
    )
    _PATTERN_LIB_AVAILABLE = True
except ImportError:
    _PATTERN_LIB_AVAILABLE = False

_KEY_ROOTS: Dict[str, int] = {
    "C":  48, "C#": 49, "Db": 49,
    "D":  50, "D#": 51, "Eb": 51,
    "E":  52,
    "F":  53, "F#": 54, "Gb": 54,
    "G":  55, "G#": 56, "Ab": 56,
    "A":  57, "A#": 58, "Bb": 58,
    "B":  59,
    "Am": 45, "Dm": 38, "Em": 40, "Bm": 47,
    "F#m": 54, "C#m": 49, "Gm": 43, "Fm": 41,
}


def _root(key: str) -> int:
    return _KEY_ROOTS.get(key, 45)


def _extract_root_note(key: str) -> str:
    """Extract root note letter from key string: 'Am' -> 'A', 'F#m' -> 'F#', 'C' -> 'C'."""
    root = ""
    for ch in key:
        if ch.isalpha():
            root += ch
        elif ch == "#":
            root += ch
        else:
            break
    return root if root else "A"


def _gen_dembow(bars: int, variation: str, key: str) -> List[Dict]:
    """Dembow drum pattern on MIDI note 36 (kick)."""
    bpb = 4
    total = bars * bpb
    notes = []
    patterns = {
        "minimal":  [0.0, 2.5],
        "standard": [0.0, 1.5, 2.0, 2.5, 3.0, 3.5],
        "double":   [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5],
    }
    pos_list = patterns.get(variation, patterns["standard"])

    for bar in range(bars):
        for pos in pos_list:
            start = bar * bpb + pos
            if start >= total:
                continue
            vel = 110 if pos == 0.0 else (90 if pos in (2.0, 3.0) else 75)
            notes.append({"pitch": 36, "start_time": start, "duration": 0.22, "velocity": vel})

    return notes


def _gen_bass(bars: int, style: str, key: str) -> List[Dict]:
    """Bass MIDI patterns. Uses BassPatterns engine when available."""
    if _PATTERN_LIB_AVAILABLE:
        style_map = {
            "bass_sub": "sub",
            "bass_pluck": "pluck",
            "bass_octaves": "sub",
            "bass_sustained": "sustained",
        }
        lib_style = style_map.get(style, "sub")
        try:
            events = BassPatterns.get_bass_line(
                bars=bars, key=key, style=lib_style
            )
            return [
                {"pitch": e.pitch, "start_time": e.start_time,
                 "duration": e.duration, "velocity": e.velocity}
                for e in events
            ]
        except Exception as exc:
            logger.warning("BassPatterns failed, using fallback: %s", exc)

    root = _root(key)
    bpb = 4
    notes = []
    for bar in range(bars):
        b = bar * bpb
        if style == "bass_sub":
            notes += [
                {"pitch": root - 12, "start_time": b,       "duration": 0.5,  "velocity": 110},
                {"pitch": root - 12, "start_time": b + 2.0, "duration": 0.5,  "velocity": 100},
            ]
        elif style == "bass_octaves":
            notes += [
                {"pitch": root - 12, "start_time": b,       "duration": 0.5,  "velocity": 110},
                {"pitch": root,      "start_time": b + 2.0, "duration": 0.5,  "velocity":  90},
                {"pitch": root - 12, "start_time": b + 3.0, "duration": 0.25, "velocity":  80},
            ]
        elif style == "bass_pluck":
            notes += [
                {"pitch": root - 12, "start_time": b,       "duration": 0.25, "velocity": 110},
                {"pitch": root - 12, "start_time": b + 1.5, "duration": 0.25, "velocity":  85},
                {"pitch": root - 7,  "start_time": b + 2.0, "duration": 0.25, "velocity":  90},
                {"pitch": root - 12, "start_time": b + 3.0, "duration": 0.25, "velocity":  80},
            ]
        elif style == "bass_sustained":
            notes.append(
                {"pitch": root - 12, "start_time": b, "duration": float(bpb) - 0.25, "velocity": 100}
            )
        else:
            notes.append({"pitch": root - 12, "start_time": b, "duration": 0.5, "velocity": 100})
    return notes


def _gen_chords(bars: int, style: str, key: str) -> List[Dict]:
    """Chord voicing patterns. Uses ChordProgressions engine when available."""
    if _PATTERN_LIB_AVAILABLE:
        prog_map = {
            "chords_verse": "i-VI-III-VII",
            "chords_chorus": "vi-IV-I-V",
        }
        prog_name = prog_map.get(style, "i-VI-III-VII")
        try:
            chords = ChordProgressions.get_progression(prog_name, key=key, bars=bars)
            notes = []
            for chord in chords:
                for pitch in chord["notes"]:
                    notes.append({
                        "pitch": pitch,
                        "start_time": chord["start_beat"],
                        "duration": chord["duration"] - 0.25,
                        "velocity": 72,
                    })
            return notes
        except Exception as exc:
            logger.warning("ChordProgressions failed, using fallback: %s", exc)

    root = _root(key)
    bpb = 4
    notes = []
    PROG_VERSE = [(0, 3, 7), (-5, -2, 2), (-3, 0, 4), (-7, -4, 0)]
    PROG_CHORUS = [(0, 3, 7), (-3, 0, 4), (5, 8, 12), (0, 3, 7)]
    prog = PROG_VERSE if "verse" in style else PROG_CHORUS

    for bar in range(bars):
        chord_intervals = prog[bar % len(prog)]
        start = float(bar * bpb)
        for interval in chord_intervals:
            notes.append({
                "pitch": root + interval,
                "start_time": start,
                "duration": float(bpb) - 0.25,
                "velocity": 72,
            })
    return notes


def _gen_melody_simple(bars: int, key: str) -> List[Dict]:
    """Melodic line. Uses MelodyGenerator engine when available."""
    if _PATTERN_LIB_AVAILABLE:
        try:
            events = MelodyGenerator.generate_melody(
                bars=bars, key=key, density=0.5
            )
            return [
                {"pitch": e.pitch, "start_time": e.start_time,
                 "duration": e.duration, "velocity": e.velocity}
                for e in events
            ]
        except Exception as exc:
            logger.warning("MelodyGenerator failed, using fallback: %s", exc)

    root = _root(key)
    scale = [0, 3, 5, 7, 10, 12]
    bpb = 4
    notes = []
    rhythm = [0.0, 0.75, 1.5, 2.0, 2.75, 3.0, 3.5]

    for bar in range(bars):
        b = bar * bpb
        for i, pos in enumerate(rhythm):
            pitch = root + scale[(bar * 3 + i) % len(scale)] + 12
            notes.append({"pitch": pitch, "start_time": b + pos, "duration": 0.5, "velocity": 85})

    return notes


def _detect_key_from_drumloops(tracks: List[TrackDef], lib_root: str,
                               tempo: float, default_key: str) -> str:
    """Detect the musical key from the first drumloop sample found.

    When a drumloop has key metadata (from filename or DB), use it as the
    project key for MIDI pattern transposition (Fase 5).

    Returns the detected key or default_key if none found.
    """
    for track in tracks:
        if track.type != "audio":
            continue
        for clip in track.clips:
            if not clip.sample:
                continue
            sample_ref = _sanitize_sample_ref(clip.sample)
            is_loop = any(kw in sample_ref.lower() for kw in
                         ("drumloop", "loop", "perc loop", "drum"))
            if not is_loop:
                continue

            abs_path, _ = _resolve_sample(sample_ref, lib_root, tempo)
            if not abs_path:
                continue

            if DB_PATH.exists():
                try:
                    rel = os.path.relpath(abs_path, lib_root).replace("\\", "/")
                    conn = sqlite3.connect(str(DB_PATH))
                    conn.row_factory = sqlite3.Row
                    c = conn.cursor()
                    c.execute("SELECT key FROM samples WHERE path = ?", (rel,))
                    row = c.fetchone()
                    conn.close()
                    if row and row["key"] and row["key"].strip():
                        db_key = row["key"].strip()
                        if db_key != "C" and len(db_key) >= 2:
                            logger.info("Detected key '%s' from drumloop: %s", db_key, os.path.basename(abs_path))
                            return db_key
                except Exception:
                    pass

            from engines.bpm_key_parser import parse_key
            fn_key = parse_key(os.path.basename(abs_path))
            if fn_key and fn_key != "C" and len(fn_key) >= 2:
                logger.info("Detected key '%s' from drumloop filename: %s", fn_key, os.path.basename(abs_path))
                return fn_key

    return default_key


# Registry: pattern_name -> generator(bars, key) -> List[Dict]
PATTERN_GENERATORS: Dict = {
    "dembow_minimal":   lambda bars, key: _gen_dembow(bars, "minimal",        key),
    "dembow_standard":  lambda bars, key: _gen_dembow(bars, "standard",       key),
    "dembow_double":    lambda bars, key: _gen_dembow(bars, "double",         key),
    "bass_sub":         lambda bars, key: _gen_bass(bars,   "bass_sub",       key),
    "bass_pluck":       lambda bars, key: _gen_bass(bars,   "bass_pluck",     key),
    "bass_octaves":     lambda bars, key: _gen_bass(bars,   "bass_octaves",   key),
    "bass_sustained":   lambda bars, key: _gen_bass(bars,   "bass_sustained", key),
    "chords_verse":     lambda bars, key: _gen_chords(bars, "chords_verse",   key),
    "chords_chorus":    lambda bars, key: _gen_chords(bars, "chords_chorus",  key),
    "melody_simple":    lambda bars, key: _gen_melody_simple(bars, key),
}


# ------------------------------------------------------------------
# ScoreRenderer — SESSION VIEW
# ------------------------------------------------------------------

class ScoreRenderer:
    """Renders a SongScore into Ableton Live Session View via TCP.

    Mapping:
      SectionDef → Ableton Scene  (one scene per section)
      TrackDef   → Ableton Track  (one track per track definition)
      ClipDef    → Clip Slot at (track_index, scene_index)

    Usage:
        renderer = ScoreRenderer(lib_root="C:\\...\\libreria\\reggaeton")
        result   = renderer.render(score, clear_first=True)
    """

    def __init__(self, lib_root: str):
        self.lib_root = str(lib_root)

    # ----------------------------------------------------------------
    # Public entry point
    # ----------------------------------------------------------------

    def render(self, score: SongScore, clear_first: bool = True) -> dict:
        """Render score into Ableton Live Session View.

        Returns:
            {
              "title":          str,
              "scenes_created": int,
              "tracks_created": list,
              "clips_created":  int,
              "errors":         list[str],
              "success":        bool,
            }
        """
        result: Dict = {
            "title":          score.meta.get("title", ""),
            "scenes_created": 0,
            "tracks_created": [],
            "clips_created":  0,
            "errors":         [],
        }

        # Validate score first (no Ableton needed)
        warnings = score.validate()
        if warnings:
            result["errors"].extend(["[VALIDATION] " + w for w in warnings])

        # 0. Clear project. Ableton leaves 1 track (minimum). We accept this
        #    and offset our track creation accordingly.
        if clear_first:
            _send("clear_project", {}, timeout=30.0)

        # 1. Set meta (tempo, signature)
        self._set_meta(score.meta, result)

        # 2. Create scenes (one per section in score.structure)
        section_scene_map = self._create_scenes(score.structure, result)

        # 3. Create tracks
        track_index_map = self._create_tracks(score.tracks, result)

        # 4. Place clips into clip slots
        self._place_clips(score, track_index_map, section_scene_map, result)

        # 6. Apply mixer settings
        self._apply_mixer(score.tracks, track_index_map, result)

        result["success"]    = len(result["errors"]) == 0
        result["section_map"] = section_scene_map
        return result

    # ----------------------------------------------------------------
    # Meta
    # ----------------------------------------------------------------

    def _set_meta(self, meta: dict, result: dict) -> None:
        tempo = meta.get("tempo", 95)
        resp  = _send("set_tempo", {"tempo": tempo}, timeout=10.0)
        if resp.get("status") != "success":
            result["errors"].append("set_tempo failed: " + resp.get("message", "?"))

        sig = meta.get("time_signature", "4/4").split("/")
        if len(sig) == 2:
            _send("set_signature",
                  {"numerator": int(sig[0]), "denominator": int(sig[1])},
                  timeout=10.0)

    # ----------------------------------------------------------------
    # Scenes — one per section
    # ----------------------------------------------------------------

    def _create_scenes(self, structure, result: dict) -> Dict[str, int]:
        """Create one scene per section. Returns {section_name: scene_index}."""
        section_scene_map: Dict[str, int] = {}

        for i, section in enumerate(structure):
            # Ableton starts with at least 1 empty scene;
            # create additional scenes as needed
            if i == 0:
                scene_idx = 0   # reuse the default first scene
            else:
                resp = _send("create_scene", {"index": -1}, timeout=15.0)
                if resp.get("status") != "success":
                    result["errors"].append(
                        "create_scene failed for '%s': %s"
                        % (section.name, resp.get("message", "?"))
                    )
                    scene_idx = i   # fallback: assume sequential
                else:
                    scene_idx = resp.get("result", {}).get("scene_index", i)

            # Name the scene
            _send("set_scene_name",
                  {"scene_index": scene_idx, "name": section.name},
                  timeout=10.0)

            section_scene_map[section.name] = scene_idx
            result["scenes_created"] += 1

        return section_scene_map

    # ----------------------------------------------------------------
    # Tracks
    # ----------------------------------------------------------------

    def _create_tracks(self, tracks: List[TrackDef], result: dict) -> Dict[str, int]:
        """Create all tracks and return {track_id: ableton_track_index}.

        IMPORTANT: Ableton groups tracks by type. All audio tracks come first,
        then all MIDI tracks. After clear_project, 1 leftover track remains.

        Strategy:
        1. Snapshot track count before creation
        2. Create all tracks (Ableton auto-groups audio before MIDI)
        3. Snapshot after creation
        4. New tracks are identified by comparing before/after
        5. Map our tracks to the new ones by type order
        """
        if not tracks:
            return {}

        # Count pre-existing tracks by type
        resp = _send("get_tracks", {}, timeout=10.0)
        pre_audio = 0
        pre_midi = 0
        if resp.get("status") == "success":
            for t in resp.get("result", {}).get("tracks", []):
                if isinstance(t, dict):
                    if t.get("is_audio"):
                        pre_audio += 1
                    elif t.get("is_midi"):
                        pre_midi += 1

        # Create all tracks
        for track in tracks:
            if track.type == "audio":
                _send("create_audio_track", {"index": -1}, timeout=15.0)
            else:
                _send("create_midi_track", {"index": -1}, timeout=15.0)

        # Now count tracks AFTER creation to find the new ones
        resp = _send("get_tracks", {}, timeout=10.0)
        ableton_tracks = resp.get("result", {}).get("tracks", []) if resp.get("status") == "success" else []

        new_audio = [t for t in ableton_tracks if isinstance(t, dict) and t.get("is_audio")]
        new_midi = [t for t in ableton_tracks if isinstance(t, dict) and t.get("is_midi")]

        # Separate our tracks by type
        our_audio = [t for t in tracks if t.type == "audio"]
        our_midi = [t for t in tracks if t.type == "midi"]

        track_index_map: Dict[str, int] = {}

        # Map audio tracks: our_audio[i] → new_audio[pre_audio + i]
        for i, our_t in enumerate(our_audio):
            ableton_idx = pre_audio + i
            if ableton_idx < len(new_audio):
                a_idx = new_audio[ableton_idx].get("index", ableton_idx)
            else:
                a_idx = ableton_idx  # fallback

            _send("set_track_name", {"track_index": a_idx, "name": our_t.name})
            _send("set_track_volume", {"track_index": a_idx, "volume": our_t.mixer.volume})
            if our_t.mixer.pan != 0.0:
                _send("set_track_pan", {"track_index": a_idx, "pan": our_t.mixer.pan})

            track_index_map[our_t.id] = a_idx
            result["tracks_created"].append({
                "id": our_t.id, "name": our_t.name,
                "index": a_idx, "type": "audio",
            })

        # Map MIDI tracks: our_midi[i] → new_midi[pre_midi + i]
        for i, our_t in enumerate(our_midi):
            ableton_idx = pre_midi + i
            if ableton_idx < len(new_midi):
                m_idx = new_midi[ableton_idx].get("index", ableton_idx)
            else:
                m_idx = ableton_idx + len(new_audio)  # fallback: after all audio

            _send("set_track_name", {"track_index": m_idx, "name": our_t.name})
            _send("set_track_volume", {"track_index": m_idx, "volume": our_t.mixer.volume})
            if our_t.mixer.pan != 0.0:
                _send("set_track_pan", {"track_index": m_idx, "pan": our_t.mixer.pan})

            if our_t.instrument:
                _send("insert_device",
                      {"track_index": m_idx,
                       "device_name": our_t.instrument,
                       "device_type": "instrument"},
                      timeout=30.0)

            track_index_map[our_t.id] = m_idx
            result["tracks_created"].append({
                "id": our_t.id, "name": our_t.name,
                "index": m_idx, "type": "midi",
            })

        return track_index_map

    # ----------------------------------------------------------------
    # Clip placement — Session View clip slots
    # ----------------------------------------------------------------

    def _place_clips(self, score: SongScore, track_index_map: Dict[str, int],
                     section_scene_map: Dict[str, int], result: dict) -> None:
        key   = score.meta.get("key",   "Am")
        tempo = score.meta.get("tempo", 95.0)

        # Fase 5: Detect key from the first drumloop loaded and use it for MIDI.
        # This ensures MIDI patterns follow the drumloop's harmonic context.
        detected_key = _detect_key_from_drumloops(score.tracks, self.lib_root, tempo, key)
        if detected_key != key:
            logger.info("Harmonic coherence: overriding key %s -> %s (from drumloop)", key, detected_key)
            result["detected_key"] = detected_key
            key = detected_key

        for track in score.tracks:
            if track.id not in track_index_map:
                continue
            t_idx = track_index_map[track.id]

            for clip in track.clips:
                # Resolve scene index from section name
                section_name = clip.section
                if section_name and section_name in section_scene_map:
                    scene_idx = section_scene_map[section_name]
                elif clip.start_bar is not None:
                    # Fallback: treat start_bar as a scene index approximation
                    scene_idx = 0
                else:
                    scene_idx = 0

                clip_label = "%s_%s" % (section_name or "clip", track.id)

                if track.type == "audio":
                    self._place_audio_clip(t_idx, scene_idx, clip, clip_label, tempo, key, result)
                else:
                    self._place_midi_clip(t_idx, scene_idx, clip, clip_label, key, result)

    def _place_audio_clip(self, track_idx: int, scene_idx: int,
                           clip: ClipDef, label: str,
                           tempo: float, key: str, result: dict) -> None:
        """Load an audio sample into a Session View clip slot.

        If the sample's native BPM differs from the project tempo, auto-warp
        is applied using warp_clip_to_bpm (Fase 3).
        """
        sample_path, sample_bpm = _resolve_sample(clip.sample, self.lib_root, tempo, key)
        if not sample_path:
            result["errors"].append(
                "Clip '%s': sample '%s' not found (lib_root=%s)"
                % (label, clip.sample, self.lib_root)
            )
            return

        resp = _send("load_sample_direct", {
            "track_index": track_idx,
            "slot_index":  scene_idx,
            "file_path":  sample_path,
            "warp":        clip.warp,
        }, timeout=30.0)

        if resp.get("status") != "success" or not (resp.get("loaded") or resp.get("status") == "success"):
            resp2 = _send("load_sample_to_clip", {
                "track_index": track_idx,
                "clip_index":  scene_idx,
                "sample_path": sample_path,
            }, timeout=30.0)

            if not (resp2.get("status") == "success" or resp2.get("loaded")):
                result["errors"].append(
                    "Audio clip '%s' failed: primary=%s fallback=%s path=%s"
                    % (label, resp.get("error", resp.get("message", "?")),
                       resp2.get("error", resp2.get("message", "?")), sample_path)
                )
                return

        result["clips_created"] += 1

        # Auto-warp (Fase 3): if sample has a known BPM that differs from project
        if sample_bpm and sample_bpm > 0 and abs(sample_bpm - tempo) > 1.5:
            warp_resp = _send("warp_clip_to_bpm", {
                "track_index": track_idx,
                "clip_index":  scene_idx,
                "original_bpm": sample_bpm,
                "target_bpm":   tempo,
            }, timeout=20.0)
            if warp_resp.get("status") == "success":
                logger.info(
                    "Auto-warped '%s': %.1f -> %.1f BPM",
                    label, sample_bpm, tempo
                )
                result.setdefault("warped", []).append({
                    "clip": label,
                    "from_bpm": sample_bpm,
                    "to_bpm": tempo,
                })
            else:
                logger.warning(
                    "Auto-warp failed for '%s': %s", label, warp_resp.get("message", "?")
                )

    def _place_midi_clip(self, track_idx: int, scene_idx: int,
                          clip: ClipDef, label: str,
                          key: str, result: dict) -> None:
        """Create a MIDI clip in Session View and fill it with notes."""
        length_beats = clip.duration_bars * 4   # assume 4/4

        # 1. Create the clip slot
        resp = _send("create_clip", {
            "track_index": track_idx,
            "clip_index":  scene_idx,
            "length":      length_beats,
        }, timeout=20.0)

        if resp.get("status") != "success":
            result["errors"].append(
                "MIDI clip create '%s' failed: %s" % (label, resp.get("message", "?"))
            )
            return

        # 2. Resolve notes
        if clip.notes:
            notes = clip.notes
        elif clip.pattern:
            gen = PATTERN_GENERATORS.get(clip.pattern)
            if gen:
                notes = gen(int(clip.duration_bars), key)
            else:
                result["errors"].append(
                    "Unknown pattern '%s' on clip '%s'" % (clip.pattern, label)
                )
                notes = []
        else:
            notes = []

        # 3. Add notes
        if notes:
            resp = _send("add_notes_to_clip", {
                "track_index": track_idx,
                "clip_index":  scene_idx,
                "notes":       notes,
            }, timeout=20.0)

            if resp.get("status") != "success":
                result["errors"].append(
                    "add_notes '%s' failed: %s" % (label, resp.get("message", "?"))
                )

        result["clips_created"] += 1

    # ----------------------------------------------------------------
    # Mixer / Effects
    # ----------------------------------------------------------------

    def _apply_mixer(self, tracks: List[TrackDef], track_index_map: Dict[str, int],
                     result: dict) -> None:
        for track in tracks:
            if track.id not in track_index_map:
                continue
            t_idx = track_index_map[track.id]
            mx    = track.mixer

            if mx.eq_preset:
                resp = _send("configure_eq",
                             {"track_index": t_idx, "preset": mx.eq_preset},
                             timeout=15.0)
                if resp.get("status") != "success":
                    result["errors"].append(
                        "EQ preset '%s' on '%s' failed: %s"
                        % (mx.eq_preset, track.id, resp.get("message", "?"))
                    )

            # Compression presets are stored but not applied (configure_compressor not available)

            if mx.send_reverb > 0:
                _send("set_track_send",
                      {"track_index": t_idx, "send_index": 0, "amount": mx.send_reverb})
            if mx.send_delay > 0:
                _send("set_track_send",
                      {"track_index": t_idx, "send_index": 1, "amount": mx.send_delay})


# ------------------------------------------------------------------
# Convenience: render a score file directly
# ------------------------------------------------------------------

def render_file(json_path: str, lib_root: str, clear_first: bool = True) -> dict:
    """Load a SongScore JSON from disk and render it into Ableton Session View."""
    score    = SongScore.load(json_path)
    renderer = ScoreRenderer(lib_root)
    return renderer.render(score, clear_first=clear_first)
