"""
This module contains the base Audio class and
the derived classes Song, Podcast and Audiobook.
"""
import json
import typing
from datetime import datetime
from dataclasses import dataclass


class MetadataValueError(Exception):
    """ Custom exception to handle invalid metadata values """
    pass


class MetadataGenerationError(Exception):
    """Custom exception to handle failed metadata generation"""
    pass


@dataclass(repr=False)
class Audio:
    """
    ************
    Description:
    ************
    *The base class for all other Audio type classes.*

    Accepts a metadata dictionary and stores it as an attribute.
    The post construction initialisation runtime checks the
    metadata for the other relevant attributes and sets them.

    The 'ID' and 'uploadtime' attributes are generated if not found in the metadata (considered
    as the first initialization of the object).
    The 'ID' is generated by taking the absolute value of the hash generated for a string that
    contains the name, duration and uploadtime attributes.
    The 'uploadtime' attribute is expected as an ISO8601 string and stored as a datetime object.

    *****************
    Class Attributes:
    *****************
    - ``ID``:       An int that uniquely identifies the audio file.
    - ``name``:     An str that describes the name of the audio file. Maximum length of 100.
    - ``duration``: A positive int that describes the length of the audio file. Must be positive.
    - ``uploadtime``:   A datetime object that is the time at which the audio file was uploaded.
    - ``metadata``: A dict that contains the audio file metadata.

    **************
    Class Methods:
    **************
    - ``validate_string``:  A method that checks if a given object is a string under 100 characters.
    - ``validate_duration``:    A method that checks if a given object is a positive integer.
    """
    metadata: dict

    def __post_init__(self):
        """ Post Construction Initialisation Runtime """

        # Collect 'name' and 'duration'
        try:
            self.name = self.metadata['name']
            self.duration = self.metadata['duration']

        except KeyError as e:
            raise MetadataValueError(f"metadata value is missing for {e}")

        # Validate the 'name'
        valid, error = self.validate_string(self.name)
        if not valid:
            raise MetadataValueError(f"metadata value is invalid for 'name' - {error}")

        # Validate the 'duration'
        valid, error = self.validate_duration(self.duration)
        if not valid:
            raise MetadataValueError(f"metadata value is invalid for 'duration' - {error}")

        # Collect and validate 'uploadtime'. Generate if not found.
        if 'uploadtime' not in self.metadata:

            try:
                self.uploadtime = datetime.utcnow()
                self.metadata['uploadtime'] = self.uploadtime.isoformat()

            except Exception as e:
                raise MetadataGenerationError(f"metadata generation failed for 'uploadtime'")
        else:

            try:
                uploadtime = self.metadata['uploadtime']
                self.uploadtime = datetime.fromisoformat(uploadtime)

            except ValueError:
                raise MetadataValueError("metadata value is invalid for 'uploadtime' - not ISO8601")
            except TypeError:
                raise MetadataValueError("metadata value is invalid for 'uploadtime' - not an str")

        # Collect and validate 'ID'. Generate if not found.
        if '_id' in self.metadata:
            self.ID = self.metadata['_id']

            if not isinstance(self.ID, int):
                raise MetadataValueError("metadata value is invalid for '_id' - not an int")

        else:

            try:
                self.ID = abs(hash(f"{self.name}-{self.duration}-{self.uploadtime.isoformat()}"))
                self.metadata['_id'] = self.ID

            except Exception as e:
                raise MetadataGenerationError(f"metadata generation failed for 'id'")

        # Collect and validate 'type'. Generate if not found.
        if 'type' in self.metadata:
            if not isinstance(self.metadata['type'], str):
                raise MetadataValueError("metadata value is invalid for 'type' - not an str")

            if self.metadata['type'] not in ['Song', 'Podcast', 'Audiobook', 'Audio']:
                raise MetadataValueError("metadata value is invalid for 'type' - not supported")

        else:
            self.metadata['type'] = "Audio"

    def __repr__(self):
        """ Representation of an Audio Object """
        return f"Audio Object <ID={self.ID}, Name={self.name}>"

    def __str__(self):
        """ String Representation of an Audio Object """
        return json.dumps(self.metadata)

    @classmethod
    def validate_duration(cls, duration: int) -> typing.Tuple[bool, str]:
        """
        A classmethod that determines if a given object is a string under 100 characters.
        Returns a validity bool and and an error str in a tuple.
        """
        if not isinstance(duration, int):
            return False, "not an int"

        if duration < 0:
            return False, "not positive"

        return True, "null"

    @classmethod
    def validate_string(cls, string: str) -> typing.Tuple[bool, str]:
        """
        A classmethod that determines if a given object is a positive integer.
        Returns a validity bool and and an error str in a tuple.
        """
        if not isinstance(string, str):
            return False, "not an str"

        if len(string) > 100:
            return False, "str too long"

        return True, "null"


