#!/usr/bin/env python3
# -*- coding: utf-8 -*- vim60:fdm=marker
#
# Copyright: 2016, Maximiliano Curia <maxy@debian.org>
#
# License: ISC
#  Permission to use, copy, modify, and/or distribute this software for any
#  purpose with or without fee is hereby granted, provided that the above
#  copyright notice and this permission notice appear in all copies.
#  .
#  THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
#  REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
#  AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
#  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
#  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
#  OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
#  PERFORMANCE OF THIS SOFTWARE.

''' Files and Directories information '''

import logging
import os

try:
    import regex as re
except ImportError:
    import re

try:
    from tqdm import tqdm
except ImportError:
    def tqdm(*a, **kw):
        if a:
            return a[0]
        return kw.get('iterable', None)

from concurrent import futures

from .datatypes import UNKNOWN, UNKNOWN_COPYRIGHTED
from .parsers import parse_file


# Compatibility function - Python 3.4 doesn't have os.path.commonpath
if not hasattr(os.path, "commonpath"):
    def commonpath(paths):
        prefix = os.path.commonprefix(paths)
        if not os.path.exists(prefix):
            prefix = prefix.rsplit('/', 1)[0]
        return prefix
    os.path.commonpath = commonpath


class FileInfo(object):

    def __init__(self, parent, name):
        self.parent = parent
        self.name = name
        self.licenses = set()
        self.license_filenames = set()
        # Keep track of files that are assigned to a group
        self.group = None
        # Value retrieved from the debian/copyright file
        self.stored_group = None
        # The pattern in the debian/copyright that matches this file
        self.matching_pattern = None
        self.copyrights = CopyrightGroup()
        # part of the wildcard logic, was this element a partially
        # printed?
        self.tallied = False
        # Store whether file was included in the files/dirs to process
        self._included = None

    def __str__(self):
        return self.fullname

    @property
    def fullname(self):
        if self.parent:
            return os.path.join(self.parent.fullname, self.name)
        return self.name

    @property
    def parsed_license(self):
        return ' or '.join(sorted(self.licenses))

    def add_copyrights(self, copyrights):
        self.copyrights.extend(copyrights)

    def add_licenses(self, licenses, filenames=None):
        '''Add one or more detected licenses for this file.

           Args:
               licenses: a set of license keys.
               filenames: for licenses stored in separate files, the filenames
                   hold the license information.
        '''
        self.licenses.update(licenses)
        if filenames:
            self.license_filenames.update(filenames)

    def get_licenses(self):
        if self.licenses or not self.parent:
            return self.licenses
        return self.parent.get_licenses()

    def get_license_filenames(self):
        if self.licenses or not self.parent:
            return self.license_filenames
        return self.parent.get_license_filenames()

    def get_license_key(self):
        '''Identifier corresponding to the license to be assigned to the file.
           The logic to choose the license is quite complex. This is the order
           for choosing:
            1 - An explicit entry in debian/copyright (i.e. not wildcarded)
            2 - The license detected when parsing the file
            3 - The license stored in debian/copyright (wildcarded)
            4 - The license for the containing directory.

           If none of those is found, then the file is marked as UNKNOWN.
        '''
        if self.stored_group and self.fullname == self.matching_pattern:
            return self.stored_group.get_license_key()
        if self.parsed_license:
            return self.parsed_license
        if self.stored_group:
            return self.stored_group.get_license_key()
        if self.parent:
            return self.parent.get_license_key()
        if self.copyrights:
            return UNKNOWN_COPYRIGHTED
        return UNKNOWN

    def get_copyright_key(self):
        ''' Identifier corresponding to the copyright holders found '''
        return self.copyrights.key()

    def get_splitting_path(self, options):
        '''Identifier corresponding to the path used for splitting groups.'''
        if self.parent:
            return self.parent.get_splitting_path(options)

    def get_group_key(self, options):
        '''Identifier to split files into groups in the copyright file.'''
        license_key = self.get_license_key()
        if options.group_by == 'copyright':
            copyright_key = self.get_copyright_key()
        else:
            copyright_key = None

        path_key = self.get_splitting_path(options)

        group_key = (license_key, copyright_key, path_key)
        return group_key

    def get_first_untallied(self):
        '''Returns the topmost entry that has not yet been tallied.
           Should not be called with an entry that has already been tallied.
        '''
        assert not self.tallied
        if self.parent and not self.parent.tallied:
            return self.parent.get_first_untallied()
        return self

    def tally(self):
        '''Marks this item and all its parents as tallied.'''
        if self.tallied:
            return
        self.tallied = True
        if self.parent:
            self.parent.tally()

    @property
    def included(self):
        if self._included is not None:
            return self._included
        if self.parent:
            self._included = self.parent.included
            return self._included
        return False

    @included.setter
    def included(self, value):
        self._included = value

    def process_licenses(self, root, options):
        '''Parse the license and copyright information for this file.'''
        # First check if we actually want to process this file
        if options.exclude_fullname_re.match(self.fullname) or \
           options.exclude_special_re.match(self.fullname):
            logging.debug('Ignored file %s (by --exclude)', self.name)
            return
        # Now read it and parse any license or copyright
        fullpath = os.path.join(root, self.fullname)
        copyrights, licenses = parse_file(fullpath, options)
        logging.debug("Adding copyrights %s, to %s", copyrights, self)
        self.add_copyrights(copyrights)
        logging.debug("Resulted in %s", self.copyrights)
        logging.debug("Adding licenses %s, to %s", licenses, self)
        self.add_licenses(licenses)
        logging.debug("Resulted in %s", self.licenses)
        return self


