"""
sample_selector.py - Selector inteligente de samples (Fase 4 mejorada)

Proporciona:
- Selección contextual basada en género, key, BPM
- Matching armónico entre samples
- Creación de kits de batería coherentes
- Recomendaciones basadas en compatibilidad
- Mapeo MIDI automático

Mejoras Fase 4:
- Ranking mejorado con múltiples factores de similitud
- Diversidad entre corridas con seeding determinista
- Validación de roles para evitar elecciones absurdas
- Penalización de familias repetidas
- Balance one-shots vs loops
- Soporte opcional para GPU/embeddings
"""

import random
import logging
import hashlib
import re
import time
import os
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass, field
from collections import defaultdict, deque
from pathlib import Path

# Detección de numpy para cálculos vectorizados
try:
    import numpy as np
    NUMPY_AVAILABLE = True
except ImportError:
    NUMPY_AVAILABLE = False
    np = None

# Detección de GPU (cupy) para aceleración
try:
    import cupy as cp
    GPU_AVAILABLE = True
except ImportError:
    GPU_AVAILABLE = False
    cp = None

# Imports del sistema de samples
try:
    from .sample_manager import SampleManager, Sample, get_manager
    from .audio_analyzer import AudioAnalyzer, calculate_key_compatibility
    MANAGER_AVAILABLE = True
except ImportError:
    try:
        from sample_manager import SampleManager, Sample, get_manager
        from audio_analyzer import AudioAnalyzer, calculate_key_compatibility
        MANAGER_AVAILABLE = True
    except ImportError:
        MANAGER_AVAILABLE = False
        SampleManager = None
        Sample = None
        AudioAnalyzer = None
        calculate_key_compatibility = None

logger = logging.getLogger("SampleSelector")

# ============================================================================
# IMPORTS DE MEMORIA DE DIVERSIDAD (Phase 5)
# ============================================================================
try:
    from .diversity_memory import (
        get_diversity_memory,
        record_sample_usage,
        record_generation_complete,
        get_penalty_for_sample,
        detect_sample_family,
        get_spectral_penalty,
        DIVERSITY_MEMORY_AVAILABLE
    )
    DIVERSITY_MEMORY_AVAILABLE = True
except ImportError:
    try:
        from diversity_memory import (
            get_diversity_memory,
            record_sample_usage,
            record_generation_complete,
            get_penalty_for_sample,
            detect_sample_family,
            get_spectral_penalty,
        )
        DIVERSITY_MEMORY_AVAILABLE = True
    except ImportError:
        DIVERSITY_MEMORY_AVAILABLE = False
        get_diversity_memory = None
        record_sample_usage = None
        record_generation_complete = None
        get_penalty_for_sample = None
        detect_sample_family = None
        get_spectral_penalty = None

try:
    from .spectral_engine import get_spectral_engine, SpectralProfile
    SPECTRAL_ENGINE_AVAILABLE = True
except ImportError:
    try:
        from spectral_engine import get_spectral_engine, SpectralProfile
        SPECTRAL_ENGINE_AVAILABLE = True
    except ImportError:
        SPECTRAL_ENGINE_AVAILABLE = False
        get_spectral_engine = None
        SpectralProfile = None

_spectral_engine_instance = None

def get_selector_spectral_engine():
    global _spectral_engine_instance
    if _spectral_engine_instance is None and SPECTRAL_ENGINE_AVAILABLE:
        _spectral_engine_instance = get_spectral_engine()
    return _spectral_engine_instance

# Memoria entre generaciones (legacy, mantener para compatibilidad)
# Ahora delegamos a diversity_memory.py para persistencia
_cross_generation_family_memory: Dict[str, int] = defaultdict(int)
_cross_generation_path_memory: Dict[str, int] = defaultdict(int)
_cross_generation_generation_count: int = 0

_recent_sample_diversity_memory: Dict[str, List[str]] = defaultdict(list)
RECENT_MEMORY_MAX_PER_ROLE = 50

def _get_cross_generation_memory() -> Dict[str, int]:
    """Retorna copia de la memoria entre generaciones."""
    return _cross_generation_family_memory.copy()

def _update_cross_generation_memory(families_used: Dict[str, int], paths_used: List[str] = None) -> None:
    """Actualiza memoria cross-generation con familias y paths usados.

    Esta función ahora delega principalmente a diversity_memory.py para
    persistencia persistente, pero mantiene la memoria en memoria para
    compatibilidad con código existente.
    """
    global _cross_generation_family_memory, _cross_generation_path_memory, _cross_generation_generation_count
    _cross_generation_generation_count += 1

    # Delegar al sistema de memoria persistente
    if DIVERSITY_MEMORY_AVAILABLE:
        try:
            record_generation_complete()
            logger.debug("Memoria cross-generation persistida (generación %d)", _cross_generation_generation_count)
        except Exception as e:
            logger.warning("Error actualizando memoria persistente: %s", e)

    # Mantener memoria en RAM para compatibilidad
    for family in list(_cross_generation_family_memory.keys()):
        _cross_generation_family_memory[family] = max(0, _cross_generation_family_memory[family] - 1)

    for path in list(_cross_generation_path_memory.keys()):
        _cross_generation_path_memory[path] = max(0, _cross_generation_path_memory[path] - 1)

    for family, count in families_used.items():
        _cross_generation_family_memory[family] = _cross_generation_family_memory.get(family, 0) + count

    if paths_used:
        for path in paths_used:
            _cross_generation_path_memory[path] = _cross_generation_path_memory.get(path, 0) + 1

    _cross_generation_family_memory = defaultdict(
        int,
        {k: v for k, v in _cross_generation_family_memory.items() if v > 0},
    )
    _cross_generation_path_memory = defaultdict(
        int,
        {k: v for k, v in _cross_generation_path_memory.items() if v > 0},
    )

def reset_cross_generation_memory() -> None:
    """Limpia toda la memoria cross-generation (RAM y persistente)."""
    global _cross_generation_family_memory, _cross_generation_path_memory, _cross_generation_generation_count, _recent_sample_diversity_memory

    # Limpiar memoria persistente
    if DIVERSITY_MEMORY_AVAILABLE:
        try:
            from .diversity_memory import reset_diversity_memory
            reset_diversity_memory()
            logger.info("Memoria de diversidad persistente reseteada")
        except ImportError:
            try:
                from diversity_memory import reset_diversity_memory
                reset_diversity_memory()
                logger.info("Memoria de diversidad persistente reseteada")
            except ImportError:
                pass

    # Limpiar memoria en RAM
    _cross_generation_family_memory.clear()
    _cross_generation_path_memory.clear()
    _cross_generation_generation_count = 0
    _recent_sample_diversity_memory.clear()

def add_to_recent_memory(role: str, sample_path: str) -> None:
    """Add a sample path to the recent memory for its role."""
    global _recent_sample_diversity_memory
    if role not in _recent_sample_diversity_memory:
        _recent_sample_diversity_memory[role] = []
    if sample_path not in _recent_sample_diversity_memory[role]:
        _recent_sample_diversity_memory[role].append(sample_path)
        if len(_recent_sample_diversity_memory[role]) > RECENT_MEMORY_MAX_PER_ROLE:
            _recent_sample_diversity_memory[role] = _recent_sample_diversity_memory[role][-RECENT_MEMORY_MAX_PER_ROLE:]

def get_recent_memory_penalty(role: str, sample_path: str) -> float:
    """Get penalty for a sample that was recently used for the same role.Returns 1.0 (no penalty) to 0.1 (strong penalty)."""
    global _recent_sample_diversity_memory
    role_samples = _recent_sample_diversity_memory.get(role, [])
    if sample_path not in role_samples:
        return 1.0
    position = role_samples.index(sample_path)
    recency = len(role_samples) - position
    if recency <= 5:
        return 0.1
    elif recency <= 10:
        return 0.25
    elif recency <= 20:
        return 0.5
    elif recency <= 30:
        return 0.7
    else:
        return 0.85

def get_recent_sample_diversity_state() -> Dict[str, List[str]]:
    """Get copy of recent sample diversity memory."""
    return {role: list(paths) for role, paths in _recent_sample_diversity_memory.items()}

def sync_cross_generation_memory_from_reference(families: Dict[str, int], paths: Dict[str, int]) -> None:
    """Sincroniza memoria cross-generation con reference_listener (para consistencia)."""
    global _cross_generation_family_memory, _cross_generation_path_memory
    for family, count in families.items():
        if count > 0:
            _cross_generation_family_memory[family] = max(
                _cross_generation_family_memory.get(family, 0), count
            )
    for path, count in paths.items():
        if count > 0:
            _cross_generation_path_memory[path] = max(
                _cross_generation_path_memory.get(path, 0), count
            )

def get_cross_generation_state() -> Tuple[Dict[str, int], Dict[str, int]]:
    """Retorna la memoria cross-generation actual (familias, paths)."""
    return (
        dict(_cross_generation_family_memory),
        dict(_cross_generation_path_memory)
    )


@dataclass
class SampleDecision:
    """Registro estructurado de decision de seleccion de sample."""
    sample_name: str
    target_role: str
    final_score: float
    selected: bool
    rejection_reasons: list[str] = field(default_factory=list)
    bonus_factors: list[str] = field(default_factory=list)
    selection_index: int = -1
    
    section_kind: str = ""  # T070: Track which section this sample was selected for
    
    score_range_tolerance: float = 0.15
    freshness_score: float = 1.0
    coherence_score: float = 1.0
    spectral_coherence_score: float = 1.0  # T024: Spectral coherence score
    pack_name: str = ""  # T066: Track pack name for pack lock

    def to_log_str(self) -> str:
        section_info = f", section={self.section_kind}" if self.section_kind else ""
        pack_info = f", pack={self.pack_name}" if self.pack_name else ""
        if self.selected:
            bonuses = ", ".join(self.bonus_factors) if self.bonus_factors else "none"
            return f"SELECTED: {self.sample_name} for {self.target_role}{section_info} (score={self.final_score:.3f}, freshness={self.freshness_score:.2f}, coherence={self.coherence_score:.2f}, spectral={self.spectral_coherence_score:.2f}{pack_info}, bonuses={bonuses})"
        else:
            reasons = ", ".join(self.rejection_reasons) if self.rejection_reasons else "low score"
            return f"REJECTED: {self.sample_name} for {self.target_role}{section_info} ({reasons})"


class GenreProfile:
    """Perfil musical para un género específico"""

    def __init__(self,
                 name: str,
                 bpm_range: Tuple[int, int],
                 common_keys: List[str],
                 drum_pattern: str,
                 bass_style: str,
                 characteristics: List[str],
                 spectral_targets: Optional[Dict[str, Dict[str, Any]]] = None):
        self.name = name
        self.bpm_range = bpm_range
        self.common_keys = common_keys
        self.drum_pattern = drum_pattern
        self.bass_style = bass_style
        self.characteristics = characteristics
        self.spectral_targets = spectral_targets or {}


# Perfiles de géneros musicales
GENRE_PROFILES = {
    'techno': GenreProfile(
        name='Techno',
        bpm_range=(125, 140),
        common_keys=['F#m', 'Am', 'Dm', 'Gm', 'Cm'],
        drum_pattern='four_on_floor',
        bass_style='rolling',
        characteristics=['driving', 'industrial', 'repetitive', 'dark']
    ),
    'industrial-techno': GenreProfile(
        name='Industrial Techno',
        bpm_range=(135, 150),
        common_keys=['F#m', 'Am', 'Dm'],
        drum_pattern='distorted_four',
        bass_style='aggressive',
        characteristics=['distorted', 'harsh', 'mechanical', 'dark']
    ),
    'minimal-techno': GenreProfile(
        name='Minimal Techno',
        bpm_range=(124, 130),
        common_keys=['F#m', 'Am', 'Em'],
        drum_pattern='sparse',
        bass_style='minimal',
        characteristics=['stripped', 'subtle', 'groove', 'reduced']
    ),
    'house': GenreProfile(
        name='House',
        bpm_range=(118, 128),
        common_keys=['Am', 'Fm', 'Cm', 'Gm', 'Dm'],
        drum_pattern='classic_house',
        bass_style='funky',
        characteristics=['soulful', 'groovy', 'warm', 'organic']
    ),
    'deep-house': GenreProfile(
        name='Deep House',
        bpm_range=(120, 124),
        common_keys=['Am', 'Fm', 'Dm', 'Gm'],
        drum_pattern='deep_house',
        bass_style='subby',
        characteristics=['deep', 'jazzy', 'warm', 'mellow']
    ),
    'tech-house': GenreProfile(
        name='Tech House',
        bpm_range=(124, 128),
        common_keys=['F#m', 'Am', 'Gm', 'Cm'],
        drum_pattern='bouncy',
        bass_style='groovy',
        characteristics=['bouncy', 'funky', 'percussive', 'club']
    ),
    'progressive-house': GenreProfile(
        name='Progressive House',
        bpm_range=(126, 132),
        common_keys=['Fm', 'Am', 'Dm', 'Gm'],
        drum_pattern='progressive',
        bass_style='driving',
        characteristics=['epic', 'buildup', 'melodic', 'anthem']
    ),
    'trance': GenreProfile(
        name='Trance',
        bpm_range=(135, 150),
        common_keys=['Fm', 'Am', 'Dm', 'Gm'],
        drum_pattern='trance',
        bass_style='rolling',
        characteristics=['euphoric', 'melodic', 'uplifting', 'energetic']
    ),
    'psytrance': GenreProfile(
        name='Psytrance',
        bpm_range=(140, 150),
        common_keys=['Fm', 'Gm', 'Am'],
        drum_pattern='psy',
        bass_style='acid',
        characteristics=['psychedelic', 'acid', 'complex', 'trippy']
    ),
    'drum-and-bass': GenreProfile(
        name='Drum & Bass',
        bpm_range=(160, 180),
        common_keys=['Am', 'Fm', 'Dm', 'Gm'],
        drum_pattern='breakbeat',
        bass_style='reese',
        characteristics=['fast', 'heavy', 'complex', 'energetic']
    ),
    'liquid-dnb': GenreProfile(
        name='Liquid Drum & Bass',
        bpm_range=(168, 174),
        common_keys=['Am', 'Fm', 'Dm'],
        drum_pattern='liquid',
        bass_style='musical',
        characteristics=['smooth', 'soulful', 'melodic', 'rolling']
    ),
    'ambient': GenreProfile(
        name='Ambient',
        bpm_range=(80, 110),
        common_keys=['C', 'Dm', 'Am', 'Em'],
        drum_pattern='none',
        bass_style='droning',
        characteristics=['atmospheric', 'textural', 'slow', 'ethereal']
    ),
    'reggaeton': GenreProfile(
        name='Reggaeton',
        bpm_range=(88, 98),
        common_keys=['Dm', 'Am', 'Fm', 'Gm', 'Cm'],
        drum_pattern='dembow_95bpm',
        bass_style='subby',
        characteristics=['latin', 'syncopated', 'urban', 'percussive', 'moombahton'],
        spectral_targets={
            'kick': {'centroid_range': (100, 400), 'flatness_max': 0.15},
            'bass': {'centroid_range': (200, 800), 'flatness_max': 0.2},
            'perc': {'centroid_range': (1500, 5000), 'flatness_max': 0.4},
            'hat':  {'centroid_range': (5000, 16000), 'flatness_max': 0.7},
        }
    ),
    'perreo': GenreProfile(
        name='Perreo',
        bpm_range=(90, 96),
        common_keys=['Am', 'Dm', 'Gm'],
        drum_pattern='dembow_hard',
        bass_style='reese_sub',
        characteristics=['dark', 'hard', 'urban', 'bass_heavy']
    ),
}


# ============================================================================
# T101-T103: LUFS GAIN STAGING FOR REGGAETON
# ============================================================================

def estimate_lufs_from_rms(rms: float) -> float:
    """
    T101: Convierte RMS (0-1) a LUFS aproximado.
    
    Fórmula: 20 * log10(rms) - 3 dB offset para mono→stereo.
    
    Args:
        rms: Valor RMS de energía (0.0 - 1.0)
    
    Returns:
        LUFS estimado (-70.0 para silencio)
    """
    import math
    if rms <= 0:
        return -70.0
    return 20 * math.log10(max(rms, 1e-10)) - 3.0


# T102: LUFS targets para reggaeton profesional
REGGAETON_LUFS_TARGETS = {
    'kick':       -12.0,  # Golpe fuerte, kick dembow prominent
    'snare':      -14.0,  # Balanceado con kick
    'clap':       -14.0,  # Mismo nivel que snare
    'hat':        -20.0,  # Hats suaves, percusión latina
    'hat_closed': -20.0,  # Mismo target para hats cerrados
    'hat_open':   -18.0,  # Ligeramente más presente
    'bass_loop':  -10.0,  # Reese bajo muy prominente
    'bass':       -10.0,  # Mismo para bass lineal
    'sub_bass':   -12.0,  # Sub bass con headroom
    'perc_loop':  -16.0,  # Percusión secundaria
    'top_loop':   -18.0,  # Top loop de apoyo
    'synth_loop': -14.0,  # Armónico principal
    'vocal_loop': -12.0,  # Vocal presente en reggaeton
    'vocal_shot': -14.0,  # Shots puntuales
    'crash_fx':   -22.0,  # FX de transición sutiles
    'fill_fx':    -18.0,  # Fills más presentes
    'snare_roll': -16.0,  # Rolls de tensión
    'atmos_fx':   -24.0,  # Atmos en back
    'pad':        -20.0,  # Pads sutiles
    'lead':       -12.0,  # Lead presente
}


# ============================================================================
# MAPEO DE ROLES VALIDOS - Evita elecciones absurdas
# ============================================================================
# Define qué tipos de samples son válidos para cada rol de drum
DRUM_ROLE_VALID_TYPES = {
    'kick': ['kick', 'bd', 'bass_drum', 'kickdrum', '808'],
    'snare': ['snare', 'snr', 'sd', 'rimshot', 'rim'],
    'clap': ['clap', 'clp', 'handclap'],
    'hat_closed': ['hat_closed', 'closed_hat', 'chh', 'hihat', 'hat'],
    'hat_open': ['hat_open', 'open_hat', 'ohh', 'hihat'],
    'hat_pedal': ['hat_pedal', 'pedal_hat', 'hihat'],
    'perc': ['perc', 'percussion', 'conga', 'bongo', 'timbale', 'tamb', 'shaker'],
    'tom': ['tom', 'tomtom'],
    'crash': ['crash', 'cymbal', 'china'],
    'ride': ['ride', 'cymbal', 'ride_bell'],
}

# Mapeo inverso: dado un sample_type, qué roles puede ocupar
SAMPLE_TYPE_TO_ROLES = defaultdict(list)
for role, valid_types in DRUM_ROLE_VALID_TYPES.items():
    for stype in valid_types:
        SAMPLE_TYPE_TO_ROLES[stype].append(role)

# Cooldown: families no se reusarán hasta después de N selecciones
COOLDOWN_WINDOW = 10  # Numero de selecciones antes de que una familia pueda reutilizarse

# Familias de samples para penalización de repeticiones
SAMPLE_FAMILIES = {
    # Drums - por fabricante/estilo
    '808': ['808', 'tr808', 'tr-808'],
    '909': ['909', 'tr909', 'tr-909'],
    'acoustic': ['acoustic', 'real', 'live', 'studio'],
    'electronic': ['electronic', 'digital', 'synthetic', 'synth'],
    'vintage': ['vintage', 'classic', 'old', 'retro'],
    'modern': ['modern', 'contemporary', 'new'],
    # Bass - por tipo
    'sub': ['sub', 'subby', 'subby'],
    'reese': ['reese', 'reese_bass'],
    'acid': ['acid', '303', 'tb303'],
    # Synth - por tipo
    'analog': ['analog', 'analogue', 'moog', 'oberheim'],
    'digital': ['digital', 'fm', 'wavetable', 'serum'],
    'vocal': ['vocal', 'voice', 'vox'],
}

# T059: Preferred packs por género para coherencia
# Para reggaeton/perreo, priorizar packs latinos coherentes
GENRE_PREFERRED_PACKS = {
    'reggaeton': ['midilatino', 'sentimientolatino2025', 'reggaeton 3'],
    'perreo': ['midilatino', 'sentimientolatino2025', 'bigcayu'],
    'techno': ['ss_rnbl', 'drumloops'],
    'tech-house': ['ss_rnbl', 'midilatino', 'drumloops'],
}