@dataclass(repr=False)
class Song(Audio):
    """
    ************
    Description:
    ************
    *The class for a Song object.*

    Accepts a metadata dictionary and stores it as an attribute.
    The post construction initialisation runtime checks the
    metadata for the other relevant attributes and sets them.

    The 'ID' and 'uploadtime' attributes are generated if not found in the metadata (considered
    as the first initialization of the object).
    The 'ID' is generated by taking the absolute value of the hash generated for a string that
    contains the name, duration and uploadtime attributes.
    The 'uploadtime' attribute is expected as an ISO8601 string and stored as a datetime object.

    *****************
    Class Attributes:
    *****************
    - ``ID``:       An int that uniquely identifies the song.
    - ``name``:     An str that describes the name of the song. Maximum length of 100.
    - ``duration``: A positive int that describes the length of the song. Must be positive.
    - ``uploadtime``:   A datetime object that is the time at which the song was uploaded.
    - ``metadata``: A dict that contains the song metadata.

    **************
    Class Methods:
    **************
    - ``validate_string``:  A method that checks if a given object is a string under 100 characters.
    - ``validate_duration``:    A method that checks if a given object is a positive integer.
    """
    metadata: dict

    def __post_init__(self):
        """ Post Construction Initialisation Runtime """
        self.metadata['type'] = "Song"
        super(Song, self).__post_init__()

    def __repr__(self):
        """ Representation of an Song Object """
        return f"Song Object <ID={self.ID}, Name={self.name}>"

    def __str__(self):
        """ String Representation of an Song Object """
        return json.dumps(self.metadata)


