#!/usr/bin/env python3
"""
Santa Tierra · Servidor LAN
============================
Reemplazo de `python -m http.server` que además provee endpoints
REST para sincronizar TV ↔ celulares sin depender de PeerJS ni de
internet ni de que el router permita visibilidad entre clientes.

Uso:
    python santatierra_server.py            # puerto 8080 por defecto
    python santatierra_server.py 8000

Endpoints disponibles:
    GET  /api/health
    GET  /api/<modulo>/state?room=XXXX
    POST /api/<modulo>/add?room=XXXX        body: {videoId, title, singer, mesa, ...}
    POST /api/<modulo>/remove?room=XXXX     body: {id}
    POST /api/<modulo>/shift?room=XXXX      avanza la cola, primer item -> nowPlaying
    POST /api/<modulo>/set_now?room=XXXX    body: {nowPlaying}
    POST /api/<modulo>/clear?room=XXXX

donde <modulo> = karaoke | saga
"""

import http.server
import json
import re
import shutil
import urllib.parse
import time
import threading
import os
import sys
import socket

# Estado en memoria. Por reinicio del server se pierde, lo cual está bien
# para una noche aislada (cada vez que arrancás el bar empezás limpio).
state = {
    'karaoke': {},   # room -> { queue:[], nowPlaying, rev, updated }
    'saga': {}
}
lock = threading.Lock()


LYRICS_CACHE_FILE = 'lyrics_cache.json'
TRANSLATE_CACHE_FILE = 'translate_cache.json'
PLAYLIST_FILE = 'ambient_playlist.json'  # playlist ambiente persistida entre reinicios

def load_disk_cache(filepath):
    if not os.path.exists(filepath): return {}
    try:
        with open(filepath, 'r', encoding='utf-8') as f:
            return json.load(f) or {}
    except: return {}

def save_disk_cache(filepath, data):
    try:
        with open(filepath, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False)
    except Exception as e:
        print(f'[cache] save error {filepath}: {e}')

def save_ambient_playlist():
    """Guarda TODAS las playlists ambiente de TODAS las salas a disco para sobrevivir reinicios.

    Nuevo formato (v2):
        {
          "SALA01": {
             "playlists": [{"id":"...","name":"Rock","items":[...]}],
             "activePlaylistId": "...",
             "ambientIdx": 0,
             "activePlaylistStartedAt": 1700000000.0
          }
        }
    """
    try:
        snap = {}
        for room_code, room_state in state.get('karaoke', {}).items():
            pls = room_state.get('playlists') or []
            if not pls: continue
            snap[room_code] = {
                'playlists': pls,
                'activePlaylistId': room_state.get('activePlaylistId'),
                'ambientIdx': room_state.get('ambientIdx', 0),
                'activePlaylistStartedAt': room_state.get('activePlaylistStartedAt'),
            }
        save_disk_cache(PLAYLIST_FILE, snap)
    except Exception as e:
        print(f'[playlist] save error: {e}')

def load_ambient_playlist_for(room_code):
    """Restaura playlists para una sala. Migra el formato viejo (single playlist) si lo detecta.

    Devuelve dict con: playlists, activePlaylistId, ambientIdx, activePlaylistStartedAt.
    """
    try:
        snap = load_disk_cache(PLAYLIST_FILE)
        r = snap.get(room_code)
        if not r: return None
        # ---- Migración formato v1 → v2 ----
        if isinstance(r.get('ambient'), list) and 'playlists' not in r:
            items = list(r['ambient'])
            if not items: return None
            pid = f'pl_default_{int(time.time())}'
            return {
                'playlists': [{'id': pid, 'name': 'Mix general', 'items': items}],
                'activePlaylistId': pid,
                'ambientIdx': int(r.get('ambientIdx') or 0),
                'activePlaylistStartedAt': time.time(),
            }
        # ---- Formato v2 ----
        if isinstance(r.get('playlists'), list) and r['playlists']:
            return {
                'playlists': list(r['playlists']),
                'activePlaylistId': r.get('activePlaylistId') or (r['playlists'][0] or {}).get('id'),
                'ambientIdx': int(r.get('ambientIdx') or 0),
                'activePlaylistStartedAt': r.get('activePlaylistStartedAt') or time.time(),
            }
    except Exception as e:
        print(f'[playlist] load error: {e}')
    return None

def get_active_playlist(r):
    """Devuelve la playlist activa de la sala (dict), o None."""
    pls = r.get('playlists') or []
    aid = r.get('activePlaylistId')
    for p in pls:
        if p.get('id') == aid:
            return p
    return pls[0] if pls else None

def maybe_rotate_playlist(r):
    """Auto-rota a la siguiente playlist si el tiempo configurado ya pasó.
    Devuelve True si rotó.
    """
    cfg = r.get('config') or {}
    minutes = cfg.get('autoRotateMinutes') or 0
    pls = r.get('playlists') or []
    if minutes <= 0 or len(pls) < 2: return False
    started = r.get('activePlaylistStartedAt') or time.time()
    elapsed_min = (time.time() - started) / 60.0
    if elapsed_min < minutes: return False
    # Avanzar al siguiente
    aid = r.get('activePlaylistId')
    idx = 0
    for i, p in enumerate(pls):
        if p.get('id') == aid: idx = i; break
    next_p = pls[(idx + 1) % len(pls)]
    r['activePlaylistId'] = next_p['id']
    r['ambientIdx'] = 0
    r['activePlaylistStartedAt'] = time.time()
    save_ambient_playlist()
    return True


DEFAULT_CONFIG = {
    'maxPerPerson': 3,         # canciones máximas en cola por cantante
    'fairQueue': True,         # round-robin entre cantantes para evitar repetidos
    'fadeMs': 10000,           # duración del fade in/out de audio karaoke (10s default)
    'transitionMs': 2500,      # duración de la pantalla "aplausos" entre canciones
    'ambientEnabled': True,    # reproducir playlist ambiente cuando no hay karaoke
    'confirmSec': 10,          # segundos para que el cantante confirme antes de auto-arrancar
    'announcerEnabled': True,  # locutor TTS + aplausos antes de cada karaoke
    'autoRotateMinutes': 0,    # 0 = off; >0 = rotar a siguiente playlist cada N min
    'shufflePlaylist': False,  # True = playlist_next aleatorio (evita las últimas N reproducidas)
    # ============ MODO DJ (solo aplica a videos ambient) ============
    'djMode': False,           # activar modo DJ
    'djSkipStart': 10,         # segundos a saltar del inicio (intro)
    'djSkipEnd': 40,           # segundos a cortar antes del final (outro)
    'djFadeMs': 5000,          # fade in/out por canción del ambient
    'djAutoProportion': False, # si True, ignora skipStart/skipEnd y los calcula por duración
    'djMinDuration': 60,       # solo aplicar a videos más largos que esto (segundos)
}

DEFAULT_AMBIENT_PLAYLIST = [
    # Música de fondo del bar — el admin puede cambiarla
    # Si está vacía, no se reproduce nada cuando no hay karaoke
]

def empty_room():
    return {
        'queue': [],
        'nowPlaying': None,
        'broadcast': None,
        'config': dict(DEFAULT_CONFIG),
        'playlists': [],            # lista de playlists con nombre
        'activePlaylistId': None,   # id de la playlist activa
        'ambientIdx': 0,            # índice dentro de la playlist activa
        'activePlaylistStartedAt': None,  # timestamp cuando se activó (para auto-rotación)
        'rev': 0,
        'updated': time.time()
    }


def haversine(lat1, lng1, lat2, lng2):
    """Distancia en metros entre dos puntos GPS."""
    import math
    R = 6371000  # radio de la tierra en metros
    p1 = math.radians(lat1); p2 = math.radians(lat2)
    dp = math.radians(lat2 - lat1); dl = math.radians(lng2 - lng1)
    a = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2
    c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
    return R * c


def singer_key(item):
    """Identificador único del cantante: nombre + mesa."""
    name = (item.get('singer') or '').strip().lower()
    mesa = (item.get('mesa') or '').strip().lower()
    return f"{name}::{mesa}"


def fair_reorder(queue):
    """
    Round-robin entre cantantes preservando orden de llegada original.

    Si Juan llegó primero y agregó 3, María después y agregó 2,
    el orden queda: J1, M1, J2, M2, J3 (intercalado)
    Si solo Juan, queda J1, J2, J3 (no cambia)
    """
    if not queue:
        return queue
    # Agrupar por cantante manteniendo orden de llegada
    buckets = {}
    order = []
    for item in queue:
        k = singer_key(item)
        if k not in buckets:
            buckets[k] = []
            order.append(k)
        buckets[k].append(item)

    # Round-robin
    result = []
    while any(buckets[k] for k in order):
        for k in order:
            if buckets[k]:
                result.append(buckets[k].pop(0))
    return result


def count_in_queue(queue, item):
    """Cuántas canciones del mismo cantante hay en la cola."""
    k = singer_key(item)
    return sum(1 for q in queue if singer_key(q) == k)