# Umbrales para clasificación one-shot vs loop
ONESHOT_MAX_DURATION = 2.0  # segundos
LOOP_MIN_DURATION = 1.0     # segundos

# Preferencia one-shot vs loop por rol
# True = prefiere one-shot, False = prefiere loop, None = sin preferencia
ROLE_ONE_SHOT_PREFERENCE = {
    'kick': True,      # Debe ser one-shot
    'clap': True,      # Debe ser one-shot
    'hat': True,       # Debe ser one-shot
    'hat_closed': True,
    'hat_open': True,
    'snare': True,
    'bass_loop': False,  # Debe ser loop
    'vocal_loop': False, # Debe ser loop
    'perc_loop': False,
    'top_loop': False,
    'synth_loop': False,
}

# Patrones de rechazo duro para roles críticos
# Estos son ERRORES semanticos que nunca deberían pasar
# Expandidos para endurecimiento del sistema (Problema #4)
HARD_REJECT_PATTERNS = {
    'kick': {
        'exclude_keywords': [
            'roll', 'fill', 'loop', 'hat', 'snare', 'clap', 'vocal', 'synth', 'pad',
            'full drum', 'full mix', 'full_mix', 'fulldrum', 'fullmix', 'demo', 'song',
            'master', 'top loop', 'top_loop', 'drum loop', 'drum_loop', 'perc loop',
            'melodic', 'chord', 'stab', 'fx', 'riser', 'downlifter', 'atmos',
            'complete', 'mixed', 'stems', 'bounce', 'preview', 'final mix'
        ],
        'exclude_subcategories': ['snare', 'hat', 'clap', 'perc', 'fx', 'vocal', 'synth'],
        'max_duration': 2.0,  # Stricter: kicks longer than 2s are loops
        'must_contain_none': ['full', 'mix', 'demo', 'song', 'master'],
        'must_contain_one': ['kick', 'bd', 'bass_drum', '808', 'kickdrum', 'bass drum'],
    },
    'clap': {
        'exclude_keywords': [
            'roll', 'fill', 'loop', 'hat', 'kick', 'vocal', 'bass',
            'full drum', 'full mix', 'demo', 'song', 'master', 'top', 'perc loop',
            'snare roll', 'snare_roll', 'snareroll', 'complete', 'mixed', 'stems'
        ],
        'exclude_subcategories': ['kick', 'hat', 'fx', 'vocal', 'bass'],
        'must_contain_one': ['clap', 'hand', 'handclap'],
        'max_duration': 2.0,
        'must_contain_none': ['full', 'mix', 'snare roll', 'snare_roll'],
    },
    'hat': {
        'exclude_keywords': [
            'roll', 'kick', 'snare', 'clap', 'vocal', 'bass', 'synth', 'pad',
            'full drum', 'full mix', 'demo', 'song', 'master', 'bass loop',
            'top loop', 'drum loop', 'perc loop', 'full_mix', 'fulldrum',
            'complete', 'mixed', 'stems', 'kick drum', 'snare drum'
        ],
        'exclude_subcategories': ['kick', 'snare', 'clap', 'bass', 'vocal'],
        'max_duration': 1.5,
        'must_contain_none': ['full', 'mix', 'demo', 'complete'],
        'must_contain_one': ['hat', 'hh', 'hihat', 'hi-hat', 'cymbal', 'open hat', 'closed hat'],
    },
    'bass_loop': {
        'exclude_keywords': [
            'drum', 'hat', 'kick', 'snare', 'clap', 'perc', 'top loop', 'top_loop',
            'full drum', 'full mix', 'full_mix', 'fulldrum', 'fullmix', 'demo', 'song',
            'master', 'vocal', 'vocal loop', 'vocal_loop', 'fx', 'atmos', 'pad',
            'drum loop', 'drum_loop', 'perc loop', 'melodic', 'chord', 'synth loop',
            'complete', 'mixed', 'stems', 'bounce', 'preview', 'final mix'
        ],
        'exclude_subcategories': ['drum', 'perc', 'fx', 'vocal', 'hat'],
        'min_duration': 2.0,
        'must_contain_one': ['bass', 'sub', 'reese', '808', 'bassline', 'bass line'],
        'must_contain_none': ['full', 'mix', 'drum', 'top', 'vocal'],
    },
    'vocal_loop': {
        'exclude_keywords': [
            'drum', 'hat', 'kick', 'snare', 'bass', 'synth', 'pad', 'fx',
            'full drum', 'full mix', 'demo', 'song', 'master', 'one shot', 'oneshot',
            'shot', 'hit', 'stab', 'drum loop', 'bass loop', 'top loop',
            'complete', 'mixed', 'stems', 'bounce', 'preview', 'loop kit'
        ],
        'exclude_subcategories': ['drum', 'bass', 'perc', 'fx', 'hat'],
        'min_duration': 2.0,  # Must be at least 2s to be a loop
        'must_contain_one': ['vocal', 'vox', 'voice', 'sing', 'chorus', 'verse', 'chant', 'acapella'],
        'must_contain_none': ['full', 'mix', 'demo', 'shot', 'hit', 'one shot'],
    },
    'top_loop': {
        'exclude_keywords': [
            'bass', 'bass loop', 'vocal', 'vocal loop', 'synth loop', 'pad',
            'demo', 'song', 'master', 'fx', 'atmos', 'riser', 'downlifter',
            'melodic', 'chord', 'stab', 'complete', 'mixed', 'stems', 'snare roll'
        ],
        'exclude_subcategories': ['bass', 'vocal', 'fx', 'pad', 'synth'],
        'must_contain_one': ['top', 'perc', 'drum', 'groove', 'hat', 'shaker', 'conga', 'bongo', 'full drum'],
        'min_duration': 1.5,
        'must_contain_none': ['bass', 'vocal', 'synth loop'],
    },
    'fill_fx': {
        'exclude_keywords': [
            'kick', 'snare', 'hat', 'clap', 'bass', 'vocal', 'synth', 'pad',
            'full mix', 'demo', 'song', 'master', 'loop', 'groove', 'drum loop',
            'complete', 'mixed', 'stems', 'bass loop', 'vocal loop'
        ],
        'exclude_subcategories': ['kick', 'snare', 'hat', 'clap', 'bass', 'vocal'],
        'must_contain_one': ['fill', 'fx', 'riser', 'impact', 'crash', 'sweep', 'atmos', 'transition', 'downlifter'],
        'max_duration': 4.0,
    },
    'snare_roll': {
        'exclude_keywords': [
            'kick', 'hat', 'clap', 'bass', 'vocal', 'synth', 'pad',
            'full mix', 'demo', 'song', 'master', 'loop', 'groove', 'atmos',
            'complete', 'mixed', 'stems', 'one shot', 'drum loop', 'bass loop'
        ],
        'exclude_subcategories': ['kick', 'hat', 'clap', 'bass', 'vocal', 'fx'],
        'must_contain_one': ['snare', 'roll', 'fill', 'snareroll', 'buildup', 'build up'],
        'max_duration': 4.0,
    },
    'atmos_fx': {
        'exclude_keywords': [
            'kick', 'snare', 'hat', 'clap', 'bass', 'vocal loop',
            'full mix', 'demo', 'song', 'master', 'loop', 'groove', 'drum loop',
            'complete', 'mixed', 'stems', 'snare roll', 'fill', 'perc loop'
        ],
        'exclude_subcategories': ['kick', 'snare', 'hat', 'clap', 'bass'],
        'must_contain_one': ['atmos', 'pad', 'drone', 'ambience', 'texture', 'fx', 'riser', 'noise', 'ambient'],
        'min_duration': 2.0,
    },
    'crash_fx': {
        'must_contain_one': ['crash', 'impact', 'cymbal', 'ride', 'uplifter', 'downlifter'],
        'exclude_keywords': ['loop', 'bass', 'vocal', 'kick', 'snare', 'full mix', 'drum loop', 'complete kit'],
        'max_duration': 3.0,
    },
    'synth_loop': {
        'exclude_keywords': [
            'drum', 'kick', 'snare', 'hat', 'vocal', 'bass loop', 'full mix',
            'demo', 'song', 'master', 'complete', 'mixed', 'stems', 'drum loop',
            'perc loop', 'top loop', 'one shot'
        ],
        'must_contain_one': ['synth', 'lead', 'pad', 'chord', 'arp', 'pluck', 'melody', 'hook', 'sequence'],
        'min_duration': 1.5,
    },
}

# Keywords sospechosos que penalizan (pero no rechazan) el score
# Penalización soft del 30% por cada keyword encontrado
SUSPICIOUS_KEYWORDS = {
    'kick': ['full', 'mix', 'demo', 'song', 'master', 'complete', 'stereo', 'stems',
             'bounce', 'preview', 'final', 'mixed', 'kit', 'pack'],
    'clap': ['full', 'mix', 'demo', 'song', 'snare roll', 'snare_roll', 'fill', 'stems',
             'bounce', 'preview', 'final', 'mixed', 'loop', 'groove', 'top loop'],
    'hat': ['full', 'mix', 'demo', 'song', 'loop', 'complete', 'stems', 'full kit',
            'bounce', 'preview', 'final', 'mixed', 'kick', 'snare', 'bass'],
    'bass_loop': ['full', 'mix', 'demo', 'vocal', 'top', 'drum loop', 'full drum', 'stems',
                  'bounce', 'preview', 'final', 'mixed', 'perc', 'snare', 'hat', 'kick'],
    'vocal_loop': ['full', 'mix', 'demo', 'shot', 'hit', 'one shot', 'drum', 'stems',
                   'bounce', 'preview', 'final', 'mixed', 'bass loop', 'loop kit'],
    'top_loop': ['bass', 'vocal', 'synth loop', 'demo', 'stems', 'snare roll',
                 'bounce', 'preview', 'final', 'mixed', 'percussion', 'hat loop'],
    'fill_fx': ['loop', 'groove', 'kick', 'snare', 'bass', 'vocal', 'stems',
                'bounce', 'preview', 'final', 'mixed', 'drum loop'],
    'snare_roll': ['loop', 'groove', 'kick', 'hat', 'bass', 'vocal', 'atmos', 'stems',
                   'bounce', 'preview', 'final', 'mixed', 'clap'],
    'atmos_fx': ['kick', 'snare', 'hat', 'clap', 'bass', 'loop', 'groove', 'stems',
                 'bounce', 'preview', 'final', 'mixed', 'drum loop', 'vocal loop'],
    'synth_loop': ['drum', 'vocal', 'bass loop', 'full mix', 'demo', 'stems',
                   'bounce', 'preview', 'final', 'mixed', 'one shot', 'hit'],
    'crash_fx': ['loop', 'bass', 'vocal', 'kick', 'snare', 'full mix', 'stems',
                 'bounce', 'preview', 'final', 'mixed', 'hat loop', 'top loop'],
    'perc_loop': ['bass', 'vocal', 'synth', 'demo', 'full mix', 'stems',
                  'bounce', 'preview', 'final', 'mixed', 'snare roll'],
}

# Keywords requeridos por rol - Validación positiva
ROLE_REQUIRED_KEYWORDS = {
    'kick': ['kick', 'bd', 'bass_drum', '808', 'kickdrum', 'bass drum'],
    'snare': ['snare', 'snr', 'sd', 'rim', 'rimshot'],
    'clap': ['clap', 'clp', 'handclap', 'hand clap'],
    'hat': ['hat', 'hh', 'hihat', 'hi hat', 'hi-hat', 'closed hat', 'open hat', 'cymbal'],
    'bass_loop': ['bass', 'sub', 'reese', '808', 'bassline', 'bass line'],
    'vocal_loop': ['vocal', 'vox', 'voice', 'acapella', 'chant', 'sing'],
    'top_loop': ['top', 'perc', 'drum', 'groove', 'hat', 'shaker', 'full drum'],
    'synth_loop': ['synth', 'lead', 'pad', 'chord', 'arp', 'pluck', 'melody'],
    'crash_fx': ['crash', 'cymbal', 'impact', 'ride', 'uplifter'],
    'fill_fx': ['fill', 'transition', 'tom', 'break', 'riser'],
    'snare_roll': ['roll', 'snare', 'build', 'buildup', 'snareroll'],
    'atmos_fx': ['atmos', 'drone', 'ambient', 'texture', 'noise', 'sweep'],
    'vocal_shot': ['vocal', 'vox', 'shot', 'chop', 'stab', 'importante'],
    'perc_loop': ['perc', 'percussion', 'shaker', 'conga', 'bongo'],
}

# ============================================================================
# SISTEMA DE EXCLUSIONES POR ROL - Problema #4
# Define qué samples NO son apropiados para cada rol
# ============================================================================
ROLE_EXCLUSION_PATTERNS = {
    'kick': {
        'exclude_keywords': [
            'full drum', 'full_mix', 'fullmix', 'fulldrum', 'full mix', 'demo', 'song',
            'master', 'top loop', 'drum loop', 'snare roll', 'fill', 'hat loop',
            'vocal loop', 'complete kit', 'full kit', 'mixed', 'stems', 'bounce', 'preview',
            'snare', 'clap', 'hat', 'bass loop', 'vocal', 'synth', 'pad', 'atmos'
        ],
        'max_duration': 2.5,  # Reject if longer than 2.5s
        'min_required_keywords': ['kick', 'bd', 'bass_drum', '808', 'kickdrum'],
    },
    'clap': {
        'exclude_keywords': [
            'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song', 'master',
            'snare roll', 'snare_roll', 'hat loop', 'kick loop', 'top loop', 'drum loop',
            'bass loop', 'complete kit', 'full kit', 'mixed', 'stems', 'bounce', 'preview',
            'kick', 'hat', 'vocal', 'bass', 'synth', 'pad'
        ],
        'max_duration': 2.0,
        'min_required_keywords': ['clap', 'hand', 'handclap'],
    },
    'hat': {
        'exclude_keywords': [
            'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song', 'master',
            'kick loop', 'snare loop', 'bass loop', 'vocal loop', 'complete', 'full kit',
            'mixed', 'stems', 'bounce', 'preview', 'kick', 'snare', 'clap', 'bass'
        ],
        'max_duration': 2.0,
        'min_required_keywords': ['hat', 'hh', 'hihat', 'cymbal', 'open hat', 'closed hat'],
    },
    'bass_loop': {
        'exclude_keywords': [
            'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song', 'master',
            'top loop', 'vocal loop', 'vocal_loop', 'drum loop', 'hat loop', 'snare loop',
            'perc loop', 'fx loop', 'atmos', 'complete', 'mixed', 'stems', 'bounce', 'preview',
            'kick', 'snare', 'hat', 'vocal'
        ],
        'min_duration': 1.5,
        'min_required_keywords': ['bass', 'sub', 'reese', '808', 'bassline', 'bass line'],
    },
    'vocal_loop': {
        'exclude_keywords': [
            'full drum', 'full_mix', 'fullmix', 'full mix', 'demo', 'song', 'master',
            'one shot', 'oneshot', 'hit', 'stab', 'drum loop', 'bass loop', 'top loop',
            'complete', 'mixed', 'stems', 'bounce', 'preview', 'kick', 'snare', 'hat', 'bass'
        ],
        'min_duration': 1.5,
        'min_required_keywords': ['vocal', 'vox', 'voice', 'sing', 'chant', 'acapella', 'phrase'],
    },
    'top_loop': {
        'exclude_keywords': [
            'bass loop', 'bass_loop', 'vocal loop', 'vocal_loop', 'demo', 'song', 'master',
            'synth loop', 'pad', 'atmos', 'riser', 'downlifter', 'complete', 'mixed',
            'stems', 'bounce', 'preview', 'bass', 'vocal', 'synth'
        ],
        'min_duration': 1.0,
        'min_required_keywords': ['top', 'perc', 'drum', 'groove', 'hat', 'full drum', 'drum loop'],
    },
    'fill_fx': {
        'exclude_keywords': [
            'kick', 'snare', 'hat', 'clap', 'bass', 'vocal', 'full mix', 'demo', 'song',
            'master', 'loop', 'groove', 'complete', 'mixed', 'stems', 'bounce', 'preview',
            'drum loop', 'bass loop', 'vocal loop'
        ],
        'max_duration': 6.0,
        'min_required_keywords': ['fill', 'fx', 'riser', 'impact', 'crash', 'sweep', 'atmos', 'transition'],
    },
    'snare_roll': {
        'exclude_keywords': [
            'kick', 'hat', 'clap', 'bass', 'vocal', 'full mix', 'demo', 'song', 'master',
            'atmos', 'pad', 'complete', 'mixed', 'stems', 'bounce', 'preview', 'one shot',
            'loop', 'groove'
        ],
        'max_duration': 6.0,
        'min_required_keywords': ['roll', 'snare', 'fill', 'buildup', 'build up', 'snareroll'],
    },
    'atmos_fx': {
        'exclude_keywords': [
            'kick', 'snare', 'hat', 'clap', 'bass', 'full mix', 'demo', 'song', 'master',
            'drum loop', 'complete', 'mixed', 'stems', 'bounce', 'preview', 'snare roll',
            'fill', 'perc loop', 'vocal'
        ],
        'min_duration': 1.5,
        'min_required_keywords': ['atmos', 'pad', 'drone', 'ambience', 'texture', 'noise', 'ambient'],
    },
    'crash_fx': {
        'exclude_keywords': [
            'full mix', 'demo', 'song', 'master', 'loop', 'complete', 'mixed', 'stems',
            'bounce', 'preview', 'bass', 'vocal', 'kick', 'snare'
        ],
        'max_duration': 4.0,
        'min_required_keywords': ['crash', 'cymbal', 'impact', 'ride', 'uplifter', 'downlifter'],
    },
}


def _check_role_exclusion(sample_name: str, role: str) -> Tuple[bool, str]:
    """
    Verifica si un sample debe ser excluido para un rol específico.

    Returns:
        (excluded, reason) - True si debe ser excluido, False si pasa
    """
    role_lower = role.lower()
    if role_lower not in ROLE_EXCLUSION_PATTERNS:
        return False, ""

    patterns = ROLE_EXCLUSION_PATTERNS[role_lower]
    name_lower = sample_name.lower()

    # Check excluded keywords
    for keyword in patterns.get('exclude_keywords', []):
        if keyword in name_lower:
            return True, f"excluded keyword '{keyword}'"

    # Check required keywords
    required = patterns.get('min_required_keywords', [])
    if required:
        found = any(kw in name_lower for kw in required)
        if not found:
            return True, f"missing required keyword (need one of: {required})"

    return False, ""

ROLE_DURATION_RANGES = {
    'kick': (0.05, 2.5),
    'snare': (0.05, 3.0),
    'clap': (0.05, 2.0),
    'hat': (0.05, 2.0),
    'bass_loop': (1.5, 32.0),
    'vocal_loop': (1.0, 32.0),
    'top_loop': (0.75, 32.0),
    'synth_loop': (0.75, 32.0),
    'crash_fx': (0.3, 8.0),
    'fill_fx': (0.3, 12.0),
    'snare_roll': (0.5, 12.0),
    'atmos_fx': (0.5, 32.0),
    'vocal_shot': (0.1, 4.0),
    'perc_loop': (0.75, 32.0),
}


# ============================================================================
# SECTION-AWARE SELECTION PROFILES
# ============================================================================
# Define quÃƒÂ© roles son apropiados para cada secciÃƒÂ³n y con quÃƒÂ© prioridad

