#!/usr/bin/env python3
# args_json: {"type":"object","required":["action"],"properties":{"action":{"type":"string","description":"list|status|open|playlist|play|pause|stop|next|previous"},"target":{"type":"string","description":"Target file, URI, or playlist description"},"player":{"type":"string","description":"Player name"},"library":{"type":"string","description":"Music library path"},"count":{"type":"integer","description":"Playlist track count (default: 15)"},"output":{"type":"string","description":"M3U8 output path"}}}
# ollie:prompt
# ## gui_media
#
# Control MPRIS media players.
#
# **Args**: `[action]` or `[action, target]`
# - Actions: `list`, `status`, `open`, `playlist`, `play`, `pause`, `stop`, `next`, `previous`
#
# ```
# gui_media(action="status")
# gui_media(action="open", target="/path/to/song.mp3")
# ```
# ollie:end
"""Reliable MPRIS control, including Elisa's Application.Open requirement."""
import hashlib
import json
import pathlib
import random
import re
import subprocess
import sys
import time

BUS = "org.freedesktop.DBus"
OBJ = "/org/mpris/MediaPlayer2"
PLAYER = "org.mpris.MediaPlayer2.Player"
PROPS = "org.freedesktop.DBus.Properties"


def call(dest, path, method, *args, check=True):
    p = subprocess.run(
        ["gdbus", "call", "--session", "--dest", dest,
         "--object-path", path, "--method", method, *args],
        text=True, capture_output=True,
    )
    if check and p.returncode:
        raise RuntimeError(p.stderr.strip() or p.stdout.strip())
    return p.stdout.strip() if p.returncode == 0 else ""


def players():
    out = call(BUS, "/org/freedesktop/DBus", BUS + ".ListNames")
    return sorted(set(re.findall(r"org\.mpris\.MediaPlayer2[\w.-]+", out)))


def choose(hint=None, activate=False):
    names = players()
    if hint:
        matches = [n for n in names if hint.lower() in n.lower()]
        if matches:
            return matches[0]
        if activate and hint.lower() == "elisa":
            call(BUS, "/org/freedesktop/DBus", BUS + ".StartServiceByName",
                 "org.kde.elisa", "0")
            time.sleep(0.5)
            matches = [n for n in players() if "elisa" in n.lower()]
            if matches:
                return matches[0]
        raise RuntimeError(f"no MPRIS player matching {hint!r}")
    if names:
        playing = [n for n in names if prop(n, "PlaybackStatus") == "Playing"]
        return (playing or names)[0]
    if activate:
        call(BUS, "/org/freedesktop/DBus", BUS + ".StartServiceByName",
             "org.kde.elisa", "0")
        time.sleep(0.5)
        names = players()
        if names:
            return names[0]
    raise RuntimeError("no MPRIS media player is available")


def prop(dest, name):
    out = call(dest, OBJ, PROPS + ".Get", PLAYER, name, check=False)
    m = re.search(r"<'([^']*)'>", out)
    return m.group(1) if m else out


def metadata(dest):
    return call(dest, OBJ, PROPS + ".Get", PLAYER, "Metadata", check=False)


def metadata_has_uri(dest, uri):
    # Elisa reports file URLs with decoded spaces, while pathlib.as_uri()
    # correctly sends percent-encoded spaces to Application.Open.
    from urllib.parse import unquote
    return uri in metadata(dest) or unquote(uri) in metadata(dest)


def uri_for(value):
    if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", value):
        return value
    path = pathlib.Path(value).expanduser().resolve()
    if not path.is_file():
        raise RuntimeError(f"media file not found: {path}")
    return path.as_uri()


def clear_playlist(dest):
    call(dest, OBJ, PLAYER + ".Stop", check=False)
    if "elisa" not in dest.lower():
        return
    call(dest, "/org/kde/elisa", "org.freedesktop.Application.ActivateAction",
         "clear-playlist", "[]", "{}")
    # Elisa resets currentTrack to zero when the queue is empty. Metadata may
    # remain stale briefly, so the index is the reliable completion signal.
    for _ in range(20):
        current = call(dest, OBJ, PROPS + ".Get", PLAYER, "currentTrack", check=False)
        if re.search(r"<0>", current):
            return
        time.sleep(0.1)
    raise RuntimeError("Elisa did not clear its playlist")