class DirInfo(FileInfo):

    def __init__(self, parent=None, name=''):
        super().__init__(parent, name)
        self.file_list = {}
        # Total number of files in sub tree
        self.total = 0
        self.tasks = set()

    @property
    def parsed_license(self):
        if self.licenses:
            return 'Inherited({})'.format(
                ' or '.join(sorted(self.licenses)))
        return ''

    def add(self, path=None, dirs=None, files=None):

        # Update the total number of files
        self.total += len(dirs) + len(files)

        if path:
            name = path[0]
            if name not in self.file_list:
                self.file_list[name] = DirInfo(self, name)
            return self.file_list[name].add(path[1:], dirs, files)

        for name in dirs:
            if name not in self.file_list:
                self.file_list[name] = DirInfo(self, name)
        for name in files:
            if name not in self.file_list:
                self.file_list[name] = FileInfo(self, name)

    def get_splitting_path(self, options):
        '''Identifier corresponding to the path used for splitting groups.
        When --split-on-license is true, this is the last dirinfo that contains
        a license, when --no-split-on-license is used, it's the root dirinfo.
        Additionally, the debian directory is treated specially when
        --split-debian is true.'''
        if (options.split_on_license and self.licenses) or not self.parent:
            return self.fullname
        if options.split_debian and self.fullname == 'debian':
            return self.fullname
        return self.parent.get_splitting_path(options)

    def __iter__(self):
        yield self
        for _, item in self.file_list.items():
            if isinstance(item, DirInfo):
                for subitem in item:
                    yield subitem
            else:
                yield item

    def process_licenses(self, root, options):
        '''Scan the directory for license files.'''

        dir_licenses = set()
        filenames = set()

        for task in futures.as_completed(self.tasks):
            fileinfo = task.result()
            if not fileinfo:
                continue
            licenses = fileinfo.licenses
            if not licenses:
                # Not a license?
                continue

            dir_licenses.update(licenses)
            filenames.add(fileinfo.fullname)

        self.tasks = set()

        if dir_licenses:
            self.add_licenses(dir_licenses, filenames)

        return self