SECTION_ROLE_PROFILES = {
    'intro': {
        'primary': ['kick', 'hat', 'atmos_fx', 'pad', 'bass_loop'],
        'secondary': ['clap', 'synth_loop', 'vocal_shot'],
        'avoid': ['snare_roll', 'fill_fx', 'crash_fx', 'vocal_loop'],
        'intensity': 'low',
        'description': 'Build anticipation, minimal elements'
    },
    'build': {
        'primary': ['kick', 'hat', 'snare_roll', 'fill_fx', 'synth_loop', 'bass_loop'],
        'secondary': ['clap', 'atmos_fx', 'vocal_shot'],
        'avoid': ['vocal_loop', 'pad'],
        'intensity': 'rising',
        'description': 'Increasing tension and energy'
    },
    'drop': {
        'primary': ['kick', 'snare', 'clap', 'hat', 'bass_loop', 'synth_loop', 'vocal_loop', 'perc_loop'],
        'secondary': ['crash_fx', 'perc_alt'],
        'avoid': ['atmos_fx', 'snare_roll'],
        'intensity': 'high',
        'description': 'Full energy, all elements present - dembow kick + perc loop always active for reggaeton'
    },
    'break': {
        'primary': ['atmos_fx', 'pad', 'vocal_shot', 'synth_loop'],
        'secondary': ['snare_roll', 'fill_fx', 'hat'],
        'avoid': ['kick', 'clap', 'crash_fx'],
        'intensity': 'low',
        'description': 'Space and atmosphere, reduced drums'
    },
    'outro': {
        'primary': ['kick', 'hat', 'atmos_fx'],
        'secondary': ['bass_loop', 'pad'],
        'avoid': ['snare_roll', 'fill_fx', 'crash_fx', 'vocal_loop'],
        'intensity': 'fading',
        'description': 'Wind down, remove elements'
    },
}

# Role groups for joint scoring
JOINT_SCORING_GROUPS = {
    'drum_kit': ['kick', 'snare', 'clap', 'hat', 'hat_closed', 'hat_open'],
    'music_group': ['bass_loop', 'synth_loop', 'pad', 'lead', 'chord'],
    'vocal_fx_group': ['vocal_loop', 'vocal_shot', 'atmos_fx', 'fill_fx'],
    'transition_group': ['fill_fx', 'snare_roll', 'crash_fx'],
}

# Folder relationship bonuses for joint scoring
FOLDER_COMPATIBILITY_BONUS = {
    'exact_same': 1.5,      # Same folder
    'same_parent': 1.3,     # Sibling folders (same pack)
    'same_grandparent': 1.15,  # Cousin folders
    'different': 0.85,      # Unrelated folders
}


def _extract_sample_family(sample_name: str) -> str:
    """Extrae la familia de un sample basado en su nombre."""
    name_lower = sample_name.lower()
    for family, keywords in SAMPLE_FAMILIES.items():
        for kw in keywords:
            if kw in name_lower:
                return family
    return 'unknown'


def _is_oneshot(sample: 'Sample') -> bool:
    """Determina si un sample es one-shot basado en duración y nombre."""
    name_lower = sample.name.lower()
    duration = sample.duration or 0

    # Indicadores de one-shot en el nombre
    oneshot_keywords = ['one shot', 'oneshot', 'hit', 'single', 'stab']
    if any(kw in name_lower for kw in oneshot_keywords):
        return True

    # Indicadores de loop en el nombre
    loop_keywords = ['loop', 'groove', 'pattern', 'sequence']
    if any(kw in name_lower for kw in loop_keywords):
        return False

    # Por duración
    if duration > 0:
        return duration < ONESHOT_MAX_DURATION

    # Default: asumir one-shot para drums
    return sample.category == 'drums'


# ============================================================================
# MAPEO MIDI
# ============================================================================

# Mapeo de notas MIDI para diferentes tipos de samples
MIDI_NOTE_MAPPING = {
    # Drums (General MIDI)
    'kick': 36,        # C1
    'kick_deep': 35,   # B0
    'snare': 38,       # D1
    'snare_rim': 37,   # C#1
    'clap': 39,        # D#1 / también 50 (D2)
    'hat_closed': 42,  # F#1
    'hat_open': 46,    # A#1
    'hat_pedal': 44,   # G#1
    'tom_low': 41,     # F1
    'tom_mid': 47,     # B1
    'tom_high': 50,    # D2
    'crash': 49,       # C#2
    'ride': 51,        # D#2
    'ride_bell': 53,   # F2
    'perc_low': 43,    # G1
    'perc_mid': 45,    # A1
    'perc_high': 48,   # C2
    'shaker': 54,      # F#2
    'tambourine': 54,  # F#2
    'cowbell': 56,     # G#2

    # Instrumentos melódicos (rango usable)
    'bass': list(range(36, 48)),      # C1-B1
    'lead': list(range(60, 84)),      # C4-B6
    'pad': list(range(48, 72)),       # C2-B4
    'pluck': list(range(60, 84)),     # C4-B6
    'arp': list(range(60, 84)),       # C4-B6
    'chord': list(range(48, 72)),     # C2-B4
    'vocal': list(range(60, 84)),     # C4-B6
}


@dataclass
class DrumKit:
    """Kit de batería completo"""
    name: str
    kick: Optional[Sample] = None
    snare: Optional[Sample] = None
    clap: Optional[Sample] = None
    hat_closed: Optional[Sample] = None
    hat_open: Optional[Sample] = None
    perc1: Optional[Sample] = None
    perc2: Optional[Sample] = None
    tom: Optional[Sample] = None
    crash: Optional[Sample] = None

    def to_dict(self) -> Dict[str, Any]:
        """Convierte el kit a diccionario"""
        return {
            'name': self.name,
            'kick': self.kick.to_dict() if self.kick else None,
            'snare': self.snare.to_dict() if self.snare else None,
            'clap': self.clap.to_dict() if self.clap else None,
            'hat_closed': self.hat_closed.to_dict() if self.hat_closed else None,
            'hat_open': self.hat_open.to_dict() if self.hat_open else None,
            'perc1': self.perc1.to_dict() if self.perc1 else None,
            'perc2': self.perc2.to_dict() if self.perc2 else None,
            'tom': self.tom.to_dict() if self.tom else None,
            'crash': self.crash.to_dict() if self.crash else None,
        }

    def get_midi_mapping(self) -> Dict[int, Optional[Sample]]:
        """Retorna mapeo de notas MIDI a samples"""
        mapping = {}
        if self.kick:
            mapping[MIDI_NOTE_MAPPING['kick']] = self.kick
        if self.snare:
            mapping[MIDI_NOTE_MAPPING['snare']] = self.snare
        if self.clap:
            mapping[MIDI_NOTE_MAPPING['clap']] = self.clap
        if self.hat_closed:
            mapping[MIDI_NOTE_MAPPING['hat_closed']] = self.hat_closed
        if self.hat_open:
            mapping[MIDI_NOTE_MAPPING['hat_open']] = self.hat_open
        if self.tom:
            mapping[MIDI_NOTE_MAPPING['tom_mid']] = self.tom
        if self.crash:
            mapping[MIDI_NOTE_MAPPING['crash']] = self.crash
        return mapping


@dataclass
class InstrumentGroup:
    """Grupo de instrumentos para un estilo"""
    genre: str
    key: str
    bpm: float
    drums: DrumKit = field(default_factory=lambda: DrumKit(name="default"))
    bass: List[Sample] = field(default_factory=list)
    synths: List[Sample] = field(default_factory=list)
    fx: List[Sample] = field(default_factory=list)
    vocals: List[Sample] = field(default_factory=list)

    def to_dict(self) -> Dict[str, Any]:
        return {
            'genre': self.genre,
            'key': self.key,
            'bpm': self.bpm,
            'drums': self.drums.to_dict(),
            'bass': [s.to_dict() for s in self.bass],
            'synths': [s.to_dict() for s in self.synths],
            'fx': [s.to_dict() for s in self.fx],
            'vocals': [s.to_dict() for s in self.vocals],
        }


# ============================================================================
# T4: ALTERNATES POOL AND COHERENCE TRACKING CLASSES
# ============================================================================

@dataclass
class AlternateCandidate:
    """Candidate sample for alternates pool with scoring metadata."""
    sample: 'Sample'
    score: float
    rank: int
    freshness_score: float = 1.0
    coherence_score: float = 1.0
    family: str = ""
    pack_name: str = ""
    rejection_reasons: List[str] = field(default_factory=list)
    selection_probability: float = 0.0

    def to_dict(self) -> Dict[str, Any]:
        return {
            'name': self.sample.name if self.sample else None,
            'path': getattr(self.sample, 'path', None) if self.sample else None,
            'score': round(self.score, 4),
            'rank': self.rank,
            'freshness': round(self.freshness_score, 2),
            'coherence': round(self.coherence_score, 2),
            'family': self.family,
            'pack': self.pack_name,
            'selection_probability': round(self.selection_probability, 2),
            'rejection_reasons': self.rejection_reasons,
        }


class AlternatesPool:
    """
    T4: Maintains a pool of 3-5 top candidates per role for diverse selection.
    
    Allows selection from alternatives within ±15% score range instead of
    always picking the #1 ranked sample. Provides fresher/coherence-weighted
    selection to balance variety with sonic coherence.
    """
    
    DEFAULT_POOL_SIZE = 5
    SCORE_TOLERANCE = 0.15  # ±15% score range for valid alternates
    
    def __init__(self, pool_size: int = DEFAULT_POOL_SIZE):
        self.pool_size = pool_size
        self.candidates_by_role: Dict[str, List[AlternateCandidate]] = defaultdict(list)
        self.selected_by_role: Dict[str, Set[str]] = defaultdict(set)  # Track paths used per role
        self._selection_history: List[Dict[str, Any]] = []
    
    def add_candidate(self, role: str, candidate: AlternateCandidate) -> bool:
        """Add a candidate to the pool for a role. Returns True if added."""
        role = role.lower()
        
        # Check if we already have enough candidates
        if len(self.candidates_by_role[role]) >= self.pool_size:
            # Only add if better than worst candidate
            worst = min(self.candidates_by_role[role], key=lambda c: c.score)
            if candidate.score <= worst.score:
                return False
            # Replace worst candidate
            self.candidates_by_role[role].remove(worst)
        
        self.candidates_by_role[role].append(candidate)
        # Keep sorted by score
        self.candidates_by_role[role].sort(key=lambda c: c.score, reverse=True)
        return True
    
    def get_alternates_in_range(self, role: str, top_score: float, tolerance: float = None) -> List[AlternateCandidate]:
        """
        Get all candidates within ±tolerance% of the top score.
        
        Args:
            role: The role to get alternates for
            top_score: The top score to compare against
            tolerance: Score tolerance (default: self.SCORE_TOLERANCE = 0.15)
        
        Returns:
            List of candidates within score range, sorted by combined freshness+coherence
        """
        role = role.lower()
        tolerance = tolerance or self.SCORE_TOLERANCE
        
        candidates = self.candidates_by_role.get(role, [])
        if not candidates:
            return []
        
        # Calculate score range
        min_score = top_score * (1.0 - tolerance)
        max_score = top_score * (1.0 + tolerance)  # Allow slightly higher too
        
        # Filter candidates in range
        in_range = [c for c in candidates if min_score <= c.score <= max_score]
        
        # Sort by combined freshness and coherence (not just raw score)
        in_range.sort(key=lambda c: (c.freshness_score * 0.6 + c.coherence_score * 0.4), reverse=True)
        
        return in_range
    
    def select_weighted(self, role: str, context: str = "") -> Optional[AlternateCandidate]:
        """
        Select a candidate using freshness + coherence weighted random.
        
        Instead of always picking #1, weights selection by:
        - 40% freshness (avoid overused samples)
        - 35% coherence (prefer pack-compatible samples)
        - 25% raw score (don't sacrifice quality)
        
        Returns:
            Selected AlternateCandidate or None
        """
        role = role.lower()
        candidates = self.candidates_by_role.get(role, [])
        if not candidates:
            return None
        
        # Get top score for range calculation
        top_score = candidates[0].score
        valid_candidates = self.get_alternates_in_range(role, top_score)
        
        if not valid_candidates:
            valid_candidates = candidates[:3]  # Fallback to top 3
        
        # Calculate weighted scores
        weighted: List[Tuple[float, AlternateCandidate]] = []
        for c in valid_candidates:
            # Combined weight: freshness + coherence + score
            combined = (
                c.freshness_score * 0.40 +
                c.coherence_score * 0.35 +
                (c.score / top_score) * 0.25
            )
            # Add small random jitter for variety
            seed_data = f"{role}_{c.sample.name if c.sample else ''}_{context}"
            seed = int(hashlib.md5(seed_data.encode()).hexdigest()[:8], 16)
            rng = random.Random(seed)
            jitter = 0.95 + (rng.random() * 0.10)  # ±5% jitter
            
            final_weight = combined * jitter
            weighted.append((final_weight, c))
        
        # Normalize to probabilities
        total = sum(w for w, _ in weighted)
        if total == 0:
            return valid_candidates[0] if valid_candidates else None
        
        # Update selection probabilities
        for i, (weight, cand) in enumerate(weighted):
            cand.selection_probability = weight / total
        
        # Weighted random selection
        pivot = random.Random(int(time.time() * 1000)).random() * total
        running = 0.0
        for weight, candidate in weighted:
            running += weight
            if pivot <= running:
                # Mark as selected
                if candidate.sample:
                    path = getattr(candidate.sample, 'path', '') or getattr(candidate.sample, 'file_path', '')
                    self.selected_by_role[role].add(path)
                return candidate
        
        return weighted[-1][1] if weighted else None
    
    def mark_used(self, role: str, sample_path: str) -> None:
        """Mark a sample as used for a role to track freshness."""
        self.selected_by_role[role.lower()].add(sample_path)
    
    def get_freshness_score(self, role: str, sample_path: str) -> float:
        """
        Calculate freshness score (0.0 to 1.0) for a sample.
        
        - 1.0 = never used (fresh)
        - 0.5 = used once
        - 0.2 = used multiple times
        - Decays over time (generations)
        """
        role = role.lower()
        times_used = sum(1 for path in self.selected_by_role.get(role, []) if path == sample_path)
        
        if times_used == 0:
            return 1.0
        elif times_used == 1:
            return 0.5
        else:
            return max(0.2, 0.5 / times_used)
    
    def get_pool_for_role(self, role: str) -> List[Dict[str, Any]]:
        """Get the full pool for a role as dicts for logging/debugging."""
        return [c.to_dict() for c in self.candidates_by_role.get(role.lower(), [])]
    
    def clear_role(self, role: str) -> None:
        """Clear the pool for a specific role."""
        role = role.lower()
        if role in self.candidates_by_role:
            del self.candidates_by_role[role]
    
    def clear_all(self) -> None:
        """Clear all pools."""
        self.candidates_by_role.clear()
        self.selected_by_role.clear()


class FresherCoherenceTracker:
    """
    T4: Tracks sample usage across roles and generations for balanced selection.
    
    Balances:
    - Freshness: Prefer samples not recently used
    - Coherence: Prefer samples from same pack/family
    - Diversity: Avoid over-dominance of single source
    """
    
    def __init__(self, max_history_per_role: int = 20):
        self.max_history = max_history_per_role
        self.usage_history: Dict[str, deque] = defaultdict(lambda: deque(maxlen=max_history_per_role))
        self.pack_usage_counts: Dict[str, int] = defaultdict(int)
        self.family_usage_counts: Dict[str, int] = defaultdict(int)
        self.generation_usage: Dict[str, Set[str]] = defaultdict(set)  # gen_id -> set of paths
    
    def record_usage(self, role: str, sample_path: str, pack_name: str, family: str, generation_id: str = None):
        """Record that a sample was used."""
        role = role.lower()
        
        # Track per-role usage
        self.usage_history[role].append({
            'path': sample_path,
            'pack': pack_name,
            'family': family,
            'timestamp': time.time(),
            'generation': generation_id,
        })
        
        # Track pack usage
        if pack_name:
            self.pack_usage_counts[pack_name] += 1
        
        # Track family usage
        if family:
            self.family_usage_counts[family] += 1
        
        # Track generation-specific usage
        if generation_id:
            self.generation_usage[generation_id].add(sample_path)
    
    def get_freshness_penalty(self, role: str, sample_path: str) -> float:
        """
        Get freshness penalty (0.0-1.0, where 1.0 = no penalty).
        
        Recent usage in same role = higher penalty
        """
        role = role.lower()
        recent = list(self.usage_history.get(role, []))
        
        # Check if used recently (last 5 selections)
        recent_paths = [r['path'] for r in recent[-5:]]
        if sample_path in recent_paths:
            recency = recent_paths.index(sample_path)  # 0 = most recent
            # Penalty based on recency: 0.1 for just used, 0.5 for used 5 ago
            return 0.1 + (recency * 0.08)
        
        # Check if used in this generation
        for gen_id, paths in self.generation_usage.items():
            if sample_path in paths:
                return 0.6  # Moderate penalty for same-gen usage
        
        return 1.0  # No penalty (fresh)
    
    def get_coherence_bonus(self, sample_path: str, dominant_packs: List[str]) -> float:
        """
        Get coherence bonus for pack compatibility.
        
        Returns:
            1.0-1.5 bonus multiplier for pack-aligned samples
        """
        if not dominant_packs:
            return 1.0
        
        sample_folder = str(Path(sample_path).parent).lower()
        
        for pack in dominant_packs:
            pack_lower = pack.lower()
            if pack_lower in sample_folder:
                # Exact match
                return 1.5
            # Check sibling/parent relationships
            sample_parent = str(Path(sample_folder).parent).lower()
            pack_parent = str(Path(pack_lower).parent).lower()
            if sample_parent == pack_parent:
                return 1.3
        
        # No pack match = slight penalty
        return 0.9
    
    def check_source_dominance(self, pack_name: str, max_ratio: float = 0.6) -> bool:
        """
        Check if a single pack is dominating selections.
        
        Args:
            pack_name: Pack to check
            max_ratio: Maximum acceptable ratio (0.6 = 60%)
        
        Returns:
            True if pack is over-dominant (should diversify)
        """
        total = sum(self.pack_usage_counts.values())
        if total == 0:
            return False
        
        pack_count = self.pack_usage_counts.get(pack_name, 0)
        ratio = pack_count / total
        
        return ratio > max_ratio
    
    def get_stats(self) -> Dict[str, Any]:
        """Get usage statistics for debugging."""
        return {
            'total_roles_tracked': len(self.usage_history),
            'total_packs_used': len(self.pack_usage_counts),
            'pack_distribution': dict(self.pack_usage_counts),
            'family_distribution': dict(self.family_usage_counts),
            'most_used_pack': max(self.pack_usage_counts.items(), key=lambda x: x[1]) if self.pack_usage_counts else None,
        }
    
    def reset_generation(self, generation_id: str):
        """Clear generation-specific tracking."""
        if generation_id in self.generation_usage:
            del self.generation_usage[generation_id]


