# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2006-2009, 2011 Lukáš Lalinský
# Copyright (C) 2009-2011, 2018-2021 Philipp Wolfer
# Copyright (C) 2011-2014 Wieland Hoffmann
# Copyright (C) 2012-2013 Michael Wiencek
# Copyright (C) 2013 Calvin Walton
# Copyright (C) 2013-2015, 2018-2021 Laurent Monin
# Copyright (C) 2016-2018 Sambhav Kothari
# Copyright (C) 2017 Ville Skyttä
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.


from __future__ import absolute_import

from os.path import isfile
import re

import mutagen.apev2
import mutagen.monkeysaudio
import mutagen.musepack
import mutagen.optimfrog
import mutagen.wavpack

from picard import log
from picard.config import get_config
from picard.coverart.image import (
    CoverArtImageError,
    TagCoverArtImage,
)
from picard.file import File
from picard.metadata import Metadata
from picard.util import (
    encode_filename,
    sanitize_date,
)
from picard.util.filenaming import (
    get_available_filename,
    move_ensure_casing,
    replace_extension,
)

from .mutagenext import (
    aac,
    tak,
)


INVALID_CHARS = re.compile('[^\x20-\x7e]')
DISALLOWED_KEYS = {'ID3', 'TAG', 'OggS', 'MP+'}
UNSUPPORTED_TAGS = {
    'gapless',
    'musicip_fingerprint',
    'podcast',
    'podcasturl',
    'show',
    'showsort',
    'r128_album_gain',
    'r128_track_gain',
}


def is_valid_key(key):
    """
    Return true if a string is a valid APE tag key.
    APE tag item keys can have a length of 2 (including) up to 255 (including)
    characters in the range from 0x20 (Space) until 0x7E (Tilde).
    Not allowed are the following keys: ID3, TAG, OggS and MP+.

    See http://wiki.hydrogenaud.io/index.php?title=APE_key
    """
    return (key and 2 <= len(key) <= 255
            and key not in DISALLOWED_KEYS
            and INVALID_CHARS.search(key) is None)