class Handler(http.server.SimpleHTTPRequestHandler):
    # ---------- helpers ----------
    def _cors(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.send_header('Cache-Control', 'no-store')

    def _send_json(self, data, status=200):
        body = json.dumps(data, ensure_ascii=False).encode('utf-8')
        self.send_response(status)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self._cors()
        self.end_headers()
        self.wfile.write(body)

    def do_OPTIONS(self):
        self.send_response(204)
        self._cors()
        self.end_headers()

    def _arcade_broadcast(self, event):
        """Manda el evento a todos los SSE subscribers (TVs conectadas)."""
        import queue as _q
        subs = state.get('_arcade_subs') or []
        lock_subs = state.get('_arcade_subs_lock')
        if not lock_subs: return
        with lock_subs:
            for sub_q in subs[:]:
                try:
                    sub_q.put_nowait(event)
                except _q.Full:
                    pass

    def _find_bar_logo(self):
        """Devuelve el path del archivo de logo si existe, sino None."""
        for ext in ('png','jpg','jpeg','svg','webp'):
            p = f'bar_logo.{ext}'
            if os.path.exists(p): return p
        return None

    def _find_applause(self):
        """Devuelve el path del archivo de aplausos custom si existe, sino None."""
        for ext in ('mp3','wav','ogg','m4a'):
            p = f'applause.{ext}'
            if os.path.exists(p): return p
        return None

    def _serve_file(self, filepath):
        """Sirve un archivo con soporte de HTTP Range (necesario para video streaming)."""
        try:
            file_size = os.path.getsize(filepath)
        except Exception:
            self.send_error(404); return
        ext = filepath.rsplit('.', 1)[-1].lower() if '.' in filepath else ''
        mime_map = {
            'mp4': 'video/mp4', 'webm': 'video/webm', 'mov': 'video/quicktime',
            'm4v': 'video/x-m4v', 'mkv': 'video/x-matroska', 'avi': 'video/x-msvideo'
        }
        mime = mime_map.get(ext, 'application/octet-stream')
        range_header = self.headers.get('Range')
        try:
            f = open(filepath, 'rb')
        except Exception:
            self.send_error(404); return
        try:
            if range_header:
                m = re.match(r'bytes=(\d+)-(\d*)', range_header)
                if m:
                    start = int(m.group(1))
                    end = int(m.group(2)) if m.group(2) else file_size - 1
                    end = min(end, file_size - 1)
                    length = end - start + 1
                    f.seek(start)
                    self.send_response(206)
                    self.send_header('Content-Type', mime)
                    self.send_header('Accept-Ranges', 'bytes')
                    self.send_header('Content-Range', f'bytes {start}-{end}/{file_size}')
                    self.send_header('Content-Length', str(length))
                    self._cors()
                    self.end_headers()
                    remaining = length
                    while remaining > 0:
                        chunk = f.read(min(64*1024, remaining))
                        if not chunk: break
                        self.wfile.write(chunk)
                        remaining -= len(chunk)
                    return
            # Sin Range: enviar archivo entero
            self.send_response(200)
            self.send_header('Content-Type', mime)
            self.send_header('Accept-Ranges', 'bytes')
            self.send_header('Content-Length', str(file_size))
            self._cors()
            self.end_headers()
            shutil.copyfileobj(f, self.wfile, length=64*1024)
        except (BrokenPipeError, ConnectionResetError):
            pass
        except Exception as e:
            print(f'[serve_file] error: {e}')
        finally:
            try: f.close()
            except: pass

    # ---------- routing ----------
    def do_GET(self):
        if self.path.startswith('/api/'):
            return self._handle_api(None)
        if self.path.startswith('/videos_ext/'):
            return self._handle_external_video()
        return super().do_GET()

    def _handle_external_video(self):
        """Sirve archivos de carpetas externas de video con soporte de Range."""
        try:
            parsed = urllib.parse.urlparse(self.path)
            parts = parsed.path.strip('/').split('/')
            if len(parts) < 3:
                self.send_error(404); return
            idx = int(parts[1])
            fname = urllib.parse.unquote('/'.join(parts[2:]))
            if '..' in fname or fname.startswith('/') or fname.startswith('\\'):
                self.send_error(400); return
            ext_dirs = state.get('video_dirs', [])
            real_idx = idx - 1
            if not (0 <= real_idx < len(ext_dirs)):
                self.send_error(404); return
            target = os.path.join(ext_dirs[real_idx]['path'], fname)
            if not os.path.isfile(target):
                self.send_error(404); return
            self._serve_file(target)
        except Exception as e:
            print(f'[handle_external_video] error: {e}')
            self.send_error(500)

    def do_POST(self):
        if self.path.startswith('/api/'):
            length = int(self.headers.get('Content-Length', 0))
            raw = self.rfile.read(length) if length else b''
            try:
                body = json.loads(raw.decode('utf-8')) if raw else {}
            except Exception:
                body = {}
            return self._handle_api(body)
        self.send_error(404, "POST sólo soportado en /api/*")

    # ---------- api ----------
    def _handle_api(self, body):
        try:
            parsed = urllib.parse.urlparse(self.path)
            parts = parsed.path.strip('/').split('/')
            qs = urllib.parse.parse_qs(parsed.query)

            # /api/health
            if parts == ['api', 'health']:
                return self._send_json({'ok': True, 'mode': 'lan', 'time': time.time()})

            # /api/server/info — devuelve la IP de LAN para que clientes generen QR/URLs correctas
            if parts == ['api', 'server', 'info']:
                lan_ip = get_local_ip()
                port = self.server.server_port
                return self._send_json({
                    'ok': True,
                    'lanIp': lan_ip,
                    'port': port,
                    'lanHost': f'{lan_ip}:{port}',
                    'localUrl': f'http://localhost:{port}',
                    'lanUrl': f'http://{lan_ip}:{port}',
                })

            # ============== SOCIAL ==============
            if parts[0] == 'api' and len(parts) >= 2 and parts[1] == 'social':
                return self._handle_social(parts[2:] if len(parts) > 2 else [], qs, body)

            # ============== MESA / COMENSALES ==============
            if parts[0] == 'api' and len(parts) >= 2 and parts[1] == 'mesa':
                return self._handle_mesa(parts[2:] if len(parts) > 2 else [], qs, body)

            # /api/karaoke/lyrics — busca letra usando lyrics.ovh + cache
            if parts == ['api', 'karaoke', 'lyrics']:
                artist = (qs.get('artist', [''])[0] or '').strip()
                title = (qs.get('title', [''])[0] or '').strip()
                if not artist or not title:
                    return self._send_json({'error': 'falta artist y title'}, 400)
                cache_key = f"{artist.lower()}::{title.lower()}"
                # Cargar cache disco al primer uso
                cache = state.get('lyrics_cache')
                if cache is None:
                    cache = load_disk_cache(LYRICS_CACHE_FILE)
                    state['lyrics_cache'] = cache
                if cache_key in cache:
                    return self._send_json(cache[cache_key])
                # Buscar via lyrics.ovh (gratuita, sin API key)
                try:
                    import urllib.request as _ur
                    api_url = f"https://api.lyrics.ovh/v1/{urllib.parse.quote(artist)}/{urllib.parse.quote(title)}"
                    req = _ur.Request(api_url, headers={'User-Agent': 'SantaTierra/1.0'})
                    with _ur.urlopen(req, timeout=8) as resp:
                        data = json.loads(resp.read().decode('utf-8'))
                    text = (data.get('lyrics') or '').strip()
                    result = {'found': bool(text), 'text': text, 'source': 'lyrics.ovh'}
                except Exception as e:
                    result = {'found': False, 'text': '', 'error': str(e)[:80], 'source': 'lyrics.ovh'}
                # Detección simple de idioma por keywords
                if result.get('text'):
                    sample = result['text'].lower()[:500]
                    es_words = sum(1 for w in [' que ',' la ',' el ',' los ',' las ',' es ',' un ',' una ',' por ',' para ',' con ',' mi ',' tu ',' yo ',' me ',' te '] if w in ' ' + sample + ' ')
                    en_words = sum(1 for w in [' the ',' you ',' and ',' i ',' to ',' a ',' is ',' my ',' your ',' me ',' for ',' with ',' of ',' it ',' that '] if w in ' ' + sample + ' ')
                    result['lang'] = 'es' if es_words > en_words else 'en'
                # Cachear (sin límite duro; el disco aguanta MUCHAS letras)
                cache[cache_key] = result
                save_disk_cache(LYRICS_CACHE_FILE, cache)
                return self._send_json(result)

            # /api/karaoke/translate — traduce texto usando MyMemory (gratuito)
            if parts == ['api', 'karaoke', 'translate']:
                if not isinstance(body, dict):
                    return self._send_json({'error': 'POST con body requerido'}, 400)
                text = (body.get('text') or '').strip()
                src = (body.get('from') or 'en').lower()
                dst = (body.get('to') or 'es').lower()
                if not text:
                    return self._send_json({'error': 'falta text'}, 400)
                # Cache (clave usa hash estable de los primeros 500 chars del texto)
                import hashlib
                txt_hash = hashlib.md5(text[:500].encode('utf-8')).hexdigest()[:16]
                cache_key = f"tr::{src}::{dst}::{txt_hash}"
                cache = state.get('translate_cache')
                if cache is None:
                    cache = load_disk_cache(TRANSLATE_CACHE_FILE)
                    state['translate_cache'] = cache
                if cache_key in cache:
                    return self._send_json(cache[cache_key])
                # MyMemory tiene límite de 500 chars por request — dividimos por líneas
                try:
                    import urllib.request as _ur
                    lines = text.split('\n')
                    translated_lines = []
                    for line in lines:
                        line = line.strip()
                        if not line:
                            translated_lines.append('')
                            continue
                        if len(line) > 500: line = line[:500]
                        api_url = f"https://api.mymemory.translated.net/get?q={urllib.parse.quote(line)}&langpair={src}|{dst}"
                        try:
                            req = _ur.Request(api_url, headers={'User-Agent': 'SantaTierra/1.0'})
                            with _ur.urlopen(req, timeout=10) as resp:
                                data = json.loads(resp.read().decode('utf-8'))
                            tr = (data.get('responseData', {}).get('translatedText') or line)
                            translated_lines.append(tr)
                        except:
                            translated_lines.append(line)
                    result = {'ok': True, 'text': '\n'.join(translated_lines), 'from': src, 'to': dst}
                except Exception as e:
                    result = {'ok': False, 'error': str(e)[:80]}
                if result.get('ok'):
                    cache[cache_key] = result
                    save_disk_cache(TRANSLATE_CACHE_FILE, cache)
                return self._send_json(result)

            # /api/karaoke/report_broken — TV reporta que un video falló
            if parts == ['api', 'karaoke', 'report_broken']:
                if isinstance(body, dict):
                    vid = (body.get('videoId') or '').strip()
                    if vid:
                        try:
                            broken_file = 'broken_videos.json'
                            broken = {}
                            if os.path.exists(broken_file):
                                with open(broken_file, 'r', encoding='utf-8') as f:
                                    broken = json.load(f)
                            broken[vid] = {
                                'reportedAt': time.time(),
                                'reason': body.get('reason', 'unknown'),
                                'errorCode': body.get('errorCode')
                            }
                            with open(broken_file, 'w', encoding='utf-8') as f:
                                json.dump(broken, f, ensure_ascii=False, indent=2)
                            # Mostrar en consola para debug
                            print(f'[karaoke] Video roto reportado: {vid} ({body.get("reason","?")})')
                        except Exception as e:
                            print(f'[karaoke] Error guardando broken: {e}')
                return self._send_json({'ok': True})

            # /api/karaoke/logo — devuelve info del logo del bar (si existe)
            if parts == ['api', 'karaoke', 'logo']:
                logo_path = self._find_bar_logo()
                if logo_path:
                    return self._send_json({
                        'ok': True,
                        'url': '/' + os.path.basename(logo_path),
                        'filename': os.path.basename(logo_path)
                    })
                return self._send_json({'ok': True, 'url': None})

            # /api/karaoke/upload_logo — admin sube el logo del bar (base64)
            if parts == ['api', 'karaoke', 'upload_logo'] and body and isinstance(body, dict):
                try:
                    data_url = body.get('dataUrl', '')
                    if not data_url.startswith('data:image/'):
                        return self._send_json({'error': 'no es una imagen'}, 400)
                    # Parsear data URL: data:image/png;base64,XXX
                    head, b64 = data_url.split(',', 1)
                    ext = 'png'
                    if 'jpeg' in head or 'jpg' in head: ext = 'jpg'
                    elif 'svg' in head: ext = 'svg'
                    elif 'webp' in head: ext = 'webp'
                    # Limpiar logos previos para no acumular
                    for old_ext in ('png','jpg','jpeg','svg','webp'):
                        old_path = f'bar_logo.{old_ext}'
                        if os.path.exists(old_path):
                            try: os.remove(old_path)
                            except: pass
                    # Guardar nuevo
                    import base64
                    raw = base64.b64decode(b64)
                    if len(raw) > 5 * 1024 * 1024:  # 5MB max
                        return self._send_json({'error': 'imagen muy grande (max 5MB)'}, 400)
                    new_name = f'bar_logo.{ext}'
                    with open(new_name, 'wb') as f:
                        f.write(raw)
                    print(f'[karaoke] Logo del bar guardado: {new_name} ({len(raw)} bytes)')
                    return self._send_json({'ok': True, 'url': '/' + new_name, 'filename': new_name})
                except Exception as e:
                    return self._send_json({'error': str(e)}, 500)

            # /api/karaoke/delete_logo — quitar logo del bar (volver al textual)
            if parts == ['api', 'karaoke', 'delete_logo']:
                removed = []
                for ext in ('png','jpg','jpeg','svg','webp'):
                    p = f'bar_logo.{ext}'
                    if os.path.exists(p):
                        try: os.remove(p); removed.append(p)
                        except: pass
                return self._send_json({'ok': True, 'removed': removed})

            # ============== 🎮 ARCADE (EmulatorJS + gamepad celular) ==============
            # state global del arcade (no por sala)
            # /api/arcade/debug_zip?file=NAME — mira dentro de un zip para diagnosticar
            if parts == ['api', 'arcade', 'debug_zip']:
                import zipfile
                fname = qs.get('file', [''])[0]
                base = os.path.join(os.getcwd(), 'roms')
                path = os.path.join(base, fname)
                if not os.path.isfile(path):
                    return self._send_json({'error': f'no existe {fname}', 'looking_at': base, 'files_in_roms': os.listdir(base) if os.path.isdir(base) else []})
                try:
                    with zipfile.ZipFile(path) as z:
                        names = z.namelist()
                        files_info = []
                        for n in names:
                            if n.endswith('/'): continue
                            ext = n.rsplit('.', 1)[-1].lower() if '.' in n else ''
                            files_info.append({
                                'name': n,
                                'ext': ext,
                                'size': z.getinfo(n).file_size,
                            })
                    return self._send_json({
                        'ok': True,
                        'zip_size': os.path.getsize(path),
                        'inner_count': len(files_info),
                        'inner_files': files_info[:50],
                    })
                except Exception as e:
                    return self._send_json({'error': str(e), 'type': type(e).__name__})

            if parts == ['api', 'arcade', 'roms']:
                base = os.path.join(os.getcwd(), 'roms')
                try: os.makedirs(base, exist_ok=True)
                except: pass
                roms = []
                # Cores soportados por EmulatorJS por extensión
                ext_to_core = {
                    'z64':'n64','n64':'n64','v64':'n64',
                    'smc':'snes','sfc':'snes',
                    'nes':'nes','fds':'nes',
                    'gba':'gba','gb':'gb','gbc':'gb',
                    'md':'segaMD','gen':'segaMD','smd':'segaMD',
                    'bin':'psx','iso':'psx','cue':'psx','pbp':'psx',
                    'pce':'pce','sms':'segaMS','gg':'segaGG',
                    'a26':'atari2600','a78':'atari7800',
                    'nds':'nds','3ds':'3ds',
                }
                # Extensiones típicas de MAME (chips desarmados)
                mame_extensions = {
                    'rom','bin','c1','c2','c3','c4','c5','c6','c7','c8',
                    'm1','p1','p2','sp1','sp2','s1','s2','v1','v2',
                    'sm1','sfix','v11','v12','v21','v22','prg','chr'
                }

                def detect_zip(zip_path):
                    """Mira dentro del zip para decidir el core. Orden de prioridad:
                       1) Si hay un .smc/.sfc/.nes/.gba/etc. dentro → ese core (gana sobre MAME)
                       2) Si hay >=3 archivos MAME → arcade
                       3) None
                    """
                    import zipfile
                    try:
                        with zipfile.ZipFile(zip_path) as z:
                            files = [n for n in z.namelist()
                                    if not n.endswith('/')
                                    and not n.startswith('__MACOSX')
                                    and not os.path.basename(n).startswith('.')]
                            if not files: return None
                            # PRIORIDAD ALTA: si encontramos UNA extensión conocida de consola
                            # (no MAME), ese es el core. Recorremos TODOS los archivos
                            # para no perdernos un .smc dentro de subcarpeta.
                            for n in files:
                                ext = n.rsplit('.', 1)[-1].lower() if '.' in n else ''
                                if ext in ext_to_core:
                                    return ext_to_core[ext]
                            # PRIORIDAD MEDIA: si hay varios chips MAME-style → arcade
                            mame_hits = 0
                            for n in files:
                                ext = n.rsplit('.', 1)[-1].lower() if '.' in n else ''
                                if ext in mame_extensions or (ext.isdigit() and len(ext) <= 3):
                                    mame_hits += 1
                            if mame_hits >= 3 or (len(files) >= 5 and mame_hits >= 2):
                                return 'arcade'
                            # Default arcade si hay al menos UN archivo MAME-style
                            if mame_hits >= 1:
                                return 'arcade'
                    except Exception as e:
                        print(f'[arcade] error leyendo {zip_path}: {e}')
                    return None

                if os.path.isdir(base):
                    for fn in sorted(os.listdir(base)):
                        if fn.startswith('.'): continue
                        ext = fn.rsplit('.', 1)[-1].lower() if '.' in fn else ''
                        core = None
                        if ext == 'zip':
                            core = detect_zip(os.path.join(base, fn))
                            if not core: continue
                        elif ext in ext_to_core:
                            core = ext_to_core[ext]
                        else:
                            continue
                        try:
                            st = os.stat(os.path.join(base, fn))
                            roms.append({
                                'filename': fn,
                                'url': '/roms/' + fn,
                                'core': core,
                                'size': st.st_size,
                                'isZip': (ext == 'zip'),
                            })
                        except: pass
                return self._send_json({'ok': True, 'roms': roms, 'folder': base})

            if parts == ['api', 'arcade', 'state']:
                arc = state.get('arcade') or {'active': False, 'controllers': {}}
                return self._send_json(arc)

            if parts == ['api', 'arcade', 'load'] and body and isinstance(body, dict):
                with lock:
                    state['arcade'] = {
                        'active': True,
                        'rom': str(body.get('rom') or ''),
                        'core': str(body.get('core') or 'n64'),
                        'startedAt': time.time(),
                        'controllers': {
                            'p1': {}, 'p2': {}, 'p3': {}, 'p4': {}
                        }
                    }
                print(f'[arcade] cargado: {body.get("rom")}')
                return self._send_json({'ok': True})

            if parts == ['api', 'arcade', 'stop']:
                with lock:
                    state['arcade'] = {'active': False, 'controllers': {}}
                return self._send_json({'ok': True})

            # /api/arcade/events — SSE para push instantáneo de inputs a la TV
            # Reemplaza al polling de 50ms. Latencia ~30ms vs 80-130ms.
            if parts == ['api', 'arcade', 'events']:
                import queue
                # Lazy init del broadcaster global
                if not hasattr(state, '_arcade_subs'):
                    state.setdefault('_arcade_subs', [])
                    state.setdefault('_arcade_subs_lock', threading.Lock())
                self.send_response(200)
                self.send_header('Content-Type', 'text/event-stream')
                self.send_header('Cache-Control', 'no-cache, no-transform')
                self.send_header('Connection', 'keep-alive')
                self.send_header('X-Accel-Buffering', 'no')  # Nginx: no bufferear
                self._cors()
                self.end_headers()
                # Suscribirse
                q = queue.Queue(maxsize=500)
                with state['_arcade_subs_lock']:
                    state['_arcade_subs'].append(q)
                try:
                    # Hello inicial
                    self.wfile.write(b'data: {"type":"hello"}\n\n')
                    self.wfile.flush()
                    last_ping = time.time()
                    while True:
                        try:
                            event = q.get(timeout=1.0)
                            msg = 'data: ' + json.dumps(event) + '\n\n'
                            self.wfile.write(msg.encode('utf-8'))
                            self.wfile.flush()
                        except queue.Empty:
                            # Keep-alive comment cada 10s para mantener conexión viva
                            if time.time() - last_ping > 10:
                                self.wfile.write(b': ping\n\n')
                                self.wfile.flush()
                                last_ping = time.time()
                except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
                    pass
                finally:
                    with state['_arcade_subs_lock']:
                        if q in state['_arcade_subs']:
                            state['_arcade_subs'].remove(q)
                return  # No usar _send_json

            if parts == ['api', 'arcade', 'press'] and body and isinstance(body, dict):
                # Phone presses a button: {player: 1, button: 'A'}
                p = int(body.get('player') or 1)
                btn = str(body.get('button') or '').strip()[:20]
                if not btn: return self._send_json({'error':'falta button'}, 400)
                with lock:
                    arc = state.get('arcade') or {}
                    if not arc.get('active'): return self._send_json({'error':'arcade off'}, 400)
                    arc.setdefault('controllers', {}).setdefault(f'p{p}', {})[btn] = True
                    arc['ts'] = time.time()
                # Broadcast SSE event (push instantáneo a TV)
                self._arcade_broadcast({'player': p, 'button': btn, 'pressed': True})
                return self._send_json({'ok': True})

            if parts == ['api', 'arcade', 'release'] and body and isinstance(body, dict):
                p = int(body.get('player') or 1)
                btn = str(body.get('button') or '').strip()[:20]
                with lock:
                    arc = state.get('arcade') or {}
                    if arc.get('controllers', {}).get(f'p{p}', {}).get(btn):
                        arc['controllers'][f'p{p}'][btn] = False
                        arc['ts'] = time.time()
                # Broadcast SSE event
                self._arcade_broadcast({'player': p, 'button': btn, 'pressed': False})
                return self._send_json({'ok': True})

            if parts == ['api', 'arcade', 'join']:
                # Phone se conecta como player N. Asigna slot libre o usa el solicitado.
                req_player = qs.get('player', [''])[0]
                with lock:
                    arc = state.get('arcade') or {'active': False, 'controllers': {}}
                    used = set(arc.get('joined', []))
                    if req_player:
                        try:
                            n = int(req_player)
                            if 1 <= n <= 4:
                                arc.setdefault('joined', []).append(n) if n not in used else None
                                return self._send_json({'ok': True, 'player': n, 'rom': arc.get('rom'), 'active': arc.get('active', False)})
                        except: pass
                    # asignar primer slot libre
                    for n in (1,2,3,4):
                        if n not in used:
                            arc.setdefault('joined', []).append(n)
                            return self._send_json({'ok': True, 'player': n, 'rom': arc.get('rom'), 'active': arc.get('active', False)})
                    return self._send_json({'error': 'arcade lleno (4 jugadores)'}, 400)

            # /api/products/images — lista archivos en images/products/ con info
            if parts == ['api', 'products', 'images']:
                base = os.path.join(os.getcwd(), 'images', 'products')
                try: os.makedirs(base, exist_ok=True)
                except: pass
                images = []
                if os.path.isdir(base):
                    for fn in sorted(os.listdir(base)):
                        if fn.startswith('.'): continue
                        ext = fn.rsplit('.', 1)[-1].lower() if '.' in fn else ''
                        if ext not in ('jpg','jpeg','png','webp','gif','avif'): continue
                        path = os.path.join(base, fn)
                        try:
                            st = os.stat(path)
                            images.append({
                                'filename': fn,
                                'url': '/images/products/' + fn,
                                'size': st.st_size,
                                'modified': st.st_mtime
                            })
                        except: pass
                return self._send_json({'ok': True, 'count': len(images), 'images': images, 'folder': base})

            # ============== BOOTSTRAP SNAPSHOT (persistir cambios admin) ==============
            # /api/bootstrap/save — guarda el snapshot de localStorage a bootstrap_data.json
            # Hace backup automático del archivo previo antes de sobrescribir.
            if parts == ['api', 'bootstrap', 'save'] and body and isinstance(body, dict):
                try:
                    snap = body.get('data') or {}
                    if not isinstance(snap, dict) or not snap:
                        return self._send_json({'error': 'data vacío o inválido'}, 400)
                    # Backup del actual con timestamp (rota: máx 10 backups)
                    bdir = 'bootstrap_backups'
                    os.makedirs(bdir, exist_ok=True)
                    if os.path.exists('bootstrap_data.json'):
                        ts = time.strftime('%Y%m%d_%H%M%S')
                        bkp = os.path.join(bdir, f'bootstrap_{ts}.json')
                        try:
                            import shutil
                            shutil.copy2('bootstrap_data.json', bkp)
                        except: pass
                        # Rotar: mantener solo los últimos 10
                        try:
                            backups = sorted([f for f in os.listdir(bdir) if f.startswith('bootstrap_')])
                            for old in backups[:-10]:
                                try: os.remove(os.path.join(bdir, old))
                                except: pass
                        except: pass
                    # Escribir nuevo snapshot
                    with open('bootstrap_data.json', 'w', encoding='utf-8') as f:
                        json.dump(snap, f, ensure_ascii=False, indent=2)
                    # Estadísticas
                    stats = {}
                    for k, v in snap.items():
                        if isinstance(v, list): stats[k] = len(v)
                        elif isinstance(v, dict): stats[k] = len(v)
                        else: stats[k] = 1
                    print(f'[bootstrap] Snapshot guardado · {len(snap)} claves · {sum(stats.values())} items totales')
                    return self._send_json({'ok': True, 'keys': len(snap), 'stats': stats, 'savedAt': time.time()})
                except Exception as e:
                    return self._send_json({'error': str(e)}, 500)

            # /api/bootstrap/info — info del estado actual del archivo en disco
            if parts == ['api', 'bootstrap', 'info']:
                info = {'exists': False}
                if os.path.exists('bootstrap_data.json'):
                    info['exists'] = True
                    try:
                        st = os.stat('bootstrap_data.json')
                        info['size'] = st.st_size
                        info['modified'] = st.st_mtime
                        with open('bootstrap_data.json', 'r', encoding='utf-8') as f:
                            data = json.load(f)
                        info['keys'] = list(data.keys())
                        info['counts'] = {k: (len(v) if isinstance(v,(list,dict)) else 1) for k,v in data.items()}
                    except Exception as e:
                        info['error'] = str(e)
                # Listar backups disponibles
                bdir = 'bootstrap_backups'
                backups = []
                if os.path.isdir(bdir):
                    for f in sorted(os.listdir(bdir), reverse=True)[:10]:
                        try:
                            st = os.stat(os.path.join(bdir, f))
                            backups.append({'name': f, 'size': st.st_size, 'modified': st.st_mtime})
                        except: pass
                info['backups'] = backups
                return self._send_json(info)

            # /api/bootstrap/restore — recargar bootstrap_data.json desde un backup
            if parts == ['api', 'bootstrap', 'restore'] and body and isinstance(body, dict):
                try:
                    name = (body.get('name') or '').strip()
                    if not name or '/' in name or '\\' in name or '..' in name:
                        return self._send_json({'error': 'nombre inválido'}, 400)
                    src = os.path.join('bootstrap_backups', name)
                    if not os.path.isfile(src):
                        return self._send_json({'error': 'backup no encontrado'}, 404)
                    import shutil
                    shutil.copy2(src, 'bootstrap_data.json')
                    with open('bootstrap_data.json', 'r', encoding='utf-8') as f:
                        data = json.load(f)
                    print(f'[bootstrap] Restaurado desde backup: {name}')
                    return self._send_json({'ok': True, 'keys': len(data)})
                except Exception as e:
                    return self._send_json({'error': str(e)}, 500)

            # /api/karaoke/applause — info del archivo de aplausos custom
            if parts == ['api', 'karaoke', 'applause']:
                p = self._find_applause()
                if p:
                    return self._send_json({'ok': True, 'url': '/' + os.path.basename(p), 'filename': os.path.basename(p)})
                return self._send_json({'ok': True, 'url': None})

            # /api/karaoke/upload_applause — admin sube audio de aplausos
            if parts == ['api', 'karaoke', 'upload_applause'] and body and isinstance(body, dict):
                try:
                    data_url = body.get('dataUrl', '')
                    if not data_url.startswith('data:audio/') and not data_url.startswith('data:application/octet-stream'):
                        return self._send_json({'error': 'no es un archivo de audio'}, 400)
                    head, b64 = data_url.split(',', 1)
                    ext = 'mp3'
                    if 'wav' in head: ext = 'wav'
                    elif 'ogg' in head: ext = 'ogg'
                    elif 'm4a' in head or 'mp4' in head: ext = 'm4a'
                    elif 'mpeg' in head or 'mp3' in head: ext = 'mp3'
                    # Limpiar viejos
                    for old in ('mp3','wav','ogg','m4a'):
                        op = f'applause.{old}'
                        if os.path.exists(op):
                            try: os.remove(op)
                            except: pass
                    import base64
                    raw = base64.b64decode(b64)
                    if len(raw) > 5 * 1024 * 1024:
                        return self._send_json({'error': 'archivo muy grande (max 5MB)'}, 400)
                    new_name = f'applause.{ext}'
                    with open(new_name, 'wb') as f:
                        f.write(raw)
                    print(f'[karaoke] Aplauso custom guardado: {new_name} ({len(raw)} bytes)')
                    return self._send_json({'ok': True, 'url': '/' + new_name, 'filename': new_name})
                except Exception as e:
                    return self._send_json({'error': str(e)}, 500)

            # /api/karaoke/delete_applause — quitar audio custom (vuelve al sintético)
            if parts == ['api', 'karaoke', 'delete_applause']:
                removed = []
                for ext in ('mp3','wav','ogg','m4a'):
                    p = f'applause.{ext}'
                    if os.path.exists(p):
                        try: os.remove(p); removed.append(p)
                        except: pass
                return self._send_json({'ok': True, 'removed': removed})

            # /api/karaoke/broken — devuelve lista de videos rotos
            if parts == ['api', 'karaoke', 'broken']:
                try:
                    if os.path.exists('broken_videos.json'):
                        with open('broken_videos.json', 'r', encoding='utf-8') as f:
                            return self._send_json(json.load(f))
                except: pass
                return self._send_json({})

            # /api/library — lista videos locales (carpeta videos/ + carpetas externas)
            if parts == ['api', 'library']:
                # Cargar karaoke_db.json una vez para cruzar metadata
                yt_index = {}  # videoId → {artist, title, source, genre}
                try:
                    if os.path.exists('karaoke_db.json'):
                        with open('karaoke_db.json', 'r', encoding='utf-8') as f:
                            db_data = json.load(f)
                        for s in (db_data.get('songs') or []):
                            yid = (s.get('yt') or '').strip()
                            if yid:
                                yt_index[yid] = {
                                    'artist': s.get('artist') or '',
                                    'title': s.get('title') or '',
                                    'source': s.get('source') or '',
                                    'genre': s.get('genre') or ''
                                }
                except Exception as e:
                    print(f'[library] no se pudo cargar karaoke_db.json: {e}')

                def parse_filename(fn_noext):
                    """Extrae artista/título de un filename limpio."""
                    raw = fn_noext.replace('_', ' ').strip()
                    # Formato "Artist - Title [videoId]" → quitar el [videoId]
                    raw = re.sub(r'\s*\[[a-zA-Z0-9_-]{11}\]\s*$', '', raw)
                    artist = ''
                    title = raw
                    for sep in [' - ', ' – ', ' — ', ' | ']:
                        if sep in raw:
                            p = raw.split(sep, 1)
                            if len(p) == 2:
                                artist = p[0].strip()
                                title = p[1].strip()
                                break
                    return artist, title

                def extract_yt_id(filename_noext):
                    """Saca el videoId de 11 chars del nombre del archivo."""
                    # Caso 1: el archivo es SOLO el videoId (ej. `9Lxm0iSnKNc.mp4`)
                    if len(filename_noext) == 11 and re.match(r'^[a-zA-Z0-9_-]{11}$', filename_noext):
                        return filename_noext
                    # Caso 2: el archivo termina en `[videoId]` (formato amigable nuevo)
                    m = re.search(r'\[([a-zA-Z0-9_-]{11})\]\s*$', filename_noext)
                    if m: return m.group(1)
                    return None

                dirs = [{'name': 'Por defecto', 'path': os.path.join(os.getcwd(), 'videos'), 'idx': 0}]
                ext_dirs = state.get('video_dirs', [])
                for i, d in enumerate(ext_dirs):
                    if d.get('path'):
                        dirs.append({'name': d.get('name') or f'Carpeta {i+1}', 'path': d['path'], 'idx': i+1})
                lib = []
                for d in dirs:
                    p = d['path']
                    try: os.makedirs(p, exist_ok=True)
                    except: pass
                    if not os.path.isdir(p): continue
                    try:
                        files = sorted(os.listdir(p))
                    except: continue
                    for fn in files:
                        if fn.startswith('.'): continue
                        ext = fn.rsplit('.', 1)[-1].lower() if '.' in fn else ''
                        if ext not in ('mp4', 'webm', 'mov', 'm4v', 'mkv', 'avi'): continue
                        fpath = os.path.join(p, fn)
                        try: size = os.path.getsize(fpath)
                        except: size = 0
                        fn_noext = fn.rsplit('.', 1)[0]
                        # Prioridad 1: tomar artista/título de karaoke_db.json si reconocemos el videoId
                        yt_id = extract_yt_id(fn_noext)
                        artist = ''; title = ''; source = ''
                        if yt_id and yt_id in yt_index:
                            artist = yt_index[yt_id]['artist']
                            title = yt_index[yt_id]['title']
                            source = yt_index[yt_id]['source']
                        # Prioridad 2: parsear el nombre del archivo (Artist - Title format)
                        if not title or title == yt_id:
                            artist2, title2 = parse_filename(fn_noext)
                            if not artist: artist = artist2
                            if not title or title == yt_id: title = title2
                        # Prioridad 3: si no hay título legible, usar el filename a secas
                        if not title: title = fn_noext
                        url = '/videos/' + fn if d['idx'] == 0 else f'/videos_ext/{d["idx"]}/' + urllib.parse.quote(fn)
                        lib.append({
                            'file': fn,
                            'url': url,
                            'title': title,
                            'artist': artist,
                            'source': source,
                            'size': size,
                            'ext': ext,
                            'sourceDir': d['name'],
                            'ytId': yt_id
                        })
                # Ordenar por artist+title para listado prolijo
                lib.sort(key=lambda v: ((v.get('artist') or 'zzz').lower(), (v.get('title') or '').lower()))
                return self._send_json({
                    'videos': lib,
                    'count': len(lib),
                    'dirs': [{'name': d['name'], 'path': d['path'], 'idx': d['idx']} for d in dirs]
                })

            # /api/karaoke/dirs — gestionar carpetas externas
            if parts == ['api', 'karaoke', 'dirs']:
                if isinstance(body, dict):
                    if body.get('action') == 'add':
                        name = (body.get('name') or '').strip()
                        path = (body.get('path') or '').strip()
                        if not path:
                            return self._send_json({'error': 'falta path'}, 400)
                        # Validar que existe
                        if not os.path.isdir(path):
                            return self._send_json({'error': f'la carpeta no existe: {path}'}, 400)
                        # Contar videos para feedback
                        count = 0
                        try:
                            for fn in os.listdir(path):
                                if fn.rsplit('.', 1)[-1].lower() in ('mp4','webm','mov','m4v','mkv','avi'):
                                    count += 1
                        except: pass
                        with lock:
                            ext = state.setdefault('video_dirs', [])
                            # Evitar duplicados
                            for d in ext:
                                if os.path.normpath(d.get('path','')) == os.path.normpath(path):
                                    return self._send_json({'error': 'esta carpeta ya está agregada'}, 400)
                            ext.append({'name': name or os.path.basename(path), 'path': path})
                        return self._send_json({'ok': True, 'videoCount': count, 'dirs': state['video_dirs']})
                    if body.get('action') == 'remove':
                        idx = body.get('index')
                        if idx is None or idx < 1:
                            return self._send_json({'error': 'index inválido'}, 400)
                        with lock:
                            ext = state.get('video_dirs', [])
                            # idx 0 es la default. Las externas empiezan en 1
                            real_idx = idx - 1
                            if 0 <= real_idx < len(ext):
                                ext.pop(real_idx)
                        return self._send_json({'ok': True, 'dirs': state.get('video_dirs', [])})
                # GET: devuelve lista
                return self._send_json({'dirs': state.get('video_dirs', [])})

            # /videos_ext/ se maneja en _handle_external_video (fuera de _handle_api)

            if len(parts) < 3:
                return self._send_json({'error': 'ruta inválida'}, 400)

            module = parts[1]
            action = parts[2]

            if module not in ('karaoke', 'saga'):
                return self._send_json({'error': 'modulo desconocido'}, 400)

            room = (qs.get('room', [''])[0] or '').upper().strip()
            if not room:
                return self._send_json({'error': 'falta parametro room'}, 400)

            with lock:
                if room not in state[module]:
                    state[module][room] = empty_room()
                    # RESTAURAR playlists persistidas de disco (sobrevive reinicio del server)
                    if module == 'karaoke':
                        restored = load_ambient_playlist_for(room)
                        if restored:
                            state[module][room]['playlists'] = restored['playlists']
                            state[module][room]['activePlaylistId'] = restored['activePlaylistId']
                            state[module][room]['ambientIdx'] = restored['ambientIdx']
                            state[module][room]['activePlaylistStartedAt'] = restored['activePlaylistStartedAt']
                            total = sum(len(p.get('items') or []) for p in restored['playlists'])
                            print(f'[playlist] sala {room}: restauradas {len(restored["playlists"])} playlists ({total} canciones)')
                r = state[module][room]

                if action == 'state':
                    return self._send_json(r)

                if action == 'add':
                    if not isinstance(body, dict):
                        return self._send_json({'error': 'body invalido'}, 400)
                    item = dict(body)
                    item['id'] = item.get('id') or f"i_{int(time.time()*1000)}_{len(r['queue'])}"
                    item['addedAt'] = time.time() * 1000

                    # ---- Aplicar límite por persona ----
                    cfg = r.get('config') or dict(DEFAULT_CONFIG)
                    max_per = int(cfg.get('maxPerPerson', 3))
                    same = count_in_queue(r['queue'], item)
                    if max_per > 0 and same >= max_per:
                        return self._send_json({
                            'error': 'limit',
                            'message': f"Ya tenés {same} canciones en cola (máximo {max_per}).",
                            'maxPerPerson': max_per,
                            'currentCount': same
                        }, 403)

                    r['queue'].append(item)

                    # ---- Aplicar fair queue (round-robin) ----
                    if cfg.get('fairQueue', True):
                        r['queue'] = fair_reorder(r['queue'])

                    # Encontrar posición real de este item en la cola
                    position = len(r['queue'])
                    for idx, q in enumerate(r['queue']):
                        if q.get('id') == item['id']:
                            position = idx + 1
                            break

                    r['rev'] += 1
                    r['updated'] = time.time()
                    return self._send_json({
                        'ok': True,
                        'item': item,
                        'position': position,
                        'totalInQueue': len(r['queue']),
                        'yourSongs': same + 1
                    })

                if action == 'config':
                    if body is not None and isinstance(body, dict):
                        cfg = r.get('config') or dict(DEFAULT_CONFIG)
                        if 'maxPerPerson' in body:
                            try: cfg['maxPerPerson'] = max(1, min(20, int(body['maxPerPerson'])))
                            except: pass
                        if 'fairQueue' in body: cfg['fairQueue'] = bool(body['fairQueue'])
                        if 'fadeMs' in body:
                            try: cfg['fadeMs'] = max(0, min(5000, int(body['fadeMs'])))
                            except: pass
                        if 'transitionMs' in body:
                            try: cfg['transitionMs'] = max(0, min(10000, int(body['transitionMs'])))
                            except: pass
                        if 'confirmSec' in body:
                            try: cfg['confirmSec'] = max(0, min(60, int(body['confirmSec'])))
                            except: pass
                        if 'ambientEnabled' in body:
                            cfg['ambientEnabled'] = bool(body['ambientEnabled'])
                        if 'announcerEnabled' in body:
                            cfg['announcerEnabled'] = bool(body['announcerEnabled'])
                        if 'autoRotateMinutes' in body:
                            try: cfg['autoRotateMinutes'] = max(0, min(360, int(body['autoRotateMinutes'])))
                            except: pass
                        if 'shufflePlaylist' in body: cfg['shufflePlaylist'] = bool(body['shufflePlaylist'])
                        # --- Modo DJ ---
                        if 'djMode' in body: cfg['djMode'] = bool(body['djMode'])
                        if 'djSkipStart' in body:
                            try: cfg['djSkipStart'] = max(0, min(300, int(body['djSkipStart'])))
                            except: pass
                        if 'djSkipEnd' in body:
                            try: cfg['djSkipEnd'] = max(0, min(600, int(body['djSkipEnd'])))
                            except: pass
                        if 'djFadeMs' in body:
                            try: cfg['djFadeMs'] = max(0, min(30000, int(body['djFadeMs'])))
                            except: pass
                        if 'djAutoProportion' in body: cfg['djAutoProportion'] = bool(body['djAutoProportion'])
                        if 'djMinDuration' in body:
                            try: cfg['djMinDuration'] = max(0, min(600, int(body['djMinDuration'])))
                            except: pass
                        r['config'] = cfg
                        if cfg.get('fairQueue', True):
                            r['queue'] = fair_reorder(r['queue'])
                        r['rev'] += 1
                        r['updated'] = time.time()
                    return self._send_json({'config': r.get('config') or dict(DEFAULT_CONFIG)})

                # ============ PLAYLISTS MÚLTIPLES (cada una con nombre + items) ============
                if action == 'playlists':
                    # GET → lista todas las playlists con metadata
                    pls = r.get('playlists') or []
                    return self._send_json({
                        'playlists': [{
                            'id': p.get('id'),
                            'name': p.get('name') or 'Sin nombre',
                            'count': len(p.get('items') or []),
                            'items': p.get('items') or []
                        } for p in pls],
                        'activePlaylistId': r.get('activePlaylistId'),
                        'ambientIdx': r.get('ambientIdx', 0),
                        'activePlaylistStartedAt': r.get('activePlaylistStartedAt'),
                    })

                if action == 'playlist_create':
                    if not isinstance(body, dict):
                        return self._send_json({'error': 'body invalido'}, 400)
                    name = (body.get('name') or '').strip()[:60]
                    if not name:
                        return self._send_json({'error': 'falta nombre'}, 400)
                    new_id = f"pl_{int(time.time()*1000)}"
                    r.setdefault('playlists', []).append({
                        'id': new_id, 'name': name, 'items': []
                    })
                    # Si era la primera, marcarla como activa
                    if not r.get('activePlaylistId'):
                        r['activePlaylistId'] = new_id
                        r['ambientIdx'] = 0
                        r['activePlaylistStartedAt'] = time.time()
                    r['rev'] += 1
                    r['updated'] = time.time()
                    save_ambient_playlist()
                    return self._send_json({'ok': True, 'id': new_id, 'playlists': r['playlists']})

                if action == 'playlist_rename':
                    if not isinstance(body, dict):
                        return self._send_json({'error': 'body invalido'}, 400)
                    pid = body.get('id')
                    name = (body.get('name') or '').strip()[:60]
                    if not pid or not name:
                        return self._send_json({'error': 'falta id o nombre'}, 400)
                    for p in (r.get('playlists') or []):
                        if p.get('id') == pid:
                            p['name'] = name
                            r['rev'] += 1
                            r['updated'] = time.time()
                            save_ambient_playlist()
                            return self._send_json({'ok': True})
                    return self._send_json({'error': 'playlist no encontrada'}, 404)

                if action == 'playlist_delete':
                    pid = (body.get('id') if isinstance(body, dict) else None) or qs.get('id', [''])[0]
                    if not pid:
                        return self._send_json({'error': 'falta id'}, 400)
                    pls = r.get('playlists') or []
                    new_pls = [p for p in pls if p.get('id') != pid]
                    if len(new_pls) == len(pls):
                        return self._send_json({'error': 'playlist no encontrada'}, 404)
                    r['playlists'] = new_pls
                    # Si borramos la activa, pasar a la primera disponible (o ninguna)
                    if r.get('activePlaylistId') == pid:
                        r['activePlaylistId'] = (new_pls[0]['id'] if new_pls else None)
                        r['ambientIdx'] = 0
                        r['activePlaylistStartedAt'] = time.time() if new_pls else None
                    r['rev'] += 1
                    r['updated'] = time.time()
                    save_ambient_playlist()
                    return self._send_json({'ok': True})

                if action == 'playlist_select':
                    # Activar una playlist específica (admin clickea ▶)
                    pid = (body.get('id') if isinstance(body, dict) else None) or qs.get('id', [''])[0]
                    if not pid:
                        return self._send_json({'error': 'falta id'}, 400)
                    found = next((p for p in (r.get('playlists') or []) if p.get('id') == pid), None)
                    if not found:
                        return self._send_json({'error': 'playlist no encontrada'}, 404)
                    r['activePlaylistId'] = pid
                    r['ambientIdx'] = 0
                    r['activePlaylistStartedAt'] = time.time()
                    r['rev'] += 1
                    r['updated'] = time.time()
                    save_ambient_playlist()
                    return self._send_json({'ok': True, 'activePlaylistId': pid, 'name': found.get('name')})

                if action == 'playlist_add':
                    if not isinstance(body, dict):
                        return self._send_json({'error': 'body invalido'}, 400)
                    vid = (body.get('videoId') or '').strip()
                    pid_yt = (body.get('playlistId') or '').strip()  # YouTube playlist ID, NO confundir
                    target_playlist_id = (body.get('targetPlaylistId') or '').strip() or r.get('activePlaylistId')
                    if not vid and not pid_yt:
                        return self._send_json({'error': 'falta videoId o playlistId'}, 400)
                    if not target_playlist_id:
                        return self._send_json({'error': 'crear primero una playlist'}, 400)
                    target = next((p for p in (r.get('playlists') or []) if p.get('id') == target_playlist_id), None)
                    if not target:
                        return self._send_json({'error': 'playlist destino no encontrada'}, 404)
                    entry = {
                        'title': (body.get('title') or '').strip(),
                        'startSec': max(0, int(body.get('startSec') or 0)),
                        'endSec': int(body.get('endSec') or 0) if body.get('endSec') else None,
                    }
                    if vid: entry['videoId'] = vid
                    if pid_yt: entry['playlistId'] = pid_yt
                    target.setdefault('items', []).append(entry)
                    r['rev'] += 1
                    r['updated'] = time.time()
                    save_ambient_playlist()
                    return self._send_json({'ok': True, 'count': len(target['items'])})

                if action == 'playlist_remove':
                    if not isinstance(body, dict):
                        return self._send_json({'error': 'body invalido'}, 400)
                    target_playlist_id = (body.get('targetPlaylistId') or '').strip() or r.get('activePlaylistId')
                    idx = body.get('index')
                    target = next((p for p in (r.get('playlists') or []) if p.get('id') == target_playlist_id), None)
                    if not target:
                        return self._send_json({'error': 'playlist destino no encontrada'}, 404)
                    try:
                        i = int(idx)
                        items = target.get('items') or []
                        if 0 <= i < len(items):
                            items.pop(i)
                            r['rev'] += 1
                            r['updated'] = time.time()
                            save_ambient_playlist()
                    except: pass
                    return self._send_json({'ok': True, 'count': len(target.get('items') or [])})

                # ----- Endpoint legacy (compat retro): devuelve items de la playlist activa -----
                if action == 'playlist':
                    active = get_active_playlist(r)
                    items = (active or {}).get('items') or []
                    return self._send_json({'playlist': items, 'index': r.get('ambientIdx', 0)})

                if action == 'pending':
                    # TV pide poner una canción en estado "pendiente confirmación"
                    if isinstance(body, dict):
                        item = body.get('item')
                        sec = int(body.get('seconds') or (r.get('config') or {}).get('confirmSec') or 10)
                        if item:
                            r['pending'] = {
                                'item': item,
                                'deadline': time.time() + sec,
                                'startedAt': time.time(),
                                'seconds': sec,
                                'confirmed': False
                            }
                            r['rev'] += 1
                            r['updated'] = time.time()
                    return self._send_json({'pending': r.get('pending')})

                if action == 'confirm':
                    # Cliente confirma que está listo
                    if r.get('pending'):
                        r['pending']['confirmed'] = True
                        r['rev'] += 1
                        r['updated'] = time.time()
                    return self._send_json({'ok': True, 'pending': r.get('pending')})

                if action == 'pending_clear':
                    # TV limpia el estado pendiente (cuando arranca o expira)
                    r['pending'] = None
                    r['rev'] += 1
                    r['updated'] = time.time()
                    return self._send_json({'ok': True})

                if action == 'playlist_skip':
                    # Saltar al siguiente item de la playlist ambiente activa
                    active = get_active_playlist(r)
                    amb = (active or {}).get('items') or []
                    if not amb:
                        return self._send_json({'item': None})
                    idx = (r.get('ambientIdx', 0)) % len(amb)
                    item = amb[idx]
                    r['ambientIdx'] = (idx + 1) % len(amb)
                    r['updated'] = time.time()
                    r['rev'] += 1
                    return self._send_json({'item': item, 'index': idx, 'total': len(amb), 'playlistName': active.get('name')})

                if action == 'playlist_next':
                    # TV pide el siguiente ambiente — antes chequear si toca auto-rotar
                    rotated = maybe_rotate_playlist(r)
                    active = get_active_playlist(r)
                    amb = (active or {}).get('items') or []
                    if not amb:
                        return self._send_json({'item': None})
                    cfg = r.get('config') or {}
                    if cfg.get('shufflePlaylist') and len(amb) > 1:
                        # Modo SHUFFLE: elige random evitando las últimas N reproducidas.
                        # Tamaño de la "memoria" = 1/3 de la playlist, mínimo 3, máximo len-1.
                        import random as _rnd
                        recent = list(r.get('ambientRecent') or [])
                        avoid_n = min(len(amb) - 1, max(3, len(amb) // 3))
                        forbidden = set(recent[-avoid_n:])
                        candidates = [i for i in range(len(amb)) if i not in forbidden]
                        if not candidates:
                            # Si la lista es muy chica, descartamos la regla y vamos a cualquiera distinto del último
                            last = recent[-1] if recent else -1
                            candidates = [i for i in range(len(amb)) if i != last] or list(range(len(amb)))
                        idx = _rnd.choice(candidates)
                        # Actualizar memoria (recortar para no crecer infinito)
                        recent.append(idx)
                        r['ambientRecent'] = recent[-(avoid_n * 2):]
                        r['ambientIdx'] = idx  # mantener compat para clientes que leen este campo
                    else:
                        # Modo SEQUENTIAL (default)
                        idx = r.get('ambientIdx', 0)
                        if idx >= len(amb): idx = 0
                        r['ambientIdx'] = (idx + 1) % len(amb)
                    item = amb[idx]
                    r['updated'] = time.time()
                    return self._send_json({
                        'item': item, 'index': idx,
                        'playlistName': active.get('name'),
                        'rotated': rotated,
                        'shuffle': bool(cfg.get('shufflePlaylist'))
                    })

                if action == 'remove':
                    item_id = (body or {}).get('id') or qs.get('id', [''])[0]
                    before = len(r['queue'])
                    r['queue'] = [q for q in r['queue'] if q.get('id') != item_id]
                    if before != len(r['queue']):
                        r['rev'] += 1
                        r['updated'] = time.time()
                    return self._send_json({'ok': True})

                if action == 'shift':
                    if r['queue']:
                        r['nowPlaying'] = r['queue'].pop(0)
                    else:
                        r['nowPlaying'] = None
                    # Limpiar letras previas
                    r['currentLyrics'] = None
                    r['rev'] += 1
                    r['updated'] = time.time()
                    return self._send_json({'ok': True, 'nowPlaying': r['nowPlaying']})

                if action == 'set_lyrics':
                    # TV envía las letras de la canción actual al server
                    if isinstance(body, dict):
                        r['currentLyrics'] = {
                            'text': str(body.get('text') or '')[:8000],
                            'translated': str(body.get('translated') or '')[:8000],
                            'lang': body.get('lang') or 'unknown',
                            'songId': body.get('songId'),
                            'artist': body.get('artist'),
                            'title': body.get('title'),
                            'ts': time.time()*1000
                        }
                        r['rev'] += 1
                        r['updated'] = time.time()
                    return self._send_json({'ok': True})

                if action == 'set_now':
                    r['nowPlaying'] = (body or {}).get('nowPlaying')
                    r['rev'] += 1
                    r['updated'] = time.time()
                    return self._send_json({'ok': True})

                if action == 'clear':
                    r['queue'] = []
                    r['nowPlaying'] = None
                    r['rev'] += 1
                    r['updated'] = time.time()
                    return self._send_json({'ok': True})

                if action == 'broadcast':
                    msg = (body or {}).get('msg')
                    r['broadcast'] = {'msg': msg, 'ts': time.time()*1000}
                    r['rev'] += 1
                    r['updated'] = time.time()
                    return self._send_json({'ok': True})

                # ============ 🎱 BINGO / JUEGOS ============
                if action == 'game_schedule':
                    # Configurar pre-game: cuenta regresiva + premio antes de arrancar
                    if not isinstance(body, dict):
                        return self._send_json({'error':'body invalido'}, 400)
                    minutes = int(body.get('minutes') or 0)
                    r['game'] = {
                        'type': 'bingo',
                        'active': False,
                        'scheduled': True,
                        'startsAt': time.time() + minutes * 60,
                        'prizeName': str(body.get('prizeName') or '')[:80],
                        'prizeImage': str(body.get('prizeImage') or '')[:200],
                        'cardPrice': int(body.get('cardPrice') or 5000),
                        'config': {
                            'speed': int(body.get('speed') or 8),
                            'totalBalls': int(body.get('totalBalls') or 75),
                            'voice': bool(body.get('voice', True)),
                        },
                        'cards': [],  # cartones comprados
                    }
                    r['rev'] += 1; r['updated'] = time.time()
                    print(f'[game] Bingo programado en sala {room} para {minutes} min')
                    return self._send_json({'ok': True, 'game': r['game']})

                if action == 'game_start':
                    if not isinstance(body, dict):
                        return self._send_json({'error':'body invalido'}, 400)
                    game_type = body.get('type', 'bingo')
                    cfg = body.get('config') or {}
                    # Preservar cartones y prize si ya estaban del schedule
                    prev = r.get('game') or {}
                    r['game'] = {
                        'type': game_type,
                        'active': True,
                        'paused': False,
                        'startedAt': time.time(),
                        'config': {
                            'speed': int(cfg.get('speed') or prev.get('config',{}).get('speed') or 8),
                            'totalBalls': int(cfg.get('totalBalls') or prev.get('config',{}).get('totalBalls') or 75),
                            'voice': bool(cfg.get('voice', True)),
                        },
                        'called': [],
                        'lastBall': None,
                        'prizeName': prev.get('prizeName',''),
                        'prizeImage': prev.get('prizeImage',''),
                        'cardPrice': prev.get('cardPrice', 5000),
                        'cards': prev.get('cards') or [],  # mantener cartones
                    }
                    r['rev'] += 1; r['updated'] = time.time()
                    print(f'[game] {game_type} iniciado en sala {room} ({len(r["game"]["cards"])} cartones)')
                    return self._send_json({'ok': True, 'game': r['game']})

                if action == 'game_buy_card':
                    # Cliente compra un cartón. Body: {userId, userName, mesa}
                    if not isinstance(body, dict): return self._send_json({'error':'body'}, 400)
                    g = r.get('game') or {}
                    if not g or (not g.get('scheduled') and not g.get('active')):
                        return self._send_json({'error':'no hay bingo activo'}, 400)
                    user_id = (body.get('userId') or '').strip()
                    user_name = (body.get('userName') or '').strip()[:30]
                    mesa = (body.get('mesa') or '').strip()[:10]
                    if not user_id: return self._send_json({'error':'falta userId'}, 400)
                    # Generar cartón B-I-N-G-O 5x5 con FREE en el centro.
                    # Cada columna toma 5 números aleatorios de su rango (rango = totalBalls/5).
                    import random as _r
                    total = g.get('config',{}).get('totalBalls', 75)
                    if total == 90:
                        # 90-bolas: 3 filas × 9 cols, 15 números entre 1-90 por cartón (estilo Eur)
                        cols = [[] for _ in range(9)]
                        for col in range(9):
                            lo = col*10 + (1 if col==0 else 0)
                            hi = (col+1)*10 - 1 if col < 8 else 90
                            cols[col] = _r.sample(range(lo, hi+1), 3)
                        grid = [[0]*9 for _ in range(3)]
                        for row in range(3):
                            chosen_cols = _r.sample(range(9), 5)
                            for col in chosen_cols:
                                grid[row][col] = cols[col].pop()
                    else:
                        # 75 o 100 bolas: 5x5 con FREE en centro
                        per = total // 5  # 15 o 20
                        grid = [[0]*5 for _ in range(5)]
                        for col in range(5):
                            lo = col*per + 1
                            hi = (col+1)*per
                            nums = _r.sample(range(lo, hi+1), 5)
                            for row in range(5):
                                grid[row][col] = nums[row]
                        grid[2][2] = 0  # FREE
                    card_id = f"c_{int(time.time()*1000)}_{_r.randint(100,999)}"
                    card = {
                        'id': card_id,
                        'userId': user_id,
                        'userName': user_name,
                        'mesa': mesa,
                        'grid': grid,
                        'boughtAt': time.time(),
                        'paid': g.get('cardPrice', 5000),
                    }
                    g.setdefault('cards', []).append(card)
                    r['rev'] += 1; r['updated'] = time.time()
                    return self._send_json({'ok': True, 'card': card, 'totalMyCards': len([c for c in g['cards'] if c['userId']==user_id])})

                if action == 'game_my_cards':
                    # GET-style: ?user=xxx
                    user_id = qs.get('user', [''])[0]
                    if not user_id: return self._send_json({'cards':[]})
                    g = r.get('game') or {}
                    cards = [c for c in (g.get('cards') or []) if c.get('userId') == user_id]
                    return self._send_json({'cards': cards, 'game': g})

                if action == 'game_claim_bingo':
                    # Cliente grita BINGO con un cartón
                    if not isinstance(body, dict): return self._send_json({'error':'body'}, 400)
                    g = r.get('game') or {}
                    if not g.get('active'): return self._send_json({'error':'bingo no activo'}, 400)
                    card_id = body.get('cardId')
                    user_id = body.get('userId')
                    card = next((c for c in (g.get('cards') or []) if c.get('id')==card_id and c.get('userId')==user_id), None)
                    if not card: return self._send_json({'error':'cartón no encontrado'}, 404)
                    # Verificar que todos los números cantados cubran un patrón ganador
                    called = set(g.get('called') or [])
                    grid = card.get('grid', [])
                    # Para 75-bolas: full card (todos marcados, FREE cuenta)
                    if g.get('config',{}).get('totalBalls', 75) == 75:
                        all_marked = all(n == 0 or n in called for row in grid for n in row)
                    else:
                        # 90-bolas: full card (los 15 números marcados)
                        all_marked = all(n == 0 or n in called for row in grid for n in row)
                    if not all_marked:
                        return self._send_json({'ok': False, 'reason':'cartón incompleto'})
                    g.setdefault('winners', []).append({
                        'cardId': card_id, 'userId': user_id,
                        'userName': card.get('userName',''), 'mesa': card.get('mesa',''),
                        'at': time.time()
                    })
                    r['rev'] += 1; r['updated'] = time.time()
                    print(f'[game] ¡BINGO! {card.get("userName")} mesa {card.get("mesa")}')
                    return self._send_json({'ok': True, 'winner': True})

                if action == 'game_stop':
                    r['game'] = None
                    r['rev'] += 1; r['updated'] = time.time()
                    return self._send_json({'ok': True})

                if action == 'game_pause':
                    if r.get('game'):
                        r['game']['paused'] = True
                        r['rev'] += 1; r['updated'] = time.time()
                    return self._send_json({'ok': True})

                if action == 'game_resume':
                    if r.get('game'):
                        r['game']['paused'] = False
                        r['rev'] += 1; r['updated'] = time.time()
                    return self._send_json({'ok': True})

                if action == 'game_call_ball':
                    # TV publica el número recién llamado (lastBall + agrega al called[])
                    if not isinstance(body, dict): return self._send_json({'error':'body'}, 400)
                    n = body.get('number')
                    if n is None: return self._send_json({'error':'falta number'}, 400)
                    if r.get('game'):
                        try:
                            n = int(n)
                            if n not in r['game']['called']:
                                r['game']['called'].append(n)
                            r['game']['lastBall'] = n
                            r['rev'] += 1; r['updated'] = time.time()
                        except: pass
                    return self._send_json({'ok': True})

                if action == 'staff_help':
                    # Cliente cantando pide ayuda del garzón. Se acumula en lista de pendientes.
                    if isinstance(body, dict):
                        helps = r.setdefault('staffHelps', [])
                        helps.append({
                            'singer': str(body.get('singer') or '').strip()[:30],
                            'mesa': str(body.get('mesa') or '').strip()[:10],
                            'context': str(body.get('context') or 'karaoke')[:30],
                            'ts': time.time()*1000,
                            'id': f"h_{int(time.time()*1000)}_{len(helps)}"
                        })
                        # Solo guardamos los últimos 20
                        if len(helps) > 20:
                            r['staffHelps'] = helps[-20:]
                        r['rev'] += 1
                        r['updated'] = time.time()
                    return self._send_json({'ok': True, 'pending': len(r.get('staffHelps') or [])})

                if action == 'staff_help_clear':
                    # Staff dismisses una notificación (por id) o todas
                    target_id = (body or {}).get('id') if isinstance(body, dict) else None
                    if target_id:
                        r['staffHelps'] = [h for h in (r.get('staffHelps') or []) if h.get('id') != target_id]
                    else:
                        r['staffHelps'] = []
                    r['rev'] += 1
                    r['updated'] = time.time()
                    return self._send_json({'ok': True})

                if action == 'player_time':
                    # TV publica el currentTime y duration del player para que clientes
                    # pinten la barra de progreso. No incrementa rev (es polling de pintura).
                    if isinstance(body, dict):
                        try:
                            r['playerTime'] = float(body.get('currentTime') or 0)
                            r['playerDuration'] = float(body.get('duration') or 0)
                            r['playerTs'] = time.time()*1000
                        except: pass
                    return self._send_json({'ok': True})

                return self._send_json({'error': f'accion desconocida: {action}'}, 400)
        except Exception as e:
            return self._send_json({'error': str(e)}, 500)

    # ============== SOCIAL HANDLER ==============
    def _handle_social(self, parts, qs, body):
        """Endpoints sociales: profile, discover, chat, gift, geofence."""
        try:
            if not parts:
                return self._send_json({'error': 'falta acción'}, 400)
            action = parts[0]

            with lock:
                social = state.setdefault('social', {
                    'profiles': {},
                    'chats': {},        # "uid1::uid2" -> [{from, to, text, ts}]
                    'gifts': [],        # lista de regalos
                    'locations': {},    # uid -> {lat, lng, ts}
                    'config': {
                        'geofence': {
                            # Santa Tierra · San Carlos, Ñuble, Chile
                            'lat': -36.4242, 'lng': -71.9583, 'radiusMeters': 150
                        },
                        'gifts': [
                            {'id':'g_pisco', 'name':'Pisco Sour',   'icon':'🍋', 'price':4500},
                            {'id':'g_mojito','name':'Mojito',       'icon':'🌿', 'price':4500},
                            {'id':'g_cerveza','name':'Cerveza schop','icon':'🍺','price':3500},
                            {'id':'g_vino',  'name':'Copa de vino', 'icon':'🍷', 'price':4000},
                            {'id':'g_chela', 'name':'Cerveza lata', 'icon':'🥫', 'price':2500},
                            {'id':'g_agua',  'name':'Agua mineral', 'icon':'💧', 'price':1500},
                            {'id':'g_cafe',  'name':'Café cortado', 'icon':'☕', 'price':2200},
                            {'id':'g_shot',  'name':'Shot tequila', 'icon':'🥃', 'price':3000},
                        ],
                        'discoverEnabled': True,
                        'chatEnabled': True,
                        'minAge': 18
                    }
                })

                # /api/social/config - GET o POST (admin)
                if action == 'config':
                    if isinstance(body, dict):
                        if 'geofence' in body and isinstance(body['geofence'], dict):
                            gf = social['config']['geofence']
                            for k in ('lat','lng','radiusMeters'):
                                if k in body['geofence']:
                                    try: gf[k] = float(body['geofence'][k])
                                    except: pass
                        if 'discoverEnabled' in body: social['config']['discoverEnabled'] = bool(body['discoverEnabled'])
                        if 'chatEnabled' in body: social['config']['chatEnabled'] = bool(body['chatEnabled'])
                        if 'gifts' in body and isinstance(body['gifts'], list):
                            social['config']['gifts'] = body['gifts']
                    return self._send_json(social['config'])

                # Usuario actual desde query
                uid = (qs.get('uid', [''])[0] or '').strip()
                if not uid and action not in ('config', 'profiles_admin'):
                    return self._send_json({'error': 'falta uid'}, 400)

                # /api/social/profile - GET propio o POST actualizar
                if action == 'profile':
                    if isinstance(body, dict):
                        prof = social['profiles'].get(uid, {'id': uid, 'createdAt': time.time()*1000})
                        # Campos permitidos
                        for k in ('name','age','mesa','bio','photo','interests','status','discoverOptIn','blocked'):
                            if k in body:
                                prof[k] = body[k]
                        prof['updatedAt'] = time.time()*1000
                        prof['lastSeen'] = time.time()*1000
                        social['profiles'][uid] = prof
                    else:
                        # GET — actualizar lastSeen
                        if uid in social['profiles']:
                            social['profiles'][uid]['lastSeen'] = time.time()*1000
                    return self._send_json({'profile': social['profiles'].get(uid)})

                # /api/social/location - POST {lat,lng}
                if action == 'location':
                    if isinstance(body, dict):
                        try:
                            social['locations'][uid] = {
                                'lat': float(body.get('lat')),
                                'lng': float(body.get('lng')),
                                'accuracy': float(body.get('accuracy') or 0),
                                'ts': time.time()
                            }
                        except: pass
                    # Verificar geofence
                    loc = social['locations'].get(uid)
                    in_bar = False
                    distance = None
                    if loc:
                        gf = social['config']['geofence']
                        distance = haversine(loc['lat'], loc['lng'], gf['lat'], gf['lng'])
                        in_bar = distance <= gf['radiusMeters']
                    return self._send_json({'inBar': in_bar, 'distanceMeters': distance})

                # /api/social/discover - GET lista de gente cercana
                if action == 'discover':
                    me = social['profiles'].get(uid, {})
                    blocked = set(me.get('blocked', []))
                    now = time.time()*1000
                    candidates = []
                    for other_uid, prof in social['profiles'].items():
                        if other_uid == uid: continue
                        if other_uid in blocked: continue
                        if not prof.get('discoverOptIn'): continue
                        # Activos en últimos 30 min
                        if now - prof.get('lastSeen', 0) > 30*60*1000: continue
                        candidates.append({
                            'id': other_uid,
                            'name': prof.get('name'),
                            'age': prof.get('age'),
                            'mesa': prof.get('mesa'),
                            'bio': prof.get('bio'),
                            'photo': prof.get('photo'),
                            'interests': prof.get('interests'),
                            'status': prof.get('status'),
                            'lastSeen': prof.get('lastSeen')
                        })
                    # Ordenar por más recientes
                    candidates.sort(key=lambda c: c.get('lastSeen', 0), reverse=True)
                    return self._send_json({'people': candidates})

                # /api/social/chat - GET/POST
                if action == 'chat':
                    other = (qs.get('with', [''])[0] or '').strip()
                    if not other:
                        # Devolver lista de chats activos
                        threads = []
                        for k, msgs in social['chats'].items():
                            ids = k.split('::')
                            if uid in ids:
                                other_id = ids[0] if ids[1] == uid else ids[1]
                                p = social['profiles'].get(other_id, {})
                                last = msgs[-1] if msgs else None
                                unread = sum(1 for m in msgs if m.get('to') == uid and not m.get('read'))
                                threads.append({
                                    'otherId': other_id,
                                    'otherName': p.get('name','?'),
                                    'otherPhoto': p.get('photo'),
                                    'last': last,
                                    'unread': unread
                                })
                        threads.sort(key=lambda t: (t['last'] or {}).get('ts', 0), reverse=True)
                        return self._send_json({'threads': threads})
                    # Conversación con uno
                    key = '::'.join(sorted([uid, other]))
                    if isinstance(body, dict) and body.get('text'):
                        msg = {
                            'id': f"m_{int(time.time()*1000)}",
                            'from': uid, 'to': other,
                            'text': str(body['text'])[:500],
                            'ts': time.time()*1000,
                            'read': False
                        }
                        social['chats'].setdefault(key, []).append(msg)
                        return self._send_json({'ok': True, 'msg': msg})
                    msgs = social['chats'].get(key, [])
                    # Marcar como leídos los del otro
                    for m in msgs:
                        if m.get('to') == uid:
                            m['read'] = True
                    return self._send_json({'messages': msgs[-100:]})

                # /api/social/gift - POST enviar regalo
                if action == 'gift':
                    if not isinstance(body, dict):
                        return self._send_json({'error': 'body inválido'}, 400)
                    to_uid = body.get('to')
                    gift_id = body.get('giftId')
                    msg = body.get('message','')[:200]
                    if not to_uid or not gift_id:
                        return self._send_json({'error': 'faltan datos'}, 400)
                    gift_def = next((g for g in social['config']['gifts'] if g['id']==gift_id), None)
                    if not gift_def:
                        return self._send_json({'error': 'regalo no existe'}, 400)
                    me = social['profiles'].get(uid, {})
                    other = social['profiles'].get(to_uid, {})
                    gift_record = {
                        'id': f"gift_{int(time.time()*1000)}",
                        'ts': time.time()*1000,
                        'fromId': uid, 'fromName': me.get('name'), 'fromMesa': me.get('mesa'),
                        'toId': to_uid, 'toName': other.get('name'), 'toMesa': other.get('mesa'),
                        'gift': gift_def, 'message': msg,
                        'status': 'pending'   # pending → preparing → delivered
                    }
                    social['gifts'].append(gift_record)
                    if len(social['gifts']) > 500:
                        social['gifts'] = social['gifts'][-500:]

                    # ============ Integrar con mesa: agregar el regalo como item ============
                    # El regalo se carga a la cuenta de QUIEN LO ENVÍA (no quien lo recibe)
                    from_mesa = me.get('mesa')
                    if from_mesa:
                        mesa_data = state.setdefault('mesa', {})
                        m = mesa_data.setdefault(from_mesa, {
                            'diners':[{'id':'common','name':'Mesa','color':'#d4af37'}],
                            'items':[], 'createdAt': time.time()*1000
                        })
                        # Buscar diner asociado al UID social, si no existe, crear uno o usar common
                        diner_id = 'common'
                        for d in m['diners']:
                            if d.get('socialUid') == uid:
                                diner_id = d['id']; break
                        gift_item = {
                            'id': f"i_gift_{int(time.time()*1000)}",
                            'ts': time.time()*1000,
                            'dinerId': diner_id,
                            'name': f"🎁 {gift_def['name']} → {other.get('name','?')}",
                            'qty': 1,
                            'price': gift_def['price'],
                            'kitchen': 'barra',
                            'isGift': True,
                            'giftId': gift_record['id'],
                            'giftToUid': to_uid,
                            'giftToName': other.get('name'),
                            'giftToMesa': other.get('mesa'),
                            'comments': msg or '',
                            'status': 'new'
                        }
                        m.setdefault('items', []).append(gift_item)
                        gift_record['linkedItemId'] = gift_item['id']
                        gift_record['linkedMesa'] = from_mesa
                    return self._send_json({'ok': True, 'gift': gift_record})

                # /api/social/gifts_inbox - GET regalos recibidos
                if action == 'gifts_inbox':
                    inbox = [g for g in social['gifts'] if g['toId'] == uid]
                    inbox.sort(key=lambda g: g['ts'], reverse=True)
                    return self._send_json({'gifts': inbox[:50]})

                # /api/social/gifts_pending - GET pending para barra (admin)
                if action == 'gifts_pending':
                    pend = [g for g in social['gifts'] if g.get('status') in ('pending','preparing')]
                    pend.sort(key=lambda g: g['ts'])
                    return self._send_json({'gifts': pend})

                # /api/social/gift_status - POST actualizar estado (barra/admin)
                if action == 'gift_status':
                    if isinstance(body, dict):
                        gid = body.get('id'); st = body.get('status')
                        if gid and st in ('pending','preparing','delivered','cancelled'):
                            for g in social['gifts']:
                                if g['id'] == gid:
                                    g['status'] = st
                                    g['statusAt'] = time.time()*1000
                                    return self._send_json({'ok': True, 'gift': g})
                    return self._send_json({'error':'no encontrado'}, 404)

                # /api/social/redeem - cliente solicita canje (genera código 6 dígitos)
                if action == 'redeem':
                    if not isinstance(body, dict): return self._send_json({'error': 'datos'}, 400)
                    gift_id = body.get('giftId')
                    if not gift_id: return self._send_json({'error': 'falta giftId'}, 400)
                    target = None
                    for g in social['gifts']:
                        if g['id'] == gift_id and g['toId'] == uid:
                            target = g; break
                    if not target: return self._send_json({'error': 'regalo no encontrado'}, 404)
                    if target.get('redeemed'):
                        return self._send_json({'error': 'ya canjeado'}, 400)
                    # Generar código de 6 dígitos
                    import random
                    code = ''.join(str(random.randint(0,9)) for _ in range(6))
                    target['claimCode'] = code
                    target['claimedAt'] = time.time()*1000
                    target['claimStatus'] = 'pending'  # pending → validated
                    return self._send_json({'ok': True, 'code': code, 'gift': target})

                # /api/social/validate_code - admin valida un código de canje
                if action == 'validate_code':
                    if not isinstance(body, dict):
                        return self._send_json({'error': 'datos'}, 400)
                    code = (body.get('code') or '').strip()
                    if not code:
                        return self._send_json({'error': 'falta code'}, 400)
                    target = None
                    for g in social['gifts']:
                        if g.get('claimCode') == code and g.get('claimStatus') == 'pending':
                            target = g
                            break
                    if not target:
                        return self._send_json({'error': 'código inválido o ya validado'}, 404)
                    target['claimStatus'] = 'validated'
                    target['validatedAt'] = time.time() * 1000
                    target['validatedBy'] = body.get('staffName', 'staff')
                    target['redeemed'] = True
                    # Sumar puntos al cliente que recibió (10% del precio)
                    recipient_uid = target.get('toId')
                    if recipient_uid and recipient_uid in social['profiles']:
                        p = social['profiles'][recipient_uid]
                        p['points'] = int(p.get('points', 0)) + max(10, int(target['gift']['price'] * 0.1))
                        social['profiles'][recipient_uid] = p
                    # Puntos al que envió (5% por generosidad)
                    sender_uid = target.get('fromId')
                    if sender_uid and sender_uid in social['profiles']:
                        p2 = social['profiles'][sender_uid]
                        p2['points'] = int(p2.get('points', 0)) + max(5, int(target['gift']['price'] * 0.05))
                        social['profiles'][sender_uid] = p2
                    return self._send_json({'ok': True, 'gift': target})

                # /api/social/points — GET puntos del usuario
                if action == 'points':
                    p = social['profiles'].get(uid, {})
                    return self._send_json({'points': int(p.get('points', 0))})

                # /api/social/rewards_catalog — GET catálogo de premios canjeables
                if action == 'rewards_catalog':
                    return self._send_json({'rewards': social.get('rewards_catalog') or []})

                # /api/social/redeem_points — POST canjear puntos por premio
                if action == 'redeem_points':
                    if not isinstance(body, dict):
                        return self._send_json({'error': 'datos'}, 400)
                    reward_id = body.get('rewardId')
                    if not reward_id:
                        return self._send_json({'error': 'falta rewardId'}, 400)
                    rew = next((r for r in (social.get('rewards_catalog') or []) if r.get('id') == reward_id), None)
                    if not rew:
                        return self._send_json({'error': 'premio no existe'}, 404)
                    p = social['profiles'].get(uid, {})
                    pts = int(p.get('points', 0))
                    cost = int(rew.get('points') or 0)
                    if pts < cost:
                        return self._send_json({'error': 'puntos insuficientes'}, 400)
                    p['points'] = pts - cost
                    social['profiles'][uid] = p
                    import random as _r
                    code = ''.join(str(_r.randint(0, 9)) for _ in range(6))
                    social.setdefault('redemptions', []).append({
                        'id': 'rp_' + str(int(time.time() * 1000)),
                        'uid': uid, 'reward': rew, 'code': code,
                        'ts': time.time() * 1000, 'status': 'pending'
                    })
                    return self._send_json({'ok': True, 'code': code, 'remaining': p['points']})

                return self._send_json({'error': 'accion desconocida: ' + str(action)}, 400)
        except Exception as e:
            import traceback
            traceback.print_exc()
            return self._send_json({'error': str(e)}, 500)


def get_local_ip():
    import socket
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(('8.8.8.8', 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return '127.0.0.1'


def banner(port):
    ip = get_local_ip()
    print("=" * 60)
    print("  SANTA TIERRA - Servidor LAN")
    print("=" * 60)
    print("  Carpeta: " + os.getcwd())
    print("  IP LAN : " + ip + ":" + str(port))
    print()
    print("  TV     : http://localhost:" + str(port) + "/INDEX.html")
    print("  Celular: http://" + ip + ":" + str(port) + "/INDEX.html")
    print()
    print("  Ctrl+C para detener")
    print("=" * 60)
    print()


if __name__ == '__main__':
    import sys
    port = 8080
    if len(sys.argv) > 1:
        try:
            port = int(sys.argv[1])
        except ValueError:
            print("Puerto invalido: " + sys.argv[1])
            sys.exit(1)
    banner(port)
    try:
        server = http.server.ThreadingHTTPServer(('0.0.0.0', port), Handler)
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nServidor detenido.")
    except OSError as e:
        if hasattr(e, 'errno') and e.errno in (10048, 98):
            print("Puerto " + str(port) + " ocupado. Probar: python santatierra_server.py 8081")
        else:
            raise