def open_uris(dest, uris, clear=False):
    if clear:
        clear_playlist(dest)
    else:
        call(dest, OBJ, PLAYER + ".Stop", check=False)
    opened = False
    if "elisa" in dest.lower():
        # Elisa advertises MPRIS OpenUri but may ignore it. Its actual file-open
        # path is org.freedesktop.Application.Open. JSON array syntax is also
        # valid GVariant text and safely handles quotes in paths.
        opened = bool(call(dest, "/org/kde/elisa",
                           "org.freedesktop.Application.Open",
                           json.dumps(uris), "{}", check=False) == "()")
    if not opened:
        if len(uris) != 1:
            raise RuntimeError("this player cannot load a playlist through D-Bus")
        call(dest, OBJ, PLAYER + ".OpenUri", uris[0])


def start_verified(dest, uri):
    for _ in range(40):
        if metadata_has_uri(dest, uri):
            break
        time.sleep(0.25)
    else:
        raise RuntimeError("player did not load the requested URI; playback left stopped")

    call(dest, OBJ, PLAYER + ".Play")
    for attempt in range(20):
        if prop(dest, "PlaybackStatus") == "Playing" and metadata_has_uri(dest, uri):
            return
        # Elisa can expose a newly opened track as Stopped while ignoring the
        # first Play. Toggling once initializes its playback state; Play then
        # reliably starts the already verified track.
        if attempt == 3 and "elisa" in dest.lower():
            call(dest, OBJ, PLAYER + ".PlayPause", check=False)
            call(dest, OBJ, PLAYER + ".Play", check=False)
        time.sleep(0.25)
    raise RuntimeError("requested URI loaded but did not start playing")


def open_media(dest, value):
    uri = uri_for(value)
    open_uris(dest, [uri])
    start_verified(dest, uri)

    print(f"player={dest}\nstatus=Playing\nuri={uri}")


AUDIO_EXTS = {".flac", ".mp3", ".ogg", ".opus", ".m4a", ".wav"}
MOODS = {
    "chill": ({"ambient", "dream", "dreamy", "soft", "slow", "quiet", "calm", "mellow", "warm", "night", "moon", "ocean", "sea", "wave", "waves", "air", "shine", "memory", "sleep", "blue", "acoustic", "instrumental", "home"},
              {"metal", "death", "war", "fire", "burn", "rage", "angry", "hate", "kill", "blood", "violent", "blast", "bore", "rancorous"}),
    "focus": ({"ambient", "instrumental", "minimal", "piano", "acoustic", "memory", "space", "air"},
              {"party", "dance", "rage", "chaos", "scream", "live"}),
    "energetic": ({"dance", "fire", "fast", "power", "run", "alive", "electric", "party", "drive", "exalter"},
                  {"sleep", "slow", "quiet", "ambient", "lament"}),
    "melancholy": ({"blue", "rain", "memory", "alone", "lost", "goodbye", "dead", "dream", "night", "winter", "ghost", "undertow"},
                   {"party", "dance", "happy", "sunshine"}),
}


def words(text):
    return set(re.findall(r"[a-z0-9]+", text.lower()))


def library_tracks(root):
    root = pathlib.Path(root).expanduser().resolve()
    if not root.is_dir():
        raise RuntimeError(f"music library not found: {root}")
    tracks = []
    for path in root.rglob("*"):
        if path.is_file() and path.suffix.lower() in AUDIO_EXTS:
            parent = path.parent.name
            artist, _, album = parent.partition(" - ")
            name = path.stem
            title = re.sub(r"^.*? - \d+\s*", "", name)
            tracks.append({"path": path, "artist": artist or parent,
                           "album": album, "title": title,
                           "text": " ".join((artist, album, title)).lower()})
    if not tracks:
        raise RuntimeError(f"no audio files found under {root}")
    return tracks