class APEv2File(File):

    """Generic APEv2-based file."""
    _File = None

    __translate = {
        "albumartist": "Album Artist",
        "remixer": "MixArtist",
        "director": "Director",
        "website": "Weblink",
        "discsubtitle": "DiscSubtitle",
        "bpm": "BPM",
        "isrc": "ISRC",
        "catalognumber": "CatalogNumber",
        "barcode": "Barcode",
        "encodedby": "EncodedBy",
        "language": "Language",
        "movementnumber": "MOVEMENT",
        "movement": "MOVEMENTNAME",
        "movementtotal": "MOVEMENTTOTAL",
        "showmovement": "SHOWMOVEMENT",
        "releasestatus": "MUSICBRAINZ_ALBUMSTATUS",
        "releasetype": "MUSICBRAINZ_ALBUMTYPE",
        "musicbrainz_recordingid": "musicbrainz_trackid",
        "musicbrainz_trackid": "musicbrainz_releasetrackid",
        "originalartist": "Original Artist",
        "replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN",
        "replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK",
        "replaygain_album_range": "REPLAYGAIN_ALBUM_RANGE",
        "replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN",
        "replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK",
        "replaygain_track_range": "REPLAYGAIN_TRACK_RANGE",
        "replaygain_reference_loudness": "REPLAYGAIN_REFERENCE_LOUDNESS",
    }
    __rtranslate = {v.lower(): k for k, v in __translate.items()}

    def __init__(self, filename):
        super().__init__(filename)
        self.__casemap = {}

    def _load(self, filename):
        log.debug("Loading file %r", filename)
        self.__casemap = {}
        file = self._File(encode_filename(filename))
        metadata = Metadata()
        if file.tags:
            for origname, values in file.tags.items():
                name_lower = origname.lower()
                if (values.kind == mutagen.apev2.BINARY
                    and name_lower.startswith("cover art")):
                    if b'\0' in values.value:
                        descr, data = values.value.split(b'\0', 1)
                        try:
                            coverartimage = TagCoverArtImage(
                                file=filename,
                                tag=name_lower,
                                data=data,
                            )
                        except CoverArtImageError as e:
                            log.error('Cannot load image from %r: %s' %
                                      (filename, e))
                        else:
                            metadata.images.append(coverartimage)

                # skip EXTERNAL and BINARY values
                if values.kind != mutagen.apev2.TEXT:
                    continue
                for value in values:
                    name = name_lower
                    if name == "year":
                        name = "date"
                        value = sanitize_date(value)
                    elif name == "track":
                        name = "tracknumber"
                        track = value.split("/")
                        if len(track) > 1:
                            metadata["totaltracks"] = track[1]
                            value = track[0]
                    elif name == "disc":
                        name = "discnumber"
                        disc = value.split("/")
                        if len(disc) > 1:
                            metadata["totaldiscs"] = disc[1]
                            value = disc[0]
                    elif name in {'performer', 'comment'}:
                        if value.endswith(')'):
                            start = value.rfind(' (')
                            if start > 0:
                                name += ':' + value[start + 2:-1]
                                value = value[:start]
                    elif name in self.__rtranslate:
                        name = self.__rtranslate[name]
                    self.__casemap[name] = origname
                    metadata.add(name, value)
        self._info(metadata, file)
        return metadata

    def _save(self, filename, metadata):
        """Save metadata to the file."""
        log.debug("Saving file %r", filename)
        config = get_config()
        try:
            tags = mutagen.apev2.APEv2(encode_filename(filename))
        except mutagen.apev2.APENoHeaderError:
            tags = mutagen.apev2.APEv2()
        images_to_save = list(metadata.images.to_be_saved_to_tags())
        if config.setting["clear_existing_tags"]:
            preserved = []
            if config.setting['preserve_images']:
                preserved = list(self._iter_cover_art_tags(tags))
            tags.clear()
            for name, value in preserved:
                tags[name] = value
        elif images_to_save:
            for name, value in self._iter_cover_art_tags(tags):
                del tags[name]
        temp = {}
        for name, value in metadata.items():
            if name.startswith("~") or not self.supports_tag(name):
                continue
            real_name = self._get_tag_name(name)
            # tracknumber/totaltracks => Track
            if name == 'tracknumber':
                if 'totaltracks' in metadata:
                    value = '%s/%s' % (value, metadata['totaltracks'])
            # discnumber/totaldiscs => Disc
            elif name == 'discnumber':
                if 'totaldiscs' in metadata:
                    value = '%s/%s' % (value, metadata['totaldiscs'])
            elif name in {'totaltracks', 'totaldiscs'}:
                continue
            # "performer:Piano=Joe Barr" => "Performer=Joe Barr (Piano)"
            elif name.startswith('performer:') or name.startswith('comment:'):
                name, desc = name.split(':', 1)
                if desc:
                    value += ' (%s)' % desc
            temp.setdefault(real_name, []).append(value)
        for name, values in temp.items():
            tags[name] = values
        for image in images_to_save:
            cover_filename = 'Cover Art (Front)'
            cover_filename += image.extension
            tags['Cover Art (Front)'] = mutagen.apev2.APEValue(
                cover_filename.encode('ascii') + b'\0' + image.data, mutagen.apev2.BINARY)
            break
            # can't save more than one item with the same name
            # (mp3tags does this, but it's against the specs)

        self._remove_deleted_tags(metadata, tags)
        tags.save(encode_filename(filename))

    def _remove_deleted_tags(self, metadata, tags):
        """Remove the tags from the file that were deleted in the UI"""
        for tag in metadata.deleted_tags:
            real_name = self._get_tag_name(tag)
            if real_name in {'Lyrics', 'Comment', 'Performer'}:
                parts = tag.split(':', 1)
                if len(parts) == 2:
                    tag_type_regex = re.compile(r"\(%s\)$" % re.escape(parts[1]))
                else:
                    tag_type_regex = re.compile(r"[^)]$")
                existing_tags = tags.get(real_name, [])
                for item in existing_tags:
                    if re.search(tag_type_regex, item):
                        existing_tags.remove(item)
                tags[real_name] = existing_tags
            elif tag in {'totaltracks', 'totaldiscs'}:
                tagstr = real_name.lower() + 'number'
                if tagstr in metadata:
                    tags[real_name] = metadata[tagstr]
            else:
                if real_name in tags:
                    del tags[real_name]

    def _get_tag_name(self, name):
        if name in self.__casemap:
            return self.__casemap[name]
        elif name.startswith('lyrics:'):
            return 'Lyrics'
        elif name == 'date':
            return 'Year'
        elif name in {'tracknumber', 'totaltracks'}:
            return 'Track'
        elif name in {'discnumber', 'totaldiscs'}:
            return 'Disc'
        elif name.startswith('performer:') or name.startswith('comment:'):
            return name.split(':', 1)[0].title()
        elif name in self.__translate:
            return self.__translate[name]
        else:
            return name.title()

    @staticmethod
    def _iter_cover_art_tags(tags):
        for name, value in tags.items():
            if value.kind == mutagen.apev2.BINARY and name.lower().startswith('cover art'):
                yield (name, value)

    @classmethod
    def supports_tag(cls, name):
        return (bool(name) and name not in UNSUPPORTED_TAGS
                and not name.startswith('~')
                and (is_valid_key(name)
                    or name.startswith('comment:')
                    or name.startswith('lyrics:')
                    or name.startswith('performer:')))


