import argparse
import os
import pathlib
import traceback
from typing import Any, Callable, Generator

import yaml
from beets import config, ui
from beets.dbcore.query import MatchQuery
from beets.library import Item, Library
from beets.plugins import BeetsPlugin
from beets.util import normpath
from plexapi import exceptions
from plexapi.server import PlexServer
from py_markdown_table.markdown_table import markdown_table
from slugify import slugify


class GdPlaylists(BeetsPlugin):
    def __init__(self):
        super().__init__()

        self.config_dir = config.config_dir()

        default_playlist_dir = os.path.join(config["directory"].get(), ".playlists")

        config["plex"].add(
            {
                "host": "localhost",
                "port": "",
                "token": "",
                "library_name": "Music",
                "secure": False,
                "ignore_cert_errors": False,
            }
        )

        config["gdplex"].add(
            {
                "playlist_dir": default_playlist_dir,
                "remote_dir": default_playlist_dir,
            }
        )

        config["plex"]["token"].redact = True
        protocol = "https" if config["plex"]["secure"].get() else "http"

        baseurl = f"{protocol}://" + config["plex"]["host"].get()

        self.plex_url = baseurl
        self.plex: PlexServer | None = None
        self.music = None

    def setup(self):
        if self.plex is not None:
            return

        try:
            self._log.debug("Attempting to connect to {}", self.plex_url)
            self.plex = PlexServer(self.plex_url, config["plex"]["token"].get())
            self._log.debug("Connection established")
        except exceptions.Unauthorized:
            raise ui.UserError("Plex authorization failed")

        try:
            self.music = self.plex.library.section(config["plex"]["library_name"].get())
        except exceptions.NotFound:
            raise ui.UserError(f"{config['plex']['library_name']} library not found")

    def config_playlist_dir(self) -> str:
        key = "playlist_dir"
        return normpath(self.config[key].get(str)).decode("utf-8")

    def config_remote_dir(self) -> str:
        key = "remote_dir"
        return normpath(self.config[key].get(str)).decode("utf-8")

    def _check_playlist(
        self,
        lib: Library,
        title: str,
        tracks: list[dict[str, Any]],
    ) -> tuple[str, int, dict[str, list[str]]]:
        """Looks through the library to find out how many tracks it contains from each playlist.

        Returns a tuple:
            * playlist title
            * percentage of tracks found
            * dict of releases that have missing tracks, and their missing tracks
        """
        found_count = 0
        missing_tracks = []
        with lib.transaction():
            for t in tracks:
                query = MatchQuery("mb_trackid", t["mbid"], fast=True)
                found = lib.items(query)
                if found:
                    found_count = found_count + 1
                else:
                    missing_tracks.append(t)

        found_pct = int(found_count / len(tracks) * 100)

        releases = dict()
        if len(missing_tracks):
            for t in missing_tracks:
                disc, track_number = t["release_position"].split("-")
                release_title = t["release_title"]

                missing_discs = releases.get(release_title, {})
                if disc in missing_discs:
                    missing_discs[disc].append(int(track_number))
                else:
                    missing_discs[disc] = [int(track_number)]

                releases[release_title] = missing_discs

            for title, discs in releases.items():
                disc_infos = []
                for k, v in discs.items():
                    disc_infos.append(f"Disc {k}: {compress_ranges(sorted(v))}")
                releases[title] = disc_infos
        else:
            releases = {t["release_title"]: [] for t in tracks}

        return (title, found_pct, releases)

    def _create_playlist(self, lib: Library, title, tracks, playlist_dir, remote_dir):
        with lib.transaction():
            playlist_tracks = []
            for t in tracks:
                query = MatchQuery("mb_trackid", t["mbid"], fast=True)
                found = lib.items(query)
                if found:
                    playlist_tracks.append(found[0])

        # Create m3u playlist
        if len(playlist_tracks) == 0:
            self._log.debug("No tracks found for {}", title)
            return

        item_path: Callable[[Item], str] = lambda item: item.path.decode("utf-8")
        paths = [item_path(item) for item in playlist_tracks]

        filename = slugify(title) + ".m3u"
        full_path = os.path.join(playlist_dir, filename)

        self._log.debug("Opening playlist file for writing: {}", full_path)
        with open(full_path, "w") as file:
            write_str = "\n".join(paths)
            file.write(write_str)

        self._log.debug(
            "Creating plex playlist from {}", os.path.join(remote_dir, filename)
        )
        try:
            assert self.plex is not None
            self.plex.createPlaylist(
                title,
                section=self.music,
                m3ufilepath=os.path.join(remote_dir, filename),
            )
        except Exception as e:
            traceback.print_exc()
            self._log.error("Error creating playlist: {}", e)

    def playlists(self) -> Generator[dict[str, Any], None, None]:
        base = pathlib.Path(
            os.path.join(pathlib.Path(__file__).parent.absolute(), "playlists")
        )

        paths = [str(i) for i in base.iterdir() if not i.is_dir()]
        paths = sorted(paths)

        for item in paths:
            with open(item, "r") as _f:
                data = yaml.safe_load(_f)
                yield data

    def sync(self, lib, playlist_dir, remote_dir):
        self.setup()
        if playlist_dir is None:
            playlist_dir = self.config_playlist_dir()

        if remote_dir is None:
            remote_dir = self.config_remote_dir()

        for data in self.playlists():
            self._create_playlist(
                lib, data["title"], data["tracks"], playlist_dir, remote_dir
            )

    def list(self, lib, *, missing_only: bool = False):
        """Lists the releases found in the current library"""
        pl_data = []
        for data in self.playlists():
            print(f"Checking {data['title']}")
            pl_data.append(
                self._check_playlist(
                    lib,
                    data["title"],
                    data["tracks"],
                )
            )

        md_data = []
        for p in pl_data:
            if missing_only and p[1] < 100 or not missing_only:
                md_data.append(
                    {
                        "title": p[0],
                        "completeness": p[1],
                        "missing": ",".join(
                            [f"{k} ({', '.join(v)})" for k, v in p[2].items()]
                        ),
                    }
                )

        markdown = markdown_table(md_data).get_markdown()
        print(markdown)

    def commands(self) -> tuple[()]:
        sync_cmd = SyncCommand(self)
        list_cmd = ListCommand(self)

        return tuple([sync_cmd, list_cmd])


