#!/usr/bin/env python

import os
import glob
import signal
import sys
import textwrap
import re
import errno
import subprocess
import platform
import itertools
from collections import defaultdict

# Python 2/3 compatibility
try:
    input = raw_input
except NameError:
    pass

dotfile_directory = os.path.dirname(os.path.realpath(__file__))

detected_platform = re.search(r'(^[^=]*$|(?<===)[^ ]*)', sys.platform).group(0)
if 'java' in detected_platform:
    detected_platform = platform.platform().lower()
    detected_platform = re.search('darwin|linux|windows|cygwin', detected_platform).group(0)
    if detected_platform == 'windows':
        detected_platform = 'win32'


def main():
    config_default, config_local = read_configs()
    config_discovered = discover_unknown(config_default, config_local)
    config_local = symlink_dotfiles(config_default, config_local, config_discovered)
    config_local = execute_executables(config_default, config_local, config_discovered)
    config_local.save()

    print(textwrap.dedent("""
        Update the dotfiles by running "git pull" and "./install" in {0}
        Check git log and README.md for changes when you update.
        """).format(substitute_home(dotfile_directory)))


class Config(object):
    """
    Reads and writes ini-like files. Example:
    [symlink]
    bashrc
    nvim = ~/.config/nvim
    vimrc

    [symlink:linux]
    fontconfig = ~/.config/fontconfig

    [execute]
    git-config

    [ignore]
    README.md
    """
    def __init__(self, file_path=None):
        self.file_path = file_path
        self.items = defaultdict(lambda: {}, {
            'symlink': {},
            'execute': {},
            'ignore': {},
        })
        if not file_path or not os.path.exists(file_path):
            return
        with open(file_path) as file:
            self.parse_file_sections(file)

    def parse_file_sections(self, file):
        section = None
        for line in file:
            line = line.rstrip()
            new_section = re.findall(r'^\[(.*)\]$', line)
            if new_section:
                section = new_section[0]
            elif line:
                src, dest = re.findall(r'^(.*?)(?: = (.*))?$', line)[0]
                self.items[section][src] = dest or None

    def save(self):
        with open(self.file_path, 'w') as file:
            for section in self.sorted_keys():
                file.write('[' + section + ']\n')
                for src, dest in sorted(self.items[section].items()):
                    if dest:
                        file.write(src + ' = ' + dest + '\n')
                    else:
                        file.write(src + '\n')
                file.write('\n')
        self.warn_overlap()

    def warn_overlap(self):
        """Warn if an entry is in 2 sections; for example, [symlink] and [ignore]"""
        for section_a, section_b in itertools.combinations(self.items.keys(), 2):
            items_overlap = sorted(set(self[section_a]) & set(self[section_b]))
            a_primary, _, a_secondary = section_a.partition(':')
            b_primary, _, b_secondary = section_a.partition(':')
            primary_section_overlaps = a_primary == b_primary
            secondary_sections_separate = primary_section_overlaps and '' not in [a_secondary, b_secondary]
            if items_overlap and not secondary_sections_separate:
                print('\nWarning: these entries are configured to both {0} and {1}:'.format(section_a, section_b))
                print(items_overlap)

    def sorted_keys(self):
        keys = []
        keys.extend(sorted([key for key in self.items.keys() if key.startswith('symlink')]))
        keys.extend(sorted([key for key in self.items.keys() if key.startswith('execute')]))
        keys.extend(sorted([key for key in self.items.keys() if key.startswith('ignore')]))
        keys.extend(sorted([key for key in self.items.keys() if key not in keys]))
        return keys

    def __getitem__(self, item):
        """Get a section, including platform-specific config"""
        merged_sections = {}
        for section in self.items.keys():
            primary_section, _, secondary_section = section.partition(':')
            if primary_section == item:
                if not secondary_section or detected_platform.startswith(secondary_section):
                    merged_sections.update(self.items[section])
        return merged_sections

    def __setitem__(self, item, value):
        return self.items.__setitem__(item, value)


def read_configs():
    config_default_file = os.path.join(dotfile_directory, 'install-config.txt')
    config_local_file = os.path.join(dotfile_directory, 'install-config.local.txt')
    config_default = Config(config_default_file)
    config_default.items['ignore'][os.path.basename(__file__)] = None
    config_default.items['ignore'][os.path.basename(config_default_file)] = None
    config_default.items['ignore'][os.path.basename(config_local_file)] = None
    config_local = Config(config_local_file)
    return config_default, config_local


def discover_unknown(config_default, config_local):
    config_discovered = Config()
    configured_items = {}
    for section in config_default.items:
        configured_items.update(config_default.items[section])
    for section in config_local.items:
        configured_items.update(config_local.items[section])
    dotfile_directory_files = glob.glob(dotfile_directory + '/*')
    for file in dotfile_directory_files:
        dotfile_name = os.path.basename(file)
        if dotfile_name not in configured_items:
            if os.access(file, os.X_OK) and not os.path.isdir(file):
                config_discovered.items['execute'][dotfile_name] = None
            else:
                config_discovered.items['symlink'][dotfile_name] = None
    return config_discovered


