#!/bin/python
import curses
import os
import posixpath
import requests
from mpd import MPDClient
import ffmpeg
import pixcat
import time
import configparser
import ueberzug.lib.v0 as ueberzug
from PIL import Image, ImageDraw

# Get config
config = configparser.ConfigParser()
config.read(os.path.expanduser("~/.config/miniplayer/config"))

if "player" not in config.sections():
    config["player"] = {"font_width":      11,
                        "font_height":     24,
                        "album_art_only":  False,
                        "volume_step":     5,
                        "auto_close":      False,
                        "show_playlist":   True,
                        }

if "art" not in config.sections():
    config["art"] = {"music_directory":  "~/Music",
                     "image_method":     "pixcat",
                    }

if "mpd" not in config.sections():
    config["mpd"] = {"host": "localhost",
                     "port": "6600",
                     }

# Initialise keybindings
default_bindings = {">":     "next_track",
                    "<":     "last_track",
                    "+":     "volume_up",
                    "-":     "volume_down",
                    "p":     "play_pause",
                    "q":     "quit",
                    "h":     "help",
                    "i":     "toggle_info",
                    "down":  "select_down",
                    "up":    "select_up",
                    "enter": "select"
                    }

if "keybindings" not in config.sections():
    config["keybindings"] = default_bindings

# Load configured keybindings
keybindings = config["keybindings"]

# Unbound actions get initialised with their default keys
# except if the keys are being used for something else
for key, action in default_bindings.items():
    if (
            action not in keybindings.values()
            and key not in keybindings.keys()
    ):
        keybindings[key] = action

player_config = config["player"]
art_config = config["art"]
mpd_config = config["mpd"]


# FPS
FPS = 20

# Image ratio
# Change this to match the (width, height) of your font.
IMAGERATIO = (player_config.getint("font_width", 11),
              player_config.getint("font_height", 24)
              )

# MPD config
MPDHOST = mpd_config.get("host", "localhost")
MPDPORT = mpd_config.getint("port", 6600)
MPDPASS = mpd_config.get("pass", False)

# What to use to draw images
IMAGEMETHOD = art_config.get("image_method", "pixcat")

# Volume step
VOLUMESTEP = player_config.getint("volume_step", 5)

# Autoclose boolean
AUTOCLOSE = player_config.getboolean("auto_close", False)

# Playlist padding
PLAYLISTMARGIN = 4

# Config option to display the playlist
DISABLEPLAYLIST = not player_config.getboolean("show_playlist", True)