class RootInfo(DirInfo):

    def __init__(self, root):
        super().__init__()
        self.root = root

    @property
    def names(self):
        for item in self:
            yield str(item)

    def __getitem__(self, key):
        if not key or key == '.':
            return self

        fullpath = key.split(os.path.sep)
        current = self

        for path in fullpath:
            if not isinstance(current, DirInfo) or \
               path not in current.file_list:
                raise IndexError('{} not found'.format(key))
            current = current.file_list[path]
        return current

    @staticmethod
    def build(options):

        tree = RootInfo(root=options.root)

        for root, dirs, files in tqdm(os.walk(options.root, topdown=True),
                                      desc='Building tree',
                                      unit='dir',
                                      dynamic_ncols=True,
                                      disable=(options.debug or
                                               not options.progress)):
            logging.debug('Listing %s', root)
            to_remove = []
            for directory in dirs:
                if options.exclude_directory_re.search(directory):
                    logging.debug('Ignoring directory %s as requested', directory)
                    to_remove.append(directory)
            for d in to_remove:
                dirs.remove(d)
            to_remove = []
            for filename in files:
                if options.exclude_file_re.search(filename):
                    logging.debug('Ignoring file %s as requested', filename)
                    to_remove.append(filename)
                elif os.path.islink(os.path.join(root, filename)):
                    logging.debug('Ignoring symlink %s', filename)
                    # Skip symlinks
                    to_remove.append(filename)

                # elif os.path.getsize(os.path.join(root, filename)) == 0:
                #     # Skip empty files
                #     logging.debug('Skipping empty file %s', filename)
                #     to_remove.append(filename)
            for f in to_remove:
                files.remove(f)

            # Remove the "./"
            base = root[len(tree.root) + 1:]
            tree.add(base.split(os.path.sep) if base else [], dirs, files)

        tree.tag_included_files(options)
        return tree

    def tag_included_files(self, options):
        '''Determine which files to include, from the specified cmd options.'''

        if not options.files:
            self.included = True

        for filename in options.files:
            # Special case, if the root directory was received as a positional
            # parameter, include the whole tree.
            if filename == ".":
                self.included = True
                return

            # Filenames not in the tree will cause an exception. Catch that and
            # log an error in that case.
            try:
                self[filename].included = True
            except IndexError:
                logging.error('%s not in source tree', filename)

    def process(self, options):
        '''Iterate over the full tree processing their licenses.'''
        kwargs = {}
        if options.jobs:
            kwargs['max_workers'] = options.jobs

        known_license_filenames = (
            r'^COPYING',
            r'^COPYRIGHT$',
            r'^LICENSE',
        )

        with futures.ThreadPoolExecutor(**kwargs) as executor:
            tasks = set()
            dirs_with_licenses = set()

            for item in tqdm(self,
                             desc='Queueing',
                             total=self.total,
                             unit='file',
                             dynamic_ncols=True,
                             disable=(options.debug or not options.progress)):

                if isinstance(item, DirInfo):
                    continue

                if not item.included:
                    continue

                task = executor.submit(item.process_licenses, self.root, options)

                for filename_re in known_license_filenames:
                    if not re.match(filename_re, item.name):
                        continue
                    item.parent.tasks.add(task)
                    dirs_with_licenses.add(item.parent)
                    break
                else:
                    tasks.add(task)

            for dirinfo in dirs_with_licenses:
                task = executor.submit(dirinfo.process_licenses, self.root,
                                       options)
                tasks.add(task)

            done_iter = futures.as_completed(tasks)
            for task in tqdm(done_iter,
                             desc='Processing',
                             total=len(tasks),
                             unit='file',
                             dynamic_ncols=True,
                             disable=(options.debug or not options.progress)):
                pass


class FileGroup(object):

    def __init__(self):
        self.files = {}

    def __len__(self):
        return len(self.files)

    def __iter__(self):
        return iter(self.files.values())

    def __repr__(self):
        return 'FileGroup({})'.format(str(self.sorted_members()))

    def add_file(self, fileinfo):
        self.files[fileinfo.fullname] = fileinfo

    def del_file(self, fileinfo):
        return self.files.pop(fileinfo.fullname)

    def get_patterns(self):
        '''Returns the wildcarded patters for the contained files.

        The order in which this method is called is important.  It should be
        called with the group that will get the more general wildcard first, and
        with the groups that will get more specific values later.
        '''
        patterns = set()
        to_tally = set()

        for fileinfo in self:
            untallied = fileinfo.get_first_untallied()
            to_tally.add(untallied)

        for fileinfo in to_tally:
            if isinstance(fileinfo, DirInfo):
                if fileinfo.fullname:
                    patterns.add(fileinfo.fullname + '/*')
                else:
                    patterns.add('*')
            else:
                patterns.add(fileinfo.fullname)
        for fileinfo in self:
            fileinfo.tally()

        return sorted(patterns)

    def key(self, options):
        if not self.files:
            return ''
        return os.path.commonpath(
            tuple(v.get_splitting_path(options) for v in self.files.values()))

    def commonpath(self):
        if not self.files:
            return ''
        return os.path.commonpath(tuple(f.fullname for f in self.files.values()))

    def sorted_members(self):
        return sorted(self.files.keys())


class CopyrightGroup(object):

    def __init__(self):
        self.members = {}
        self.by_email = {}

    def __len__(self):
        return len(self.members)

    def __iter__(self):
        return iter(self.members.values())

    def __repr__(self):
        return 'CopyrightGroup({})'.format(str(list(self.sorted_members())))

    def add(self, copyright_):
        if copyright_.email and copyright_.email in self.by_email:
            previous = self.by_email[copyright_.email]
            return previous.merge(copyright_)
        elif copyright_.person in self.members:
            previous = self.members[copyright_.person]
            return previous.merge(copyright_)

        if copyright_.email:
            self.by_email[copyright_.email] = copyright_
        self.members[copyright_.person] = copyright_
        return copyright_

    def extend(self, copyrights):
        for copyright_ in copyrights:
            self.add(copyright_)

    def key(self):
        return tuple(sorted(
            copyright.email if copyright.email else copyright.person
            for copyright in self.members.values()))

    def merge(self, other):
        for _, value in other.members.items():
            self.add(value)
        return self

    def sorted_members(self):
        return (str(self.members[key]) for key in sorted(self.members.keys()))