def symlink_dotfiles(config_default, config_local, config_discovered):
    symlinks = {}
    symlinks.update(config_default['symlink'])
    symlinks.update(config_local['symlink'])
    symlinks.update(config_discovered['symlink'])
    for dotfile_name, dotfile_dest in sorted(symlinks.items()):
        dotfile_path = os.path.join(dotfile_directory, dotfile_name)
        if not os.path.exists(dotfile_path):
            continue
        if dotfile_dest:
            install_location = os.path.expanduser(dotfile_dest)
        else:
            install_location = os.path.join(
                os.path.expanduser('~'), '.' + dotfile_name)
        if os.path.exists(install_location) or os.path.islink(install_location):
            short_install_location = substitute_home(install_location)
            short_dotfile = substitute_home(dotfile_path)
            if os.path.realpath(install_location) != dotfile_path:
                print(short_install_location + ' already exists and is not symlinked to ' +
                      short_dotfile + ', skipping')
            else:
                print(short_install_location + ' already exists, skipping')
            continue
        if dotfile_name in config_local['ignore']:
            print(dotfile_name + ' is ignored; skipping')
            continue
        if dotfile_dest:
            prompt = 'Symlink ' + dotfile_name + ' to ' + substitute_home(install_location) + '? y=yes n=no q=quit [Y/n/q]: '
        else:
            prompt = 'Symlink ' + dotfile_name + '? y=yes n=no q=quit [Y/n/q]: '
        config_local = do_prompt(prompt, 'Ynq', {'y': 'l', 'n': 'i'},
                                 dotfile_name, dotfile_path, dotfile_dest,
                                 config_default, config_local)
    return config_local


def execute_executables(config_default, config_local, config_discovered):
    config_local = execute_configured_executables(config_default, config_local)
    config_local = execute_discovered_executables(config_default, config_local, config_discovered)
    return config_local


def execute_configured_executables(config_default, config_local):
    configured = set(config_default['execute'].keys()) | set(config_local['execute'].keys())
    for executable in sorted(configured):
        executable_name = executable
        executable = os.path.join(dotfile_directory, executable)
        if not os.path.exists(executable):
            continue
        if executable_name in config_local['ignore']:
            print(executable_name + ' is ignored; skipping')
            continue
        if executable_name in config_local['execute']:
            prompt = 'Execute ' + executable_name + '? (verify first!) y=yes n=no q=quit [Y/n/q]: '
            valid_actions = 'Ynq'
        elif executable_name in config_default['execute']:
            prompt = 'Execute ' + executable_name + '? (verify first!) y=yes n=no i=ignore q=quit [Y/n/i/q]: '
            valid_actions = 'Yniq'
        else:
            continue
        config_local = do_prompt(prompt, valid_actions, {'y': 'x', 'n': 's'},
                                 executable_name, executable, None,
                                 config_default, config_local)
    return config_local


def execute_discovered_executables(config_default, config_local, config_discovered):
    for executable in sorted(set(config_discovered['execute'].keys())):
        executable_name = executable
        executable = os.path.join(dotfile_directory, executable)
        if not os.path.exists(executable):
            continue
        prompt = ('Execute ' + executable_name +
                  '? (verify first!) y=yes n=no i=ignore l=symlink q=quit [Y/n/i/l/q]: ')
        config_local = do_prompt(prompt, 'Ynilq', {'y': 'x', 'n': 's'},
                                 executable_name, executable, None,
                                 config_default, config_local)
    return config_local


def do_prompt(prompt, valid_actions, action_map,
              item_name, item_path, item_dest,
              config_default, config_local):
    while True:
        action = input(prompt)
        if not action:
            action = re.sub(r'[a-z]', '', valid_actions)  # capitalize default action
        action = action.lower()
        if action and action in valid_actions.lower():
            break
        print('  Invalid input.')
    if action in action_map:
        action = action_map[action]
    if action == 'q':
        config_local.save()
        exit(0)
    if action == 's':
        print('  Skip ' + item_name)
    if action == 'i':
        ignore(config_default, config_local, item_name, item_dest)
    if action == 'l':
        symlink(config_default, config_local, item_name, item_path, item_dest)
    if action == 'x':
        execute(config_default, config_local, item_name, item_path)
    return config_local


def ignore(config_default, config_local, item_name, item_dest):
    local_non_ignore = set(config_local['symlink']) | set(config_local['execute'])
    if item_name not in config_default['ignore'] and item_name not in local_non_ignore:
        config_local.items['ignore'][item_name] = item_dest
        print('  Ignore ' + item_name)
    else:
        print('  Skip ' + item_name)


def symlink(config_default, config_local, item_name, item_path, item_dest):
    if item_dest:
        install_location = os.path.expanduser(item_dest)
    else:
        install_location = os.path.join(
            os.path.expanduser('~'), '.' + item_name)
    if item_name not in config_default['symlink']:
        config_local.items['symlink'][item_name] = item_dest
    print('  Symlink ' + item_name + ' to ' + install_location)
    if not os.path.exists(os.path.dirname(install_location)):
        mkdir_p(os.path.dirname(install_location))
    os.symlink(item_path, install_location)


def execute(config_default, config_local, item_name, item_path):
    if item_name not in config_default['execute']:
        config_local.items['execute'][item_name] = None
    print('  Execute ' + item_path)
    subprocess.Popen([item_path], cwd=dotfile_directory).wait()


def substitute_home(path):
    home_path = os.path.expanduser('~')
    if path.startswith(home_path):
        return path.replace(home_path, '~', 1)
    return path


def mkdir_p(path):
    try:
        os.makedirs(path)
    except OSError as exc:  # Python >2.5
        if exc.errno == errno.EEXIST and os.path.isdir(path):
            pass
        else:
            raise


def handle_sigint():
    def clean_exit(signal, frame):
        print('')
        sys.exit(0)
    signal.signal(signal.SIGINT, clean_exit)


if __name__ == '__main__':
    handle_sigint()
    main()
