"""Play whatever Discord asked for, on the Kodi box, with no inbound ports.

── WHY IT POLLS ─────────────────────────────────────────────────────────────
The bot runs on playgrn in a datacentre; Kodi runs at home behind a router.
Nothing can reach in, so this reaches out: it fetches a small JSON file over
plain HTTP every few seconds and acts on it. No port forwarding, no VPN, no
change to the router, and it works from any network the machine happens to be
on.

── WHY IT IS STATELESS ON THE SERVER ────────────────────────────────────────
The server only ever writes the slot; it never tracks who consumed it. This
remembers the last requestId it played in a file next to itself, so a repeated
poll is a no-op and a bot restart cannot make it replay last night's film.

── THE PART THAT IS NOT OBVIOUS ─────────────────────────────────────────────
GRN Shows refuses playback URLs that did not come from its own listings:
player_check() only accepts mode == "playback.<playback_key>", where the key is
a per-session nonce kept in a window property, not a stored setting. So the URL
cannot be hardcoded - the key is read back out of the running Kodi over
JSON-RPC each time, and passed both as the mode suffix and as a parameter,
which is what external_playback_check() looks for.

    py -3 lounge_agent.py --once     # act on the current slot and exit
    py -3 lounge_agent.py            # poll forever
"""
from __future__ import annotations

import argparse
import json
import os
import socket
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from urllib.parse import urlencode

DEFAULT_SLOT = "http://167.114.157.225:8087/movielounge/play.json"
# Where the resolved link is reported. HTTPS, because the token travels with it.
REPORT_TO = "https://lounge.playgrn.com/movielounge/api/source"
TOKEN = ""  # set by --token or the GRN_LOUNGE_TOKEN environment variable
STATE_FILE = Path(__file__).resolve().parent / ".lounge_agent_state.json"
ADDON = "plugin.video.grnshows"


class Kodi:
    """Kodi's raw TCP JSON-RPC.

    The HTTP interface is unreachable on the Microsoft Store build because the
    app runs in an AppContainer and Windows blocks loopback to it, so this uses
    port 9090, which is not affected.
    """

    def __init__(self, host="127.0.0.1", port=9090, timeout=30):
        self.host, self.port, self.timeout = host, port, timeout

    def call(self, method, params=None):
        payload = json.dumps({"jsonrpc": "2.0", "method": method,
                              "params": params or {}, "id": 1})
        connection = socket.create_connection((self.host, self.port), timeout=self.timeout)
        try:
            connection.sendall(payload.encode("utf-8"))
            buffer = ""
            decoder = json.JSONDecoder()
            while True:
                chunk = connection.recv(65536)
                if not chunk:
                    raise RuntimeError("Kodi closed the connection")
                buffer += chunk.decode("utf-8", errors="replace")
                index = 0
                while index < len(buffer):
                    try:
                        message, end = decoder.raw_decode(buffer, index)
                    except ValueError:
                        break
                    index = end
                    while index < len(buffer) and buffer[index] in " \r\n\t":
                        index += 1
                    # Kodi interleaves notifications; take the reply with our id
                    if isinstance(message, dict) and message.get("id") == 1:
                        return message
                buffer = buffer[index:]
        finally:
            connection.close()

    def alive(self):
        try:
            return self.call("JSONRPC.Ping").get("result") == "pong"
        except Exception:
            return False

    def info(self, label):
        result = self.call("XBMC.GetInfoLabels", {"labels": [label]})
        return (result.get("result") or {}).get(label, "")

    def playback_key(self):
        """The add-on's per-session nonce, without which playback is refused."""
        return self.info("Window(10000).Property(grnshows.playback_key)").strip()

    def playing(self):
        result = self.call("Player.GetActivePlayers")
        return bool(result.get("result"))

    def open(self, url):
        return self.call("Player.Open", {"item": {"file": url}})

    def resolved_file(self):
        """The real URL Kodi is playing, once the scraper has done its work.

        This is the whole point of the bridge. Kodi holds the scraper and the
        debrid session, so only Kodi can turn a TMDb id into a playable link.
        Player.GetItem hands that link back, and the server streams it from
        there rather than this machine pushing video anywhere.
        """
        for player in (self.call("Player.GetActivePlayers").get("result") or []):
            item = (self.call("Player.GetItem", {
                "playerid": player["playerid"], "properties": ["file"],
            }).get("result") or {}).get("item") or {}
            path = item.get("file") or ""
            # Skip the add-on's own plugin:// URL; that is the request, not the
            # answer. Only an http(s) link is something the box can fetch.
            if path.startswith(("http://", "https://")):
                return path
        return None

    def stop_all(self):
        """Clear any current playback before starting the next film.

        Player.Open usually replaces what is playing, but switching mid-film
        while the channel is watching is exactly when a half-replaced player
        looks broken, so this makes it explicit.
        """
        for player in (self.call("Player.GetActivePlayers").get("result") or []):
            try:
                self.call("Player.Stop", {"playerid": player["playerid"]})
            except Exception:
                pass

    def fullscreen(self):
        """The Go Live share is of the Kodi window, so a windowed Kodi means
        everyone watches a desktop with a small video in it."""
        try:
            self.call("GUI.SetFullscreen", {"fullscreen": True})
        except Exception:
            pass

    def notify(self, title, message):
        try:
            self.call("GUI.ShowNotification", {"title": title, "message": message})
        except Exception:
            pass


