# Copyright (c) 2014-2020 Cedric Bellegarde <cedric.bellegarde@adishatz.org>
# Copyright (c) 2019 Jordi Romera <jordiromera@users.sourceforge.net>
# 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 3 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, see <http://www.gnu.org/licenses/>.

from gi.repository import GLib, GObject, Gio

from gi.repository.Gio import FILE_ATTRIBUTE_STANDARD_NAME, \
                              FILE_ATTRIBUTE_STANDARD_TYPE, \
                              FILE_ATTRIBUTE_STANDARD_IS_HIDDEN,\
                              FILE_ATTRIBUTE_STANDARD_IS_SYMLINK,\
                              FILE_ATTRIBUTE_STANDARD_SYMLINK_TARGET,\
                              FILE_ATTRIBUTE_TIME_MODIFIED

from gettext import gettext as _
from time import time

from lollypop.inotify import Inotify
from lollypop.define import App, ScanType, Type, StorageType, ScanUpdate
from lollypop.sqlcursor import SqlCursor
from lollypop.tagreader import TagReader, Discoverer
from lollypop.logger import Logger
from lollypop.database_history import History
from lollypop.objects_track import Track
from lollypop.utils_file import is_audio, is_pls, get_mtime
from lollypop.utils_album import tracks_to_albums
from lollypop.utils import emit_signal, profile


SCAN_QUERY_INFO = "{},{},{},{},{},{}".format(
                                       FILE_ATTRIBUTE_STANDARD_NAME,
                                       FILE_ATTRIBUTE_STANDARD_TYPE,
                                       FILE_ATTRIBUTE_STANDARD_IS_HIDDEN,
                                       FILE_ATTRIBUTE_STANDARD_IS_SYMLINK,
                                       FILE_ATTRIBUTE_STANDARD_SYMLINK_TARGET,
                                       FILE_ATTRIBUTE_TIME_MODIFIED)