def rank_playlist(description, tracks, count):
    query = words(description)
    positive, negative = set(), set()
    for mood, (good, bad) in MOODS.items():
        if mood in query or (mood == "chill" and query & {"relax", "relaxed", "mellow", "calm"}):
            positive |= good
            negative |= bad
    positive |= query - {"make", "playlist", "music", "songs", "tracks", "some", "me", "a", "an", "the"}
    seed = int(hashlib.sha256(description.lower().encode()).hexdigest()[:16], 16)
    rng = random.Random(seed)
    ranked = []
    for track in tracks:
        tokens = words(track["text"])
        score = 4 * len(tokens & positive) - 5 * len(tokens & negative)
        # Album/artist matches influence the whole selection; a tiny seeded
        # jitter makes equally ranked results varied but reproducible.
        score += 2 * len(words(track["album"]) & positive)
        score += len(words(track["artist"]) & positive)
        ranked.append((score + rng.random() * 0.2, track))
    ranked.sort(key=lambda item: item[0], reverse=True)

    selected, artists, albums = [], {}, {}
    for _, track in ranked:
        artist = track["artist"].lower()
        album = (artist, track["album"].lower())
        if artists.get(artist, 0) >= 3 or albums.get(album, 0) >= 2:
            continue
        selected.append(track)
        artists[artist] = artists.get(artist, 0) + 1
        albums[album] = albums.get(album, 0) + 1
        if len(selected) == count:
            break
    return selected


def build_playlist(dest, description, root, count, output=None):
    if count < 1 or count > 100:
        raise RuntimeError("playlist count must be between 1 and 100")
    selected = rank_playlist(description, library_tracks(root), count)
    if not selected:
        raise RuntimeError("no tracks matched the playlist description")
    if output:
        playlist = pathlib.Path(output).expanduser().resolve()
    else:
        slug = re.sub(r"[^a-z0-9]+", "-", description.lower()).strip("-")[:50] or "playlist"
        playlist = pathlib.Path.home() / ".local/share/ollie/playlists" / f"{slug}.m3u8"
    playlist.parent.mkdir(parents=True, exist_ok=True)
    playlist.write_text("#EXTM3U\n" + "\n".join(str(t["path"]) for t in selected) + "\n",
                        encoding="utf-8")
    uris = [t["path"].as_uri() for t in selected]
    open_uris(dest, uris, clear=True)
    start_verified(dest, uris[0])
    print(f"player={dest}\nstatus=Playing\nplaylist={playlist}\ntracks={len(selected)}")
    for index, track in enumerate(selected, 1):
        print(f"{index:02d}. {track['artist']} — {track['title']}")


def main():
    import os as _os
    sys.path.insert(0, _os.path.dirname(_os.path.abspath(__file__)))
    from _lib.args import parse_args as _parse_args
    _a = _parse_args()

    class _Args:
        command = _a.require("action")
        target = _a.get("target")
        player = _a.get("player")
        library = _a.get("library", str(pathlib.Path.home() / "music"))
        count = _a.get_int("count", 15)
        output = _a.get("output")
    args = _Args()

    if args.command == "list":
        for name in players():
            print(f"{name}\t{prop(name, 'PlaybackStatus')}")
        return

    dest = choose(args.player, activate=args.command in {"open", "playlist"})
    if args.command == "open":
        if not args.target:
            ap.error("open requires PATH_OR_URI")
        open_media(dest, args.target)
    elif args.command == "playlist":
        if not args.target:
            ap.error("playlist requires a description")
        build_playlist(dest, args.target, args.library, args.count, args.output)
    elif args.command == "status":
        print(f"player={dest}\nstatus={prop(dest, 'PlaybackStatus')}\nmetadata={metadata(dest)}")
    else:
        call(dest, OBJ, PLAYER + "." + args.command.capitalize())
        print(f"player={dest}\nstatus={prop(dest, 'PlaybackStatus')}")


if __name__ == "__main__":
    try:
        main()
    except RuntimeError as e:
        print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)
