#!/usr/bin/env python3
"""
Song-Erkennungs-Backend für M-029.
Weg A: Mikrofon (Raum-Audio) — Studio Display-Mikrofon
Weg B: BlackHole 2ch (System-Loopback)
Spotify-Speicherung via spotipy OAuth (playlist-modify-public).
"""
from __future__ import annotations
import asyncio
import json
import os
import tempfile
import threading
import time
import wave
from pathlib import Path
from typing import Optional

import numpy as np
import sounddevice as sd

DISPATCHER = Path(__file__).parent.parent
SPOTIFY_CONFIG_FILE = DISPATCHER / "hue" / "spotify_config.json"
SPOTIFY_CACHE_FILE  = DISPATCHER / "hue" / ".spotify_song_cache"
SPOTIFY_REDIRECT_URI = "http://127.0.0.1:8089/spotify/callback"
SPOTIFY_SCOPE = (
    "ugc-image-upload "
    "user-read-playback-state user-modify-playback-state user-read-currently-playing "
    "streaming "
    "playlist-read-private playlist-read-collaborative "
    "playlist-modify-private playlist-modify-public "
    "user-follow-modify user-follow-read "
    "user-read-playback-position user-top-read user-read-recently-played "
    "user-library-modify user-library-read "
    "user-read-email user-read-private"
)
SONG_LOG_FILE = DISPATCHER / "spotify_history" / "erkannte_songs.jsonl"

# Playlist-Name der "Erkannt"-Sammlung
ERKANNT_PLAYLIST_NAME = "🎵 Erkannt"

# Globaler Job-State — wird vom Server abgefragt
_job: dict = {
    "state": "idle",   # idle | recording | recognizing | saving | done | error
    "song": None,      # {"title": ..., "artist": ..., "track_id": ...}
    "error": None,
    "ts": None,
}
_job_lock = threading.Lock()


# ────────────────────────────────────────────────────
# Hilfsfunktionen
# ────────────────────────────────────────────────────

def _set_state(state: str, *, song=None, error=None):
    with _job_lock:
        _job["state"] = state
        _job["song"]  = song
        _job["error"] = error
        _job["ts"]    = time.time()


def get_status() -> dict:
    with _job_lock:
        return dict(_job)


def _spotify_cfg() -> dict:
    try:
        return json.loads(SPOTIFY_CONFIG_FILE.read_text())
    except Exception:
        return {}


def _spotify_client():
    """Gibt ein authentifiziertes spotipy.Spotify zurück oder None."""
    try:
        import spotipy
        from spotipy.oauth2 import SpotifyOAuth
        cfg = _spotify_cfg()
        client_id = cfg.get("client_id", "")
        client_secret = cfg.get("client_secret", "")
        if not client_id:
            return None
        auth = SpotifyOAuth(
            client_id=client_id,
            client_secret=client_secret,
            redirect_uri=SPOTIFY_REDIRECT_URI,
            scope=SPOTIFY_SCOPE,
            cache_handler=spotipy.CacheFileHandler(cache_path=str(SPOTIFY_CACHE_FILE)),
            open_browser=False,
        )
        token = auth.get_cached_token()
        if not token:
            return None
        if auth.is_token_expired(token):
            token = auth.refresh_access_token(token["refresh_token"])
        return spotipy.Spotify(auth=token["access_token"])
    except Exception:
        return None


def spotify_auth_url() -> str:
    """Gibt die Spotify-OAuth-URL zurück (für einmaligen Login-Knopf)."""
    import spotipy
    from spotipy.oauth2 import SpotifyOAuth
    cfg = _spotify_cfg()
    auth = SpotifyOAuth(
        client_id=cfg.get("client_id", ""),
        client_secret=cfg.get("client_secret", ""),
        redirect_uri=SPOTIFY_REDIRECT_URI,
        scope=SPOTIFY_SCOPE,
        cache_handler=spotipy.CacheFileHandler(cache_path=str(SPOTIFY_CACHE_FILE)),
        open_browser=False,
    )
    return auth.get_authorize_url()


def spotify_handle_callback(code: str) -> bool:
    """Tauscht den Auth-Code gegen einen Token und speichert ihn."""
    try:
        import spotipy
        from spotipy.oauth2 import SpotifyOAuth
        cfg = _spotify_cfg()
        auth = SpotifyOAuth(
            client_id=cfg.get("client_id", ""),
            client_secret=cfg.get("client_secret", ""),
            redirect_uri=SPOTIFY_REDIRECT_URI,
            scope=SPOTIFY_SCOPE,
            cache_handler=spotipy.CacheFileHandler(cache_path=str(SPOTIFY_CACHE_FILE)),
            open_browser=False,
        )
        auth.get_access_token(code)
        return True
    except Exception:
        return False


