"""
Data models relevant for hubs and nodes.
"""
from enum import Enum
import mutagen
from mutagen.id3 import ID3
import os
import hashlib


class _Model:
    def __repr__(self):
        return "{}({})".format(self.__class__.__name__, self.__dict__)

    def __str__(self):
        return self.__repr__()

    def __eq__(self, other):
        return isinstance(other, self.__class__) and self.__dict__ == other.__dict__


class Song(_Model):
    """
    Represents a song in a music collection.
    """

    supported_text_tags = ["artist", "album_artist", "record", "disc_number", "genre", "original_artist", "title",
                           "track", "unsynced_lyrics", "involved_people", "playcount", "rating", "release_time",
                           "original_release_time", "image_hash"]

    properties = ["path", "last_mod", "size", "bitrate"] + supported_text_tags

    def __init__(self, path, last_mod, size, bitrate, id3=None, **plain_text_tags):
        """
        Creates a Song instance.

        :param path: the audio file's path
        :type path: str
        :param last_mod: the timestamp of the last file modification
        :type last_mod: int
        :param size: the file size in bytes
        :type size: int
        :param bitrate: the audio file's bitrate in kbps
        :type bitrate: int
        :param tags: the song's id3 tags
        :type tags: mutagen.id3.ID3 | None
        :param plain_text_tags:
        """
        self.path = path
        self.last_mod = last_mod
        self.size = size
        self.bitrate = bitrate
        if id3 is not None:
            self.tags = id3
        else:
            self.tags = {k: v for k, v in plain_text_tags.items()
                         if k in Song.supported_text_tags and v != "" and v is not None}

    def with_tags(self, tags):
        return Song(path=self.path, last_mod=self.last_mod, size=self.size, bitrate=self.bitrate, tags=tags)

    @property
    def artist(self):
        return str(self.tags["TPE1"]) if "TPE1" in self.tags else self.tags.get("artist")

    @property
    def album_artist(self):
        return str(self.tags["TPE2"]) if "TPE2" in self.tags else self.tags.get("album_artist")

    @property
    def record(self):
        return str(self.tags["TALB"]) if "TALB" in self.tags else self.tags.get("record")

    @property
    def disc_number(self):
        return +self.tags["TPOS"] if "TPOS" in self.tags else self.tags.get("disc_number")

    @property
    def genre(self):
        return str(self.tags["TCON"]) if "TCON" in self.tags else self.tags.get("genre")

    @property
    def original_artist(self):
        return str(self.tags["TOPE"]) if "TOPE" in self.tags else self.tags.get("original_artist")

    @property
    def title(self):
        return str(self.tags["TIT2"]) if "TIT2" in self.tags else self.tags.get("title")

    @property
    def track(self):
        return +self.tags["TRCK"] if "TRCK" in self.tags else self.tags.get("track")

    @property
    def unsynced_lyrics(self):
        return str(self.tags["USLT"]) if "USLT" in self.tags else self.tags.get("unsynced_lyrics")

    @property
    def involved_people(self):
        frame = self.tags.get("TIPL") or self.tags.get("TMCL")
        if frame is not None:
            return dict(frame.people)
        if "involved_people" in self.tags:
            return dict([o.split(":") for o in self.tags["involved_people"].split(";")])
        return None

    @property
    def playcount(self):
        if "PCNT" in self.tags:
            return int(self.tags["PCNT"])
        if "POPM:" in self.tags:
            return int(self.tags["POPM:"].count)
        return self.tags.get("playcount")

    @property
    def rating(self):
        if "TXXX:FMPS_Rating" in self.tags:
            return float(self.tags["TXXX:FMPS_Rating"])
        if "POPM:" in self.tags:
            return int(self.tags["POPM:"].rating) / 255.0
        return self.tags.get("rating")

    @property
    def release_time(self):
        """
        :return: year number, YYYY-MM-DD HH:MM:SS or None
        """
        timestamp = self.tags.get("TDRC") or self.tags.get("TDRL")
        if timestamp is not None:
            return str(timestamp)
        if "TYER" in self.tags:
            return int(self.tags["TYER"])
        return self.tags.get("release_time")

    @property
    def original_release_time(self):
        """
        :return: YYYY-MM-DD HH:MM:SS or None
        """
        return str(self.tags["TDOR"]) if "TDOR" in self.tags else self.tags.get("original_release_time")

    @property
    def recording_time(self):
        return str(self.tags["TDRC"]) if "TDRC" in self.tags else self.tags.get("recording_time")

    @property
    def image_hash(self):
        if not isinstance(self.tags, ID3):
            return self.tags.get("image_hash")

        frames = [frame for name, frame in self.tags.items() if name.startswith("APIC:")]
        if not frames:
            return None

        m = hashlib.md5()
        for frame in frames:
            m.update(frame.mime)
            m.update(str(frame.type))
            m.update(frame.desc)
            m.update(str(len(frame.data)))
        return m.hexdigest()

    def export(self):
        info = {k: getattr(self, k) for k in Song.properties}

        if info["involved_people"] is not None:
            info["involved_people"] = ";".join([":".join(s) for s in map(list, info["involved_people"].items())])

        # empty values are empty strings instead of None
        for k, v in info.items():
            if v is None:
                info[k] = ""
        return info

    def __eq__(self, other):
        return isinstance(other, Song) and self.export() == other.export()