class ListCommand(ui.Subcommand):
    name = "gdlist"
    aliases = ()
    help = "List the releases found in the local database"

    def __init__(self, plugin: GdPlaylists):
        self.plugin = plugin

        parser = argparse.ArgumentParser()
        parser.add_argument("-m", "--missing", action="store_true")

        super(ListCommand, self).__init__(
            self.name, parser, self.help, aliases=self.aliases
        )

    def func(self, lib, opts, _):
        v = vars(opts)
        self.plugin.list(lib, missing_only=v.get("missing"))

    def parse_args(self, args):
        return self.parser.parse_args(args), []


class SyncCommand(ui.Subcommand):
    name = "gdplex"
    aliases = ()
    help = "Sync Grateful Dead live releases as plex playlists"

    def __init__(self, plugin: GdPlaylists):
        self.plugin = plugin

        parser = argparse.ArgumentParser()
        parser.set_defaults(func=self.sync)

        subparsers = parser.add_subparsers(
            prog=parser.prog + " gdplex", dest="command", required=False
        )

        sync_parser = subparsers.add_parser("sync")
        sync_parser.set_defaults(func=self.sync)
        sync_parser.add_argument(
            "--playlist-dir",
            dest="playlist_dir",
            metavar="PATH",
            help="local directory to write files to",
        )
        sync_parser.add_argument(
            "--remote-playlist-dir",
            dest="remote_dir",
            metavar="PATH",
            help="where plex will see the playlist_dir",
        )

        super(SyncCommand, self).__init__(
            self.name, parser, self.help, aliases=self.aliases
        )

    def sync(self, lib, opts):
        o = vars(opts)
        self.plugin.sync(lib, o.get("playlist_dir"), o.get("remote_dir"))

    def func(self, lib, opts, _):
        opts.func(lib, opts)

    def parse_args(self, args):
        return self.parser.parse_args(args), []


def compress_ranges(nums):
    if not nums:
        return ""

    result = []
    start = nums[0]
    end = nums[0]

    for i in range(1, len(nums)):
        if nums[i] == end + 1:
            end = nums[i]
        else:
            if start == end:
                result.append(str(start))
            else:
                result.append(f"{start}-{end}")
            start = end = nums[i]

    if start == end:
        result.append(str(start))
    else:
        result.append(f"{start}-{end}")

    return ", ".join(result)
