#!/usr/bin/env python3

"""
Copyright 2019 Raúl Benencia

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/>.
"""


import argparse
import os
import sys
import re


class DirCombiner():
    ignore_files = ['.known', '.gitignore']
    ignore_dirs = ['.git', '.svn', '_darcs']

    def __init__(self, include, exclude, status_filename, verbose):
        self.include = re.compile(include)
        self.exclude = re.compile(exclude)
        self.status_filename = status_filename
        self.verbose = verbose

    def _filter_files(self, filenames):
        return [
            fn for fn in filenames
            if fn not in DirCombiner.ignore_files
            and self.include.match(fn)
            and not self.exclude.match(fn)
        ]

    def _filter_dirs(self, directories):
        return [
            sd for sd in directories
            if sd not in DirCombiner.ignore_dirs
            and self.include.match(sd)
            and not self.exclude.match(sd)
        ]

    def _create_symlinks(self, status, dest, dirpath, filenames):
        # Create symlinks for each file
        for f in filenames:
            src = os.path.abspath(os.path.normpath(os.path.join(dirpath, f)))
            dst = os.path.normpath(os.path.join(dest, dirpath, f))

            status.mark_as_seen(dst)
            replace_msg = ""
            if os.path.lexists(dst):
                if not os.path.islink(dst):
                    sys.stderr.write(
                        "{} in {} is also in {}\n".format(f, dirpath, dest)
                    )
                    continue
                elif os.path.realpath(dst) != src:
                    replace_msg = "(previously pointing to: {})".format(
                        os.path.realpath(dst))
                    try:
                        os.remove(dst)
                    except IOError:
                        raise DeleteFileError(dst)
                else:
                    # Symlink is already pointing to the current file
                    continue

            try:
                os.symlink(src, dst)
            except OSError:
                raise CreateSymlinkError(dst)

            if replace_msg == "" or (replace_msg != "" and self.verbose):
                sys.stdout.write("{} -> {} {}\n".format(src, dst, replace_msg))

    def _create_directories(self, dest, dirpath, subdirs):
        for sd in subdirs:
            dst = os.path.normpath(os.path.join(dest, dirpath, sd))
            if os.path.lexists(dst):
                if os.path.isdir(dst):
                    continue
                else:
                    raise DirIsFileError(dst)

            try:
                os.mkdir(dst)
            except OSError:
                raise CreateDirError(dst)

            sys.stdout.write(f"{dst} created\n")

    def combine(self, dest, directories):
        # Loop through all the directories. The later ones can override
        # links from the previous ones.
        for directory in directories:
            try:
                os.chdir(directory)
            except OSError:
                raise ChangeDirError(directory)

            status = DirCombinerStatus(self.status_filename)
            for dirpath, subdirs, filenames in os.walk('.'):
                # Modify filenames and subdirs in place to reduce the
                # scope of the search.
                subdirs[:] = self._filter_dirs(subdirs)
                filenames[:] = self._filter_files(filenames)

                self._create_symlinks(status, dest, dirpath, filenames)
                self._create_directories(dest, dirpath, subdirs)

            status.finish()


class DirCombinerStatus():
    def __init__(self, status_filename):
        self._seen = []
        self._status_filename = status_filename

        try:
            if os.path.exists(self._status_filename):
                if not os.path.isfile(self._status_filename):
                    raise StatusIsNotFileError(self._status_filename)
                else:
                    with open(self._status_filename, 'r') as f:
                        self._known = f.read().split('\n')[:-1]
            else:
                self._known = []
        except IOError:
            raise StatusFileError(self._status_filename)

    def _clean_old(self):
        old = {f for f in self._known if f not in self._seen}
        for f in old:
            try:
                if os.path.lexists(f):
                    rp = os.path.realpath(f)
                    if not os.path.islink(f) or os.path.exists(rp):
                        sys.stderr.write(
                            f"Not deleting {f} as it's a valid file\n")
                    else:
                        os.remove(f)
                        sys.stdout.write(f"Deleted old file {f}\n")
            except IOError:
                raise DeleteFileError(f)

    def mark_as_seen(self, filename):
        self._seen.append(filename)

    def finish(self):
        self._clean_old()
        with open(self._status_filename, 'w') as f:
            for filename in self._seen:
                f.write(filename + '\n')


class Error(Exception):
    """Base class for exceptions in this program """
    msg = ''

    def __init__(self, param):
        self.param = param

    def message(self):
        return self.msg.format(self.param) + '\n'


class DirIsFileError(Error):
    msg = 'Target directory {} already exists as a non-dir file.'


class CreateDirError(Error):
    msg = 'Failure creating directory {}. Do you have appropriate permissions?'


class CreateSymlinkError(Error):
    msg = 'Failure creating symlink {}. Do you have appropriate permissions?'


class ChangeDirError(Error):
    msg = 'Failure changing to dir {}. Do you have appropriate permissions?'


class StatusFileError(Error):
    msg = 'Error while opening status file {}. ' + \
        'Do you have appropriate permissions?'


class StatusIsNotFile(Error):
    msg = 'The status path {} does not have a file. Maybe it holds an old dir?'


class DeleteFileError(Error):
    msg = 'Unable to delete file {}. Do you have appropriate permissions?'


def main():
    parser = argparse.ArgumentParser()

    parser.add_argument('-i', '--include', default='.*',
                        help='regex for filenames to include')

    parser.add_argument('-e', '--exclude', default='^$',
                        help='regex for filenames to exclude')

    parser.add_argument('-s', '--status-filename', default='.known',
                        help='regex for filenames to exclude')

    parser.add_argument('dest', metavar='dest', type=str,
                        help='directory where to install links and filenames')

    parser.add_argument('dirs', metavar='dir', type=str, nargs='+',
                        help='directories to retrieve filenames from')

    parser.add_argument('-v', '--verbose', default=False,
                        help='Verbose output')


    args = parser.parse_args()
    dc = DirCombiner(args.include, args.exclude, args.status_filename, args.verbose)

    try:
        dc.combine(args.dest, args.dirs)

    except Error as e:
        sys.stderr.write(e.message())
        sys.exit(1)


if __name__ == "__main__":
    main()