class ActionType(Enum):
    """
    An enum to describe actions.
    """

    # ordered by priority

    edit = 1
    """Actions with this type modify a song's tags and/or moves a song to another path."""

    replace = 2
    """Actions with this type might modify a song's bit rate, tags or path."""

    remove = 3
    """Actions with this type remove a song."""

    add = 4
    """Actions with this type add a song."""


class Action(_Model):
    """
    An Action represents a change in the collection.

    Properties from constructor args:
     - all
    """
    def __init__(self, action_type, path, prev_path=None, **song_properties):
        """
        Creates a new Action instance.

        :param path: the involved song's path
        :type path: str
        :param action_type: this action's type
        :type action_type: ActionType
        :param song: the song involved in this action
        :type song: Song
        :param prev_path: should be set to the current path of the song in case this action will move it
        :type prev_path: str or None
        :param song_properties: a dict containing

                                - a song's properties for an 'add' action
                                - a song's changed properties for 'edit' and 'replace' actions
                                  (keys must be in Song.properties)
                                - only the path for a 'remove' action

                                valid keys are found in 'Song.properties'
        :type song_properties: dict
        """
        if not path:
            raise ValueError("path cannot be empty")
        if not isinstance(action_type, ActionType):
            raise ValueError("action_type must be a value of enum ActionType")
        if prev_path and action_type != ActionType.edit and action_type != ActionType.replace:
            raise ValueError("prev_path can only be set for ActionType.edit or ActionType.replace")
        for prop in song_properties.keys():
            if prop not in Song.properties:
                raise ValueError("got unexpected argument '{}'".format(prop))
        if action_type is ActionType.remove and song_properties:
            raise ValueError("song_properties should be empty for 'remove' actions")

        self.path = path
        self.action_type = action_type
        if prev_path:
            self.prev_path = prev_path
        self.__dict__.update({k: v for k, v in song_properties.items() if v is not None})

    def export(self, revision_nb=None):
        data = dict()
        if revision_nb is not None:
            data["revision_nb"] = revision_nb
        data.update({k: None for k in Song.supported_text_tags})
        data["prev_path"] = getattr(self, "prev_path", None)
        data.update(self.__dict__)
        data["action_type"] = data["action_type"].value
        return data

    @staticmethod
    def import_(action_type, prev_path, **kwargs):
        data = dict(action_type=ActionType(action_type))
        data.update({k: v for k, v in kwargs.items() if k in Song.properties and v is not None})
        if prev_path is not None:
            data["prev_path"] = prev_path
        return Action(**data)

    @property
    def tags(self):
        return {k: v for k, v in self.__dict__.items() if k in Song.supported_text_tags}

    def updated(self, **kwargs):
        return Action(**(self.__dict__ + kwargs))

    def __getattr__(self, item):
        if item not in self.__dict__ and item in ["prev_path"] + Song.supported_text_tags:
            return None
        return super().__getattribute__(item)


class Revision(_Model):
    """
    A Revision bundles multiple Actions. The state of a collection is defined by the sum of all previous Revisions.

    Properties from constructor args:
     - all
    """

    def __init__(self, number, timestamp, actions, node_id=None):
        """
        Creates a new Revision instance.

        :param number: the revision number
        :type number: int
        :param timestamp: the timestamp of the revision's introduction
        :type timestamp: int
        :param actions: the actions that make up this revision
        :type actions: list of Action
        :param node_id: the id of the node that pushed this revision or None, if it was the hub
        :type node_id: str
        """
        self.number = number
        self.node_id = node_id
        self.timestamp = timestamp
        self.actions = actions


class NodeInfo(_Model):
    """
    Contains all information about a node connected to a hub.

    Properties from constructor args:
     - all
    """
    def __init__(self, node_name, connection_time, last_activity, revision_nb):
        """
        Creates a new NodeInfo instance.

        :param node_name: the node's name
        :type node_name: str
        :param connection_time: the timestamp of when the node was connected to the relevant hub
        :type connection_time: int
        :param last_activity: the timestamp of the latest activity of the node in the relevant hub
        :type last_activity: int
        :param revision_nb: the node's local revision
        :type revision_nb: int
        """
        self.node_name = node_name
        self.connection_time = connection_time
        self.last_activity = last_activity
        self.revision_nb = revision_nb