class CollectionScanner(GObject.GObject, TagReader):
    """
        Scan user music collection
    """
    __gsignals__ = {
        "scan-finished": (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
        "artist-updated": (GObject.SignalFlags.RUN_FIRST, None, (int, int)),
        "genre-updated": (GObject.SignalFlags.RUN_FIRST, None, (int, int)),
        "album-updated": (GObject.SignalFlags.RUN_FIRST, None, (int, int))
    }

    def __init__(self):
        """
            Init collection scanner
        """
        GObject.GObject.__init__(self)
        self.__thread = None
        self.__history = History()
        self.__progress_total = 1
        self.__progress_count = 0
        self.__new_non_album_artists = []
        self.__disable_compilations = True
        if App().settings.get_value("auto-update"):
            self.__inotify = Inotify()
        else:
            self.__inotify = None
        App().albums.update_max_count()

    def update(self, scan_type, uris=[]):
        """
            Update database
            @param scan_type as ScanType
            @param uris as [str]
        """
        App().lookup_action("update_db").set_enabled(False)
        # Stop previous scan
        if self.is_locked() and scan_type != ScanType.EXTERNAL:
            self.stop()
            GLib.timeout_add(250, self.update, scan_type, uris)
        elif App().spotify.is_running:
            App().stop_spotify()
            GLib.timeout_add(250, self.update, scan_type, uris)
        else:
            self.__disable_compilations = not App().settings.get_value(
                "show-compilations")

            if scan_type == ScanType.FULL:
                uris = App().settings.get_music_uris()
            if not uris:
                return
            # Register to progressbar
            if scan_type != ScanType.EXTERNAL:
                App().window.container.progress.add(self)
                App().window.container.progress.set_fraction(0, self)
            Logger.info("Scan started")
            # Launch scan in a separate thread
            self.__thread = App().task_helper.run(self.__scan, scan_type, uris)

    def update_album(self, album_id, album_artist_ids,
                     genre_ids, year, timestamp):
        """
            Update album artists based on album-artist and artist tags
            This code auto handle compilations: empty "album artist" with
            different artists
            @param album_id as int
            @param album_artist_ids as [int]
            @param genre_ids as [int]
            @param year as int
            @param timestamp as int
            @commit needed
        """
        if album_artist_ids:
            App().albums.set_artist_ids(album_id, album_artist_ids)
        # Set artist ids based on content
        else:
            new_album_artist_ids = App().albums.calculate_artist_ids(album_id)
            App().albums.set_artist_ids(album_id, new_album_artist_ids)
        # Update album genres
        for genre_id in genre_ids:
            App().albums.add_genre(album_id, genre_id)
        # Update year based on tracks
        year = App().tracks.get_year_for_album(album_id)
        App().albums.set_year(album_id, year)
        timestamp = App().tracks.get_timestamp_for_album(album_id)
        App().albums.set_timestamp(album_id, timestamp)

    def save_album(self, album_artists, aa_sortnames, mb_album_artist_id,
                   album_name, mb_album_id, uri, album_loved, album_pop,
                   album_rate, album_synced, album_mtime, storage_type):
        """
            Add album to DB
            @param artists as str
            @param a_sortnames as str
            @param mb_artist_id as str
            @param album_artists as str
            @param aa_sortnames as str
            @param mb_album_artist_id as str
            @param album_name as str
            @param mb_album_id as str
            @param uri as str
            @param album_loved as int
            @param album_pop as int
            @param album_rate as int
            @param album_synced as int
            @param album_mtime as int
            @param storage_type as int
            @return (True, int, [int], [int])
        """
        Logger.debug("CollectionScanner::save_album(): "
                     "Add album artists %s" % album_artists)
        (added_album_artist_ids,
         album_artist_ids) = self.add_artists(album_artists, aa_sortnames,
                                              mb_album_artist_id)
        Logger.debug("CollectionScanner::save_track(): Add album: "
                     "%s, %s" % (album_name, album_artist_ids))
        (album_added, album_id) = self.add_album(album_name, mb_album_id,
                                                 album_artist_ids,
                                                 uri, album_loved, album_pop,
                                                 album_rate, album_synced,
                                                 album_mtime, storage_type)
        return (album_added, album_id, album_artist_ids,
                added_album_artist_ids)

    def save_track(self, genres, artists, a_sortnames, mb_artist_id, uri,
                   title, duration, tracknumber, discnumber, discname,
                   year, timestamp, track_mtime, track_pop, track_rate,
                   track_loved, track_ltime, mb_track_id, bpm,
                   album_added, album_id, album_artist_ids,
                   added_album_artist_ids, storage_type):
        """
            Add track to DB
            @param genres as str/None
            @param artists as str
            @param a_sortnames as str
            @param mb_artist_id as str
            @param uri as str
            @param title as str
            @param duration as int
            @param tracknumber as int
            @param discnumber as int
            @param discname as str
            @param year as int
            @param timestamp as int
            @param track_mtime as int
            @param track_pop as int
            @param track_rate as int
            @param track_loved as int
            @param track_ltime as int
            @param mb_track_id as str
            @param bpm as int
            @param album_added as bool
            @param album_id as int
            @param album_artist_ids as [int]
            @param added_album_artist_ids as [int]
            @param storage_type as int
            @return (track_id as int, album_id as int)
        """
        Logger.debug(
            "CollectionScanner::save_track(): Add artists %s" % artists)
        (added_artist_ids,
         artist_ids) = self.add_artists(artists, a_sortnames, mb_artist_id)

        # User does not want compilations
        if self.__disable_compilations and not album_artist_ids:
            album_artist_ids = artist_ids

        missing_artist_ids = list(set(album_artist_ids) - set(artist_ids))
        # Special case for broken tags
        # If all artist album tags are missing
        # Can't do more because don't want to break split album behaviour
        if len(missing_artist_ids) == len(album_artist_ids):
            artist_ids += missing_artist_ids

        if genres is None:
            added_genre_ids = []
            genre_ids = [Type.WEB]
        else:
            (added_genre_ids, genre_ids) = self.add_genres(genres)

        # Add track to db
        Logger.debug("CollectionScanner::save_track(): Add track")
        track_id = App().tracks.add(title, uri, duration,
                                    tracknumber, discnumber, discname,
                                    album_id, year, timestamp, track_pop,
                                    track_rate, track_loved, track_ltime,
                                    track_mtime, mb_track_id, bpm,
                                    storage_type)
        Logger.debug("CollectionScanner::save_track(): Update track")
        self.update_track(track_id, artist_ids, genre_ids)
        Logger.debug("CollectionScanner::save_track(): Update album")
        self.update_album(album_id, album_artist_ids,
                          genre_ids, year, timestamp)
        SqlCursor.commit(App().db)
        # We need to keep new artist because we don't know yet if we
        # are playing with a compilation or an album
        self.__new_non_album_artists += list(
            set(added_artist_ids) - set(added_album_artist_ids))
        if storage_type & (StorageType.COLLECTION |
                           StorageType.SPOTIFY_NEW_RELEASES |
                           StorageType.SPOTIFY_SIMILARS):
            for artist_id in added_album_artist_ids:
                if artist_id in self.__new_non_album_artists:
                    self.__new_non_album_artists.remove(artist_id)
                emit_signal(self, "artist-updated", artist_id,
                            ScanUpdate.ADDED)
            for genre_id in added_genre_ids:
                emit_signal(self, "genre-updated", genre_id, ScanUpdate.ADDED)
            if album_added:
                emit_signal(self, "album-updated", album_id, ScanUpdate.ADDED)
            else:
                emit_signal(self, "album-updated", album_id,
                            ScanUpdate.MODIFIED)
        return (track_id, album_id)

    def update_track(self, track_id, artist_ids, genre_ids):
        """
            Set track artists/genres
            @param track_id as int
            @param artist_ids as [int]
            @param genre_ids as [int]
            @commit needed
        """
        # Set artists/genres for track
        for artist_id in artist_ids:
            App().tracks.add_artist(track_id, artist_id)
        for genre_id in genre_ids:
            App().tracks.add_genre(track_id, genre_id)

    def del_from_db(self, uri, backup):
        """
            Delete track from db
            @param uri as str
            @param backup as bool
            @return (popularity, ltime, mtime,
                     loved album, album_popularity)
        """
        try:
            track_id = App().tracks.get_id_by_uri(uri)
            duration = App().tracks.get_duration(track_id)
            album_id = App().tracks.get_album_id(track_id)
            album_artist_ids = App().albums.get_artist_ids(album_id)
            artist_ids = App().tracks.get_artist_ids(track_id)
            track_pop = App().tracks.get_popularity(track_id)
            track_rate = App().tracks.get_rate(track_id)
            track_ltime = App().tracks.get_ltime(track_id)
            album_mtime = App().tracks.get_mtime(track_id)
            track_loved = App().tracks.get_loved(track_id)
            album_pop = App().albums.get_popularity(album_id)
            album_rate = App().albums.get_rate(album_id)
            album_loved = App().albums.get_loved(album_id)
            album_synced = App().albums.get_synced(album_id)
            if backup:
                f = Gio.File.new_for_uri(uri)
                name = f.get_basename()
                self.__history.add(name, duration, track_pop, track_rate,
                                   track_ltime, album_mtime, track_loved,
                                   album_loved, album_pop, album_rate,
                                   album_synced)
            App().tracks.remove(track_id)
            # Force genre for album
            genre_ids = App().tracks.get_genre_ids(track_id)
            App().albums.set_genre_ids(album_id, genre_ids)
            App().albums.clean()
            App().genres.clean()
            App().artists.clean()
            SqlCursor.commit(App().db)
            if not App().albums.get_name(album_id):
                emit_signal(self, "album-updated", album_id,
                            ScanUpdate.REMOVED)
                for artist_id in album_artist_ids + artist_ids:
                    emit_signal(self, "artist-updated", artist_id,
                                ScanUpdate.REMOVED)
                for genre_id in genre_ids:
                    emit_signal(self, "genre-updated", genre_id,
                                ScanUpdate.REMOVED)
            else:
                emit_signal(self, "album-updated", album_id,
                            ScanUpdate.MODIFIED)
            return (track_pop, track_rate, track_ltime, album_mtime,
                    track_loved, album_loved, album_pop, album_rate)
        except Exception as e:
            Logger.error("CollectionScanner::del_from_db: %s" % e)

    def is_locked(self):
        """
            True if db locked
            @return bool
        """
        return self.__thread is not None and self.__thread.isAlive()

    def stop(self):
        """
            Stop scan
        """
        self.__thread = None

    @property
    def inotify(self):
        """
            Get Inotify object
            @return Inotify
        """
        return self.__inotify

#######################
# PRIVATE             #
#######################
    def __update_progress(self, current, total):
        """
            Update progress bar status
            @param scanned items as int, total items as int
        """
        GLib.idle_add(App().window.container.progress.set_fraction,
                      current / total,
                      self)

    def __finish(self, new_track_ids):
        """
            Notify from main thread when scan finished
            @param new_track_ids as int
        """
        self.__thread = None
        Logger.info("Scan finished")
        App().lookup_action("update_db").set_enabled(True)
        App().window.container.progress.set_fraction(1.0, self)
        self.stop()
        emit_signal(self, "scan-finished", new_track_ids)
        # Update max count value
        App().albums.update_max_count()
        App().start_spotify()

    def __add_monitor(self, dirs):
        """
            Monitor any change in a list of directory
            @param dirs as str or list of directory to be monitored
        """
        if self.__inotify is None:
            return
        # Add monitors on dirs
        for d in dirs:
            # Handle a stop request
            if self.__thread is None:
                break
            if d.startswith("file://"):
                self.__inotify.add_monitor(d)

    @profile
    def __get_objects_for_uris(self, scan_type, uris):
        """
            Get all tracks and dirs in uris
            @param scan_type as ScanType
            @param uris as string
            @return (tracks [mtimes: int, uri: str], dirs as [uri: str])
        """
        files = []
        dirs = []
        walk_uris = []
        # Check collection exists
        for uri in uris:
            f = Gio.File.new_for_uri(uri)
            if f.query_exists():
                walk_uris.append(uri)
            else:
                return (None, None)

        while walk_uris:
            uri = walk_uris.pop(0)
            try:
                # Directly add files, walk through directories
                f = Gio.File.new_for_uri(uri)
                info = f.query_info(SCAN_QUERY_INFO,
                                    Gio.FileQueryInfoFlags.NONE,
                                    None)
                if info.get_file_type() == Gio.FileType.DIRECTORY:
                    dirs.append(uri)
                    infos = f.enumerate_children(SCAN_QUERY_INFO,
                                                 Gio.FileQueryInfoFlags.NONE,
                                                 None)
                    for info in infos:
                        f = infos.get_child(info)
                        child_uri = f.get_uri()
                        if info.get_is_hidden():
                            continue
                        # User do not want internal symlinks
                        elif info.get_is_symlink() and\
                                App().settings.get_value("ignore-symlinks"):
                            continue
                        walk_uris.append(child_uri)
                    infos.close(None)
                # Only happens if files passed as args
                else:
                    mtime = get_mtime(info)
                    files.append((mtime, uri))
            except Exception as e:
                Logger.error("CollectionScanner::__get_objects_for_uris(): %s"
                             % e)
        files.sort(reverse=True)
        return (files, dirs)

    @profile
    def __scan(self, scan_type, uris):
        """
            Scan music collection for music files
            @param scan_type as ScanType
            @param uris as [str]
            @thread safe
        """
        self.__new_non_album_artists = []
        SqlCursor.add(App().db)
        App().art.clean_rounded()

        (files, dirs) = self.__get_objects_for_uris(scan_type, uris)

        if files is None:
            App().notify.send("Lollypop",
                              _("Scan disabled, missing collection"))
            return

        if scan_type == ScanType.NEW_FILES:
            db_uris = App().tracks.get_uris(uris)
        else:
            db_uris = App().tracks.get_uris()

        # Get mtime of all tracks to detect which has to be updated
        db_mtimes = App().tracks.get_mtimes()
        self.__progress_total = len(files)
        self.__progress_count = 0
        (current_track_ids,
         new_track_ids) = self.__scan_files(files, db_mtimes, scan_type)
        self.__remove_old_tracks(db_uris, scan_type)

        SqlCursor.remove(App().db)

        if scan_type != ScanType.EXTERNAL:
            self.__add_monitor(dirs)
            GLib.idle_add(self.__finish, new_track_ids)

        if scan_type == ScanType.EXTERNAL:
            track_ids = current_track_ids + new_track_ids
            albums = tracks_to_albums(
                [Track(track_id) for track_id in track_ids])
            App().player.play_albums(albums)

    def __scan_to_handle(self, uri):
        """
            Check if file has to be handle by scanner
            @param f as Gio.File
            @return bool
        """
        try:
            f = Gio.File.new_for_uri(uri)
            # Scan file
            if is_pls(f):
                # Import playlist
                if App().settings.get_value("import-playlists"):
                    App().playlists.import_tracks(f)
            elif is_audio(f):
                return True
            else:
                Logger.debug("Not detected as a music file: %s" % f.get_uri())
        except Exception as e:
            Logger.error("CollectionScanner::__scan_to_handle(): %s" % e)
        return False

    def __scan_files(self, files, db_mtimes, scan_type):
        """
            Scan music collection for new audio files
            @param files as [str]
            @param db_mtimes as {}
            @param scan_type as ScanType
            @return (track_ids as [int], new_track_ids as [int])
            @thread safe
        """
        track_ids = new_track_ids = []
        SqlCursor.add(App().db)
        discoverer = Discoverer()
        try:
            # Scan new files
            for (mtime, uri) in files:
                # Handle a stop request
                if self.__thread is None and scan_type != ScanType.EXTERNAL:
                    raise Exception("cancelled")
                try:
                    if not self.__scan_to_handle(uri):
                        continue
                    db_mtime = db_mtimes.get(uri, 0)
                    if mtime > db_mtime:
                        # If not saved, use 0 as mtime, easy delete on quit
                        if scan_type == ScanType.EXTERNAL:
                            storage_type = StorageType.EXTERNAL
                        else:
                            storage_type = StorageType.COLLECTION
                        # Do not use mtime if not intial scan
                        if db_mtimes:
                            mtime = int(time())
                        Logger.debug("Adding file: %s" % uri)
                        track_id = self.__add2db(
                            discoverer, uri, mtime, storage_type)
                        new_track_ids.append(track_id)
                    elif scan_type == ScanType.EXTERNAL:
                        track_id = App().tracks.get_id_by_uri(uri)
                        track_ids.append(track_id)
                except Exception as e:
                    Logger.error("Adding file: %s, %s" % (uri, e))
                self.__progress_count += 1
                self.__update_progress(self.__progress_count,
                                       self.__progress_total)
        except Exception as e:
            Logger.warning("CollectionScanner::__scan_files(): % s" % e)
        for artist_id in self.__new_non_album_artists:
            album_ids = App().albums.get_ids([artist_id], [],
                                             StorageType.COLLECTION)
            if album_ids:
                emit_signal(self, "artist-updated",
                            artist_id, ScanUpdate.ADDED)
        SqlCursor.remove(App().db)
        return (track_ids, new_track_ids)

    def __remove_old_tracks(self, uris, scan_type):
        """
            Remove non existent tracks from DB
            @param scan_type as ScanType
        """
        if scan_type != ScanType.EXTERNAL and self.__thread is not None:
            # We need to check files are always in collections
            if scan_type == ScanType.FULL:
                collections = App().settings.get_music_uris()
            else:
                collections = None
            for uri in uris:
                # Handle a stop request
                if self.__thread is None:
                    raise Exception("cancelled")
                in_collection = True
                if collections is not None:
                    in_collection = False
                    for collection in collections:
                        if collection in uri:
                            in_collection = True
                            break
                f = Gio.File.new_for_uri(uri)
                if not in_collection:
                    Logger.warning(
                        "Removed, not in collection anymore: %s -> %s",
                        uri, collections)
                    self.del_from_db(uri, True)
                elif not f.query_exists():
                    Logger.warning("Removed, file has been deleted: %s", uri)
                    self.del_from_db(uri, True)

    def __add2db(self, discoverer, uri,
                 track_mtime, storage_type=StorageType.COLLECTION):
        """
            Add new file(or update one) to db with information
            @param discoverer as Discoverer
            @param uri as string
            @param track_mtime as int
            @param storage_type as StorageType
            @return track_id as int
        """
        f = Gio.File.new_for_uri(uri)
        Logger.debug("CollectionScanner::add2db(): Read tags")
        info = discoverer.get_info(uri)
        tags = info.get_tags()
        name = f.get_basename()
        title = self.get_title(tags, name)
        version = self.get_version(tags)
        artists = self.get_artists(tags)
        composers = self.get_composers(tags)
        performers = self.get_performers(tags)
        remixers = self.get_remixers(tags)
        if remixers != "":
            artists += ";%s" % remixers
        a_sortnames = self.get_artist_sortnames(tags)
        aa_sortnames = self.get_album_artist_sortnames(tags)
        album_artists = self.get_album_artists(tags)
        album_name = self.get_album_name(tags)
        album_synced = 0
        mb_album_id = self.get_mb_album_id(tags)
        mb_track_id = self.get_mb_track_id(tags)
        mb_artist_id = self.get_mb_artist_id(tags)
        mb_album_artist_id = self.get_mb_album_artist_id(tags)
        genres = self.get_genres(tags)
        discnumber = self.get_discnumber(tags)
        discname = self.get_discname(tags)
        tracknumber = self.get_tracknumber(tags, name)
        track_popm = self.get_popm(tags)
        bpm = self.get_bpm(tags)
        (year, timestamp) = self.get_original_year(tags)
        if year is None:
            (year, timestamp) = self.get_year(tags)
        duration = int(info.get_duration() / 1000000)

        if version != "":
            title += " (%s)" % version

        # If no artists tag, use album artist
        if artists == "":
            artists = album_artists
        # if artists is always null, no album artists too,
        # use composer/performer
        if artists == "":
            artists = performers
            album_artists = composers
            if artists == "":
                artists = album_artists
            if artists == "":
                artists = _("Unknown")

        Logger.debug("CollectionScanner::add2db(): Restore stats")
        # Restore stats
        track_id = App().tracks.get_id_by_uri(uri)
        if track_id is None:
            basename = f.get_basename()
            track_id = App().tracks.get_id_by_basename_duration(basename,
                                                                duration)
        if track_id is None:
            (track_pop, track_rate, track_ltime,
             album_mtime, track_loved, album_loved,
             album_pop, album_rate, album_synced) = self.__history.get(
                name, duration)
        # Delete track and restore from it
        else:
            (track_pop, track_rate, track_ltime,
             album_mtime, track_loved, album_loved,
             album_pop, album_rate) = self.del_from_db(uri, False)
        # Prefer popm to internal rate
        if track_popm != 0:
            track_rate = track_popm
        # If nothing in stats, use track mtime
        if album_mtime == 0:
            album_mtime = track_mtime

        (album_saved, album_id,
         album_artist_ids, added_album_artist_ids) = self.save_album(
                        album_artists, aa_sortnames, mb_album_artist_id,
                        album_name, mb_album_id, uri, album_loved, album_pop,
                        album_rate, album_synced, album_mtime, storage_type)
        (track_id, album_id) = self.save_track(
                        genres, artists, a_sortnames, mb_artist_id,
                        uri, title, duration, tracknumber, discnumber,
                        discname, year, timestamp, track_mtime, track_pop,
                        track_rate, track_loved, track_ltime, mb_track_id,
                        bpm, album_saved, album_id, album_artist_ids,
                        added_album_artist_ids, storage_type)
        return track_id