class LoopGeometryTracker:
    """
    T4: Tracks loop lengths and patterns to avoid identical geometry.
    
    Prevents "copy-paste mirror" effect where sections have identical
    loop structures, creating rigid visual/audio symmetry.
    """
    
    def __init__(self):
        self.section_geometries: Dict[str, Dict[str, Any]] = {}  # section -> geometry
        self.used_loop_lengths: Dict[str, Set[float]] = defaultdict(set)  # role -> lengths
        self.spacing_patterns: Dict[str, List[float]] = defaultdict(list)  # section -> clip positions
    
    def record_geometry(self, section: str, role: str, loop_length: float, clip_positions: List[float]):
        """Record the geometry of a loop in a section."""
        if section not in self.section_geometries:
            self.section_geometries[section] = {}
        
        self.section_geometries[section][role] = {
            'loop_length': loop_length,
            'clip_positions': clip_positions,
        }
        
        self.used_loop_lengths[role].add(loop_length)
        self.spacing_patterns[section] = clip_positions
    
    def calculate_geometry_similarity(self, section1: str, section2: str) -> float:
        """
        Calculate similarity between two sections' loop geometries.
        
        Returns:
            0.0-1.0 where 1.0 = identical geometry, 0.0 = completely different
        """
        geo1 = self.section_geometries.get(section1, {})
        geo2 = self.section_geometries.get(section2, {})
        
        if not geo1 or not geo2:
            return 0.0
        
        # Compare loop lengths
        lengths1 = {role: g['loop_length'] for role, g in geo1.items()}
        lengths2 = {role: g['loop_length'] for role, g in geo2.items()}
        
        common_roles = set(lengths1.keys()) & set(lengths2.keys())
        if not common_roles:
            return 0.0
        
        length_matches = sum(1 for role in common_roles if lengths1[role] == lengths2[role])
        length_sim = length_matches / len(common_roles)
        
        # Compare spacing patterns
        pattern1 = self.spacing_patterns.get(section1, [])
        pattern2 = self.spacing_patterns.get(section2, [])
        
        if len(pattern1) == len(pattern2) and len(pattern1) > 0:
            # Check if spacing is identical
            diffs1 = [pattern1[i+1] - pattern1[i] for i in range(len(pattern1)-1)]
            diffs2 = [pattern2[i+1] - pattern2[i] for i in range(len(pattern2)-1)]
            
            if diffs1 == diffs2:
                pattern_sim = 1.0
            else:
                # Calculate average difference
                avg_diff = sum(abs(d1 - d2) for d1, d2 in zip(diffs1, diffs2)) / len(diffs1)
                pattern_sim = max(0.0, 1.0 - (avg_diff / 4.0))  # Normalize to 4-bar chunks
        else:
            pattern_sim = 0.0
        
        # Combined similarity
        return (length_sim * 0.6 + pattern_sim * 0.4)
    
    def suggest_different_loop_length(self, role: str, preferred_length: float, tolerance: float = 2.0) -> float:
        """
        Suggest a loop length that differs from already used lengths.
        
        Args:
            role: The role to suggest for
            preferred_length: Preferred length
            tolerance: Acceptable difference from preferred
        
        Returns:
            A loop length that's different from existing ones
        """
        used = self.used_loop_lengths.get(role, set())
        if not used:
            return preferred_length
        
        # If preferred length is already used, find alternative
        if preferred_length in used:
            # Try common alternatives
            alternatives = [preferred_length * 2, preferred_length / 2, preferred_length + 4, preferred_length - 4]
            for alt in alternatives:
                if alt > 0 and alt not in used and abs(alt - preferred_length) <= tolerance * 2:
                    return alt
        
        return preferred_length
    
    def check_mirror_symmetry(self, sections: List[str], threshold: float = 0.85) -> List[Tuple[str, str]]:
        """
        Check for mirror symmetry between section pairs.
        
        Returns:
            List of (section1, section2) pairs that are too similar
        """
        mirrors = []
        for i, s1 in enumerate(sections):
            for s2 in sections[i+1:]:
                sim = self.calculate_geometry_similarity(s1, s2)
                if sim >= threshold:
                    mirrors.append((s1, s2))
        
        return mirrors
    
    def get_geometry_report(self) -> Dict[str, Any]:
        """Get a report of all tracked geometries."""
        return {
            'sections': {
                section: {
                    role: {
                        'length': geo['loop_length'],
                        'positions': geo['clip_positions'][:5],  # First 5 positions
                    }
                    for role, geo in section_geos.items()
                }
                for section, section_geos in self.section_geometries.items()
            },
            'used_loop_lengths_by_role': {role: list(lengths) for role, lengths in self.used_loop_lengths.items()},
            'mirror_pairs': self.check_mirror_symmetry(list(self.section_geometries.keys())),
        }