def spotify_is_connected() -> bool:
    return _spotify_client() is not None


# ────────────────────────────────────────────────────
# Audio-Aufnahme
# ────────────────────────────────────────────────────

def _find_device(name_fragment: str) -> Optional[int]:
    """Sucht ein Gerät anhand eines Name-Fragmentes. Gibt Index zurück oder None.
    Normalisiert Non-Breaking Spaces (\xa0) zu regulären Spaces für den Vergleich."""
    devices = sd.query_devices()
    fragment_norm = name_fragment.lower().replace("\xa0", " ")
    for i, d in enumerate(devices):
        name_norm = d["name"].lower().replace("\xa0", " ")
        if fragment_norm in name_norm and d["max_input_channels"] > 0:
            return i
    return None


def record_audio(duration: int = 10, source: str = "mikrofon") -> bytes:
    """
    Nimmt 'duration' Sekunden Audio auf.
    source: 'mikrofon' → Studio Display-Mikrofon
            'blackhole' → BlackHole 2ch (System-Loopback)
    Gibt WAV-Bytes zurück.
    """
    samplerate = 44100
    channels = 1

    if source == "blackhole":
        device_idx = _find_device("BlackHole 2ch")
        channels = 2  # BlackHole ist stereo
    else:
        device_idx = _find_device("Studio Display")
        if device_idx is None:
            device_idx = None  # Standard-Mikrofon als Fallback

    frames = sd.rec(
        int(duration * samplerate),
        samplerate=samplerate,
        channels=channels,
        dtype="int16",
        device=device_idx,
    )
    sd.wait()

    # Stereo → Mono für shazamio
    if channels == 2:
        frames = frames.mean(axis=1).astype(np.int16)

    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
        tmp_path = f.name

    with wave.open(tmp_path, "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)  # int16 = 2 Bytes
        wf.setframerate(samplerate)
        wf.writeframes(frames.tobytes())

    data = Path(tmp_path).read_bytes()
    os.unlink(tmp_path)
    return data


# ────────────────────────────────────────────────────
# Shazam-Erkennung
# ────────────────────────────────────────────────────

async def _shazam_recognize(wav_bytes: bytes) -> Optional[dict]:
    from shazamio import Shazam
    shazam = Shazam()
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
        f.write(wav_bytes)
        tmp = f.name
    try:
        result = await shazam.recognize(tmp)
        if not result or "track" not in result:
            return None
        track = result["track"]
        title  = track.get("title", "")
        artist = track.get("subtitle", "")
        isrc   = track.get("isrc", "")
        return {"title": title, "artist": artist, "isrc": isrc, "raw": track}
    finally:
        try:
            os.unlink(tmp)
        except Exception:
            pass


def recognize_audio(wav_bytes: bytes) -> Optional[dict]:
    """Synchroner Wrapper um shazamio (asyncio)."""
    try:
        loop = asyncio.new_event_loop()
        result = loop.run_until_complete(_shazam_recognize(wav_bytes))
        loop.close()
        return result
    except Exception as e:
        return None


# ────────────────────────────────────────────────────
# Spotify-Speicherung
# ────────────────────────────────────────────────────

def _get_or_create_playlist(sp) -> Optional[str]:
    """Findet oder erstellt die 'Erkannt'-Playlist. Gibt die Playlist-ID zurück."""
    try:
        me = sp.current_user()
        user_id = me["id"]
        offset = 0
        while True:
            result = sp.current_user_playlists(limit=50, offset=offset)
            items = result.get("items", [])
            if not items:
                break
            for p in items:
                if p and p.get("name") == ERKANNT_PLAYLIST_NAME:
                    return p["id"]
            if result.get("next") is None:
                break
            offset += 50

        # Playlist existiert nicht → anlegen
        new_pl = sp.user_playlist_create(
            user=user_id,
            name=ERKANNT_PLAYLIST_NAME,
            public=False,
            description="Automatisch erkannte Songs via Dispatcher-Cockpit",
        )
        return new_pl["id"]
    except Exception:
        return None


def save_to_spotify(song_info: dict) -> dict:
    """
    Sucht den Song auf Spotify und speichert ihn:
    - Als Liked Song (user-library-modify, falls Scope vorhanden)
    - In der 'Erkannt'-Playlist (immer)
    Gibt {"ok": True/False, "track_id": ..., "url": ...} zurück.
    """
    sp = _spotify_client()
    if not sp:
        return {"ok": False, "error": "Spotify nicht verbunden"}

    title  = song_info.get("title", "")
    artist = song_info.get("artist", "")
    isrc   = song_info.get("isrc", "")

    track_id = None

    # 1. Per ISRC suchen (exaktester Treffer)
    if isrc:
        try:
            r = sp.search(q=f"isrc:{isrc}", type="track", limit=1)
            items = r.get("tracks", {}).get("items", [])
            if items:
                track_id = items[0]["id"]
        except Exception:
            pass

    # 2. Fallback: Titel + Künstler
    if not track_id and title:
        try:
            q = f"track:{title}"
            if artist:
                q += f" artist:{artist}"
            r = sp.search(q=q, type="track", limit=1)
            items = r.get("tracks", {}).get("items", [])
            if items:
                track_id = items[0]["id"]
        except Exception:
            pass

    if not track_id:
        return {"ok": False, "error": f"Song '{title}' nicht auf Spotify gefunden"}

    track_uri = f"spotify:track:{track_id}"
    track_url = f"https://open.spotify.com/track/{track_id}"

    saved_as = []

    # 3. Liked Song (falls Scope vorhanden)
    try:
        sp.current_user_saved_tracks_add([track_id])
        saved_as.append("liked")
    except Exception:
        pass  # Scope fehlt → nicht kritisch

    # 4. In Erkannt-Playlist
    try:
        pl_id = _get_or_create_playlist(sp)
        if pl_id:
            sp.playlist_add_items(pl_id, [track_uri])
            saved_as.append("playlist")
    except Exception as e:
        pass

    # 5. Ins lokale Log
    _log_song(song_info, track_id)

    if not saved_as:
        return {"ok": False, "error": "Gespeichert konnte nicht werden (Scope-Problem?)"}

    return {"ok": True, "track_id": track_id, "url": track_url, "saved_as": saved_as}


def _log_song(song_info: dict, track_id: str):
    try:
        import datetime
        entry = {
            "ts": datetime.datetime.now().isoformat(),
            "title": song_info.get("title"),
            "artist": song_info.get("artist"),
            "isrc": song_info.get("isrc"),
            "track_id": track_id,
        }
        SONG_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
        with SONG_LOG_FILE.open("a", encoding="utf-8") as f:
            f.write(json.dumps(entry, ensure_ascii=False) + "\n")
    except Exception:
        pass


# ────────────────────────────────────────────────────
# Haupt-Flow (läuft im Hintergrund-Thread)
# ────────────────────────────────────────────────────

def run_recognition_flow(source: str = "mikrofon", duration: int = 10):
    """Startet den kompletten Flow im Hintergrund-Thread."""
    def _flow():
        try:
            _set_state("recording")
            wav = record_audio(duration=duration, source=source)

            _set_state("recognizing")
            song = recognize_audio(wav)

            if not song or not song.get("title"):
                _set_state("error", error="Song nicht erkannt")
                return

            _set_state("done", song={
                "title": song["title"],
                "artist": song["artist"],
                "isrc": song.get("isrc", ""),
            })
        except Exception as e:
            _set_state("error", error=str(e))

    t = threading.Thread(target=_flow, daemon=True)
    t.start()


def run_save_flow(song_info: dict):
    """Speichert einen bereits erkannten Song in Spotify (Hintergrund)."""
    def _save():
        try:
            _set_state("saving", song=song_info)
            result = save_to_spotify(song_info)
            if result["ok"]:
                song_with_url = dict(song_info)
                song_with_url["url"] = result.get("url")
                song_with_url["saved_as"] = result.get("saved_as", [])
                _set_state("done", song=song_with_url)
            else:
                _set_state("error", error=result.get("error", "Unbekannter Fehler"))
        except Exception as e:
            _set_state("error", error=str(e))

    t = threading.Thread(target=_save, daemon=True)
    t.start()


if __name__ == "__main__":
    # Schneller CLI-Test
    print("Starte Aufnahme (10s, Mikrofon)…")
    wav = record_audio(duration=10, source="mikrofon")
    print(f"Aufgenommen: {len(wav)} Bytes")
    print("Erkenne…")
    result = recognize_audio(wav)
    print("Ergebnis:", result)