@dataclass(repr=False)
class Podcast(Audio):
    """
    ************
    Description:
    ************
    *The class for a Podcast object.*

    Accepts a metadata dictionary and stores it as an attribute.
    The post construction initialisation runtime checks the
    metadata for the other relevant attributes and sets them.

    The 'ID' and 'uploadtime' attributes are generated if not found in the metadata (considered
    as the first initialization of the object).
    The 'ID' is generated by taking the absolute value of the hash generated for a string that
    contains the name, duration and uploadtime attributes.
    The 'uploadtime' attribute is expected as an ISO8601 string and stored as a datetime object.
    The 'participants' attribute is set to an empty list if none are passed.

    *****************
    Class Attributes:
    *****************
    - ``ID``:       An int that uniquely identifies the podcast.
    - ``name``:     An str that describes the name of the podcast. Maximum length of 100.
    - ``host``:     An str that describes the host of the podcast. Maximum length of 100.
    - ``participants``: A list of str that describes the participants of the podcast.
    - ``duration``: A positive int that describes the length of the podcast. Must be positive.
    - ``uploadtime``:   A datetime object that is the time at which the podcast was uploaded.
    - ``metadata``: A dict that contains the podcast metadata.

    **************
    Class Methods:
    **************
    - ``validate_string``:  A method that checks if a given object is a string under 100 characters.
    - ``validate_duration``:    A method that checks if a given object is a positive integer.
    - ``validate_participants``:    A method that checks if a given object is a list of valid str.
    """
    metadata: dict

    def __post_init__(self):
        """ Post Construction Initialisation Runtime """
        self.metadata['type'] = "Podcast"
        super(Podcast, self).__post_init__()

        # Collect 'host' and 'participants'
        try:
            self.host = self.metadata['host']
            self.participants = self.metadata.get('participants')

        except KeyError as e:
            raise MetadataValueError(f"metadata value is missing for {e}")

        # Validate the 'host'
        valid, error = self.validate_string(self.host)
        if not valid:
            raise MetadataValueError(f"metadata value is invalid for 'host' - {error}")

        if not self.participants:
            self.participants = []

        # Validate the 'participants'
        valid, error = self.validate_participants(self.participants)
        if not valid:
            raise MetadataValueError(f"metadata value is invalid for 'participants' - {error}")

    def __repr__(self):
        """ Representation of an Podcast Object """
        return f"Podcast Object <ID={self.ID}, Name={self.name}>"

    def __str__(self):
        """ String Representation of an Podcast Object """
        return json.dumps(self.metadata)

    @classmethod
    def validate_participants(cls, participants: list) -> typing.Tuple[bool, str]:
        """
        A classmethod that determines if a given object is a list of valid strings (under
        100 characters). Returns a validity bool and and an error str in a tuple.
        """
        if not isinstance(participants, list):
            return False, "not a list"

        if len(participants) > 10:
            return False, "too many participants"

        for count, participant in enumerate(participants, start=1):
            valid, error = cls.validate_string(participant)
            if not valid:
                return False, f"participant {count} - {error}"

        return True, "null"


@dataclass(repr=False)
class Audiobook(Audio):
    """
    ************
    Description:
    ************
    *The class for a Audiobook object.*

    Accepts a metadata dictionary and stores it as an attribute.
    The post construction initialisation runtime checks the
    metadata for the other relevant attributes and sets them.

    The 'ID' and 'uploadtime' attributes are generated if not found in the metadata (considered
    as the first initialization of the object).
    The 'ID' is generated by taking the absolute value of the hash generated for a string that
    contains the name, duration and uploadtime attributes.
    The 'uploadtime' attribute is expected as an ISO8601 string and stored as a datetime object.

    *****************
    Class Attributes:
    *****************
    - ``ID``:       An int that uniquely identifies the song.
    - ``name``:     An str that describes the name of the song. Maximum length of 100.
    - ``author``:   An str that describes the author of the audiobook. Maximum length of 100.
    - ``narrator``: An str that describes the narrator of the audiobook. Maximum length of 100.
    - ``duration``: A positive int that describes the length of the song. Must be positive.
    - ``uploadtime``:   A datetime object that is the time at which the song was uploaded.
    - ``metadata``: A dict that contains the song metadata.

    **************
    Class Methods:
    **************
    - ``validate_string``:  A method that checks if a given object is a string under 100 characters.
    - ``validate_duration``:    A method that checks if a given object is a positive integer.
    """
    metadata: dict

    def __post_init__(self):
        """ Post Construction Initialisation Runtime """
        self.metadata['type'] = "Audiobook"
        super(Audiobook, self).__post_init__()

        # Collect 'author' and 'narrator'
        try:
            self.author = self.metadata['author']
            self.narrator = self.metadata['narrator']

        except KeyError as e:
            raise MetadataValueError(f"metadata value is missing for {e}")

        # Validate the 'author'
        valid, error = self.validate_string(self.author)
        if not valid:
            raise MetadataValueError(f"metadata value is invalid for 'author' - {error}")

        # Validate the 'narrator'
        valid, error = self.validate_string(self.narrator)
        if not valid:
            raise MetadataValueError(f"metadata value is invalid for 'narrator' - {error}")

    def __repr__(self):
        """ Representation of an Audiobook Object """
        return f"Audiobook Object <ID={self.ID}, Name={self.name}>"

    def __str__(self):
        """ String Representation of an Audiobook Object """
        return json.dumps(self.metadata)
