#!/usr/bin/env python
"""spotifyctl - Control Spotify via the Web API.

Usage:
  spotifyctl auth --client-id <ID> --client-secret <SECRET>
  spotifyctl status
  spotifyctl devices
  spotifyctl play [--uri <uri>] [--device <id>]
  spotifyctl pause
  spotifyctl next
  spotifyctl previous
  spotifyctl seek <ms>
  spotifyctl volume <0-100|+N|-N>
  spotifyctl shuffle on|off
  spotifyctl repeat track|context|off
  spotifyctl transfer <device_id>
  spotifyctl search "query" [--type track|album|artist|playlist] [--limit 10]
  spotifyctl now
  spotifyctl queue <track_uri|track_id>
  spotifyctl me

Set SPOTIFYCTL_CLIENT_ID / SPOTIFYCTL_CLIENT_SECRET env vars to skip --client-id/secret.
"""
import argparse
import base64
import json
import os
import sys
import time
import urllib.parse
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from threading import Thread

try:
    import requests
except ImportError:
    sys.exit("Missing dependency: requests. Install with `pip install requests`.")

API = "https://api.spotify.com/v1"
AUTH = "https://accounts.spotify.com"
REDIRECT = "http://127.0.0.1:8765/callback"
SCOPE = "user-read-playback-state user-modify-playback-state user-read-currently-playing playlist-read-private user-library-read user-read-email"
TOKEN_PATH = Path.home() / ".config" / "spotifyctl" / "token.json"
CREDS_PATH = Path.home() / ".config" / "spotifyctl" / "creds.json"


def out(data, human=None, as_json=True):
    if as_json and human is None:
        print(json.dumps(data, indent=2, ensure_ascii=False))
    elif human is not None:
        print(human)
    else:
        print(json.dumps(data, indent=2, ensure_ascii=False))


def err(msg):
    print(f"spotifyctl: {msg}", file=sys.stderr)
    sys.exit(1)


def load_creds():
    cid = os.environ.get("SPOTIFYCTL_CLIENT_ID")
    csec = os.environ.get("SPOTIFYCTL_CLIENT_SECRET")
    if cid and csec:
        return cid, csec
    if CREDS_PATH.exists():
        d = json.loads(CREDS_PATH.read_text())
        return d.get("client_id"), d.get("client_secret")
    return None, None


def save_creds(cid, csec):
    CREDS_PATH.parent.mkdir(parents=True, exist_ok=True)
    CREDS_PATH.write_text(json.dumps({"client_id": cid, "client_secret": csec}))


def load_token():
    if not TOKEN_PATH.exists():
        return None
    return json.loads(TOKEN_PATH.read_text())


def save_token(tok):
    TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
    TOKEN_PATH.write_text(json.dumps(tok))


def get_token():
    tok = load_token()
    if not tok:
        err("not authorized. run: spotifyctl auth --client-id <ID> --client-secret <SECRET>")
    if tok.get("expires_at", 0) - 30 > int(time.time()):
        return tok["access_token"]
    cid, csec = load_creds()
    if not cid:
        err("client credentials missing. run auth again.")
    r = requests.post(
        f"{AUTH}/api/token",
        data={"grant_type": "refresh_token", "refresh_token": tok["refresh_token"]},
        auth=(cid, csec),
        timeout=15,
    )
    if r.status_code != 200:
        err(f"token refresh failed ({r.status_code}): {r.text}")
    data = r.json()
    data["expires_at"] = int(time.time()) + data.get("expires_in", 3600)
    data.setdefault("refresh_token", tok["refresh_token"])
    save_token(data)
    return data["access_token"]