def read_state():
    try:
        return json.loads(STATE_FILE.read_text(encoding="utf-8"))
    except Exception:
        return {}


def write_state(state):
    STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")


def fetch_slot(url, timeout=15):
    request = urllib.request.Request(url, headers={
        "User-Agent": "GRN-LoungeAgent/1.0", "Cache-Control": "no-cache"})
    with urllib.request.urlopen(request, timeout=timeout) as response:
        return json.loads(response.read().decode("utf-8") or "{}")


def playback_url(key, slot):
    """The URL the add-on's own listings would have produced.

    autoplay=true is what makes it skip the source list and start the first
    playable result, which is the whole point of driving it from Discord.
    """
    media_type = "episode" if slot.get("kind") == "tv" else "movie"
    params = {
        "mode": "playback.%s" % key,
        "media_type": media_type,
        "tmdb_id": str(slot["tmdbId"]),
        "autoplay": "true",
        key: key,  # external_playback_check() requires the key as a parameter too
    }
    return "plugin://%s/?%s" % (ADDON, urlencode(params))


def act(kodi, slot, state, force=False):
    request_id = slot.get("requestId")
    if not request_id or not slot.get("tmdbId"):
        return "empty slot"
    if not force and state.get("lastRequestId") == request_id:
        return None  # already handled; silence is correct here
    if not kodi.alive():
        return "Kodi is not reachable on 9090"

    key = kodi.playback_key()
    if not key:
        # The property only exists once the add-on has run in this session.
        return ("no playback key yet - open GRN Shows in Kodi once so it "
                "initialises, then this will work")

    title = slot.get("title", "?")
    url = playback_url(key, slot)
    kodi.notify("GRN Movie Lounge", "Starting %s" % title)
    kodi.stop_all()
    time.sleep(1)
    kodi.open(url)
    # after playback starts, not before: Kodi drops back to the GUI on stop
    time.sleep(2)
    kodi.fullscreen()

    # Kodi's locale.audiolanguage only works when the file tags its tracks, and
    # plenty of releases do not, so the track is chosen explicitly once the
    # streams are known. Waiting matters: the list is empty until the file is
    # actually open.
    language_note = ""
    for _ in range(10):
        time.sleep(2)
        try:
            from lounge_lang import apply as apply_language
            outcome = apply_language(kodi, log=lambda *a: None)
        except Exception as exc:
            language_note = " (language fix failed: %s)" % exc
            break
        if outcome != "no audio streams reported" and outcome != "nothing is playing":
            language_note = " [%s]" % outcome
            break
    handoff = ""
    if REPORT_TO and TOKEN:
        url_found = None
        for _ in range(20):
            url_found = kodi.resolved_file()
            if url_found:
                break
            time.sleep(2)
        if url_found:
            try:
                request = urllib.request.Request(
                    REPORT_TO,
                    data=json.dumps({"url": url_found, "title": title}).encode(),
                    headers={"Content-Type": "application/json",
                             "Authorization": "Bearer " + TOKEN},
                    method="POST")
                with urllib.request.urlopen(request, timeout=30) as response:
                    answer = json.load(response)
                handoff = " -> server: %s" % answer.get("message", "?")
                # The server pulls the same link now, so leaving Kodi playing
                # would download the film twice over a home connection.
                kodi.stop_all()
            except Exception as exc:
                handoff = " (handoff failed: %s)" % exc
        else:
            handoff = " (Kodi never resolved a playable link)"

    state["lastRequestId"] = request_id
    state["lastTitle"] = title
    state["lastPlayedAt"] = time.strftime("%Y-%m-%dT%H:%M:%S")
    write_state(state)
    return "playing %s (requested by %s)%s%s" % (
        title, slot.get("requestedBy", "?"), language_note, handoff)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--slot", default=DEFAULT_SLOT, help="URL of play.json")
    parser.add_argument("--kodi-host", default="127.0.0.1")
    parser.add_argument("--kodi-port", type=int, default=9090)
    parser.add_argument("--interval", type=int, default=10, help="seconds between polls")
    parser.add_argument("--once", action="store_true", help="check once and exit")
    parser.add_argument("--token", default="",
                        help="shared token for reporting the resolved link back")
    parser.add_argument("--check", action="store_true",
                        help="verify the token is accepted by the server, then exit")
    parser.add_argument("--force", action="store_true",
                        help="play the current slot even if it was already played")
    arguments = parser.parse_args()

    global TOKEN
    TOKEN = arguments.token or os.environ.get("GRN_LOUNGE_TOKEN", "")
    if not TOKEN:
        print("no token given, so resolved links will not be reported to the "
              "server; pass --token or set GRN_LOUNGE_TOKEN")

    if arguments.check:
        # Post nothing at all. A good token gets "that is not a usable URL",
        # a bad one gets 401, and the difference is exactly what needs testing.
        # It proves the credential without starting a film to find out.
        if not TOKEN:
            print("FAIL: no token available (neither --token nor GRN_LOUNGE_TOKEN)")
            return 1
        request = urllib.request.Request(
            REPORT_TO, data=b"{}",
            headers={"Content-Type": "application/json",
                     "Authorization": "Bearer " + TOKEN},
            method="POST")
        try:
            urllib.request.urlopen(request, timeout=30)
            print("OK: token accepted")
            return 0
        except urllib.error.HTTPError as error:
            if error.code == 401:
                print("FAIL: the server rejected the token")
                return 1
            if error.code == 400:
                print("OK: token accepted (server has no URL to act on, as expected)")
                return 0
            print("FAIL: server returned %s" % error.code)
            return 1
        except Exception as error:
            print("FAIL: could not reach %s: %s" % (REPORT_TO, error))
            return 1

    kodi = Kodi(arguments.kodi_host, arguments.kodi_port)
    print("polling %s every %ds" % (arguments.slot, arguments.interval))
    while True:
        try:
            slot = fetch_slot(arguments.slot)
            outcome = act(kodi, slot, read_state(), force=arguments.force)
            if outcome:
                print("%s  %s" % (time.strftime("%H:%M:%S"), outcome))
        except urllib.error.URLError as error:
            print("%s  slot unreachable: %s" % (time.strftime("%H:%M:%S"), error.reason))
        except Exception as error:  # a bad poll must never kill the agent
            print("%s  %s: %s" % (time.strftime("%H:%M:%S"), type(error).__name__, error))
        if arguments.once:
            return
        time.sleep(max(3, arguments.interval))


if __name__ == "__main__":
    sys.exit(main())