def albumArtSize(album_space, window_width):
    """
    Calculates the album art size given the window width and the height
    of the album art space
    """
    if window_width * IMAGERATIO[0] > album_space * IMAGERATIO[1]:
        image_width_px = album_space * IMAGERATIO[1]
    else:
        image_width_px = window_width * IMAGERATIO[0]

    image_width  = int(image_width_px  // IMAGERATIO[0])
    image_height = int(image_width_px  // IMAGERATIO[1])

    return image_width_px, image_width, image_height





class Player:
    def __init__(self):
        # Curses initialisation
        self.stdscr = curses.initscr()
        self.stdscr.nodelay(True)
        self.stdscr.keypad(True)

        # Curses config
        curses.noecho()
        curses.curs_set(0)

        curses.cbreak()

        curses.start_color()
        curses.use_default_colors()
        curses.init_pair(1, curses.COLOR_GREEN, -1)
        curses.init_pair(2, curses.COLOR_YELLOW, -1)

        # MPD init
        self.client = MPDClient()
        self.client.connect(MPDHOST, MPDPORT)
        if MPDPASS:
            self.client.password(MPDPASS)

        self.last_song = None

        # Album art only flag
        self.album_art_only = player_config.getboolean("album_art_only", False)

        # Screen size
        maxyx = self.stdscr.getmaxyx()
        self.screen_height, self.screen_width = maxyx

        # Album art window
        self.art_window_height, self.art_window_width = self.albumArtWinWidth(*maxyx)
        self.art_win = curses.newwin(
            self.art_window_height, self.art_window_width,
            0, 0
        )

        # Playlist window
        if self.playlistFits(*maxyx) and not self.album_art_only:
            self.draw_playlist = True
            self.playlist_window_width = maxyx[1] - self.art_window_width - PLAYLISTMARGIN
            self.playlist_window_height = maxyx[0]

            self.playlist_win = curses.newwin(
                self.playlist_window_height, self.playlist_window_width,
                0, self.art_window_width + PLAYLISTMARGIN
            )
        else:
            self.draw_playlist = False
            self.playlist_win = None

        self.text_start = int(self.art_window_height - 5)
        self.album_space = self.text_start - 2

        # Calculate the size of the image
        self.image_width_px, self.image_width, self.image_height = albumArtSize(self.album_space, self.art_window_width)
        self.image_y_pos = (self.album_space - self.image_height) // 2 + 1

        # Album art location
        self.album_art_loc = "/tmp/aartminip.png"

        # Toggle for help menu
        self.help = False
        self.cleared = False

        # Ueberzug placement
        self.art_placement = None

        # Update needed flag
        self.update_needed = False

        # Flag to check if any music has been played
        self.has_music_been_played = False

        # A counter to check how long since playlist has moved
        self.control_cycle = 0

        # Selected song in playlist
        self.selected_song = 0


    def playlistFits(self, height, width):
        """
        A function that checks if the playlist display should be drawn
        based on the provided height and width
        """
        return height / width < 1/3 and not DISABLEPLAYLIST


    def albumArtWinWidth(self, height, width):
        """
        A function that calculates the album art window height and
        width based on the window height and width
        """
        if self.playlistFits(height, width) and not self.album_art_only:
            return height, round(width * 2/5)
        else:
            return height, width


    def fitText(self):
        """
        A function that fits album name, artist name and song name
        to the screen with the given width.
        """
        state = 0
        song = self.title
        album = self.album
        artist = self.artist
        width = self.art_window_width

        if len(song) > width:
            song = song[:width - len(song)]
            song = song[:-4].strip() + "..."

        if len(album) == 0:
            sep = 0
        else:
            sep = 3

        if len(artist) + len(album) + sep > width:
            state = 1
            if len(artist) > width:
                artist = artist[:width - len(artist)]
                artist = artist[:-4].strip() + "..."
            if len(album) > width:
                album = album[:width - len(album)]
                album = album[:-4].strip() + "..."

        if len(album) == 0:
            state = 2

        return (state, album, artist, song)


    def updateWindowSize(self, force_update=False):
        """
        A function to check if the window size changed
        """
        window_height, window_width = self.stdscr.getmaxyx()

        if (window_height, window_width) != (self.screen_height, self.screen_width) or force_update:

            self.draw_playlist = self.playlistFits(window_height, window_width) and not self.album_art_only

            # Album art window
            self.art_window_height, self.art_window_width = self.albumArtWinWidth(window_height, window_width)

            # Playlist window
            if self.draw_playlist:
                self.playlist_window_width  = window_width - self.art_window_width - PLAYLISTMARGIN
                self.playlist_window_height = window_height

            # Close the playlist window if it exists
            elif self.playlist_win is not None:
                del self.playlist_win
                self.playlist_win = None

            # Check if we are drawing info
            if self.album_art_only:
                self.text_start = int(self.art_window_height)
                self.album_space = self.text_start - 1
            else:
                self.text_start = int(self.art_window_height - 5)
                self.album_space = self.text_start - 2

            # Calculate the size of the image
            self.image_width_px, self.image_width, self.image_height = albumArtSize(self.album_space, self.art_window_width)
            self.image_y_pos = (self.album_space - self.image_height) // 2 + 1

            # Check if playlist window exists and if we are drawing it
            if self.playlist_win is not None and self.draw_playlist:
                self.playlist_win.clear()
                self.playlist_win.refresh()

                self.playlist_win.resize(
                    self.playlist_window_height,
                    self.playlist_window_width
                )

                self.playlist_win.mvwin(0, self.art_window_width + PLAYLISTMARGIN)

            elif self.draw_playlist:
                self.playlist_win = curses.newwin(
                    self.playlist_window_height, self.playlist_window_width,
                    0, self.art_window_width + PLAYLISTMARGIN
                )

            self.last_song = None

            # Resize the window
            self.art_win.clear()
            self.art_win.resize(self.art_window_height, self.art_window_width)

            self.screen_height, self.screen_width = window_height, window_width

    def getAlbumArt(self, song_file):
        """
        A function that fetches the album art and saves
        it to self.album_art_loc
        """
        http_base_url = art_config.get("http_base_url")

        if http_base_url:
            self._getAlbumArtFromHttpServer(http_base_url, song_file)
        else:
            self._getAlbumArtFromFile(song_file)



    def _getAlbumArtFromHttpServer(self, base_url, song_file):
        """
        A function that fetches the album art from the configured
        HTTP server, and saves it to self.album_art_loc
        """

        album = os.path.dirname(song_file)
        album_art_url = posixpath.join(base_url, album, "cover.jpg")

        try:
            album_art_resp = requests.get(album_art_url)
        except requests.RequestException:
            # If any exception occurs, simply give up and show default art.
            self.drawDefaultAlbumArt()

        if album_art_resp.ok:
            with open(self.album_art_loc, "wb") as f:
                f.write(album_art_resp.content)
        else:
            self.drawDefaultAlbumArt()


    def _getAlbumArtFromFile(self, song_file):
        """
        A function that extracts the album art from song_file and
        saves it to self.album_art_loc
        """
        music_dir = os.path.expanduser(
                art_config.get("music_directory", "~/Music"))

        song_file_abs = os.path.join(music_dir, song_file)

        process = (
            ffmpeg
            .input(song_file_abs)
            .output(self.album_art_loc)
        )

        try:
            process.run(quiet=True, overwrite_output=True)
        except ffmpeg._run.Error:
            self.drawDefaultAlbumArt()


    def drawDefaultAlbumArt(self):
        foregroundCol = "#D8DEE9"
        backgroundCol = "#262A33"

        size = 512*4

        art = Image.new("RGB", (size, size), color=backgroundCol)
        d = ImageDraw.Draw(art)

        for i in range(4):
            offset = (i - 2) * 70

            external = size/3

            x0 = round(external) - offset
            y0 = round(external) + offset
            x1 = round(external*2) - offset
            y1 = round(external*2) + offset

            externalyx = [(x0, y0), (x1, y1)]

            d.rectangle(externalyx, outline=foregroundCol, width=40)

        art.resize((512, 512))
        art.save(self.album_art_loc, "PNG")


    def getSongInfo(self, song):
        """
        A function that returns a tuple of the given songs
        album, artist, title
        if they do not exist, the function will return
        "", "", filename respectively
        """
        try:
            album = song["album"]
        except KeyError:
            album = ""

        try:
            artist = song["artist"]
        except KeyError:
            artist = ""

        try:
            title = song["title"]
        except KeyError:
            # If no title, use base file name
            aux = song["file"]
            aux = os.path.basename(aux)
            title = aux

        return album, artist, title


    def checkSongUpdate(self):
        """
        Checks if there is a new song playing

        Returns:
            1 -- if song state is "stop"
            0 -- if there is no change
            2 -- if there is a new song
        """
        status = self.client.status()

        if status["state"] == "stop":
            return 1

        song = self.client.currentsong()
        self.elapsed = float(status["elapsed"])
        self.duration = float(status["duration"])
        self.progress = self.elapsed/self.duration

        if self.last_song != song:
            self.art_win.clear()

            # Move selected_song to the currently playing one
            if self.control_cycle == 0:
                self.selected_song = int(song["pos"])

            self.album, self.artist, self.title = self.getSongInfo(song)
            self.getAlbumArt(song["file"])
            self.last_song = song

            return 0

        else:
            return 2


    def toggleInfo(self):
        """
        A function that toggles the display of track info
        """
        self.album_art_only = not self.album_art_only
        self.updateWindowSize(force_update=True)
        self.art_win.clear()
        self.art_win.refresh()


    def handleKeypress(self):
        """
        A function to handle keypresses

        Keys:
            '>' -- Next track
            '<' -- Last track
            '+' -- Volume up +5
            '-' -- Volume down -5
            'p' -- Play/pause
            'q' -- Quit
            'h' -- Help
        """

        anytime_keys = ["quit", "help", "select_up", "select_down", "select"]

        special_key_map = {curses.KEY_UP:     "up",
                           curses.KEY_DOWN:   "down",
                           curses.KEY_LEFT:   "left",
                           curses.KEY_RIGHT:  "right",
                           curses.KEY_ENTER:  "enter",
                           10:                "enter",
                           32:                "space"
                           }

        if self.checkSongUpdate() == 1:
            stopped = True
        else:
            stopped = False

        # Get key
        key = self.stdscr.getch()

        while key > 0:
            # Resolve every key in buffer
            if key in special_key_map.keys():
                keyChar = special_key_map[key]
            else:
                keyChar = chr(key).lower()

            # Get playlist length
            playlist_length = len(self.client.playlist())

            # Parse key
            if keyChar not in keybindings.keys():
                key = self.stdscr.getch()
                continue
            else:
                action = keybindings[keyChar]


            if stopped and action not in anytime_keys:
                key = self.stdscr.getch()
                continue

            if action == "next_track":
                self.client.next()
                self.update_needed = True

            elif action == "last_track":
                self.client.previous()
                self.update_needed = True

            elif action == "play_pause":
                self.client.pause()

            elif action == "volume_up":
                self.client.volume(str(VOLUMESTEP))

            elif action == "volume_down":
                self.client.volume(str(-VOLUMESTEP))

            elif action == "quit":
                raise KeyboardInterrupt

            elif action == "help":
                self.help = not self.help
                self.cleared = False
                self.update_needed = True

            elif action == "toggle_info":
                self.toggleInfo()
                self.update_needed = True

            elif action == "select_up":
                self.control_cycle = 1
                if playlist_length > 0:
                    self.selected_song = (self.selected_song - 1) % playlist_length
                self.update_needed = True

            elif action == "select_down":
                self.control_cycle = 1
                if playlist_length > 0:
                    self.selected_song = (self.selected_song + 1) % playlist_length
                self.update_needed = True

            elif action == "select":
                self.control_cycle = 1

                if playlist_length > 0:
                    self.client.play(self.selected_song % playlist_length)
                    self.update_needed = True
                    self.last_song = None


            key = self.stdscr.getch()

    def drawInfo(self):
        """
        A function to draw the info below the album art
        """
        state, album, artist, title = self.fitText()

        if len(self.artist) == 0:
            seperator = ""
        else:
            seperator = " - "

        if state == 0:
            # Everything fits
            self.art_win.addstr(self.text_start,     0, f"{title}")
            self.art_win.addstr(self.text_start + 1, 0, f"{artist}{seperator}{album}")

        elif state == 1:
            # Too wide
            self.art_win.addstr(self.text_start - 1, 0, f"{title}")
            self.art_win.addstr(self.text_start,     0, f"{album}")
            self.art_win.addstr(self.text_start + 1, 0, f"{artist}")

        else:
            # No album
            self.art_win.addstr(self.text_start,     0, f"{title}")
            self.art_win.addstr(self.text_start + 1, 0, f"{artist}")


        # Progress bar
        song_duration = (int(self.duration / 60), round(self.duration % 60))
        song_elapsed  = (int(self.elapsed / 60),  round(self.elapsed % 60))

        self.art_win.addstr(
            self.text_start + 2, 0,
            "-"*(int((self.art_window_width - 1) * self.progress)) + ">",
            curses.color_pair(1)
        )

        # Duration string
        time_string = f"{song_elapsed[0]}:{song_elapsed[1]:02d}/{song_duration[0]}:{song_duration[1]:02d}"

        self.art_win.addstr(
            self.text_start + 3, 0,
            f"{time_string:>{self.art_window_width}}",
            curses.color_pair(2)
        )

        self.art_win.refresh()


    def drawPlaylist(self):
        """
        A function that draws the playlist
        """

        # Draw playlist
        if not self.draw_playlist:
            return

        playlist = self.client.playlistinfo()
        current_song = self.client.currentsong()

        # selected_pos = int(current_song["pos"])
        playlist_length = len(self.client.playlist())
        if playlist_length == 0:
            selected_pos = 0
            self.playlist_win.erase()
            self.playlist_win.refresh()
            return

        selected_pos = self.selected_song % len(playlist)

        # Determine where to start the playlist
        if selected_pos > self.playlist_window_height // 2 and len(playlist) > self.playlist_window_height:
            start = selected_pos - (self.playlist_window_height - 1) // 2
        else:
            start = 0

        start = min(abs(len(playlist) - self.playlist_window_height), start)

        line = 0
        while line < self.playlist_window_height:
            # Check if playlist is empty
            if line + start < len(playlist):
                playlist_item = playlist[start + line]
            else:
                playlist_item = None

            # Decide color
            pair = 0

            if playlist_item == current_song:
                pair = curses.color_pair(2)

            if playlist_item == playlist[selected_pos]:
                pair = curses.color_pair(2) | curses.A_REVERSE

            # Move and write text
            try:
                self.playlist_win.move(line, 0)

                if playlist_item is not None:
                    _, artist, title = self.getSongInfo(playlist_item)

                    if artist == "":
                        sep = ""
                    else:
                        sep = " - "

                    self.playlist_win.addstr(
                        f"{artist}{sep}{title}"[:self.playlist_window_width - 1],
                        pair
                    )

                self.playlist_win.clrtoeol()

            except curses.error:
                return

            line += 1

        self.playlist_win.refresh()


    def hideAlbumArt(self):
        """
        A function that hides the album art
        """
        if IMAGEMETHOD == "ueberzug":
            self.art_placement.visibility = ueberzug.Visibility.INVISIBLE


    def drawAlbumArt(self):
        """
        A function to draw the album art
        """

        if IMAGEMETHOD == "ueberzug":
            # Figure out new placement
            self.art_placement.x = (self.art_window_width - self.image_width)//2
            self.art_placement.y = self.image_y_pos

            # Figure out height and width
            self.art_placement.width = self.image_width
            self.art_placement.height = self.album_space

            # Update image
            self.art_placement.path = self.album_art_loc

            # Display image
            self.art_placement.visibility = ueberzug.Visibility.VISIBLE

        elif IMAGEMETHOD == "pixcat":
            (
                pixcat.Image(self.album_art_loc)
                .thumbnail(self.image_width_px)
                .show(x=(self.art_window_width - self.image_width)//2, y=self.image_y_pos)
            )


    def centerText(self, y: int, string: str):
        """
        A function that draws centered text in the window
        given a string and a line.

        Arguments:
            y      -- The y position to draw the string
            string -- The string to draw
        """

        x_pos = self.art_window_width / 2 - len(string) / 2
        self.art_win.addstr(y, int(x_pos), string)


    def drawHelp(self):
        """
        The function that draws the keymap help
        """

        # Top vspace
        top_vspace = 3

        # Left and right margin pct
        lr_margin_pct = 0.1
        lr_margin = round(self.art_window_width * lr_margin_pct)

        # Actual space for text
        x_space = self.art_window_width - 2 * (lr_margin)

        # Check if window has been cleared
        if not self.cleared:
            self.art_win.clear()
            self.cleared = True

        # Figure out center, y_start and x_start
        y_start = top_vspace
        x_start = int(lr_margin)

        # Draw title
        self.centerText(y_start, "Keymap")

        # Draw help
        for key, desc in keybindings.items():
            y_start += 1
            sep = "." * (x_space - len(key) - len(desc) - 2)
            desc = desc.replace("_", " ").capitalize()
            self.art_win.addstr(y_start, x_start, f"{key} {sep} {desc}")

        self.art_win.refresh()


    def draw(self):
        """
        The function that draws the now playing window
        """
        if not self.cleared:
            self.art_win.clear()
            self.cleared = True

        # Force window nings
        self.art_win.redrawln(0, 1)
        self.art_win.addstr(0, 0, " ")

        # Get mpd state
        state = self.checkSongUpdate()

        # Check if state is stop
        if state == 1:
            if self.has_music_been_played and AUTOCLOSE:
                # Check if the playlist has concluded and if we should close
                raise KeyboardInterrupt

            self.art_win.clear()
            self.hideAlbumArt()

            infomsg = "Put some beats on!"

            self.art_win.addstr(self.art_window_height // 2, (self.art_window_width - len(infomsg)) // 2, infomsg)
            self.art_win.refresh()
            self.drawPlaylist()

            return

        self.has_music_been_played = True

        # Draw the window
        if not self.album_art_only:
            self.drawInfo()
            self.drawPlaylist()

        self.drawAlbumArt()


    @ueberzug.Canvas()
    def loop(self, canvas):
        """
        The main program loop
        """

        if self.art_placement is None and IMAGEMETHOD == "ueberzug":
            # Create album art placement if we are using ueberzug
            self.art_placement = canvas.create_placement(
                "art",
                scaler=ueberzug.ScalerOption.FIT_CONTAIN.value
            )

        # Check if we need to recalculate window size
        # because of album art only initially
        if self.album_art_only:
            self.updateWindowSize(force_update=True)

        try:
            i = 0
            while True:
                s = time.perf_counter()

                self.handleKeypress()
                if i == 0 or self.update_needed:
                    # Checko for window size update
                    self.updateWindowSize()

                    if not self.help:
                        self.draw()

                    else:
                        self.hideAlbumArt()
                        self.drawHelp()

                    self.update_needed = False

                # Update control_cycle once a second if it is not 0
                if i == 0 and self.control_cycle != 0:
                    self.control_cycle = (self.control_cycle + 1) % 30

                e = time.perf_counter()

                sleeptime = abs(1/FPS - (e-s))

                time.sleep(sleeptime)
                i = (i + 1) % FPS

        except KeyboardInterrupt:
            error = False
        except pixcat.terminal.KittyAnswerTimeout:
            error = "Kitty did not answer in time. Are you using Kitty?"
        finally:
            curses.nocbreak()
            curses.endwin()
            self.client.close()
            self.client.disconnect()
            if error:
                print(error)


try:
    player = Player()
    player.loop()
except ConnectionRefusedError:
    curses.nocbreak()
    curses.endwin()
    print(f"Could not connect to mpd on {MPDHOST}:{MPDPORT}")