"""This module contains the class for RekordboxTrack.

RekordboxTrack is an implementation of Track which operates on the XML format
that Rekordbox exports.
"""

from __future__ import annotations
from datetime import datetime
import os
from pathlib import Path
import re
from typing import Any, List
from urllib.parse import quote, unquote

import bs4

from djtools.collection.base_track import Track
from djtools.utils.helpers import make_path


# pylint: disable=no-member,duplicate-code


class RekordboxTrack(Track):
    "Track implementation for usage with Rekordbox."

    def __init__(self, track: bs4.element.Tag):
        """Deserialize a track from a BeautifulSoup TRACK Tag.

        Args:
            track: BeautifulSoup Tag representing a track.
        """
        # Prefix of the path to the audio file corresponding to this track.
        super().__init__()
        self.__location_prefix = (
            "file://localhost" if os.name == "posix" else "file://localhost/"
        )

        # Set class attributes from TRACK Tag attributes.
        for key, value in track.attrs.items():
            if key in [
                "BitRate",
                "DiscNumber",
                "PlayCount",
                "SampleRate",
                "Size",
                "TotalTime",
                "TrackNumber",
            ]:
                value = int(value)
            if key == "AverageBpm":
                value = float(value)
            if key == "DateAdded":
                # We need to keep the original date added string because
                # Rekordbox doesn't format date strings consistently i.e.
                # ensuring perfect serialization symmetry is not possible
                # without this.
                self.__original_date_added = value
                value = datetime.strptime(value, "%Y-%m-%d")
            if key == "Genre":
                value = [x.strip() for x in value.split("/")]
            if key == "Location":
                value = Path(unquote(value).split(self.__location_prefix)[-1])
            if key == "Rating":
                value = {
                    "0": 0,
                    "51": 1,
                    "102": 2,
                    "153": 3,
                    "204": 4,
                    "255": 5,
                }.get(value)
            setattr(self, f"_{key}", value)

        # Parse MyTag data from Comments attribute.
        my_tags = re.search(r"(?<=\/\*).*(?=\*\/)", self._Comments)
        self._MyTags = (  # pylint: disable=invalid-name
            [x.strip() for x in my_tags.group().split("/")] if my_tags else []
        )

        # Merge Genre and MyTag data into a new attribute.
        self._Tags = self._Genre + self._MyTags  # pylint: disable=invalid-name

        # Parse TEMPO Tags as the beat grid attribute.
        self._beat_grid = track.find_all("TEMPO")
        if self._beat_grid:
            self._beat_grid = [point.attrs for point in self._beat_grid]

        # Parse POSITION_MARK Tags as the hot cues attribute.
        self._hot_cues = track.find_all("POSITION_MARK")
        if self._hot_cues:
            self._hot_cues = [hot_cue.attrs for hot_cue in self._hot_cues]

    def __repr__(self) -> str:
        """Produces a string representation of this track.

        Returns:
            Track represented as a string.
        """
        # Enforce a maximum width for a Track representation.
        max_width = 79
        # Eventual repr string to return.
        string = "{}(\n{}\n)"
        # Body of the repr string to fill out with track contents.
        body = " " * 4

        # Dunder members aren't represented. Public members (i.e. methods)
        # aren't represented either.
        repr_attrs = {
            key[1:]: value
            for key, value in self.__dict__.items()
            if not (
                key.startswith(f"_{type(self).__name__}")
                or not key.startswith("_")
            )
        }

        # Build a representation of this track.
        for key, value in repr_attrs.items():
            # Prettify output by enforcing `max_width`.
            if len(body.split("\n", maxsplit=-1)[-1]) > max_width:
                body += f"\n{' ' * 4}"

            # Rather than display each beat grid or hot cue attribute, simply
            # represent as the number of those attributes.
            if key in ["beat_grid", "hot_cues"]:
                value = len(value)

            # Represent string values with surrounding double quotes.
            if isinstance(value, str):
                value = f'"{value}"'

            # Truncate the HH:MM:SS part of the datetime.
            if isinstance(value, datetime):
                value = f'"{value.strftime("%Y-%m-%d")}"'

            # Append the attribute's name and value to the representation.
            body += f"{key}={value}, "

        # Truncate final comma and space.
        body = body[:-2]

        return string.format(type(self).__name__, body)

    def __str__(self) -> str:
        """Produces a string representation of this track.

        Returns:
            Track represented as a string.
        """
        return str(self.serialize())

    def get_artists(self) -> str:
        """Gets the track artists.

        Returns:
            A string representing the track's artists.
        """
        return self._Artist

    def get_bpm(self) -> float:
        """Gets the track BPM.

        Returns:
            A float representing BPM.
        """
        return self._AverageBpm

    def get_comments(self) -> str:
        """Gets the track comments.

        Returns:
            A string representing the track's comments.
        """
        return self._Comments

    def get_date_added(self) -> str:
        """Gets the track's date added.

        Returns:
            A datetime representing the track's date added.
        """
        return self._DateAdded

    def get_genre_tags(self) -> List[str]:
        """Gets the genre tags of the track.

        Returns:
            A list of the track's genre tags.
        """
        return self._Genre

    def get_id(self) -> str:
        """Get the track ID.

        Returns:
            The ID of this track.
        """
        return self._TrackID

    def get_key(self) -> Any:
        """Gets the track key.

        Returns:
            The key of this track.
        """
        return self._Tonality

    def get_label(self) -> Any:
        """Gets the track label.

        Returns:
            The label of this track.
        """
        return self._Label

    def get_location(self) -> Path:
        """Gets the location of the track.

        Returns:
            The Path for the location of the track.
        """
        return self._Location

    def get_rating(self) -> int:
        """Gets the rating of the track.

        Returns:
            The rating of the track.
        """
        return self._Rating

    def get_tags(self) -> List[str]:
        """Gets the tags of the track.

        Returns:
            A set of the track's tags.
        """
        return self._Tags

    def get_year(self) -> str:
        """Gets the year of the track.

        Returns:
            The year of the track.
        """
        return self._Year

    def serialize(
        self, *args, playlist: bool = False, **kwargs
    ) -> bs4.element.Tag:
        """Serializes this track as a BeautifulSoup TRACK Tag.

        Args:
            playlist: Whether or not to serialize this track as a member of a
                playlist.

        Raises:
            ValueError: The DateAdded attribute must serialize into its
                original format.

        Returns:
            BeautifulSoup Tag representing this track.
        """
        # BeautifulSoup Tag to populate with attributes of this track.
        track_tag = bs4.Tag(name="TRACK", can_be_empty_element=True)

        # TRACK Tags in playlists are serialized differently from top-level
        # TRACK Tags.
        if playlist:
            track_tag["Key"] = self.get_id()

            return track_tag

        # Dunder members aren't serialized. Public members (i.e. methods)
        # aren't serialized either.
        serialize_attrs = {
            key[1:]: value
            for key, value in self.__dict__.items()
            if not (
                key.startswith(f"_{type(self).__name__}")
                or not key.startswith("_")
                or key in ["_MyTags", "_Tags"]
            )
        }

        # Serialize attributes into a TRACK Tag.
        for key, value in serialize_attrs.items():
            # Beat grid and hot cue data is serialized as TEMPO and
            # POSITION_MARK Tags, respectively.
            if key in ["beat_grid", "hot_cues"]:
                for val in value:
                    tag = bs4.Tag(
                        name="POSITION_MARK" if key == "hot_cues" else "TEMPO",
                        can_be_empty_element=True,
                    )
                    tag.attrs = val
                    track_tag.extend([bs4.NavigableString("\n"), tag])
                continue

            # Cast integers back into a string.
            if key in [
                "BitRate",
                "DiscNumber",
                "PlayCount",
                "SampleRate",
                "Size",
                "TotalTime",
                "TrackNumber",
            ]:
                value = str(value)

            # Increase BPM precision to make serialization 100% symmetrical.
            if key == "AverageBpm":
                value = f"{value:0,.2f}"

            # Truncate the HH:MM:SS part of the datetime.
            if isinstance(value, datetime):
                # Rekordbox doesn't consistently format dates with or without
                # leading zeros on the month and day portion of the date
                # string, so we have to try all three of these formats to see
                # if the resulting formatting matches the original one.
                date_formats = [
                    "%Y-%m-%d",
                    ("%Y-%-m-%d" if os.name == "posix" else "%Y-%#m-%d"),
                    ("%Y-%-m-%-d" if os.name == "posix" else "%Y-%#m-%#d"),
                ]
                for date_format in date_formats:
                    attempt = value.strftime(date_format)
                    if attempt == self.__original_date_added:
                        break
                value = attempt

                if value != self.__original_date_added:
                    raise ValueError(  # pragma: no cover
                        f"Failed to serialize the datetime {value} into its "
                        f"original format {self.__original_date_added}"
                    )

            # Re-join genre tags with forward slashes.
            if key == "Genre":
                value = " / ".join(value)

            # Re-insert the location prefix and quote the path.
            if key == "Location":
                track_path = quote(value.as_posix(), safe="/,()!+=#;$:")
                value = f"{self.__location_prefix}{track_path}"
                value = re.sub(
                    r"%[0-9A-Z]{2}", lambda x: x.group(0).lower(), value
                )

            # Reverse the rating value to the range recognized by Rekordbox.
            if key == "Rating":
                value = {
                    0: "0",
                    1: "51",
                    2: "102",
                    3: "153",
                    4: "204",
                    5: "255",
                }.get(value)

            # Otherwise the data is serialized as TRACK Tag attributes.
            track_tag[key] = value

        # If this TRACK Tag has children, append a final newline character.
        if len(track_tag) > 1:
            track_tag.append(bs4.NavigableString("\n"))

        return track_tag

    @make_path
    def set_location(self, location: Path):
        """Sets the path of the track to location.

        Args:
            location: New location of the track.
        """
        self._Location = location  # pylint: disable=attribute-defined-outside-init,invalid-name

    def set_track_number(self, number: int):
        """Sets the track number of a track.

        Args:
            number: Number to set for TrackNumber.
        """
        self._TrackNumber = number  # pylint: disable=attribute-defined-outside-init,invalid-name