class SampleSelector:
    """
    Selector inteligente de samples (Fase 4 mejorada).

    Proporciona selección contextual basada en:
    - Género musical
    - Tonalidad (key) y compatibilidad armónica
    - BPM y tempo
    - Estilo y características

    Mejoras Fase 4:
    - Ranking multi-factor con scoring vectorizado
    - Seeding determinista para reproducibilidad
    - Validación de roles para evitar elecciones absurdas
    - Penalización de familias repetidas
    - Balance one-shots vs loops
    """

    def __init__(self, manager: Optional[SampleManager] = None, session_seed: Optional[int] = None):
        """
        Inicializa el selector.

        Args:
            manager: Instancia de SampleManager (usa global si None)
            session_seed: Semilla para reproducibilidad dentro de una sesión
        """
        if manager is None and MANAGER_AVAILABLE:
            manager = get_manager()

        self.manager = manager
        self.analyzer = AudioAnalyzer() if MANAGER_AVAILABLE else None

        # Historial de samples usados (ID -> timestamp)
        self._recent_sample_ids = deque(maxlen=100)
        # Historial de familias usadas (family -> count)
        self._recent_families = defaultdict(int)
        # Historial de roles usados (role -> [sample_ids])
        self._role_history = defaultdict(list)

        # Tracking de cooldown para familias
        self._family_last_used: Dict[str, int] = {}  # family -> selection_index
        self._selection_counter: int = 0  # Increment each time a sample is selected

        # Semilla de sesión para diversidad controlada
        self._session_seed = session_seed or int(time.time() * 1000) % (2**31)

        # Preferencias de balance one-shot vs loop
        self._oneshot_preference = 0.7  # 70% preferencia one-shots para drums
        self._loop_preference = 0.6     # 60% preferencia loops para synths

        # Configuración de GPU
        self._use_gpu = GPU_AVAILABLE
        if self._use_gpu:
            logger.info("GPU disponible, usando aceleración para cálculos vectorizados")

        # Decision logging
        self._decision_log: list[SampleDecision] = []
        self._log_decisions: bool = False  # Por defecto False para no impactar performance

        # Section-aware selection context
        self._section_context: Optional[str] = None
        self._section_role_selections: Dict[str, Dict[str, Any]] = {}  # section -> role -> sample info
        self._joint_scoring_enabled: bool = True

        # Joint scoring weights for role combinations
        self._joint_score_weights = {
            'drum_kit': 0.25,  # kick + snare + clap + hats
            'music_group': 0.20,  # bass + synth
            'vocal_fx_group': 0.20,  # vocal + fx
        }

        # T4: New tracking systems for freer but coherent selection
        self._alternates_pool = AlternatesPool(pool_size=5)
        self._freshness_tracker = FresherCoherenceTracker(max_history_per_role=20)
        self._geometry_tracker = LoopGeometryTracker()
        self._generation_id: Optional[str] = None
        self._dominant_packs: List[str] = []
        
        # T4: Alternates selection configuration
        self._alternates_enabled: bool = True
        self._alternates_tolerance: float = 0.15  # ±15% score range
        self._max_same_pack_ratio: float = 0.60  # Max 60% from single pack
        
        # T066: Pack lock for forcing same-pack selection (reggaeton)
        self._pack_lock: Optional[str] = None
        self._pack_lock_penalty: float = 0.1  # 90% penalty for non-matching pack
        
        # T068: Section cooldown queue - samples used in sections have TTL
        self._section_cooldown_queue: deque = deque(maxlen=100)  # (sample_path, section, ttl)
        self._section_cooldown_ttl: int = 2  # TTL in sections
        self._section_cooldown_penalty: float = 0.5  # 50% penalty
        
        # T069: Diversity check - track sample usage count in arrangement
        self._arrangement_sample_counts: Dict[str, int] = defaultdict(int)
        self._cooldown_window: int = 3  # Max times a sample can appear

    def force_pack_lock(self, pack_name: str) -> None:
        """
        T066: Force selection from a specific pack (for reggaeton coherence).
        
        When enabled, samples not from the specified pack receive a 90% penalty.
        Call this after detecting the dominant pack from the first selection.
        """
        self._pack_lock = pack_name.lower()
        logger.info(f"[T066] Pack lock enabled: {pack_name}")

    def clear_pack_lock(self) -> None:
        """T066: Clear the pack lock constraint."""
        self._pack_lock = None
        logger.debug("[T066] Pack lock cleared")

    def _apply_pack_lock_penalty(self, sample_path: str) -> float:
        """T066: Check if sample matches the locked pack. Returns penalty multiplier."""
        if not self._pack_lock:
            return 1.0
        if not sample_path:
            return 0.5
        sample_pack = self._extract_pack_from_path(sample_path)
        if sample_pack.lower() == self._pack_lock.lower():
            return 1.0
        sample_parent = str(Path(sample_path).parent.parent).lower()
        if self._pack_lock in sample_parent or sample_parent in self._pack_lock:
            return 0.8
        return self._pack_lock_penalty

    def _extract_pack_from_path(self, sample_path: str) -> str:
        """Extract pack name from sample path."""
        if not sample_path:
            return "unknown"
        path = Path(sample_path)
        parts = [p for p in path.parts if p and p not in {path.anchor}]
        for part in reversed(parts):
            part_lower = part.lower()
            if "ss_rnbl" in part_lower:
                return "ss_rnbl"
            if "midilatino" in part_lower:
                return "midilatino"
            if "reggaeton" in part_lower:
                return "reggaeton"
            if "bigcayu" in part_lower:
                return "bigcayu"
        if len(parts) >= 2:
            return parts[-2]
        return parts[-1] if parts else "unknown"

    def add_to_section_cooldown(self, sample_path: str, section_kind: str) -> None:
        """T068: Add sample to section cooldown queue after use in a section."""
        entry = (sample_path, section_kind, self._section_cooldown_ttl)
        self._section_cooldown_queue.append(entry)
        logger.debug(f"[T068] Added to cooldown: {Path(sample_path).name} for {section_kind}")

    def _get_section_cooldown_penalty(self, sample_path: str, current_section: str) -> float:
        """T068: Get cooldown penalty for a sample in current section."""
        if not self._section_cooldown_queue:
            return 1.0
        for path, section, ttl in self._section_cooldown_queue:
            if path == sample_path and section != current_section and ttl > 0:
                return self._section_cooldown_penalty
        return 1.0

    def decrement_section_cooldown(self) -> None:
        """T068: Decrement TTL for all samples in cooldown queue."""
        new_queue = deque(maxlen=100)
        for path, section, ttl in self._section_cooldown_queue:
            if ttl > 1:
                new_queue.append((path, section, ttl - 1))
        self._section_cooldown_queue = new_queue

    def check_diversity_before_confirm(self, sample_path: str) -> bool:
        """T069: Check if sample should be confirmed based on diversity rules."""
        count = self._arrangement_sample_counts.get(sample_path, 0)
        if count >= self._cooldown_window:
            logger.debug(f"[T069] Diversity reject: {Path(sample_path).name} used {count} times")
            return False
        return True

    def record_sample_in_arrangement(self, sample_path: str) -> None:
        """T069: Record that a sample was used in the arrangement."""
        self._arrangement_sample_counts[sample_path] += 1

    def reset_arrangement_diversity(self) -> None:
        """T069: Reset arrangement sample counts for a new generation."""
        self._arrangement_sample_counts.clear()
        self._section_cooldown_queue.clear()


    def set_generation_context(self, generation_id: str, dominant_packs: List[str] = None) -> None:
        """
        T4: Set generation context for tracking and coherence.
        
        Args:
            generation_id: Unique identifier for this generation
            dominant_packs: List of dominant pack names for coherence
        """
        self._generation_id = generation_id
        self._dominant_packs = dominant_packs or []
        self._alternates_pool.clear_all()
        self._geometry_tracker = LoopGeometryTracker()
        self.reset_arrangement_diversity()  # T069: Reset diversity tracking
        self.clear_pack_lock()  # T066: Clear any previous pack lock
        logger.info(f"[T4] Generation context set: {generation_id}, packs: {dominant_packs}")

    def get_alternates_for_role(self, 
                                role: str, 
                                samples: List['Sample'],
                                target_key: Optional[str] = None,
                                target_bpm: Optional[float] = None,
                                target_genre: Optional[str] = None) -> Dict[str, Any]:
        """
        T4: Get alternates pool for a role with full scoring documentation.
        
        Returns dict with:
        - 'selected': The chosen sample
        - 'alternates': List of valid alternate candidates
        - 'rejected': List of rejected candidates with reasons
        - 'selection_method': How the selection was made
        - 'pool_stats': Statistics about the pool
        """
        role = role.lower()
        
        # Build the pool
        self._alternates_pool.clear_role(role)
        
        scored_candidates = []
        rejected_candidates = []
        
        for idx, sample in enumerate(samples):
            # Calculate base score
            score = self._calculate_sample_score(
                sample,
                target_key=target_key,
                target_bpm=target_bpm,
                target_role=role,
                target_genre=target_genre
            )
            
            # Get sample metadata
            sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '')
            family = _extract_sample_family(sample.name)
            pack = str(Path(sample_path).parent) if sample_path else ''
            
            # Check hard reject
            should_reject, reject_reason = self._hard_reject_check(sample, role)
            if should_reject:
                rejected_candidates.append({
                    'name': sample.name,
                    'score': score,
                    'reason': reject_reason,
                    'stage': 'hard_reject'
                })
                continue
            
            # Calculate freshness and coherence scores
            freshness = self._freshness_tracker.get_freshness_penalty(role, sample_path)
            coherence = self._freshness_tracker.get_coherence_bonus(sample_path, self._dominant_packs)
            
            # Check pack dominance
            if self._freshness_tracker.check_source_dominance(pack, self._max_same_pack_ratio):
                # Apply penalty for over-dominant pack
                coherence *= 0.8
            
            # Create candidate
            candidate = AlternateCandidate(
                sample=sample,
                score=score,
                rank=idx,
                freshness_score=freshness,
                coherence_score=coherence,
                family=family,
                pack_name=pack
            )
            
            if self._alternates_pool.add_candidate(role, candidate):
                scored_candidates.append(candidate)
            else:
                rejected_candidates.append({
                    'name': sample.name,
                    'score': score,
                    'reason': 'pool_full_lower_score',
                    'stage': 'pool_limit'
                })
        
        # Select using weighted freshness+coherence
        selected = self._alternates_pool.select_weighted(role, context=f"gen_{self._generation_id}")
        
        # Get alternates in score range
        top_score = max(c.score for c in self._alternates_pool.candidates_by_role.get(role, [])) if self._alternates_pool.candidates_by_role.get(role) else 0
        alternates = self._alternates_pool.get_alternates_in_range(role, top_score, self._alternates_tolerance)
        
        # Record usage if selected
        if selected and selected.sample:
            sample_path = getattr(selected.sample, 'path', '') or getattr(selected.sample, 'file_path', '')
            self._freshness_tracker.record_usage(
                role, sample_path, selected.pack_name, selected.family, self._generation_id
            )
        
        return {
            'selected': selected.to_dict() if selected else None,
            'alternates': [a.to_dict() for a in alternates if a != selected],
            'rejected': rejected_candidates[:10],  # Limit rejected list
            'selection_method': 'freshness_coherence_weighted' if self._alternates_enabled else 'top_score',
            'pool_stats': {
                'total_candidates': len(samples),
                'pool_size': len(self._alternates_pool.candidates_by_role.get(role, [])),
                'valid_alternates': len(alternates),
                'top_score': round(top_score, 4),
                'score_tolerance': self._alternates_tolerance,
            }
        }

    def record_loop_geometry(self, section: str, role: str, loop_length: float, clip_positions: List[float]) -> None:
        """T4: Record loop geometry to avoid mirror patterns."""
        self._geometry_tracker.record_geometry(section, role, loop_length, clip_positions)
    
    def check_section_mirror_symmetry(self, sections: List[str]) -> List[Tuple[str, str, float]]:
        """
        T4: Check for mirror symmetry between sections.
        
        Returns list of (section1, section2, similarity_score) for mirrors.
        """
        mirrors = []
        for i, s1 in enumerate(sections):
            for s2 in sections[i+1:]:
                sim = self._geometry_tracker.calculate_geometry_similarity(s1, s2)
                if sim >= 0.85:
                    mirrors.append((s1, s2, sim))
        return mirrors

    def get_selection_report(self) -> Dict[str, Any]:
        """T4: Get comprehensive selection report."""
        return {
            'alternates_pool': {
                role: self._alternates_pool.get_pool_for_role(role)
                for role in self._alternates_pool.candidates_by_role.keys()
            },
            'freshness_stats': self._freshness_tracker.get_stats(),
            'geometry_report': self._geometry_tracker.get_geometry_report(),
            'dominant_packs': self._dominant_packs,
            'generation_id': self._generation_id,
        }
        """
        Genera una semilla determinista para cada selección.
        Combina session_seed, contador y contexto.
        """
        self._selection_counter += 1
        seed_data = f"{self._session_seed}_{self._selection_counter}_{context}"
        return int(hashlib.md5(seed_data.encode()).hexdigest()[:8], 16)

    def _calculate_sample_score(self,
                                 sample: 'Sample',
                                 target_key: Optional[str] = None,
                                 target_bpm: Optional[float] = None,
                                 target_role: Optional[str] = None,
                                 target_genre: Optional[str] = None,
                                 prefer_oneshot: Optional[bool] = None) -> float:
        """
        Calcula un score completo para un sample basado en múltiples factores.

        Factores:
        - Rating del sample (peso: 0.15)
        - Compatibilidad de key (peso: 0.20)
        - Compatibilidad de BPM (peso: 0.15)
        - Ajuste de género (peso: 0.10)
        - Validación de rol (peso: 0.15)
        - Penalización por repetición (peso: 0.10)
        - Balance one-shot/loop (peso: 0.10)
        - Energía y características (peso: 0.05)

        Returns:
            Score normalizado entre 0 y 1
        """
        score = 0.0
        weights = 0.0

        # 1. Rating del sample (0-5 -> 0-1)
        rating_score = min(1.0, (sample.rating or 0) / 5.0)
        score += rating_score * 0.15
        weights += 0.15

        # 2. Compatibilidad de key
        if target_key and sample.key:
            if MANAGER_AVAILABLE:
                key_compat = calculate_key_compatibility(target_key, sample.key)
            else:
                key_compat = 1.0 if sample.key == target_key else 0.5
            score += key_compat * 0.20
            weights += 0.20
        elif target_key:
            # Sin key info, score neutral
            score += 0.5 * 0.20
            weights += 0.20

        # 3. Compatibilidad de BPM
        if target_bpm and sample.bpm:
            bpm_diff = abs(sample.bpm - target_bpm)
            if bpm_diff == 0:
                bpm_score = 1.0
            elif bpm_diff <= 3:
                bpm_score = 0.95
            elif bpm_diff <= 6:
                bpm_score = 0.85
            elif bpm_diff <= 10:
                bpm_score = 0.70
            else:
                bpm_score = max(0.2, 1.0 - (bpm_diff / 30))
            score += bpm_score * 0.15
            weights += 0.15
        elif target_bpm:
            score += 0.5 * 0.15
            weights += 0.15

        # 4. Ajuste de género
        if target_genre and sample.genres:
            genre_lower = target_genre.lower().replace(' ', '-')
            sample_genres_lower = [g.lower().replace(' ', '-') for g in sample.genres]
            if genre_lower in sample_genres_lower:
                genre_score = 1.0
            elif any(g in genre_lower or genre_lower in g for g in sample_genres_lower):
                genre_score = 0.7
            else:
                genre_score = 0.3
            score += genre_score * 0.10
            weights += 0.10

        # 5. Validación de rol (EVITA ELECCIONES ABSURDAS)
        if target_role:
            role_score = self._validate_sample_for_role(sample, target_role)
            score += role_score * 0.15
            weights += 0.15

        # 6. Penalización por repetición reciente
        repetition_penalty = self._calculate_repetition_penalty(sample)
        score += repetition_penalty * 0.10
        weights += 0.10

        # 7. Balance one-shot vs loop
        if prefer_oneshot is not None:
            is_oneshot = _is_oneshot(sample)
            if prefer_oneshot and is_oneshot:
                balance_score = 0.9
            elif not prefer_oneshot and not is_oneshot:
                balance_score = 0.9
            else:
                balance_score = 0.5
            score += balance_score * 0.10
            weights += 0.10

        # Bonus por tipo correcto (one-shot vs loop) para roles críticos
        if target_role and target_role.lower() in ROLE_ONE_SHOT_PREFERENCE:
            prefers_oneshot = ROLE_ONE_SHOT_PREFERENCE[target_role.lower()]
            is_oneshot = _is_oneshot(sample)
            if prefers_oneshot == is_oneshot:
                score *= 1.2  # 20% bonus por tipo correcto
                weights += 0.1

        # 8. Energía y características espectrales
        if sample.rms_energy > 0:
            # Preferir samples con buena energía (no muy bajos ni saturados)
            energy_score = min(1.0, sample.rms_energy * 2)
            score += energy_score * 0.05
            weights += 0.05

        # T017: Factor brightness_fit (peso 0.10)
        brightness_score = self._calculate_brightness_fit(sample, target_role)
        if brightness_score < 1.0:
            score += brightness_score * 0.10
            weights += 0.10

        # 9. Cooldown por familia (penaliza familias recientemente usadas)
        if target_role and target_role.lower() in ['kick', 'clap', 'hat', 'bass_loop', 'vocal_loop']:
            family = _extract_sample_family(sample.name)
            cooldown_penalty = self._get_family_cooldown_penalty(family)
            score *= cooldown_penalty
            weights += 0.15  # Peso significativo para cooldown
            if cooldown_penalty < 0.5:
                logger.debug("COOLDOWN: family '%s' has cooldown penalty %.2f (used %d selections ago)",
                            family, cooldown_penalty, self._selection_counter - self._family_last_used.get(family, 0))

        # 10. Cross-generation penalty para roles críticos
        if target_role and target_role.lower() in ['kick', 'clap', 'hat', 'bass_loop', 'vocal_loop', 'top_loop', 'synth_loop', 'snare']:
            family = _extract_sample_family(sample.name)
            sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
            cross_penalty = self._get_cross_generation_penalty(family, sample_path, target_role.lower())
            if cross_penalty < 1.0:
                score *= cross_penalty
                weights += 0.12
                logger.debug("CROSS_GEN: family '%s' has cross-gen penalty %.2f for role '%s' (used in %d prev generations)",
                            family, cross_penalty, target_role.lower(), _cross_generation_family_memory.get(family, 0))

        # 11. SECTION-AWARE BONUS: Adjust score based on section appropriateness
        if self._section_context and target_role:
            section_bonus, section_reason = self._get_section_role_bonus(target_role)
            if section_bonus != 1.0:
                score *= section_bonus
                weights += 0.15
                logger.debug("SECTION_CONTEXT [%s]: role '%s' gets %.2f bonus (%s)",
                            self._section_context, target_role, section_bonus, section_reason)

        # 12. JOINT SCORING: Compatibility with already selected samples in this section
        if self._section_context and target_role:
            section_selections = self._section_role_selections.get(self._section_context, {})
            if section_selections:
                joint_factor = self._calculate_joint_score(sample, target_role,
                                                           {r: info['sample'] for r, info in section_selections.items()})
                if joint_factor != 1.0:
                    score *= joint_factor
                    weights += 0.20
                    logger.debug("JOINT_SCORE [%s]: sample gets %.2f factor",
                                target_role, joint_factor)

        # T022: Factor de fatiga persistente (opcional - requiere integración con server.py)
        # Este factor se aplica si el server.py pasa datos de fatiga al selector
        if hasattr(self, '_fatigue_data') and target_role:
            sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
            fatigue_factor = self._get_persistent_fatigue(sample_path, target_role.lower())
            if fatigue_factor < 1.0:
                score *= fatigue_factor
                weights += 0.10
                logger.debug("FATIGUE: sample '%s' has fatigue factor %.2f for role '%s'",
                            Path(sample_path).name, fatigue_factor, target_role.lower())

        # T026: Palette bonus (integración con server.py)
        if hasattr(self, '_palette_data') and target_role:
            sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
            bus = self._role_to_bus(target_role.lower())
            if bus and bus in self._palette_data:
                anchor_folder = self._palette_data[bus]
                palette_bonus = self._calculate_palette_bonus(sample_path, anchor_folder)
                score *= palette_bonus
                weights += 0.15
                logger.debug("PALETTE: sample '%s' has palette bonus %.2f for bus '%s'",
                            Path(sample_path).name, palette_bonus, bus)

        # STRICT SAME-PACK SELECTION: atmos_fx, vocal_shot, fill_fx, snare_roll
        # These roles MUST come from the same pack as main musical elements when possible
        if target_role and target_role.lower() in {'atmos_fx', 'vocal_shot', 'fill_fx', 'snare_roll'}:
            sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''

            # Build list of main pack folders from palette data
            main_pack_folders = []
            if hasattr(self, '_palette_data'):
                # Include drums, bass, music as main pack references
                for main_bus in ['drums', 'bass', 'music']:
                    if main_bus in self._palette_data:
                        main_pack_folders.append(self._palette_data[main_bus])

            if main_pack_folders and sample_path:
                strict_bonus, selection_type, reason = self._calculate_same_pack_strict_bonus(
                    sample_path, main_pack_folders, target_role.lower()
                )
                score *= strict_bonus
                weights += 0.25  # Heavy weight for same-pack constraint

                # Log selection type with clear markers
                if selection_type == "same_pack":
                    logger.info("SAME_PACK [%s]: Selected from main pack - %s (score: %.2f)",
                               target_role.upper(), Path(sample_path).name, strict_bonus)
                elif selection_type == "same_parent":
                    logger.info("SAME_PARENT [%s]: Selected from related folder - %s (score: %.2f, %s)",
                               target_role.upper(), Path(sample_path).name, strict_bonus, reason)
                else:
                    logger.warning("FALLBACK [%s]: Cross-pack selection - %s (penalty: %.2f, %s)",
                                  target_role.upper(), Path(sample_path).name, strict_bonus, reason)

        # T017-T030: Spectral engine integration for timbral similarity scoring
        if SPECTRAL_ENGINE_AVAILABLE and target_role:
            spectral_engine = get_selector_spectral_engine()
            if spectral_engine:
                sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
                if sample_path and os.path.exists(sample_path):
                    profile = spectral_engine.analyze(sample_path)
                    if profile:
                        # T023: Spectral penalty for incompatible samples
                        if target_genre and target_genre.lower() == 'reggaeton':
                            genre_profile = GENRE_PROFILES.get('reggaeton')
                            if genre_profile and genre_profile.spectral_targets:
                                role_spectral_target = genre_profile.spectral_targets.get(target_role.lower())
                                if role_spectral_target:
                                    centroid_range = role_spectral_target.get('centroid_range', (0, 99999))
                                    
                                    # T025: Reggaeton bass - reject bright samples
                                    if target_role.lower() == 'bass' and profile.centroid_mean > 1500:
                                        logger.debug("[SPECTRAL_GATE] REJECTED: bass sample too bright (centroid=%.0f > 1500 Hz)", profile.centroid_mean)
                                        score *= 0.3
                                        weights += 0.15
                                    
                                    # T026: Reggaeton kick - reject long samples
                                    elif target_role.lower() == 'kick' and profile.duration > 1.5:
                                        logger.debug("[SPECTRAL_GATE] REJECTED: kick sample too long (duration=%.2f > 1.5s)", profile.duration)
                                        score *= 0.4
                                        weights += 0.15
                                    
                                    # T027: synth_loop in reggaeton - prefer tonal samples
                                    elif target_role.lower() == 'synth_loop' and profile.spectral_flatness > 0.3:
                                        logger.debug("[SPECTRAL_GATE] PENALIZED: synth_loop too noisy (flatness=%.2f > 0.3)", profile.spectral_flatness)
                                        score *= 0.7
                                        weights += 0.10
                                    
                                    # T028: top_loop in reggaeton - accept up to 0.6 flatness
                                    elif target_role.lower() in ('top_loop', 'perc_loop') and profile.spectral_flatness > 0.6:
                                        logger.debug("[SPECTRAL_GATE] PENALIZED: %s too noisy (flatness=%.2f > 0.6)", target_role.lower(), profile.spectral_flatness)
                                        score *= 0.75
                                        weights += 0.10
                                    
                                    # General centroid range check
                                    elif centroid_range[0] <= profile.centroid_mean <= centroid_range[1]:
                                        score *= 1.1
                                        weights += 0.10
                                    elif profile.centroid_mean < centroid_range[0] * 0.8 or profile.centroid_mean > centroid_range[1] * 1.2:
                                        logger.debug("[SPECTRAL_GATE] PENALIZED: centroid %.0f Hz outside range %s", profile.centroid_mean, centroid_range)
                                        score *= 0.85
                                        weights += 0.10

        # T083: Apply spectral penalty from diversity_memory for inter-session diversity
        if DIVERSITY_MEMORY_AVAILABLE and get_spectral_penalty and target_role:
            sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
            if sample_path and os.path.exists(sample_path):
                # Determine centroid bucket from sample name or spectral analysis
                centroid_bucket = 'mid'  # default
                if SPECTRAL_ENGINE_AVAILABLE and sample_path:
                    try:
                        profile = spectral_engine.analyze(sample_path)
                        if profile:
                            if profile.centroid_mean < 1000:
                                centroid_bucket = 'low'
                            elif profile.centroid_mean > 5000:
                                centroid_bucket = 'high'
                            else:
                                centroid_bucket = 'mid'
                    except Exception:
                        pass
                
                spectral_penalty = get_spectral_penalty(centroid_bucket, target_role.lower())
                if spectral_penalty < 1.0:
                    score *= spectral_penalty
                    weights += 0.10
                    logger.debug("T083: SPECTRAL_PENALTY: bucket '%s' for role '%s' has penalty %.2f",
                                centroid_bucket, target_role.lower(), spectral_penalty)

        # T103: LUFS bonus for reggaeton - prefer samples closer to target LUFS
        if target_genre and target_genre.lower() == 'reggaeton' and target_role:
            rms_energy = getattr(sample, 'rms_energy', None) or 0.0
            if rms_energy > 0:
                lufs_estimated = estimate_lufs_from_rms(rms_energy)
                lufs_target = REGGAETON_LUFS_TARGETS.get(target_role.lower(), -16.0)
                lufs_delta = abs(lufs_estimated - lufs_target)
                # Bonus de 0→1 basado en cercanía al target (0 si difiere > 12 LUFS)
                lufs_bonus = max(0.0, 1.0 - lufs_delta / 12.0)
                if lufs_bonus > 0.5:
                    # Peso del 15% para samples con buena sonoridad
                    score += lufs_bonus * 0.15
                    weights += 0.15
                    logger.debug("T103: LUFS_BONUS: role '%s' estimated %.1f LUFS (target %.1f), delta=%.1f, bonus=%.2f",
                                target_role.lower(), lufs_estimated, lufs_target, lufs_delta, lufs_bonus)
                elif lufs_delta > 6:
                    # Penalizar samples muy lejanos del target
                    score *= (1.0 - (lufs_delta - 6) / 24.0)  # Penalización gradual
                    weights += 0.10
                    logger.debug("T103: LUFS_PENALTY: role '%s' estimated %.1f LUFS far from target %.1f (delta=%.1f)",
                                target_role.lower(), lufs_estimated, lufs_target, lufs_delta)

        # Normalizar
        return score / weights if weights > 0 else 0.5

    def _validate_sample_for_role(self, sample: 'Sample', target_role: str) -> float:
        """
        Valida si un sample es apropiado para un rol específico.
        Retorna un score de 0 a 1, donde 0 significa "completamente inapropiado".

        Esto EVITA ELECCIONES ABSURDAS como:
        - Snare roll donde va clap
        - Hi-hat donde va kick
        - Vocal sample en drum kit
        """
        target_role_lower = target_role.lower()
        sample_name_lower = sample.name.lower()
        sample_type_lower = (sample.sample_type or '').lower()
        sample_subcat_lower = (sample.subcategory or '').lower()
        sample_duration = getattr(sample, 'duration', None) or 0

        # Check using old DRUM_ROLE_VALID_TYPES (legacy support)
        valid_types = DRUM_ROLE_VALID_TYPES.get(target_role_lower, [])
        for vtype in valid_types:
            if vtype in sample_type_lower or sample_type_lower in vtype:
                return 1.0
            if vtype in sample_subcat_lower or sample_subcat_lower in vtype:
                return 0.95

        for vtype in valid_types:
            if vtype in sample_name_lower:
                return 0.9

        # Check using ROLE_REQUIRED_KEYWORDS for required keywords validation
        required_keywords = ROLE_REQUIRED_KEYWORDS.get(target_role_lower, [])
        if required_keywords:
            for kw in required_keywords:
                if kw in sample_name_lower:
                    return 0.85
                if kw in sample_type_lower:
                    return 0.80

        duration_min, duration_max = ROLE_DURATION_RANGES.get(target_role_lower, (0.0, 999.0))
        if sample_duration > 0 and duration_max < 999.0:
            if duration_min <= sample_duration <= duration_max:
                pass
            elif sample_duration < duration_min:
                return 0.25
            elif sample_duration > duration_max:
                return 0.20

        if sample.category == 'drums':
            return 0.30

        exclusive_roles = {
            'kick': ['vocal', 'bass', 'synth', 'pad', 'fx'],
            'snare': ['vocal', 'bass', 'synth'],
            'clap': ['vocal', 'bass', 'kick'],
            'hat_closed': ['vocal', 'bass', 'kick'],
            'hat_open': ['vocal', 'bass', 'kick'],
            'bass_loop': ['drum', 'vocal', 'fx'],
            'vocal_loop': ['drum', 'bass', 'kick'],
        }

        excluded = exclusive_roles.get(target_role_lower, [])
        for excluded_type in excluded:
            if excluded_type in sample_name_lower:
                return 0.0

        return 0.15

    def _hard_reject_check(self, sample: 'Sample', target_role: str) -> tuple[bool, str]:
        """
        Verifica rechazo duro para roles críticos.

        Retorna (should_reject, reason) donde:
        - should_reject: True si el sample debe ser rechazado completamente
        - reason: string explicando por qué

        Esto es más estricto que _validate_sample_for_role() y captura
        casos que son claramente errores semánticos.

        Mejorado para Problema #4:
        - Integra ROLE_EXCLUSION_PATTERNS
        - Logging detallado de rechazos
        """
        target_role_lower = target_role.lower()
        sample_name_lower = sample.name.lower()
        sample_duration = getattr(sample, 'duration', None)

        # 1. Check ROLE_EXCLUSION_PATTERNS (nuevo sistema endurecido)
        excluded, exclusion_reason = _check_role_exclusion(sample.name, target_role)
        if excluded:
            logger.debug("HARD_REJECT (exclusion): %s for role '%s': %s",
                        sample.name, target_role, exclusion_reason)
            return True, f"ROLE_EXCLUSION: {exclusion_reason}"

        # 2. Check HARD_REJECT_PATTERNS (sistema existente)
        if target_role_lower not in HARD_REJECT_PATTERNS:
            # Fallback a rangos de duración si no hay patrones específicos
            duration_min, duration_max = ROLE_DURATION_RANGES.get(target_role_lower, (0.0, 999.0))
            if sample_duration and duration_max < 999.0:
                if sample_duration < duration_min:
                    return True, f"duration {sample_duration:.1f}s below min {duration_min}s for {target_role}"
                if sample_duration > duration_max:
                    return True, f"duration {sample_duration:.1f}s exceeds max {duration_max}s for {target_role}"
            return False, ""

        patterns = HARD_REJECT_PATTERNS[target_role_lower]
        sample_type_lower = (sample.sample_type or '').lower()
        sample_subcat_lower = (sample.subcategory or '').lower()

        # Check excluded keywords
        for kw in patterns.get('exclude_keywords', []):
            if kw in sample_name_lower:
                logger.debug("HARD_REJECT (keyword): %s for role '%s': contains '%s'",
                            sample.name, target_role, kw)
                return True, f"contains excluded keyword '{kw}'"

        # Check excluded subcategories
        for subcat in patterns.get('exclude_subcategories', []):
            if subcat in sample_subcat_lower or subcat in sample_type_lower:
                logger.debug("HARD_REJECT (subcat): %s for role '%s': subcategory '%s'",
                            sample.name, target_role, subcat)
                return True, f"has excluded subcategory '{subcat}'"

        # Check duration constraints
        max_duration = patterns.get('max_duration')
        min_duration = patterns.get('min_duration')
        if sample_duration:
            if max_duration and sample_duration > max_duration:
                logger.debug("HARD_REJECT (duration): %s for role '%s': %.1fs > max %.1fs",
                            sample.name, target_role, sample_duration, max_duration)
                return True, f"duration {sample_duration:.1f}s exceeds max {max_duration}s"
            if min_duration and sample_duration < min_duration:
                logger.debug("HARD_REJECT (duration): %s for role '%s': %.1fs < min %.1fs",
                            sample.name, target_role, sample_duration, min_duration)
                return True, f"duration {sample_duration:.1f}s below min {min_duration}s"

        # Check must_contain requirements
        must_contain = patterns.get('must_contain_one', [])
        if must_contain:
            found = any(kw in sample_name_lower or kw in sample_type_lower for kw in must_contain)
            if not found:
                logger.debug("HARD_REJECT (missing): %s for role '%s': needs one of %s",
                            sample.name, target_role, must_contain)
                return True, f"does not contain any of: {must_contain}"

        # Check must_contain_none keywords
        for kw in patterns.get('must_contain_none', []):
            if kw in sample_name_lower:
                logger.debug("HARD_REJECT (forbidden): %s for role '%s': contains '%s'",
                            sample.name, target_role, kw)
                return True, f"contains excluded keyword '{kw}'"

        return False, ""


    def _validate_loop_preference(self, sample: 'Sample', target_role: str) -> tuple[bool, str]:
        """
        Valida preferencia de one-shot vs loop para roles críticos.

        Retorna (is_valid, reason) donde:
        - is_valid: True si el sample cumple la preferencia
        - reason: string explicando violación si aplica
        """
        target_role_lower = target_role.lower()

        if target_role_lower not in ROLE_ONE_SHOT_PREFERENCE:
            return True, ""  # No hay preferencia definida

        prefers_oneshot = ROLE_ONE_SHOT_PREFERENCE[target_role_lower]
        is_oneshot = _is_oneshot(sample)

        if prefers_oneshot and not is_oneshot:
            return False, f"role requires one-shot but sample is loop (duration={sample.duration:.1f}s)"
        elif not prefers_oneshot and is_oneshot:
            return False, f"role requires loop but sample is one-shot (duration={sample.duration:.1f}s)"

        return True, ""

    def _calculate_brightness_fit(self, sample: 'Sample', target_role: Optional[str]) -> float:
        """
        T017: Calcula ajuste de brillo espectral para el rol objetivo.

        Retorna score 0-1 donde 1.0 = perfecto ajuste, <1.0 = penalización aplicada.

        Reglas:
        - atmos, pad, drone: penalizar spectral_centroid > 8000 Hz (demasiado brillante)
        - bass, sub_bass: penalizar spectral_centroid > 3000 Hz (pierde sub)
        - lead, chord: sin penalización por brillo, pero preferir centrado medio
        """
        if not target_role:
            return 1.0

        target_role_lower = target_role.lower()

        # Obtener spectral_centroid del sample (si está disponible)
        spectral_centroid = getattr(sample, 'spectral_centroid', None) or 5000.0

        # Roles que prefieren sonidos oscuros/cálidos
        dark_preferred_roles = ['atmos', 'pad', 'drone', 'ambience', 'texture']
        if any(r in target_role_lower for r in dark_preferred_roles):
            if spectral_centroid > 8000:
                # Penalización progresiva: >8000 = 0.5, >10000 = 0.3
                return max(0.3, 1.0 - (spectral_centroid - 8000) / 4000)
            elif spectral_centroid > 6000:
                return 0.8
            else:
                return 1.0

        # Roles de bajo que necesitan contenido de graves
        bass_roles = ['bass', 'sub_bass', 'bassline', '808', 'sub']
        if any(r in target_role_lower for r in bass_roles):
            if spectral_centroid > 3000:
                # Penalización severa para bass sin graves
                return max(0.2, 1.0 - (spectral_centroid - 3000) / 2000)
            elif spectral_centroid > 1500:
                return 0.7
            else:
                return 1.0

        # Roles brillantes permitidos
        bright_roles = ['lead', 'chord', 'stab', 'pluck', 'arp', 'synth']
        if any(r in target_role_lower for r in bright_roles):
            # Preferir rango medio-alto, no demasiado brillante ni opaco
            if 2000 <= spectral_centroid <= 8000:
                return 1.0
            elif spectral_centroid < 1000:
                return 0.7  # Quizás demasiado opaco
            elif spectral_centroid > 12000:
                return 0.8  # Quizás demasiado brillante/agudo
            else:
                return 0.9

        # Default: sin penalización
        return 1.0

    def set_fatigue_data(self, fatigue_data: Dict[str, Dict[str, Any]]) -> None:
        """
        T022: Carga datos de fatiga persistente desde server.py.
        Permite que el selector aplique penalización por uso previo.
        """
        self._fatigue_data = fatigue_data
        logger.debug(f"Fatigue data cargada: {len(fatigue_data)} samples")

    def _get_persistent_fatigue(self, sample_path: str, role: str) -> float:
        """
        T022: Obtiene factor de fatiga persistente para un sample y rol.

        Retorna:
        - 1.0: Sin fatiga (0 usos)
        - 0.75: Fatiga ligera (1-3 usos)
        - 0.50: Fatiga moderada (4-10 usos)
        - 0.20: Fatiga severa (10+ usos)
        """
        if not hasattr(self, '_fatigue_data') or not self._fatigue_data:
            return 1.0

        sample_fatigue = self._fatigue_data.get(sample_path, {})
        role_data = sample_fatigue.get(role, {})
        uses = role_data.get("uses", 0)

        if uses == 0:
            return 1.0
        elif 1 <= uses <= 3:
            return 0.75
        elif 4 <= uses <= 10:
            return 0.50
        else:
            return 0.20

    def set_palette_data(self, palette_data: Dict[str, str]) -> None:
        """
        T026: Carga datos de palette desde server.py.
        Permite aplicar bonus/penalización por compatibilidad con ancla.
        """
        self._palette_data = palette_data
        logger.debug(f"Palette data cargada: {palette_data}")

    def _role_to_bus(self, role: str) -> Optional[str]:
        """Mapea un rol a su bus correspondiente."""
        bus_mapping = {
            'kick': 'drums', 'clap': 'drums', 'hat': 'drums', 'snare': 'drums',
            'perc': 'drums', 'top_loop': 'drums', 'drum_loop': 'drums',
            'bass': 'bass', 'sub_bass': 'bass', 'bass_loop': 'bass', '808': 'bass',
            'synth': 'music', 'pad': 'music', 'lead': 'music', 'chord': 'music',
            'arp': 'music', 'pluck': 'music', 'synth_loop': 'music',
            'vocal': 'vocal', 'vocal_loop': 'vocal', 'vox': 'vocal',
            'fx': 'fx', 'riser': 'fx', 'impact': 'fx', 'atmos': 'fx'
        }
        return bus_mapping.get(role.lower())

    def _calculate_palette_bonus(self, sample_path: str, anchor_folder: str) -> float:
        """
        T026: Calcula bonus por compatibilidad con folder ancla.

        - Folder exacto: 1.4x
        - Subfolder del ancla: 1.3x
        - Folder hermano (mismo padre): 1.2x
        - Diferente: 0.9x
        """
        import os
        if not anchor_folder:
            return 1.0

        # Normalize paths to use forward slashes
        sample_folder = str(Path(sample_path).parent).replace(os.sep, '/')
        anchor = anchor_folder.replace(os.sep, '/')

        # Match exacto
        if sample_folder == anchor:
            return 1.4

        # Subfolder del ancla
        if sample_folder.startswith(anchor + '/'):
            return 1.3

        # Mismo padre (hermano)
        sample_parent = str(Path(sample_folder).parent).replace(os.sep, '/')
        anchor_parent = str(Path(anchor).parent).replace(os.sep, '/')
        if sample_parent == anchor_parent:
            return 1.2

        # Diferente
        return 0.9

    def _calculate_same_pack_strict_bonus(self, sample_path: str, main_pack_folders: List[str], role: str) -> Tuple[float, str, str]:
        """
        Calculates strict same-pack bonus for atmos_fx and vocal_shot roles.

        These roles should ONLY come from the same pack environment as the main
        musical elements (drums, bass, music) when possible.

        Args:
            sample_path: Path to the sample being evaluated
            main_pack_folders: List of main pack folder paths (drums, bass, music anchors)
            role: The role being selected (atmos_fx or vocal_shot)

        Returns:
            Tuple of (score_multiplier, selection_type, reason)
            - score_multiplier: Float bonus/penalty to apply
            - selection_type: "same_pack", "same_parent", "fallback"
            - reason: Human-readable explanation
        """
        if not main_pack_folders:
            return 1.0, "fallback", "no main pack context available"

        import os
        sample_folder = str(Path(sample_path).parent).replace(os.sep, '/')
        sample_parent = str(Path(sample_folder).parent).replace(os.sep, '/')

        best_bonus = 0.4  # Default penalty for different packs
        best_type = "fallback"
        best_reason = "different pack lineage - strict penalty applied"

        for main_folder in main_pack_folders:
            main_folder = main_folder.replace(os.sep, '/')
            main_parent = str(Path(main_folder).parent).replace(os.sep, '/')

            # Extract pack root names for comparison
            sample_pack_name = Path(sample_parent).name.lower() if sample_parent else ""
            main_pack_name = Path(main_parent).name.lower() if main_parent else ""

            # Exact same folder - strongest match
            if sample_folder == main_folder:
                return 2.0, "same_pack", f"exact folder match with main pack"

            # Subfolder of main pack
            if sample_folder.startswith(main_folder + '/'):
                return 1.8, "same_pack", f"subfolder of main pack"

            # Same parent directory AND same pack name (sibling folders in same pack)
            if sample_parent == main_parent:
                # This is a proper sibling relationship within the same pack
                return 1.5, "same_parent", f"sibling folder to main pack"

            # Check if this is actually the same pack with different subfolder structure
            # Only consider cousin relationship if pack names match (e.g., pack_a/drums vs pack_a/fx)
            if sample_pack_name and main_pack_name and sample_pack_name == main_pack_name:
                # Same pack root, different subfolder structure
                return 1.3, "same_parent", f"same pack root (cousin folder)"

        # If we get here, no match was found - return penalty
        return best_bonus, best_type, best_reason

    def set_section_context(self, section_kind: str) -> None:
        """
        Sets the current section context for selection.

        Args:
            section_kind: Type of section (intro, build, drop, break, outro)
        """
        self._section_context = section_kind.lower() if section_kind else None
        if section_kind:
            logger.debug("SECTION_CONTEXT: Set to '%s'", section_kind)

    def clear_section_context(self) -> None:
        """Clears the current section context."""
        self._section_context = None
        self._section_role_selections.clear()

    def _get_section_role_bonus(self, target_role: str) -> Tuple[float, str]:
        """
        Calculates bonus based on section context and role appropriateness.

        Returns:
            Tuple of (bonus_multiplier, reason)
        """
        if not self._section_context:
            return 1.0, "no section context"

        profile = SECTION_ROLE_PROFILES.get(self._section_context)
        if not profile:
            return 1.0, f"unknown section '{self._section_context}'"

        role_lower = target_role.lower()

        # Check if role is in primary list
        if role_lower in [r.lower() for r in profile['primary']]:
            return 1.3, f"primary role for {self._section_context}"

        # Check if role is in secondary list
        if role_lower in [r.lower() for r in profile['secondary']]:
            return 1.1, f"secondary role for {self._section_context}"

        # Check if role should be avoided
        if role_lower in [r.lower() for r in profile['avoid']]:
            return 0.5, f"role avoided in {self._section_context}"

        return 1.0, "neutral role for section"

    def _get_folder_relationship(self, folder1: str, folder2: str) -> str:
        """
        Determines the relationship between two folders.

        Returns:
            Relationship type: 'exact_same', 'same_parent', 'same_grandparent', 'different'
        """
        import os
        f1 = folder1.replace(os.sep, '/')
        f2 = folder2.replace(os.sep, '/')

        if f1 == f2:
            return 'exact_same'

        p1 = str(Path(f1).parent).replace(os.sep, '/')
        p2 = str(Path(f2).parent).replace(os.sep, '/')

        if p1 == p2:
            return 'same_parent'

        gp1 = str(Path(p1).parent).replace(os.sep, '/') if p1 else ''
        gp2 = str(Path(p2).parent).replace(os.sep, '/') if p2 else ''

        if gp1 == gp2 and gp1:
            return 'same_grandparent'

        return 'different'

    def _calculate_joint_score(self,
                               sample: 'Sample',
                               target_role: str,
                               selected_samples: Dict[str, 'Sample']) -> float:
        """
        Calculates joint compatibility score with already selected samples.

        This ensures that samples selected for related roles come from
        compatible folders and share sonic characteristics.

        Args:
            sample: Sample being evaluated
            target_role: Role this sample would fill
            selected_samples: Dict of role -> sample already selected

        Returns:
            Score multiplier (0.5 to 1.5)
        """
        if not selected_samples or not self._joint_scoring_enabled:
            return 1.0

        sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
        if not sample_path:
            return 1.0

        sample_folder = str(Path(sample_path).parent)

        # Find which joint scoring group this role belongs to
        target_group = None
        for group_name, roles in JOINT_SCORING_GROUPS.items():
            if target_role.lower() in [r.lower() for r in roles]:
                target_group = group_name
                break

        if not target_group:
            return 1.0

        # Calculate compatibility with other samples in the same group
        total_bonus = 1.0
        bonuses_applied = []

        for selected_role, selected_sample in selected_samples.items():
            # Only consider samples from the same joint scoring group
            if selected_role.lower() not in [r.lower() for r in JOINT_SCORING_GROUPS[target_group]]:
                continue

            selected_path = getattr(selected_sample, 'path', '') or getattr(selected_sample, 'file_path', '') or ''
            if not selected_path:
                continue

            selected_folder = str(Path(selected_path).parent)
            relationship = self._get_folder_relationship(sample_folder, selected_folder)
            bonus = FOLDER_COMPATIBILITY_BONUS.get(relationship, 0.85)

            if bonus != 1.0:
                total_bonus *= bonus
                bonuses_applied.append(f"{selected_role}:{relationship}")

        if bonuses_applied:
            logger.debug("JOINT_SCORE [%s]: bonuses from %s = %.2f",
                        target_role, ', '.join(bonuses_applied), total_bonus)

        return max(0.5, min(1.5, total_bonus))

    def record_section_selection(self, section: str, role: str, sample: 'Sample') -> None:
        """
        Records a sample selection for a specific section and role.
        This is used for joint scoring of subsequent selections.

        Args:
            section: Section kind (intro, build, drop, etc.)
            role: Role that was filled
            sample: Sample that was selected
        """
        if section not in self._section_role_selections:
            self._section_role_selections[section] = {}

        if isinstance(sample, dict):
            sample_path = str(sample.get('path', '') or sample.get('file_path', '') or '')
            sample_name = str(sample.get('name', '') or sample.get('file_name', '') or Path(sample_path).name or '')
        else:
            sample_path = str(getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or '')
            sample_name = str(getattr(sample, 'name', '') or Path(sample_path).name or '')

        self._section_role_selections[section][role] = {
            'sample': sample,
            'folder': str(Path(sample_path).parent) if sample_path else '',
            'name': sample_name,
        }

        logger.debug("SECTION_RECORD [%s.%s]: %s", section, role, sample_name)

    def _calculate_repetition_penalty(self, sample: 'Sample') -> float:
        """
        Calcula penalizacion por repeticion de sample y familia.
        Retorna 1.0 (sin penalizacion) a 0.1 (penalizacion maxima).
        """
        penalty = 1.0

        if getattr(sample, "id", None) in self._recent_sample_ids:
            penalty *= 0.3

        family = _extract_sample_family(sample.name)
        family_count = self._recent_families.get(family, 0)
        if family_count > 0:
            penalty *= max(0.3, 1.0 - (family_count * 0.15))

        return penalty

    def get_section_selections(self, section: str) -> Dict[str, Any]:
        """
        Returns all recorded selections for a section.

        Args:
            section: Section kind

        Returns:
            Dict of role -> selection info
        """
        return self._section_role_selections.get(section, {})

    def _remember_sample(self, sample: Optional['Sample'], role: str = None) -> None:
        """Registra un sample como usado para evitar repeticiones.

        Ahora integra con diversity_memory.py para persistencia cross-generation.
        """
        if sample is not None and getattr(sample, "id", None):
            self._recent_sample_ids.append(sample.id)
            family = _extract_sample_family(sample.name)
            self._recent_families[family] += 1

            # Track para esta generación específica
            if hasattr(self, '_generation_families'):
                self._generation_families[family] += 1

            # Track path para cross-generation memory
            if hasattr(self, '_generation_paths'):
                sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
                if sample_path:
                    self._generation_paths[sample_path] += 1

            # Track para cooldown (dentro de generación)
            self._selection_counter += 1
            self._family_last_used[family] = self._selection_counter

            # Add to recent sample diversity memory
            if role:
                sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
                if sample_path:
                    add_to_recent_memory(role, sample_path)

            # REGISTRAR EN MEMORIA PERSISTENTE (diversity_memory.py)
            # Solo para roles críticos para evitar overhead excesivo
            if role and DIVERSITY_MEMORY_AVAILABLE:
                try:
                    sample_path = getattr(sample, 'path', '') or getattr(sample, 'file_path', '') or ''
                    if sample_path:
                        record_sample_usage(role, sample_path, sample.name)
                except Exception as e:
                    logger.debug("Error registrando sample en memoria persistente: %s", e)

    def _get_family_cooldown_penalty(self, family: str) -> float:
        """
        Calcula penalización por cooldown de familia.

        Retorna 1.0 (sin penalización) a 0.0 (penalización máxima - rechazo duro).

        Las familias recientemente usadas tienen penalización progresiva:
        - Usado hace 0 selecciones: 0.0 (rechazo duro - no reusable inmediatamente)
        - Usado hace 1 selección: 0.20
        - Usado hace 2 selecciones: 0.40
        - Usado hace 3 selecciones: 0.55
        - Usado hace 4 selecciones: 0.70
        - Usado hace 5 selecciones: 0.85
        - Usado hace COOLDOWN_WINDOW o más: 1.0 (sin penalización)
        """
        if family not in self._family_last_used:
            return 1.0

        selections_ago = self._selection_counter - self._family_last_used[family]

        if selections_ago <= 0:
            return 0.0
        elif selections_ago == 1:
            return 0.20
        elif selections_ago == 2:
            return 0.40
        elif selections_ago == 3:
            return 0.55
        elif selections_ago == 4:
            return 0.70
        elif selections_ago == 5:
            return 0.85
        elif selections_ago >= COOLDOWN_WINDOW:
            return 1.0
        else:
            return min(1.0, 0.20 + (selections_ago / COOLDOWN_WINDOW) * 0.80)

    def _get_cross_generation_penalty(self, family: str, path: str = None, role: str = None) -> float:
        """
        Calcula penalización por uso en generaciones anteriores.

        Retorna factor de penalty (0.0 - 1.0) basado en uso reciente.

        Ahora integra con diversity_memory.py para penalización persistente
        de familias para roles críticos.
        """
        # PRIMERO: Usar sistema persistente si está disponible y es rol crítico
        if role and DIVERSITY_MEMORY_AVAILABLE:
            try:
                persistent_penalty = get_penalty_for_sample(role, path or '', '')
                if persistent_penalty < 1.0:
                    logger.debug("CROSS_GEN (persistent): family penalty for role '%s': %.2f",
                                role, persistent_penalty)
                    return persistent_penalty
            except Exception as e:
                logger.debug("Error obteniendo penalización persistente: %s", e)

        # FALLBACK: Usar memoria en RAM (legacy)
        family_penalty = 1.0
        cross_gen_count = _cross_generation_family_memory.get(family, 0)
        if cross_gen_count >= 4:
            family_penalty = 0.08
        elif cross_gen_count >= 3:
            family_penalty = 0.20
        elif cross_gen_count >= 2:
            family_penalty = 0.40
        elif cross_gen_count >= 1:
            family_penalty = 0.70

        path_penalty = 1.0
        if path and path in _cross_generation_path_memory:
            path_count = _cross_generation_path_memory.get(path, 0)
            if path_count >= 3:
                path_penalty = 0.05
            elif path_count >= 2:
                path_penalty = 0.15
            else:
                path_penalty = 0.35

        recent_role_penalty = 1.0
        if role and path:
            recent_role_penalty = get_recent_memory_penalty(role, path)

        return family_penalty * path_penalty * recent_role_penalty

    def _apply_suspicion_penalty(self, score: float, sample_name: str, role: str) -> float:
        """
        Aplica penalty a samples con nombres sospechosos para el rol.

        A diferencia de HARD_REJECT_PATTERNS, esto es un penalty suave
        que reduce el score pero no elimina completamente el candidato.

        Args:
            score: Score base del sample
            sample_name: Nombre del sample
            role: Rol objetivo

        Returns:
            Score ajustado con penalty aplicado
        """
        role_lower = role.lower() if role else ""
        if role_lower not in SUSPICIOUS_KEYWORDS:
            return score

        name_lower = sample_name.lower()
        suspicious = SUSPICIOUS_KEYWORDS[role_lower]

        penalty = 1.0
        for kw in suspicious:
            if kw in name_lower:
                penalty *= 0.7  # 30% penalty per suspicious keyword found

        return score * penalty

    def _break_tie_randomized(self, candidates: List[Dict], seed_base: str = "") -> List[Dict]:
        """
        Rompe empates con jitter determinista basado en hash.

        Cuando los scores son muy cercanos (dentro del 5%), usa randomización
        determinista para evitar que siempre gane el mismo candidato.

        Args:
            candidates: Lista de dicts con 'score' o 'final_score' y 'sample'
            seed_base: String base para el seed determinista

        Returns:
            Lista reordenada con empates rotos
        """
        if len(candidates) <= 1:
            return candidates

        # Group by similar scores (within 5%)
        result = []
        i = 0
        while i < len(candidates):
            # Find all candidates with similar scores
            current_score = candidates[i].get('final_score', candidates[i].get('score', 0))
            group = [candidates[i]]
            j = i + 1
            while j < len(candidates):
                other_score = candidates[j].get('final_score', candidates[j].get('score', 0))
                if abs(current_score - other_score) / max(current_score, other_score, 0.001) < 0.05:
                    group.append(candidates[j])
                    j += 1
                else:
                    break

            if len(group) > 1:
                # Shuffle group deterministically based on names
                sample_names = ""
                for c in group:
                    sample = c.get('sample')
                    if sample:
                        sample_names += getattr(sample, 'name', '')
                seed = int(hashlib.md5((seed_base + sample_names).encode()).hexdigest()[:8], 16)
                rng = random.Random(seed)
                rng.shuffle(group)

            result.extend(group)
            i = j

        return result

    def reset_cooldown_tracking(self) -> None:
        """Resetea el tracking de cooldown para nueva generación."""
        self._family_last_used.clear()
        self._selection_counter = 0
        self._recent_families.clear()
        self._recent_sample_ids.clear()

    def start_generation_tracking(self) -> None:
        """Marca el inicio de una nueva generación (llamar al inicio de generate_track)."""
        self._generation_families = defaultdict(int)
        self._generation_paths: Dict[str, int] = defaultdict(int)

    def end_generation_tracking(self) -> None:
        """Marca el fin de una generación y actualiza memoria cross-generation."""
        if hasattr(self, '_generation_families'):
            paths_used = list(self._generation_paths.keys()) if hasattr(self, '_generation_paths') else []
            _update_cross_generation_memory(self._generation_families, paths_used)
            delattr(self, '_generation_families')
            if hasattr(self, '_generation_paths'):
                delattr(self, '_generation_paths')

    def _log_decision(self, decision: SampleDecision) -> None:
        """Registra una decisión si logging está activado."""
        if self._log_decisions:
            self._decision_log.append(decision)
            logger.debug("SAMPLE_DECISION: %s", decision.to_log_str())

    def _pick_ranked_sample(self,
                             samples: List['Sample'],
                             target_key: Optional[str] = None,
                             target_bpm: Optional[float] = None,
                             target_role: Optional[str] = None,
                             target_genre: Optional[str] = None,
                             prefer_oneshot: Optional[bool] = None,
                             pool_size: int = 12,
                             context: str = "",
                             use_alternates: bool = True) -> Optional['Sample']:
        """
        Selecciona un sample usando ranking multi-factor con weighted random.

        T4: Enhanced to optionally use alternates pool for fresher selection.

        Args:
            samples: Lista de samples candidatos
            target_key: Key objetivo para matching armonico
            target_bpm: BPM objetivo para matching de tempo
            target_role: Rol objetivo para validacion (ej: 'kick', 'clap')
            target_genre: Genero objetivo
            prefer_oneshot: Preferencia por one-shot (True) o loop (False)
            pool_size: Tamano del pool de mejores candidatos
            context: Contexto para seeding determinista
            use_alternates: Usar el nuevo sistema de alternates pool (T4)

        Returns:
            Sample seleccionado o None si no hay candidatos
        """
        if not samples:
            return None

        # T4: Use alternates pool system if enabled
        if use_alternates and self._alternates_enabled and target_role:
            result = self.get_alternates_for_role(
                role=target_role,
                samples=samples,
                target_key=target_key,
                target_bpm=target_bpm,
                target_genre=target_genre
            )
            selected_data = result.get('selected')
            if selected_data and selected_data.get('name'):
                # Find the actual sample object
                selected_name = selected_data['name']
                for sample in samples:
                    if sample.name == selected_name:
                        self._remember_sample(sample, role=target_role)
                        if self._section_context and target_role:
                            self.record_section_selection(self._section_context, target_role, sample)
                        
                        # Log if enabled
                        if self._log_decisions:
                            decision = SampleDecision(
                                sample_name=sample.name,
                                target_role=target_role,
                                final_score=selected_data.get('score', 0),
                                selected=True,
                                selection_index=0,
                                bonus_factors=['alternates_pool', f"freshness={selected_data.get('freshness', 1.0):.2f}"],
                                freshness_score=selected_data.get('freshness', 1.0),
                                coherence_score=selected_data.get('coherence', 1.0)
                            )
                            self._log_decision(decision)
                        
                        return sample
        
        # Legacy selection path (when alternates disabled)
        # Calcular scores para todos los samples
        scored_samples = []
        for sample in samples:
            score = self._calculate_sample_score(
                sample,
                target_key=target_key,
                target_bpm=target_bpm,
                target_role=target_role,
                target_genre=target_genre,
                prefer_oneshot=prefer_oneshot
            )
            # Apply suspicion penalty for samples with suspicious names
            if target_role:
                score = self._apply_suspicion_penalty(score, sample.name, target_role)
            scored_samples.append({'score': score, 'sample': sample, 'rejection_reasons': []})

        # Ordenar por score descendente
        scored_samples.sort(key=lambda x: x['score'], reverse=True)

        # Apply tie-breaking with deterministic randomization
        scored_samples = self._break_tie_randomized(scored_samples, context)

        # Filtrar por rechazo duro para roles criticos
        if target_role:
            filtered_samples = []
            for s in scored_samples:
                should_reject, reason = self._hard_reject_check(s['sample'], target_role)
                if should_reject:
                    s['rejection_reasons'].append(f"hard_reject: {reason}")
                    logger.debug("HARD_REJECT: %s for role '%s': %s", s['sample'].name, target_role, reason)
                else:
                    filtered_samples.append(s)
            scored_samples = filtered_samples

            if not scored_samples:
                logger.warning("All samples hard-rejected for role '%s', using fallback", target_role)
        # Validar preferencia one-shot/loop para roles criticos
        if target_role:
            filtered_samples = []
            for s in scored_samples:
                is_valid, reason = self._validate_loop_preference(s['sample'], target_role)
                if not is_valid:
                    s['rejection_reasons'].append(f"loop_pref: {reason}")
                    logger.debug("LOOP_PREF: rejecting %s for role '%s': %s", s['sample'].name, target_role, reason)
                else:
                    filtered_samples.append(s)
            scored_samples = filtered_samples

            if not scored_samples:
                logger.warning("All samples rejected by loop preference for role '%s'", target_role)


        # Tomar top pool_size candidatos
        top_samples = scored_samples[:max(1, min(pool_size, len(scored_samples)))]

        # Aplicar jitter con seeding determinista
        selection_seed = self._generate_selection_seed(context)
        rng = random.Random(selection_seed)

        # Weighted random selection con jitter
        weighted: List[Tuple[float, 'Sample']] = []
        for rank, s in enumerate(top_samples):
            score = s['score']
            sample = s['sample']
            # Decaimiento por posicion en el ranking
            rank_weight = max(0.2, 1.0 - (rank * 0.07))
            # Jitter aleatorio
            jitter = 0.85 + (rng.random() * 0.30)
            final_weight = max(0.01, score * rank_weight * jitter)
            weighted.append((final_weight, sample))

        # Seleccion por weighted random
        if NUMPY_AVAILABLE and len(weighted) > 3:
            # Usar numpy para mejor performance
            weights = np.array([w for w, _ in weighted])
            weights = weights / weights.sum()
            idx = np.random.default_rng(selection_seed).choice(len(weighted), p=weights)
            selected = weighted[idx][1]
            final_score = weighted[idx][0]
            selected_idx = idx
        else:
            # Fallback a random estandar
            total = sum(weight for weight, _ in weighted)
            pivot = rng.random() * total
            running = 0.0
            selected = weighted[0][1]  # default
            final_score = weighted[0][0]
            selected_idx = 0
            for idx, (weight, sample) in enumerate(weighted):
                running += weight
                if pivot <= running:
                    selected = sample
                    final_score = weight
                    selected_idx = idx
                    break

        self._remember_sample(selected, role=target_role)

        # Record selection for joint scoring if we have section context
        if self._section_context and target_role and selected:
            self.record_section_selection(self._section_context, target_role, selected)

        # Log decision if enabled
        if self._log_decisions and selected:
            # Determine bonus factors (would need to be tracked during scoring)
            bonus_list = []

            # Log the selected sample
            decision = SampleDecision(
                sample_name=selected.name,
                target_role=target_role or "unknown",
                final_score=final_score,
                selected=True,
                selection_index=selected_idx,
                bonus_factors=bonus_list
            )
            self._log_decision(decision)

            # Also log top 5 rejections
            for idx, s in enumerate(scored_samples[:5]):  # Top 5 rejected
                if s['sample'].name != selected.name:
                    reject_decision = SampleDecision(
                        sample_name=s['sample'].name,
                        target_role=target_role or "unknown",
                        final_score=s['score'],
                        selected=False,
                        selection_index=idx,
                        rejection_reasons=s.get('rejection_reasons', [])
                    )
                    self._log_decision(reject_decision)

        return selected

    def _pick_multiple_ranked(self,
                               samples: List['Sample'],
                               count: int,
                               target_key: Optional[str] = None,
                               target_bpm: Optional[float] = None,
                               target_role: Optional[str] = None,
                               target_genre: Optional[str] = None,
                               prefer_oneshot: Optional[bool] = None,
                               pool_size: int = 15,
                               context: str = "") -> List['Sample']:
        """
        Selecciona múltiples samples con diversidad garantizada.
        """
        chosen: List['Sample'] = []
        if not samples or count <= 0:
            return chosen

        remaining = list(samples)
        seen_ids = set()
        sub_context = context

        while remaining and len(chosen) < count:
            selected = self._pick_ranked_sample(
                remaining,
                target_key=target_key,
                target_bpm=target_bpm,
                target_role=target_role,
                target_genre=target_genre,
                prefer_oneshot=prefer_oneshot,
                pool_size=pool_size,
                context=f"{sub_context}_{len(chosen)}"
            )
            if selected is None:
                break
            if selected.id not in seen_ids:
                chosen.append(selected)
                seen_ids.add(selected.id)
            remaining = [sample for sample in remaining if sample.id != selected.id]

        return chosen

    def get_decision_log(self) -> list[SampleDecision]:
        """Retorna el log de decisiones acumulado."""
        return self._decision_log.copy()

    def clear_decision_log(self) -> None:
        """Limpia el log de decisiones."""
        self._decision_log.clear()

    def enable_decision_logging(self, enabled: bool = True) -> None:
        """Activa/desactiva logging de decisiones."""
        self._log_decisions = enabled

    def select_for_genre(self,
                         genre: str,
                         key: Optional[str] = None,
                         bpm: Optional[float] = None,
                         variation: str = "standard",
                         session_seed: Optional[int] = None) -> InstrumentGroup:
        """
        Selecciona un grupo completo de instrumentos para un género.

        Args:
            genre: Género musical
            key: Tonalidad preferida (auto-selecciona si None)
            bpm: BPM preferido (auto-selecciona si None)
            variation: Variación del estilo
            session_seed: Semilla para reproducibilidad (actualiza si se provee)

        Returns:
            InstrumentGroup con samples seleccionados
        """
        # Actualizar semilla de sesión si se provee
        if session_seed is not None:
            self._session_seed = session_seed
            self._selection_counter = 0

        # Normalizar género
        genre_profile = self._get_genre_profile(genre)

        # Seleccionar key si no se especificó (con seeding determinista)
        if key is None:
            rng = random.Random(self._generate_selection_seed("key"))
            key = rng.choice(genre_profile.common_keys)

        # Seleccionar BPM si no se especificó (con seeding determinista)
        if bpm is None:
            rng = random.Random(self._generate_selection_seed("bpm"))
            bpm = rng.randint(genre_profile.bpm_range[0], genre_profile.bpm_range[1])

        # Crear grupo
        group = InstrumentGroup(
            genre=genre_profile.name,
            key=key,
            bpm=float(bpm)
        )

        # Seleccionar drums CON validación de roles
        group.drums = self._select_drum_kit(genre, variation, target_key=key)

        # Seleccionar bass con matching armónico
        group.bass = self._select_bass_samples(genre, key, bpm, count=3)

        # Seleccionar synths con diversidad
        group.synths = self._select_synth_samples(genre, key, bpm, count=3)

        # Seleccionar FX
        group.fx = self._select_fx_samples(genre, count=2, target_bpm=bpm)

        return group

    def _get_genre_profile(self, genre: str) -> GenreProfile:
        """Obtiene el perfil de un género"""
        genre_lower = genre.lower().replace(' ', '-')

        # Búsqueda exacta
        if genre_lower in GENRE_PROFILES:
            return GENRE_PROFILES[genre_lower]

        # Búsqueda parcial
        for name, profile in GENRE_PROFILES.items():
            if genre_lower in name or name in genre_lower:
                return profile

        # Fallback a techno
        logger.warning(f"Género '{genre}' no encontrado, usando techno")
        return GENRE_PROFILES['techno']

    def _select_drum_kit(self, genre: str, variation: str = "standard", target_key: Optional[str] = None) -> DrumKit:
        """
        Selecciona un kit de batería coherente con validación de roles.

        Mejoras Fase 4:
        - Valida que cada sample sea apropiado para su rol
        - Penaliza samples inapropiados (ej: snare en rol clap)
        - Balancea entre one-shots preferentemente
        """
        if not self.manager:
            return DrumKit(name="empty")

        kit = DrumKit(name=f"{genre}_{variation}")

        # Función mejorada para encontrar drums con validación de rol
        def find_drum(drum_role: str, keywords: List[str], prefer_oneshot: bool = True) -> Optional[Sample]:
            all_results = []

            # Buscar con múltiples keywords y acumular
            for keyword in keywords:
                results = self.manager.search(
                    query=keyword,
                    category="drums",
                    limit=50
                )
                all_results.extend(results)

            # Eliminar duplicados
            seen_ids = set()
            unique_results = []
            for s in all_results:
                if s.id not in seen_ids:
                    seen_ids.add(s.id)
                    unique_results.append(s)

            if not unique_results:
                return None

            # Usar el selector mejorado con validación de rol
            return self._pick_ranked_sample(
                unique_results,
                target_key=target_key,
                target_role=drum_role,  # Validación de rol
                target_genre=genre,
                prefer_oneshot=prefer_oneshot,
                pool_size=12,
                context=f"drum_{drum_role}"
            )

        # Kick - siempre one-shot
        kit.kick = find_drum("kick", ["kick", "bd", "bass_drum"], prefer_oneshot=True)

        # Snare o Clap según género - CON VALIDACIÃƒÆ’Ã¢â‚¬Å“N DE ROL
        if genre in ['house', 'tech-house', 'deep-house']:
            # En house, clap es más común que snare
            kit.clap = find_drum("clap", ["clap", "handclap"], prefer_oneshot=True)
            kit.snare = find_drum("snare", ["snare", "rim"], prefer_oneshot=True)
        else:
            # En techno, snare es más común
            kit.snare = find_drum("snare", ["snare", "rimshot"], prefer_oneshot=True)
            kit.clap = find_drum("clap", ["clap"], prefer_oneshot=True)

        # Hats - validar que sean realmente hats
        kit.hat_closed = find_drum("hat_closed", ["closed hat", "hihat", "hat"], prefer_oneshot=True)
        kit.hat_open = find_drum("hat_open", ["open hat", "ohh"], prefer_oneshot=True)

        # Percusión adicional - validar roles
        kit.perc1 = find_drum("perc", ["perc", "shaker", "tamb"], prefer_oneshot=True)
        kit.perc2 = find_drum("perc", ["percussion", "conga", "bongo"], prefer_oneshot=True)

        # Tom
        kit.tom = find_drum("tom", ["tom", "tomtom"], prefer_oneshot=True)

        # Crash (opcional)
        kit.crash = find_drum("crash", ["crash", "cymbal"], prefer_oneshot=True)

        # Registrar roles usados
        if kit.kick:
            self._role_history['kick'].append(kit.kick.id)
        if kit.snare:
            self._role_history['snare'].append(kit.snare.id)
        if kit.clap:
            self._role_history['clap'].append(kit.clap.id)

        return kit

    def _filter_role_candidates(self,
                                samples: List[Sample],
                                include_tokens: Optional[List[str]] = None,
                                exclude_tokens: Optional[List[str]] = None,
                                target_bpm: Optional[float] = None,
                                max_bpm_diff: float = 8.0) -> List[Sample]:
        filtered: List[Sample] = []
        include_tokens = [token.lower() for token in (include_tokens or []) if token]
        exclude_tokens = [token.lower() for token in (exclude_tokens or []) if token]

        for sample in samples:
            haystack = " ".join([
                sample.name,
                sample.path,
                sample.category,
                sample.subcategory,
                sample.sample_type,
                " ".join(sample.tags or []),
            ]).lower()

            if include_tokens and not any(token in haystack for token in include_tokens):
                continue
            if exclude_tokens and any(token in haystack for token in exclude_tokens):
                continue
            if target_bpm:
                bpm_hint = sample.bpm or self._extract_bpm_hint(haystack)
                if bpm_hint is not None:
                    diff = abs(float(bpm_hint) - float(target_bpm))
                    half_double_diff = min(
                        abs(float(bpm_hint) - (float(target_bpm) * 2.0)),
                        abs(float(bpm_hint) - (float(target_bpm) / 2.0)),
                    )
                    if diff > max_bpm_diff and half_double_diff > 3.0:
                        continue
            filtered.append(sample)

        return filtered or samples

    def _extract_bpm_hint(self, text: str) -> Optional[float]:
        for match in re.finditer(r"(?<!\d)(\d{2,3})(?!\d)", str(text or "").lower()):
            bpm = float(match.group(1))
            if 60.0 <= bpm <= 200.0:
                return bpm
        return None

    def _select_bass_samples(self,
                              genre: str,
                              key: str,
                              bpm: float,
                              count: int = 3) -> List[Sample]:
        """
        Selecciona samples de bajo compatibles con mejor ranking.

        Mejoras Fase 4:
        - Matching armónico mejorado
        - Balance one-shot vs loop según contexto
        - Penalización de familias repetidas
        """
        if not self.manager:
            return []

        # Buscar por key primero
        results = self.manager.search(
            category="bass",
            key=key,
            bpm=bpm,
            bpm_tolerance=5,
            limit=count * 10
        )

        # Si no hay suficientes, buscar sin key
        if len(results) < count:
            more = self.manager.search(
                category="bass",
                bpm=bpm,
                bpm_tolerance=10,
                limit=count * 10
            )
            results.extend(more)

        # Buscar por género también
        genre_results = self.manager.search(
            category="bass",
            genres=[genre],
            limit=count * 8
        )
        results.extend(genre_results)
        if len(results) < count:
            results.extend(self.manager.search(
                category="bass",
                limit=count * 12
            ))

        # Eliminar duplicados
        seen = set()
        unique = []
        for s in results:
            if s.id not in seen:
                seen.add(s.id)
                unique.append(s)
        unique = self._filter_role_candidates(
            unique,
            include_tokens=["bass", "sub", "808", "reese", "bassline", "bass line"],
            exclude_tokens=["drum loop", "drumloops", "kick", "snare", "hat", "clap", "vocal", "vox", "v.chop", "impact", "fx"],
            target_bpm=bpm,
            max_bpm_diff=6.0,
        )

        # Para bass, preferimos loops en la mayoría de casos
        # excepto para bass one-shots (808, stabs)
        prefer_oneshot = 'trap' in genre.lower() or 'hip-hop' in genre.lower()

        return self._pick_multiple_ranked(
            unique,
            count=count,
            target_key=key,
            target_bpm=bpm,
            target_genre=genre,
            prefer_oneshot=prefer_oneshot,
            pool_size=15,
            context="bass"
        )

    def _select_synth_samples(self,
                               genre: str,
                               key: str,
                               bpm: float,
                               count: int = 3) -> List[Sample]:
        """
        Selecciona samples de sintetizador compatibles con mejor ranking.

        Mejoras Fase 4:
        - Diversidad de tipos (lead, pad, pluck, chord)
        - Balance loops preferentemente para texturas
        - Penalización de familias repetidas
        """
        if not self.manager:
            return []

        # Buscar diferentes tipos de synths
        synth_types = ['lead', 'pad', 'pluck', 'chord']
        results = []

        for synth_type in synth_types:
            type_results = self.manager.search(
                sample_type=synth_type,
                key=key,
                bpm=bpm,
                bpm_tolerance=5,
                limit=12
            )
            results.extend(type_results)

        # Completar con búsqueda general
        if len(results) < count * 2:
            more = self.manager.search(
                category="synths",
                key=key,
                limit=count * 10
            )
            results.extend(more)
        if len(results) < count:
            results.extend(self.manager.search(
                category="synths",
                limit=count * 10
            ))

        # Eliminar duplicados
        seen = set()
        unique = []
        for s in results:
            if s.id not in seen:
                seen.add(s.id)
                unique.append(s)
        unique = self._filter_role_candidates(
            unique,
            include_tokens=["synth", "lead", "pad", "pluck", "chord", "melody", "music", "instrumental", "hook"],
            exclude_tokens=["drum loop", "drumloops", "kick", "snare", "hat", "clap", "perc", "vocal", "vox", "v.chop", "bass808"],
            target_bpm=bpm,
            max_bpm_diff=6.0,
        )

        # Para synths, preferimos loops para pads y chords
        # one-shots para leads y plucks
        prefer_oneshot = False  # Default a loops para texturas

        return self._pick_multiple_ranked(
            unique,
            count=count,
            target_key=key,
            target_bpm=bpm,
            target_genre=genre,
            prefer_oneshot=prefer_oneshot,
            pool_size=15,
            context="synth"
        )

    def _select_fx_samples(self, genre: str, count: int = 2, target_bpm: Optional[float] = None) -> List[Sample]:
        """
        Selecciona efectos apropiados con mejor ranking.
        """
        if not self.manager:
            return []

        results = self.manager.search(
            category="one_shots",
            sample_type="fx",
            genres=[genre],
            limit=count * 8
        )

        # También buscar en category fx directamente
        fx_results = self.manager.search(
            category="fx",
            limit=count * 6
        )
        results.extend(fx_results)
        if len(results) < count:
            results.extend(self.manager.search(limit=count * 8))

        # Eliminar duplicados
        seen = set()
        unique = []
        for s in results:
            if s.id not in seen:
                seen.add(s.id)
                unique.append(s)

        return self._pick_multiple_ranked(
            unique,
            count=count,
            target_bpm=target_bpm,
            target_genre=genre,
            prefer_oneshot=True,  # FX generalmente son one-shots
            pool_size=10,
            context="fx"
        )

    def find_compatible_samples(self,
                                 reference_sample: Sample,
                                 sample_type: str = "",
                                 max_results: int = 10) -> List[Tuple[Sample, float]]:
        """
        Encuentra samples compatibles con uno de referencia.

        Calcula score de compatibilidad basado en:
        - Key (armonía)
        - BPM (tempo)
        - Género
        - Características de audio
        """
        if not self.manager:
            return []

        # Buscar candidatos
        candidates = self.manager.search(
            sample_type=sample_type or reference_sample.sample_type,
            limit=50
        )

        results = []
        for candidate in candidates:
            if candidate.id == reference_sample.id:
                continue

            score = self._calculate_compatibility(reference_sample, candidate)
            if score > 0.5:  # Umbral mínimo
                results.append((candidate, score))

        # Ordenar por score
        results.sort(key=lambda x: x[1], reverse=True)
        return results[:max_results]

    def _calculate_compatibility(self, sample1: Sample, sample2: Sample) -> float:
        """Calcula un score de compatibilidad entre dos samples"""
        score = 0.0
        weights = 0.0

        # Compatibilidad de key (peso: 0.4)
        if sample1.key and sample2.key:
            if MANAGER_AVAILABLE:
                key_compat = calculate_key_compatibility(sample1.key, sample2.key)
            else:
                key_compat = 1.0 if sample1.key == sample2.key else 0.5
            score += key_compat * 0.4
            weights += 0.4

        # Compatibilidad de BPM (peso: 0.3)
        if sample1.bpm and sample2.bpm:
            bpm_diff = abs(sample1.bpm - sample2.bpm)
            if bpm_diff == 0:
                bpm_compat = 1.0
            elif bpm_diff <= 3:
                bpm_compat = 0.9
            elif bpm_diff <= 6:
                bpm_compat = 0.7
            elif bpm_diff <= 10:
                bpm_compat = 0.5
            else:
                bpm_compat = max(0.0, 1.0 - (bpm_diff / 50))
            score += bpm_compat * 0.3
            weights += 0.3

        # Compatibilidad de género (peso: 0.2)
        if sample1.genres and sample2.genres:
            common_genres = set(sample1.genres) & set(sample2.genres)
            if common_genres:
                genre_compat = len(common_genres) / max(len(sample1.genres), len(sample2.genres))
                score += genre_compat * 0.2
                weights += 0.2

        # Compatibilidad de categoría (peso: 0.1)
        if sample1.category == sample2.category:
            score += 0.1
            weights += 0.1

        return score / weights if weights > 0 else 0.0

    def get_midi_mapping_for_kit(self, kit: DrumKit) -> Dict[str, Any]:
        """
        Genera un mapeo MIDI completo para un kit de batería.

        Returns:
            Dict con información de mapeo para Ableton
        """
        mapping = {
            'kit_name': kit.name,
            'notes': {},
            'drum_rack_slots': {},
        }

        midi_map = kit.get_midi_mapping()

        for note, sample in midi_map.items():
            note_name = self._midi_note_to_name(note)
            mapping['notes'][note] = {
                'name': note_name,
                'sample': sample.name if sample else None,
                'sample_path': sample.path if sample else None,
            }

            # Mapeo para Drum Rack (0-127 pads)
            if note in range(36, 52):  # Rango de drums común
                drum_rack_slot = note - 36
                mapping['drum_rack_slots'][drum_rack_slot] = {
                    'note': note,
                    'sample': sample.name if sample else None,
                    'sample_path': sample.path if sample else None,
                }

        return mapping

    def _midi_note_to_name(self, note: int) -> str:
        """Convierte número de nota MIDI a nombre"""
        note_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
        octave = (note // 12) - 1
        name = note_names[note % 12]
        return f"{name}{octave}"

    def suggest_key_change(self,
                          current_key: str,
                          direction: str = "fifth_up") -> str:
        """
        Sugiere un cambio de key armónico.

        Args:
            current_key: Key actual
            direction: 'fifth_up', 'fifth_down', 'relative', 'parallel'

        Returns:
            Nueva key sugerida
        """
        # Círculo de quintas
        circle_major = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F']
        circle_minor = ['Am', 'Em', 'Bm', 'F#m', 'C#m', 'G#m', 'D#m', 'A#m', 'Fm', 'Cm', 'Gm', 'Dm']

        is_minor = current_key.endswith('m')
        root = current_key.rstrip('m')

        circle = circle_minor if is_minor else circle_major

        try:
            idx = circle.index(current_key)
        except ValueError:
            # Intentar encontrar equivalente
            return current_key

        if direction == "fifth_up":
            new_idx = (idx + 1) % 12
            return circle[new_idx]
        elif direction == "fifth_down":
            new_idx = (idx - 1) % 12
            return circle[new_idx]
        elif direction == "relative":
            # Cambiar entre mayor/menor relativo
            if is_minor:
                # De menor a mayor relativo (3 semitonos arriba)
                rel_idx = (idx + 3) % 12
                return circle_major[rel_idx]
            else:
                # De mayor a menor relativo (3 semitonos abajo)
                rel_idx = (idx - 3) % 12
                return circle_minor[rel_idx]
        elif direction == "parallel":
            # Cambiar entre mayor/menor paralelo
            if is_minor:
                return root
            else:
                return root + 'm'

        return current_key

    def create_variation(self,
                        original_group: InstrumentGroup,
                        variation_type: str = "energy_up") -> InstrumentGroup:
        """
        Crea una variación de un grupo de instrumentos.

        Args:
            original_group: Grupo original
            variation_type: Tipo de variación

        Returns:
            Nuevo InstrumentGroup variado
        """
        new_group = InstrumentGroup(
            genre=original_group.genre,
            key=original_group.key,
            bpm=original_group.bpm
        )

        if variation_type == "energy_up":
            # Buscar samples más intensos
            new_group.drums = self._select_drum_kit(
                original_group.genre,
                variation="heavy",
                target_key=original_group.key
            )
            # Mantener key, buscar bass más agresivo
            new_group.bass = self._select_bass_samples(
                original_group.genre,
                original_group.key,
                original_group.bpm,
                count=3
            )

        elif variation_type == "breakdown":
            # Reducir elementos, mantener key
            new_group.drums = DrumKit(name="minimal")
            new_group.drums.kick = original_group.drums.kick
            new_group.drums.hat_closed = original_group.drums.hat_closed
            # Solo pads y elementos atmosféricos
            new_group.synths = self._select_synth_samples(
                original_group.genre,
                original_group.key,
                original_group.bpm,
                count=2
            )

        elif variation_type == "key_change":
            # Cambiar de tonalidad
            new_key = self.suggest_key_change(original_group.key, "fifth_up")
            new_group.key = new_key
            new_group.bass = self._select_bass_samples(
                original_group.genre,
                new_key,
                original_group.bpm,
                count=3
            )
            new_group.synths = self._select_synth_samples(
                original_group.genre,
                new_key,
                original_group.bpm,
                count=3
            )

        return new_group


# ============================================================================
# Funciones de conveniencia
# ============================================================================

_selector: Optional[SampleSelector] = None


def get_selector(session_seed: Optional[int] = None) -> SampleSelector:
    """Obtiene la instancia global del selector"""
    global _selector
    if _selector is None:
        _selector = SampleSelector(session_seed=session_seed)
    elif session_seed is not None:
        _selector._session_seed = session_seed
        _selector._selection_counter = 0
    return _selector


def reset_selector():
    """Resetea el selector global para una nueva sesión"""
    global _selector
    _selector = None


def select_samples_for_track(genre: str,
                              key: Optional[str] = None,
                              bpm: Optional[float] = None,
                              session_seed: Optional[int] = None) -> Dict[str, Any]:
    """
    Selecciona samples para un track completo.

    Args:
        genre: Género musical
        key: Tonalidad (auto-selecciona si None)
        bpm: BPM (auto-selecciona si None)
        session_seed: Semilla para reproducibilidad

    Returns:
        Dict con toda la información de selección
    """
    selector = get_selector(session_seed=session_seed)
    group = selector.select_for_genre(genre, key, bpm)

    return {
        'genre': group.genre,
        'key': group.key,
        'bpm': group.bpm,
        'drum_kit': group.drums.to_dict(),
        'midi_mapping': selector.get_midi_mapping_for_kit(group.drums),
        'bass_samples': [s.to_dict() for s in group.bass],
        'synth_samples': [s.to_dict() for s in group.synths],
        'fx_samples': [s.to_dict() for s in group.fx],
        'session_seed': selector._session_seed,
    }


def get_drum_kit(genre: str, variation: str = "standard", key: Optional[str] = None) -> Dict[str, Any]:
    """
    Obtiene un kit de batería para un género.

    Args:
        genre: Género musical
        variation: Variación del kit
        key: Key para matching armónico
    """
    selector = get_selector()
    kit = selector._select_drum_kit(genre, variation, target_key=key)

    return {
        'kit': kit.to_dict(),
        'midi_mapping': selector.get_midi_mapping_for_kit(kit),
    }


def find_compatible(sample_path: str, max_results: int = 10) -> List[Dict[str, Any]]:
    """Encuentra samples compatibles con uno dado"""
    selector = get_selector()
    manager = get_manager()

    sample = manager.get_by_path(sample_path)
    if not sample:
        return []

    compatible = selector.find_compatible_samples(sample, max_results=max_results)
    return [
        {
            'sample': s.to_dict(),
            'compatibility_score': score
        }
        for s, score in compatible
    ]


# ============================================================================
# Funciones para GPU/Embeddings (opcional)
# ============================================================================

def calculate_embedding_similarity(samples: List['Sample'],
                                    reference: 'Sample',
                                    use_gpu: bool = True) -> List[Tuple['Sample', float]]:
    """
    Calcula similitud de embeddings entre samples usando operaciones vectorizadas.
    Requiere que los samples tengan embeddings pre-calculados.

    Args:
        samples: Lista de samples a comparar
        reference: Sample de referencia
        use_gpu: Usar GPU si está disponible

    Returns:
        Lista de (sample, similarity_score) ordenada por similitud
    """
    if not NUMPY_AVAILABLE:
        logger.warning("NumPy no disponible, usando similitud básica")
        return [(s, 0.5) for s in samples]

    # Verificar si hay embeddings disponibles
    ref_embedding = getattr(reference, 'embedding', None)
    if ref_embedding is None:
        logger.warning("No hay embedding de referencia, usando similitud básica")
        return [(s, 0.5) for s in samples]

    results = []
    xp = cp if (use_gpu and GPU_AVAILABLE) else np

    try:
        ref_vec = xp.array(ref_embedding)
        ref_norm = xp.linalg.norm(ref_vec)

        for sample in samples:
            sample_embedding = getattr(sample, 'embedding', None)
            if sample_embedding is not None:
                sample_vec = xp.array(sample_embedding)
                sample_norm = xp.linalg.norm(sample_vec)

                if ref_norm > 0 and sample_norm > 0:
                    similarity = float(xp.dot(ref_vec, sample_vec) / (ref_norm * sample_norm))
                else:
                    similarity = 0.0
            else:
                similarity = 0.0

            results.append((sample, similarity))

        # Ordenar por similitud descendente
        results.sort(key=lambda x: x[1], reverse=True)

    except Exception as e:
        logger.warning(f"Error calculando similitud de embeddings: {e}")
        return [(s, 0.5) for s in samples]

    return results


def batch_score_samples(samples: List['Sample'],
                        target_key: Optional[str] = None,
                        target_bpm: Optional[float] = None,
                        target_genre: Optional[str] = None,
                        use_gpu: bool = True) -> List[Tuple['Sample', float]]:
    """
    Calcula scores para múltiples samples de forma vectorizada.
    Usa NumPy o CuPy para aceleración.

    Args:
        samples: Lista de samples a puntuar
        target_key: Key objetivo
        target_bpm: BPM objetivo
        target_genre: Género objetivo
        use_gpu: Usar GPU si está disponible

    Returns:
        Lista de (sample, score) ordenada por score descendente
    """
    if not samples:
        return []

    if not NUMPY_AVAILABLE or len(samples) < 10:
        # Para pocos samples, usar scoring individual
        selector = get_selector()
        results = []
        for sample in samples:
            score = selector._calculate_sample_score(
                sample,
                target_key=target_key,
                target_bpm=target_bpm,
                target_genre=target_genre
            )
            results.append((sample, score))
        results.sort(key=lambda x: x[1], reverse=True)
        return results

    # Vectorized scoring con NumPy/CuPy
    xp = cp if (use_gpu and GPU_AVAILABLE) else np

    ratings = xp.array([min(1.0, (s.rating or 0) / 5.0) for s in samples])

    # Key compatibility
    key_scores = xp.zeros(len(samples))
    if target_key:
        for i, s in enumerate(samples):
            if s.key:
                if MANAGER_AVAILABLE:
                    key_scores[i] = calculate_key_compatibility(target_key, s.key)
                else:
                    key_scores[i] = 1.0 if s.key == target_key else 0.5
            else:
                key_scores[i] = 0.5

    # BPM compatibility
    bpm_scores = xp.zeros(len(samples))
    if target_bpm:
        for i, s in enumerate(samples):
            if s.bpm:
                diff = abs(s.bpm - target_bpm)
                if diff == 0:
                    bpm_scores[i] = 1.0
                elif diff <= 3:
                    bpm_scores[i] = 0.95
                elif diff <= 6:
                    bpm_scores[i] = 0.85
                elif diff <= 10:
                    bpm_scores[i] = 0.70
                else:
                    bpm_scores[i] = max(0.2, 1.0 - (diff / 30))
            else:
                bpm_scores[i] = 0.5

    # Genre compatibility
    genre_scores = xp.zeros(len(samples))
    if target_genre:
        genre_lower = target_genre.lower().replace(' ', '-')
        for i, s in enumerate(samples):
            if s.genres:
                sample_genres = [g.lower().replace(' ', '-') for g in s.genres]
                if genre_lower in sample_genres:
                    genre_scores[i] = 1.0
                elif any(g in genre_lower or genre_lower in g for g in sample_genres):
                    genre_scores[i] = 0.7
                else:
                    genre_scores[i] = 0.3
            else:
                genre_scores[i] = 0.5

    # Combined score (weighted)
    weights = xp.array([0.25, 0.25, 0.25, 0.25])  # rating, key, bpm, genre
    scores_matrix = xp.stack([ratings, key_scores, bpm_scores, genre_scores])
    final_scores = xp.dot(weights, scores_matrix)

    # Convertir a lista y ordenar
    results = [(samples[i], float(final_scores[i])) for i in range(len(samples))]
    results.sort(key=lambda x: x[1], reverse=True)

    return results


# T4: Enhanced selection with alternates pool
def select_samples_with_alternates_for_genre(
    genre: str,
    key: Optional[str] = None,
    bpm: Optional[float] = None,
    session_seed: Optional[int] = None,
    dominant_packs: Optional[List[str]] = None,
    generation_id: Optional[str] = None
) -> Dict[str, Any]:
    """
    T4: Enhanced sample selection with alternates pool and coherence tracking.

    Returns a complete pack with multiple valid options per role and
    documentation of why alternatives were selected/rejected.

    Args:
        genre: Genero musical
        key: Tonalidad (auto-selecciona si None)
        bpm: BPM (auto-selecciona si 0)
        session_seed: Semilla para reproducibilidad
        dominant_packs: Lista de packs dominantes para coherencia
        generation_id: ID de generacion para tracking

    Returns:
        Dict con:
        - 'primary_selection': Seleccion principal por rol
        - 'alternates_by_role': Alternativas validas por rol
        - 'rejection_log': Por que se descartaron alternativas
        - 'freshness_stats': Estadisticas de uso
        - 'coherence_score': Score de coherencia del pack
    """
    selector = get_selector(session_seed=session_seed)
    
    # Set generation context
    gen_id = generation_id or f"gen_{int(time.time())}"
    selector.set_generation_context(gen_id, dominant_packs or [])
    
    # Get base selection
    group = selector.select_for_genre(genre, key, bpm)
    
    # Get alternates report
    selection_report = selector.get_selection_report()
    
    # Build response
    primary_selection = {
        'drums': {
            'kick': group.drums.kick.to_dict() if group.drums.kick else None,
            'snare': group.drums.snare.to_dict() if group.drums.snare else None,
            'clap': group.drums.clap.to_dict() if group.drums.clap else None,
            'hat_closed': group.drums.hat_closed.to_dict() if group.drums.hat_closed else None,
            'hat_open': group.drums.hat_open.to_dict() if group.drums.hat_open else None,
        },
        'bass': [s.to_dict() for s in group.bass[:3]],
        'synths': [s.to_dict() for s in group.synths[:3]],
        'fx': [s.to_dict() for s in group.fx[:2]],
    }
    
    alternates_by_role = {}
    for role in ['kick', 'snare', 'clap', 'hat', 'bass_loop', 'synth_loop']:
        pool = selector._alternates_pool.get_pool_for_role(role)
        if pool:
            alternates_by_role[role] = pool
    
    return {
        'generation_id': gen_id,
        'genre': group.genre,
        'key': group.key,
        'bpm': group.bpm,
        'primary_selection': primary_selection,
        'alternates_by_role': alternates_by_role,
        'freshness_stats': selection_report.get('freshness_stats', {}),
        'coherence_info': {
            'dominant_packs_used': dominant_packs or [],
            'pack_distribution': selection_report.get('freshness_stats', {}).get('pack_distribution', {}),
        },
        'selection_method': 'alternates_pool_freshness_weighted',
        'pool_config': {
            'pool_size': selector._alternates_pool.pool_size,
            'score_tolerance': selector._alternates_pool.SCORE_TOLERANCE,
            'alternates_enabled': selector._alternates_enabled,
        }
    }


def get_selection_report() -> Dict[str, Any]:
    """T4: Get comprehensive selection report from current selector."""
    selector = get_selector()
    return selector.get_selection_report()


def reset_alternates_pool() -> None:
    """T4: Clear the alternates pool for a fresh start."""
    selector = get_selector()
    selector._alternates_pool.clear_all()
    selector._freshness_tracker = FresherCoherenceTracker()
    selector._geometry_tracker = LoopGeometryTracker()


# Testing
if __name__ == "__main__":

    logging.basicConfig(level=logging.INFO)

    print("Sample Selector - Test (Fase 4 mejorada)")
    print("=" * 60)

    selector = SampleSelector()

    # Test de selección por género
    genres = ['techno', 'house', 'tech-house', 'deep-house']

    for genre in genres:
        print(f"\n{genre.upper()}:")
        profile = selector._get_genre_profile(genre)
        print(f"  BPM: {profile.bpm_range}")
        print(f"  Keys: {profile.common_keys}")
        print(f"  Características: {', '.join(profile.characteristics)}")

    # Test de selección completa con reproducibilidad
    print("\n" + "=" * 60)
    print("SELECCIÃƒÆ’Ã¢â‚¬Å“N PARA TECHNO (session_seed=12345):")

    # Usar semilla para reproducibilidad
    selector_test = SampleSelector(session_seed=12345)
    group = selector_test.select_for_genre('techno', key='F#m', bpm=130)

    print(f"\nKey: {group.key}, BPM: {group.bpm}")
    print(f"Session Seed: {selector_test._session_seed}")
    print(f"\nDrum Kit: {group.drums.name}")
    if group.drums.kick:
        print(f"  Kick: {group.drums.kick.name} (role validated)")
    if group.drums.snare:
        print(f"  Snare: {group.drums.snare.name} (role validated)")
    if group.drums.clap:
        print(f"  Clap: {group.drums.clap.name} (role validated)")
    if group.drums.hat_closed:
        print(f"  Hat: {group.drums.hat_closed.name} (role validated)")

    print(f"\nBass samples: {len(group.bass)}")
    print(f"Synth samples: {len(group.synths)}")

    # Test de reproducibilidad - segunda corrida con misma semilla
    print("\n" + "=" * 60)
    print("TEST DE REPRODUCIBILIDAD (misma semilla):")

    selector_test2 = SampleSelector(session_seed=12345)
    group2 = selector_test2.select_for_genre('techno', key='F#m', bpm=130)

    print(f"Misma key: {group.key == group2.key}")
    print(f"Mismo BPM: {group.bpm == group2.bpm}")

    # Test de validación de roles
    print("\n" + "=" * 60)
    print("TEST DE VALIDACIÃƒÆ’Ã¢â‚¬Å“N DE ROLES:")

    # Crear un sample mock para testing
    class MockSample:
        def __init__(self, name, sample_type, category):
            self.name = name
            self.sample_type = sample_type
            self.category = category
            self.subcategory = ""
            self.id = name
            self.key = None
            self.bpm = None
            self.rating = 3
            self.genres = []
            self.rms_energy = 0.5
            self.duration = 0.5

    # Test samples correctos
    kick_sample = MockSample("Techno_Kick_01", "kick", "drums")
    snare_sample = MockSample("Techno_Snare_02", "snare", "drums")
    clap_sample = MockSample("Techno_Clap_03", "clap", "drums")

    print(f"  Kick para rol 'kick': {selector._validate_sample_for_role(kick_sample, 'kick'):.2f}")
    print(f"  Snare para rol 'snare': {selector._validate_sample_for_role(snare_sample, 'snare'):.2f}")
    print(f"  Clap para rol 'clap': {selector._validate_sample_for_role(clap_sample, 'clap'):.2f}")

    # Test samples incorrectos (ABSURDOS)
    print(f"  Snare para rol 'kick': {selector._validate_sample_for_role(snare_sample, 'kick'):.2f} (debería ser bajo)")
    print(f"  Clap para rol 'hat_closed': {selector._validate_sample_for_role(clap_sample, 'hat_closed'):.2f} (debería ser bajo)")

    print("\n" + "=" * 60)
    print(f"NumPy disponible: {NUMPY_AVAILABLE}")
    print(f"GPU disponible: {GPU_AVAILABLE}")
