# -*- coding: utf-8 -*-
#
# codimension - graphics python two-way code editor and analyzer
# Copyright (C) 2010-2016  Sergey Satskiy <sergey.satskiy@gmail.com>
#
# 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/>.
#

"""
The watcher cares of the given directories structures.
If somethig has been changed then it emits a signal.
It accepts a set of exclude filters and a set of dirs to watch.
The watcher will ignore the directories which do not exist.
"""

# pylint: disable=W0702
# pylint: disable=W0703

import os
import os.path
import re
from ui.qt import QObject, QFileSystemWatcher, pyqtSignal


class Watcher(QObject):

    """Filesystem watcher implementation"""

    sigFSChanged = pyqtSignal(list)

    def __init__(self, excludeFilters, dirToWatch):
        QObject.__init__(self)
        self.__dirWatcher = QFileSystemWatcher(self)

        # data members
        self.__excludeFilter = []       # Files exclude filter
        self.__srcDirsToWatch = set()   # Came from the user

        self.__fsTopLevelSnapshot = {}  # Current snapshot
        self.__fsSnapshot = {}          # Current snapshot

        # Sets of dirs which are currently watched
        self.__dirsToWatch = set()
        self.__topLevelDirsToWatch = set()      # Generated till root

        # precompile filters
        for flt in excludeFilters:
            self.__excludeFilter.append(re.compile(flt))

        # Initialise the list of dirs to watch
        self.__srcDirsToWatch.add(dirToWatch)

        self.__topLevelDirsToWatch = self.__buildTopDirsList(
            self.__srcDirsToWatch)
        self.__fsTopLevelSnapshot = self.__buildTopLevelSnapshot(
            self.__topLevelDirsToWatch, self.__srcDirsToWatch)
        self.__dirsToWatch = self.__buildSnapshot()

        # Here __dirsToWatch and __topLevelDirsToWatch have a complete
        # set of what should be watched

        # Add the dirs to the watcher
        dirs = []
        for path in self.__dirsToWatch | self.__topLevelDirsToWatch:
            dirs.append(path)
        self.__dirWatcher.addPaths(dirs)
        self.__dirWatcher.directoryChanged.connect(self.__onDirChanged)

        # self.debug()

    @staticmethod
    def __buildTopDirsList(srcDirs):
        """Takes a list of dirs to be watched and builds top dirs set"""
        topDirsList = set()
        for path in srcDirs:
            parts = path.split(os.path.sep)
            for index in range(1, len(parts) - 1):
                candidate = os.path.sep.join(parts[0:index]) + os.path.sep
                if os.path.exists(candidate):
                    if os.access(candidate, os.R_OK):
                        topDirsList.add(candidate)
        return topDirsList

    @staticmethod
    def __buildTopLevelSnapshot(topLevelDirs, srcDirs):
        """Takes top level dirs and builds their snapshot"""
        snapshot = {}
        for path in topLevelDirs:
            itemsSet = set()
            # search for all the dirs to be watched
            for candidate in topLevelDirs | srcDirs:
                if len(candidate) <= len(path):
                    continue
                if candidate.startswith(path):
                    candidate = candidate[len(path):]
                    slashIndex = candidate.find(os.path.sep) + 1
                    item = candidate[:slashIndex]
                    if os.path.exists(path + item):
                        itemsSet.add(item)
            snapshot[path] = itemsSet
        return snapshot

    def __buildSnapshot(self):
        """Builds the filesystem snapshot"""
        snapshotDirs = set()
        for path in self.__srcDirsToWatch:
            self.__addSnapshotPath(path, snapshotDirs)
        return snapshotDirs

    def __addSnapshotPath(self, path, snapshotDirs, itemsToReport=None):
        """Adds one path to the FS snapshot"""
        if not os.path.exists(path):
            return

        snapshotDirs.add(path)
        dirItems = set()
        for item in os.listdir(path):
            if self.__shouldExclude(item):
                continue
            if os.path.isdir(path + item):
                dirName = path + item + os.path.sep
                dirItems.add(item + os.path.sep)
                if itemsToReport is not None:
                    itemsToReport.append("+" + dirName)
                self.__addSnapshotPath(dirName, snapshotDirs, itemsToReport)
                continue
            dirItems.add(item)
            if itemsToReport is not None:
                itemsToReport.append("+" + path + item)
        self.__fsSnapshot[path] = dirItems
        return

    def __onDirChanged(self, path):
        """Triggered when the dir is changed"""
        if not path.endswith(os.path.sep):
            path = path + os.path.sep

        # Check if it is a top level dir
        try:
            oldSet = self.__fsTopLevelSnapshot[path]

            # Build a new set of what is in that top level dir
            newSet = set()
            for item in os.listdir(path):
                if not os.path.isdir(path + item):
                    continue    # Only dirs are of interest for the top level
                item = item + os.path.sep
                if item in oldSet:
                    newSet.add(item)
            # Now we have an old set and a new one with those from the old
            # which actually exist
            diff = oldSet - newSet

            # diff are those which disappeared. We need to do the following:
            # - build a list of all the items in the fs snapshot which start
            #   from this dir
            # - build a list of dirs which should be deregistered from the
            #   watcher. This list includes both top level and project level
            # - deregister dirs from the watcher
            # - emit a signal of what disappeared
            if not diff:
                return  # no changes

            self.__fsTopLevelSnapshot[path] = newSet

            dirsToBeRemoved = []
            itemsToReport = []

            for item in diff:
                self.__processRemoveTopDir(path + item, dirsToBeRemoved,
                                           itemsToReport)

            # Here: it is possible that the last dir to watch disappeared
            if not newSet:
                # There is nothing to watch here anymore
                dirsToBeRemoved.append(path)
                del self.__fsTopLevelSnapshot[path]

                parts = path[1:-1].split(os.path.sep)
                for index in range(len(parts) - 2, 0, -1):
                    candidate = os.path.sep + \
                                os.path.sep.join(parts[0:index]) + \
                                os.path.sep
                    dirSet = self.__fsTopLevelSnapshot[candidate]
                    dirSet.remove(parts[index + 1] + os.path.sep)
                    if not dirSet:
                        dirsToBeRemoved.append(candidate)
                        del self.__fsTopLevelSnapshot[candidate]
                        continue
                    break   # it is not the last item in the set

            # Update the watcher
            if dirsToBeRemoved:
                self.__dirWatcher.removePaths(dirsToBeRemoved)

            # Report
            if itemsToReport:
                self.sigFSChanged.emit(itemsToReport)
            return
        except:
            # it is not a top level dir - no key
            pass

        # Here: the change is in the project level dir
        try:
            oldSet = self.__fsSnapshot[path]

            # Build a new set of what is in that top level dir
            newSet = set()
            for item in os.listdir(path):
                if self.__shouldExclude(item):
                    continue
                if os.path.isdir(path + item):
                    newSet.add(item + os.path.sep)
                else:
                    newSet.add(item)

            # Here: we have a new and old snapshots
            # Lets calculate the difference
            deletedItems = oldSet - newSet
            addedItems = newSet - oldSet

            if not deletedItems and not addedItems:
                return  # No changes

            # Update the changed dir set
            self.__fsSnapshot[path] = newSet

            # We need to build some lists:
            # - list of files which were added
            # - list of dirs which were added
            # - list of files which were deleted
            # - list of dirs which were deleted
            # The deleted dirs must be unregistered in the watcher
            # The added dirs must be registered
            itemsToReport = []
            dirsToBeAdded = []
            dirsToBeRemoved = []

            for item in addedItems:
                if item.endswith(os.path.sep):
                    # directory was added
                    self.__processAddedDir(path + item,
                                           dirsToBeAdded, itemsToReport)
                else:
                    itemsToReport.append("+" + path + item)

            for item in deletedItems:
                if item.endswith(os.path.sep):
                    # directory was deleted
                    self.__processRemovedDir(path + item,
                                             dirsToBeRemoved, itemsToReport)
                else:
                    itemsToReport.append("-" + path + item)

            # Update the watcher
            if dirsToBeRemoved:
                self.__dirWatcher.removePaths(dirsToBeRemoved)
            if dirsToBeAdded:
                self.__dirWatcher.addPaths(dirsToBeAdded)

            # Report
            self.sigFSChanged.emit(itemsToReport)
        except:
            # It could be a queued signal about what was already reported
            pass

        # self.debug()
        return

    def __shouldExclude(self, name):
        """Tests if a file must be excluded"""
        for excl in self.__excludeFilter:
            if excl.match(name):
                return True
        return False

    def __processAddedDir(self, path, dirsToBeAdded, itemsToReport):
        """called for an appeared dir in the project tree"""
        dirsToBeAdded.append(path)
        itemsToReport.append("+" + path)

        # it should add dirs recursively into the snapshot and care
        # of the items to report
        dirItems = set()
        for item in os.listdir(path):
            if self.__shouldExclude(item):
                continue
            if os.path.isdir(path + item):
                dirName = path + item + os.path.sep
                dirItems.add(item + os.path.sep)
                self.__processAddedDir(dirName, dirsToBeAdded, itemsToReport)
                continue
            itemsToReport.append("+" + path + item)
            dirItems.add(item)
        self.__fsSnapshot[path] = dirItems

    def __processRemovedDir(self, path, dirsToBeRemoved, itemsToReport):
        """called for a disappeared dir in the project tree"""
        # it should remove the dirs recursively from the fs snapshot
        # and care of items to report
        dirsToBeRemoved.append(path)
        itemsToReport.append("-" + path)

        oldSet = self.__fsSnapshot[path]
        for item in oldSet:
            if item.endswith(os.path.sep):
                # Nested dir
                self.__processRemovedDir(path + item, dirsToBeRemoved,
                                         itemsToReport)
            else:
                # a file
                itemsToReport.append("-" + path + item)
        del self.__fsSnapshot[path]

    def __processRemoveTopDir(self, path, dirsToBeRemoved, itemsToReport):
        """Called for a disappeared top level dir"""
        if path in self.__fsTopLevelSnapshot:
            # It is still a top level dir
            dirsToBeRemoved.append(path)
            for item in self.__fsTopLevelSnapshot[path]:
                self.__processRemoveTopDir(path + item, dirsToBeRemoved,
                                           itemsToReport)
            del self.__fsTopLevelSnapshot[path]
        else:
            # This is a project level dir
            self.__processRemovedDir(path, dirsToBeRemoved,
                                     itemsToReport)

    def reset(self):
        """Resets the watcher (it does not report any changes)"""
        self.__dirWatcher.removePaths(self.__dirWatcher.directories())

        self.__srcDirsToWatch = set()

        self.__fsTopLevelSnapshot = {}
        self.__fsSnapshot = {}

        self.__dirsToWatch = set()
        self.__topLevelDirsToWatch = set()

    def registerDir(self, path):
        """Adds a directory to the list of watched ones"""
        if not path.endswith(os.path.sep):
            path = path + os.path.sep

        if path in self.__srcDirsToWatch:
            return  # It is there already

        # It is necessary to do the following:
        # - add the dir to the fs snapshot
        # - collect dirs to add to the watcher
        # - collect items to report
        self.__srcDirsToWatch.add(path)

        dirsToWatch = set()
        itemsToReport = []
        self.__registerDir(path, dirsToWatch, itemsToReport)

        # It might be that top level dirs should be updated too
        newTopLevelDirsToWatch = self.__buildTopDirsList(self.__srcDirsToWatch)
        addedDirs = newTopLevelDirsToWatch - self.__topLevelDirsToWatch

        for item in addedDirs:
            dirsToWatch.add(item)

            # Identify items to be watched by this dir
            dirItems = set()
            for candidate in newTopLevelDirsToWatch | self.__srcDirsToWatch:
                if len(candidate) <= len(item):
                    continue
                if candidate.startswith(item):
                    candidate = candidate[len(item):]
                    slashIndex = candidate.find(os.path.sep) + 1
                    dirName = candidate[:slashIndex]
                    if os.path.exists(item + dirName):
                        dirItems.add(dirName)
            # Update the top level dirs snapshot
            self.__fsTopLevelSnapshot[item] = dirItems

        # Update the top level snapshot with the added dir
        upperDir = os.path.dirname(path[:-1]) + os.path.sep
        dirName = path.replace(upperDir, '')
        self.__fsTopLevelSnapshot[upperDir].add(dirName)

        # Update the list of top level dirs to watch
        self.__topLevelDirsToWatch = newTopLevelDirsToWatch

        # Update the watcher
        if dirsToWatch:
            dirs = []
            for item in dirsToWatch:
                dirs.append(item)
            self.__dirWatcher.addPaths(dirs)

        # Report the changes
        if itemsToReport:
            self.sigFSChanged.emit(itemsToReport)

        # self.debug()
        return

    def __registerDir(self, path, dirsToWatch, itemsToReport):
        """Adds one path to the FS snapshot"""
        if not os.path.exists(path):
            return

        dirsToWatch.add(path)
        itemsToReport.append("+" + path)

        dirItems = set()
        for item in os.listdir(path):
            if self.__shouldExclude(item):
                continue
            if os.path.isdir(path + item):
                dirName = path + item + os.path.sep
                dirItems.add(item + os.path.sep)
                itemsToReport.append("+" + path + item + os.path.sep)
                self.__addSnapshotPath(dirName, dirsToWatch, itemsToReport)
                continue
            dirItems.add(item)
            itemsToReport.append("+" + path + item)
        self.__fsSnapshot[path] = dirItems

    def deregisterDir(self, path):
        """Removes the directory from the list of the watched ones"""
        if not path.endswith(os.path.sep):
            path = path + os.path.sep

        if path not in self.__srcDirsToWatch:
            return  # It is not there already
        self.__srcDirsToWatch.remove(path)

        # It is necessary to do the following:
        # - remove the dir from the fs snapshot
        # - collect the dirs to be removed from watching
        # - collect item to report

        itemsToReport = []
        dirsToBeRemoved = []

        self.__deregisterDir(path, dirsToBeRemoved, itemsToReport)

        # It is possible that some of the top level watched dirs should be
        # removed as well
        newTopLevelDirsToWatch = self.__buildTopDirsList(self.__srcDirsToWatch)
        deletedDirs = self.__topLevelDirsToWatch - newTopLevelDirsToWatch

        for item in deletedDirs:
            dirsToBeRemoved.append(item)
            del self.__fsTopLevelSnapshot[item]

        # It might be the case that some of the items should be deleted in the
        # top level dirs sets
        for dirName in self.__fsTopLevelSnapshot:
            itemsSet = self.__fsTopLevelSnapshot[dirName]
            for item in itemsSet:
                candidate = dirName + item
                if candidate == path or candidate in deletedDirs:
                    itemsSet.remove(item)
                    self.__fsTopLevelSnapshot[dirName] = itemsSet
                    break

        # Update the list of dirs to be watched
        self.__topLevelDirsToWatch = newTopLevelDirsToWatch

        # Update the watcher
        if dirsToBeRemoved:
            self.__dirWatcher.removePaths(dirsToBeRemoved)

        # Report the changes
        if itemsToReport:
            self.sigFSChanged.emit(itemsToReport)

        # self.debug()

    def __deregisterDir(self, path, dirsToBeRemoved, itemsToReport):
        """Deregisters a directory recursively"""
        dirsToBeRemoved.append(path)
        itemsToReport.append("-" + path)
        if path in self.__fsTopLevelSnapshot:
            # This is a top level dir
            for item in self.__fsTopLevelSnapshot[path]:
                if item.endswith(os.path.sep):
                    # It's a dir
                    self.__deregisterDir(path + item, dirsToBeRemoved,
                                         itemsToReport)
                else:
                    # It's a file
                    itemsToReport.append("-" + path + item)
            del self.__fsTopLevelSnapshot[path]
            return

        # It is from an a project level snapshot
        if path in self.__fsSnapshot:
            for item in self.__fsSnapshot[path]:
                if item.endswith(os.path.sep):
                    # It's a dir
                    self.__deregisterDir(path + item, dirsToBeRemoved,
                                         itemsToReport)
                else:
                    # It's a file
                    itemsToReport.append("-" + path + item)
            del self.__fsSnapshot[path]
        return

    def debug(self):
        """Debugging printouts"""
        print("Top level dirs to watch: " + str(self.__topLevelDirsToWatch))
        print("Project dirs to watch: " + str(self.__dirsToWatch))

        print("Top level snapshot: " + str(self.__fsTopLevelSnapshot))
        print("Project snapshot: " + str(self.__fsSnapshot))