#!/usr/bin/env python

import csv
from configparser import ConfigParser
import json
import os
import shutil
import subprocess
import sys
from argparse import ArgumentParser
from collections import OrderedDict
from fnmatch import fnmatch

script_dir = os.path.dirname(os.path.realpath(__file__))
used_preprocessors = set()

def load_config(user_config_file=None):
    global config

    defaults_file = os.path.join(script_dir, '..', 'frogfs_defaults.json')
    with open(defaults_file) as f:
        config = json.load(f)

    user_config = OrderedDict()
    if user_config_file:
        if not os.path.exists(user_config_file):
            print('{user_config_file} cannot be opened', file=sys.stderr)
            sys.exit(1)
        with open(user_config_file) as f:
            user_config = json.load(f)
    else:
        print("Not loading user config file")

    def merge_section(sec_name):
        if sec_name in user_config:
            for subsec_name, subsec in user_config[sec_name].items():
                if subsec is None:
                    if subsec_name in config[sec_name]:
                        del config[sec_name][subsec_name]
                else:
                    if sec_name == 'filters':
                        if subsec_name not in config[sec_name]:
                            config[sec_name] = []
                        if isinstance(config[sec_name][subsec_name], str):
                            config[sec_name][subsec_name] = \
                                    [config[sec_name][subsec_name]]
                        if isinstance(subsec, str):
                            subsec = [subsec]
                        config[sec_name][subsec_name] += subsec
                    else:
                        config[sec_name][subsec_name] = subsec

    for sec_name in ('preprocessors', 'compressors', 'filters'):
        merge_section(sec_name)
        for subsec_name, subsec in config.get(sec_name, OrderedDict()).items():
            if isinstance(subsec, str):
                config[sec_name][subsec_name] = [subsec]
            elif isinstance(subsec, dict):
                for subsubsec_name, subsubsec in subsec.items():
                    if isinstance(subsubsec, str):
                        subsec[subsubsec_name] = [subsubsec]

    class pattern_sort:
        def __init__(self, path, *args):
            self.pattern, _ = path

        def __lt__(self, other):
            if self.pattern == '*':
                return False
            if other.pattern == '*':
                return True
            if self.pattern.startswith('*') and \
                    not other.pattern.startswith('*'):
                return False
            if not self.pattern.startswith('*') and \
                    other.pattern.startswith('*'):
                return True
            return self.pattern < other.pattern

    config['filters'] = OrderedDict(sorted(config['filters'].items(),
            key = pattern_sort))

    preprocessors = list(config['preprocessors'].keys())
    actions = list()
    for action in preprocessors + ['cache', 'discard']:
        actions.append(action)
        actions.append('no-' + action)
    actions += ['skip-preprocessing', 'gzip', 'heatshrink', 'uncompressed']
    config['actions'] = actions

    for filter, actions in config['filters'].items():
        for action in actions:
            if action not in config['actions']:
                print(f"Unknown action `{action}' for filter `{filter}'",
                        file=sys.stderr)
                sys.exit(1)

def get_preprocessors(path):
    global config, used_preprocessors

    preprocessors = OrderedDict()
    for pattern, actions in config['filters'].items():
        if fnmatch(path, pattern):
            for action in actions:
                enable = not action.startswith('no-')
                if not enable:
                    action = action[3:]
                if action in config['preprocessors']:
                    if enable:
                        preprocessors[action] = None
                        used_preprocessors.add(action)
                    else:
                        try:
                            del preprocessors[action]
                        except:
                            pass
                    preprocessors[action] = enable
        if 'skip-preprocessing' in actions:
            return ()

    return tuple(preprocessors)

def get_flags(path):
    global config

    flags = OrderedDict()
    for pattern, actions in config['filters'].items():
        if fnmatch(path, pattern):
            for action in actions:
                enable = not action.startswith('no-')
                if not enable:
                    action = action[3:]
                if action in ('cache', 'discard', 'skip'):
                    flags[action] = enable

    return flags

def get_compressor(path):
    global config

    compressor = 'uncompressed'
    for pattern, actions in config['filters'].items():
        if fnmatch(path, pattern):
            for action in actions:
                if action in ('gzip', 'heatshrink', 'uncompressed'):
                    compressor = action
    return compressor

def load_state(dst_dir):
    state = dict()
    state_file = os.path.join(dst_dir, '.state')
    if os.path.exists(state_file):
        with open(state_file, newline='') as f:
            reader = csv.reader(f, quoting=csv.QUOTE_NONNUMERIC)
            for data in reader:
                path, type, mtime, flags, preprocessors, compressor = data
                state[path] = {
                    'type': type,
                    'mtime': mtime,
                    'preprocessors': () if not preprocessors else \
                            tuple(preprocessors.split(',')),
                    'flags': () if not flags else tuple(flags.split(',')),
                    'compressor': compressor,
                }
    return state

def save_state(dst_dir, state):
    with open(os.path.join(dst_dir, '.state'), 'w', newline='') as f:
        writer = csv.writer(f, quoting=csv.QUOTE_NONNUMERIC)
        for path, data in state.items():
            row = (path, data['type'], data['mtime'],
                    ','.join(data['flags']),
                    ','.join(data['preprocessors']),
                    data['compressor'])
            writer.writerow(row)

    dotconfig = ConfigParser()
    dotconfig['gzip'] = {
        'level': config['compressors']['gzip']['level'],
    }
    dotconfig['heatshrink'] = {
        'window_sz2': config['compressors']['heatshrink']['window_sz2'],
        'lookahead_sz2': config['compressors']['heatshrink']['lookahead_sz2'],
    }
    with open(os.path.join(dst_dir, '.config'), 'w') as f:
        dotconfig.write(f)