class MusepackFile(APEv2File):

    """Musepack file."""
    EXTENSIONS = [".mpc", ".mp+"]
    NAME = "Musepack"
    _File = mutagen.musepack.Musepack

    def _info(self, metadata, file):
        super()._info(metadata, file)
        metadata['~format'] = "Musepack, SV%d" % file.info.version


class WavPackFile(APEv2File):

    """WavPack file."""
    EXTENSIONS = [".wv"]
    NAME = "WavPack"
    _File = mutagen.wavpack.WavPack

    def _move_or_rename_wvc(self, old_filename, new_filename):
        wvc_filename = replace_extension(old_filename, ".wvc")
        if not isfile(wvc_filename):
            return
        wvc_new_filename = replace_extension(new_filename, ".wvc")
        wvc_new_filename = get_available_filename(wvc_new_filename, wvc_filename)
        log.debug('Moving Wavepack correction file %r => %r', wvc_filename, wvc_new_filename)
        move_ensure_casing(wvc_filename, wvc_new_filename)

    def _move_additional_files(self, old_filename, new_filename, config):
        """Includes an additional check for WavPack correction files"""
        if config.setting["rename_files"] or config.setting["move_files"]:
            self._move_or_rename_wvc(old_filename, new_filename)
        return super()._move_additional_files(old_filename, new_filename, config)


class OptimFROGFile(APEv2File):

    """OptimFROG file."""
    EXTENSIONS = [".ofr", ".ofs"]
    NAME = "OptimFROG"
    _File = mutagen.optimfrog.OptimFROG

    def _info(self, metadata, file):
        super()._info(metadata, file)
        # mutagen.File.filename can be either a bytes or str object
        filename = file.filename
        if isinstance(filename, bytes):
            filename = filename.decode()
        if filename.lower().endswith(".ofs"):
            metadata['~format'] = "OptimFROG DualStream Audio"
        else:
            metadata['~format'] = "OptimFROG Lossless Audio"


class MonkeysAudioFile(APEv2File):

    """Monkey's Audio file."""
    EXTENSIONS = [".ape"]
    NAME = "Monkey's Audio"
    _File = mutagen.monkeysaudio.MonkeysAudio


class TAKFile(APEv2File):

    """TAK file."""
    EXTENSIONS = [".tak"]
    NAME = "Tom's lossless Audio Kompressor"
    _File = tak.TAK


class AACFile(APEv2File):
    EXTENSIONS = [".aac"]
    NAME = "AAC"
    _File = aac.AACAPEv2

    def _info(self, metadata, file):
        super()._info(metadata, file)
        if file.tags:
            metadata['~format'] = "%s (APEv2)" % self.NAME

    def _save(self, filename, metadata):
        config = get_config()
        if config.setting['aac_save_ape']:
            super()._save(filename, metadata)
        elif config.setting['remove_ape_from_aac']:
            try:
                mutagen.apev2.delete(encode_filename(filename))
            except BaseException:
                log.exception('Error removing APEv2 tags from %s', filename)

    @classmethod
    def supports_tag(cls, name):
        config = get_config()
        if config.setting['aac_save_ape']:
            return APEv2File.supports_tag(name)
        else:
            return False