def auth_flow(client_id, client_secret):
    save_creds(client_id, client_secret)
    verifier = base64.urlsafe_b64encode(os.urandom(64)).rstrip(b"=").decode()
    challenge = base64.urlsafe_b64encode(
        __import__("hashlib").sha256(verifier.encode()).digest()
    ).rstrip(b"=").decode()
    state = base64.urlsafe_b64encode(os.urandom(16)).rstrip(b"=").decode()
    params = {
        "client_id": client_id,
        "response_type": "code",
        "redirect_uri": REDIRECT,
        "code_challenge_method": "S256",
        "code_challenge": challenge,
        "scope": SCOPE,
        "state": state,
    }
    url = f"{AUTH}/authorize?{urllib.parse.urlencode(params)}"
    captured = {"code": None, "err": None}

    class H(BaseHTTPRequestHandler):
        def do_GET(self):
            q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
            if q.get("state", [""])[0] != state:
                captured["err"] = "state mismatch"
            elif "error" in q:
                captured["err"] = q["error"][0]
            else:
                captured["code"] = q["code"][0]
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"<h1>You can close this tab.</h1>")

        def log_message(self, *a, **k):
            pass

    srv = HTTPServer(("127.0.0.1", 8765), H)
    def serve_once():
        srv.handle_request()
    Thread(target=serve_once, daemon=True).start()
    webbrowser.open(url)
    print("opening browser for Spotify login... (waiting up to 120s)")
    srv.handle_request()
    srv.server_close()
    if captured["err"]:
        err(f"auth failed: {captured['err']}")
    if not captured["code"]:
        err("no auth code received")
    r = requests.post(
        f"{AUTH}/api/token",
        data={
            "grant_type": "authorization_code",
            "code": captured["code"],
            "redirect_uri": REDIRECT,
            "client_id": client_id,
            "code_verifier": verifier,
        },
        timeout=15,
    )
    if r.status_code != 200:
        err(f"token exchange failed ({r.status_code}): {r.text}")
    data = r.json()
    data["expires_at"] = int(time.time()) + data.get("expires_in", 3600)
    save_token(data)
    print("authorized. token saved to", TOKEN_PATH)


def api(method, path, token, **kw):
    headers = {"Authorization": f"Bearer {token}"}
    if "json" in kw:
        headers["Content-Type"] = "application/json"
    r = requests.request(method, f"{API}{path}", headers=headers, timeout=15, **kw)
    if r.status_code == 401:
        err("token rejected. re-run: spotifyctl auth")
    if r.status_code == 429:
        retry = int(r.headers.get("Retry-After", 5))
        print(f"rate limited, waiting {retry}s...", file=sys.stderr)
        time.sleep(retry)
        r = requests.request(method, f"{API}{path}", headers=headers, timeout=15, **kw)
    if r.status_code >= 400:
        err(f"{method} {path} -> {r.status_code}: {r.text}")
    if not r.text:
        return {}
    return r.json()


def cmd_auth(args):
    if not args.client_id or not args.client_secret:
        cid, csec = load_creds()
        if not cid:
            err("--client-id and --client-secret required (or set SPOTIFYCTL_CLIENT_ID/SECRET)")
        args.client_id, args.client_secret = cid, csec
    auth_flow(args.client_id, args.client_secret)


def cmd_status(args):
    tok = load_token()
    if not tok:
        out({"authorized": False}, human="not authorized")
        return
    expires_in = tok["expires_at"] - int(time.time())
    token = get_token()
    me = api("GET", "/me", token)
    out({
        "authorized": True,
        "user": me.get("display_name") or me.get("id"),
        "email": me.get("email"),
        "plan": (me.get("product") or "unknown"),
        "country": me.get("country"),
        "token_expires_in_s": expires_in,
    }, human=f"{me.get('display_name')} ({me.get('product')}) - token ok for {expires_in}s")


def cmd_devices(args):
    token = get_token()
    data = api("GET", "/me/player/devices", token)
    out(data.get("devices", []),
        human="\n".join(
            f"{d['id'][:8]}.. {d['name']:<20} {d['type']:<10} {'ACTIVE' if d.get('is_active') else 'idle'}"
            for d in data.get("devices", [])
        ) or "no devices online")


def cmd_play(args):
    token = get_token()
    body = {}
    if args.uri:
        body["uris"] = [args.uri] if args.uri.startswith("spotify:track:") else None
        if body["uris"] is None and args.uri.startswith("spotify:album:"):
            body["context_uri"] = args.uri
        elif body["uris"] is None and args.uri.startswith("spotify:playlist:"):
            body["context_uri"] = args.uri
    if args.device:
        body["device_id"] = args.device
    api("PUT", "/me/player/play", token, json=body)
    out({"ok": True}, human="playing")


def cmd_pause(args):
    token = get_token()
    api("PUT", "/me/player/pause", token)
    out({"ok": True}, human="paused")


def cmd_next(args):
    token = get_token()
    api("POST", "/me/player/next", token)
    out({"ok": True}, human="skipped to next")


def cmd_previous(args):
    token = get_token()
    api("POST", "/me/player/previous", token)
    out({"ok": True}, human="previous track")


def cmd_seek(args):
    token = get_token()
    api("PUT", f"/me/player/seek?position_ms={args.ms}", token)
    out({"ok": True, "position_ms": args.ms}, human=f"seeked to {args.ms}ms")