def build_state(src_dir):
    state = dict()
    for dir, _, files in os.walk(src_dir, followlinks=True):
        reldir = os.path.relpath(dir, src_dir).replace('\\', '/').lstrip('.') \
                .lstrip('/')
        absdir = os.path.abspath(dir)
        if reldir and os.path.exists(absdir):
            state[reldir] = {
                'type': 'dir',
                'mtime': os.path.getmtime(absdir),
                'preprocessors': (),
                'flags': (),
                'compressor': 'uncompressed',
            }
        for file in files:
            relfile = os.path.join(reldir, file).replace('\\','/').lstrip('/')
            absfile = os.path.join(absdir, file)
            if os.path.exists(absfile):
                state[relfile] = {
                    'type': 'file',
                    'mtime': os.path.getmtime(absfile),
                    'preprocessors': get_preprocessors(relfile),
                    'flags': get_flags(relfile),
                    'compressor': get_compressor(relfile),
                }
    return state

def install_preprocessors(config, root_dir):
    global used_preprocessors

    # Work around a bug in npm -- if `node_modules` doesn't exist it will
    # create one at some random path above. Sometimes. Depending on the version.
    # Except on Tuesdays in Norway.
    node_modules = os.path.join(root_dir.replace('/', os.path.sep), 'node_modules')
    if not os.path.exists(node_modules):
        os.mkdir(node_modules)

    for name in used_preprocessors:
        preprocessor = config['preprocessors'][name]
        if 'install' in preprocessor:
            install = preprocessor['install']
            subprocess.check_call(install, shell=True, cwd=root_dir)
        elif 'npm' in preprocessor:
            for npm in preprocessor['npm']:
                test_path = os.path.join(root_dir.replace('/', os.path.sep),
                                         'node_modules',
                                         npm.replace('/', os.path.sep))
                if not os.path.exists(test_path):
                    subprocess.check_call(f'npm install {npm}', shell=True, cwd=root_dir)

def preprocess(path, preprocessors):
    global config

    src_abs = os.path.join(args.src_dir, path)
    dst_abs = os.path.join(args.dst_dir, path)
    if os.path.isdir(src_abs):
        if os.path.isdir(dst_abs):
            pass
        else:
            os.mkdir(dst_abs)
    else:
        os.makedirs(os.path.dirname(dst_abs), exist_ok=True)

        with open(src_abs, 'rb') as f:
            data = f.read()

        if preprocessors:
            print(f'       - preprocessing {path}', file=sys.stderr)

        for preprocessor in preprocessors:
            print(f'         - running {preprocessor}')
            command = config['preprocessors'][preprocessor]['command']
            if command[0].startswith('tools/'):
                command[0] = os.path.join(script_dir, command[0][6:])
            # These are implemented as `.cmd` files on Windows, which explicitly
            # requires them to be run under `cmd /c`
            if os.name == 'nt':
                command = ["cmd", "/c"] + command
            process = subprocess.Popen(command, stdin=subprocess.PIPE,
                    stdout=subprocess.PIPE, shell=True)
            data = process.communicate(input=data)[0]

        with open(dst_abs, 'wb') as f:
            f.write(data)

def main():
    global args, config

    parser = ArgumentParser()
    parser.add_argument('src_dir', metavar='SRC', help='source directory')
    parser.add_argument('dst_dir', metavar='DST', help='destination directory')
    parser.add_argument('--config', help='user configuration')
    parser.add_argument('--root', metavar='ROOT', help='build root directory')
    args = parser.parse_args()

    load_config(args.config)

    old_state = load_state(args.dst_dir)
    new_state = build_state(args.src_dir)

    print(f"Root: {args.root}")
    install_preprocessors(config, args.root)

    old_paths = set(old_state.keys())
    new_paths = set(new_state.keys())

    delete_paths = old_paths - new_paths
    copy_paths = new_paths - old_paths
    compare_paths = old_paths & new_paths

    if not delete_paths and not copy_paths and not compare_paths:
        sys.exit(0)

    for path in delete_paths:
        dst_abs = os.path.join(args.dst_dir, path)
        if os.path.exists(dst_abs):
            if os.path.isdir(dst_abs):
                shutil.rmtree(dst_abs, True)
            else:
                os.unlink(dst_abs)

    for path in copy_paths:
        preprocess(path, new_state[path]['preprocessors'])

    changes = bool(delete_paths or copy_paths)
    for path in compare_paths:
        if old_state[path]['type'] != new_state[path]['type'] or \
                old_state[path]['preprocessors'] != \
                        new_state[path]['preprocessors'] or \
                old_state[path]['mtime'] < new_state[path]['mtime']:

            changes = True

            dst_abs = os.path.join(args.dst_dir, path)

            if os.path.exists(dst_abs):
                if os.path.isdir(dst_abs):
                    shutil.rmtree(dst_abs, True)
                else:
                    os.unlink(dst_abs)

            preprocess(path, new_state[path]['preprocessors'])

    if changes:
        save_state(args.dst_dir, new_state)

if __name__ == '__main__':
    main()