def cmd_volume(args):
    token = get_token()
    v = args.value
    if isinstance(v, str):
        if v.startswith("+") or v.startswith("-"):
            cur = api("GET", "/me/player", token).get("device", {}).get("volume_percent", 50)
            v = max(0, min(100, cur + int(v)))
        else:
            v = int(v)
    v = max(0, min(100, v))
    api("PUT", f"/me/player/volume?volume_percent={v}", token)
    out({"ok": True, "volume_percent": v}, human=f"volume = {v}%")


def cmd_shuffle(args):
    token = get_token()
    api("PUT", f"/me/player/shuffle?state={'true' if args.state == 'on' else 'false'}", token)
    out({"ok": True, "shuffle": args.state}, human=f"shuffle {args.state}")


def cmd_repeat(args):
    token = get_token()
    api("PUT", f"/me/player/repeat?state={args.state}", token)
    out({"ok": True, "repeat": args.state}, human=f"repeat {args.state}")


def cmd_transfer(args):
    token = get_token()
    api("PUT", "/me/player", token, json={"device_ids": [args.device_id], "play": True})
    out({"ok": True}, human=f"transferred to {args.device_id}")


def cmd_search(args):
    token = get_token()
    q = {"q": args.query, "type": args.type, "limit": str(args.limit)}
    data = api("GET", f"/search?{urllib.parse.urlencode(q)}", token)
    out(data, human=f"{sum(len(data.get(k+'s', {}).get('items', [])) for k in [args.type])} {args.type} results")


def cmd_now(args):
    token = get_token()
    data = api("GET", "/me/player/currently-playing", token)
    if not data or not data.get("item"):
        out({"is_playing": False}, human="nothing playing right now")
        return
    item = data["item"]
    artists = ", ".join(a["name"] for a in item.get("artists", []))
    out({
        "is_playing": data.get("is_playing"),
        "track": item.get("name"),
        "artists": artists,
        "album": item.get("album", {}).get("name"),
        "uri": item.get("uri"),
        "device": data.get("device", {}).get("name"),
        "progress_ms": data.get("progress_ms"),
        "duration_ms": item.get("duration_ms"),
    }, human=f"{'▶' if data.get('is_playing') else '⏸'} {item['name']} - {artists} (on {data.get('device', {}).get('name', '?')})")


def cmd_queue(args):
    token = get_token()
    uri = args.uri if args.uri.startswith("spotify:") else f"spotify:track:{args.uri}"
    api("POST", f"/me/player/queue?uri={uri}", token)
    out({"ok": True, "queued": uri}, human=f"queued {uri}")


def cmd_me(args):
    token = get_token()
    out(api("GET", "/me", token))


def main():
    p = argparse.ArgumentParser(prog="spotifyctl", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--human", action="store_true", help="human-readable output")
    sub = p.add_subparsers(dest="cmd", required=True)

    s = sub.add_parser("auth")
    s.add_argument("--client-id")
    s.add_argument("--client-secret")

    sub.add_parser("status")
    sub.add_parser("devices")
    sub.add_parser("pause")
    sub.add_parser("next")
    sub.add_parser("previous")

    s = sub.add_parser("play")
    s.add_argument("--uri")
    s.add_argument("--device")

    s = sub.add_parser("seek")
    s.add_argument("ms", type=int)

    s = sub.add_parser("volume")
    s.add_argument("value")

    s = sub.add_parser("shuffle")
    s.add_argument("state", choices=["on", "off"])

    s = sub.add_parser("repeat")
    s.add_argument("state", choices=["track", "context", "off"])

    s = sub.add_parser("transfer")
    s.add_argument("device_id")

    s = sub.add_parser("search")
    s.add_argument("query")
    s.add_argument("--type", default="track", choices=["track", "album", "artist", "playlist"])
    s.add_argument("--limit", type=int, default=10)

    sub.add_parser("now")
    sub.add_parser("me")

    s = sub.add_parser("queue")
    s.add_argument("uri")

    args = p.parse_args()
    global human_mode
    human_mode = args.human
    {
        "auth": cmd_auth, "status": cmd_status, "devices": cmd_devices,
        "play": cmd_play, "pause": cmd_pause, "next": cmd_next, "previous": cmd_previous,
        "seek": cmd_seek, "volume": cmd_volume, "shuffle": cmd_shuffle, "repeat": cmd_repeat,
        "transfer": cmd_transfer, "search": cmd_search, "now": cmd_now, "queue": cmd_queue, "me": cmd_me,
    }[args.cmd](args)


if __name__ == "__main__":
    main()
