#!/usr/bin/python3

import ctypes.util
import functools
import os
import shutil
import signal
import subprocess
import sys
import tempfile

import xdg.BaseDirectory

PKG_LIB_DIR = "/usr/lib/games/dwarf-fortress"
PKG_DATA_DIR = "/usr/share/games/dwarf-fortress/gamedata"
LOCAL_DATA_DIR = "/usr/local/share/games/dwarf-fortress"
# Matches as a prefix if it ends with '/', otherwise it matches a full path
FAKE_WRITABLE = {
    'data/dipscript/',
    'data/announcement/',
    'data/help/',
    'data/index',
}
# Always matches a directory and all its descendants
WRITABLE = {
    'data/save': True,
    'data/movies': True,
    'gamelog.txt': False,
    'errlog.txt': False,
}
# data/init is much like a WRITABLE, but its placement in XDG_CONFIG_HOME requires special handling
CONFIG_PATH = 'data/init'
STATE_PATH = 'debian-state' # Relative to the run dir
CONFIG_STATE_PATH = 'config' # Relative to the state path

def get_user_run_dir():
    old_run_dir = xdg.BaseDirectory.save_data_path('dwarf-fortress')
    new_run_dir = os.path.join(old_run_dir, 'run')
    if not os.path.exists(new_run_dir):
        to_migrate = os.listdir(old_run_dir)
        os.mkdir(new_run_dir)
        for migratee in to_migrate:
            os.rename(os.path.join(old_run_dir, migratee),
                      os.path.join(new_run_dir, migratee))
    return new_run_dir

def get_state_path():
    state_dir = os.path.join(xdg.BaseDirectory.save_data_path('dwarf-fortress'), STATE_PATH)
    os.makedirs(state_dir, exist_ok=True)
    return state_dir

def get_config_state_path(state_path):
    return os.path.join(state_path, CONFIG_STATE_PATH)

def get_user_config_dirs(user_config_save_dir):
    return filter(lambda p: not os.path.samefile(p, user_config_save_dir),
                  xdg.BaseDirectory.load_config_paths('dwarf-fortress'))

def get_user_data_dirs(run_dir, state_dir):
    def not_ignored_data_dir(p):
        return not (os.path.samefile(p, run_dir) or os.path.samefile(p, state_dir))

    for df_dir in xdg.BaseDirectory.load_data_paths('dwarf-fortress'):
        data_dirs = os.listdir(df_dir)
        full_data_dirs = map(functools.partial(os.path.join, df_dir),
                             data_dirs)
        yield from filter(not_ignored_data_dir, full_data_dirs)

def get_data_dirs(run_dir, state_dir, user_config_save_dir):
    yield ('', run_dir)
    for user_config_dir in get_user_config_dirs(user_config_save_dir):
        yield (CONFIG_PATH, user_config_dir)
    for user_data_dir in get_user_data_dirs(run_dir, state_dir):
        yield ('', user_data_dir)
    yield ('', PKG_LIB_DIR)
    if os.path.isdir(LOCAL_DATA_DIR):
        for dir_entry in os.scandir(LOCAL_DATA_DIR):
            if dir_entry.is_dir():
                yield ('', dir_entry.path)
    yield ('', PKG_DATA_DIR)

def is_fake_writable_file(filepath):
    for copy_prefix in FAKE_WRITABLE:
        if copy_prefix.endswith('/'):
            if filepath.startswith(copy_prefix):
                return True
        else:
            if filepath == copy_prefix:
                return True
    return False

def resolve_data_files(run_dir, config_dir, data_dirs):
    data_map = {}
    config_map = {}
    config_prefix = CONFIG_PATH if CONFIG_PATH.endswith('/') else CONFIG_PATH + '/'
    # Writeable prefixes are mapped to a real place in the run dir
    for writable_prefix, create in WRITABLE.items():
        real_writable_path = os.path.join(run_dir, writable_prefix)
        if create:
            os.makedirs(real_writable_path, exist_ok=True)
        data_map[writable_prefix] = real_writable_path
    data_map[CONFIG_PATH] = config_dir
    for data_dir_mount_point, data_dir in data_dirs:
        for (dirpath, subdirs, filenames) in os.walk(data_dir):
            local_dirpath = os.path.join(data_dir_mount_point, os.path.relpath(dirpath, data_dir))
            if local_dirpath in WRITABLE:
                # Prune subdirectories from walk
                del subdirs[:]
                continue
            for filename in filenames:
                filepath = os.path.join(dirpath, filename)
                local_filepath = os.path.normpath(os.path.join(local_dirpath, filename))
                # Earlier entries take precedence
                if local_filepath.startswith(config_prefix):
                    config_local_filepath = local_filepath[len(config_prefix):]
                    if config_local_filepath not in config_map:
                        config_map[config_local_filepath] = filepath
                else:
                    if local_filepath not in data_map:
                        data_map[local_filepath] = filepath
    return (data_map, config_map)

def populate_user_config_dir(user_config_dir, state_dir, config_map):
    """Populate user config save dir

    This is done by looking up existing configuration in the data and
    configuration paths. The user config save path is populated with
    this data. For each config file found, we look into the config
    save path. If the file is not already present there, we create a
    symlink to the file found in the search path, otherwise we leave
    it alone to avoid overwriting the users custom configuration. When
    the user config save dir already contains a symlink, we only want
    to update it, if it is a symlink we created ourselves, but leave
    it alone, if the user created it. For this reason we create two
    symlinks. The real symlink for that dwarf fortress will use and
    one hidden where we can remember what we last symlinked. If the
    real and remembered symlink are out of sync or no remembered
    symlink exists, we assume that the symlink belongs to the user and
    leave it alone.

    This is kind of a hack to ensure that dwarf fortress can directly
    read the configuration from system directories, but still be able
    to overwrite the files. This only works, because dwarf fortress
    doesn't just try to write on an existing symlink (which wouldn't
    work, because the symlink points to a file owned by root) but
    insteads unlinks the symlink and replaces it by its own
    configuration.

    """
    config_state_dir = get_config_state_path(state_dir)
    for local_path, source_path in config_map.items():
        target_path = os.path.join(user_config_dir, local_path)
        state_target_path = os.path.join(config_state_dir, local_path)
        if not os.path.lexists(target_path):
            if os.path.lexists(state_target_path):
                os.unlink(state_target_path)
            else:
                os.makedirs(os.path.dirname(state_target_path), exist_ok=True)
            os.symlink(source_path, state_target_path)

            os.makedirs(os.path.dirname(target_path), exist_ok=True)
            os.symlink(source_path, target_path)
        else:
            # If the configuration file already exists and is not a
            # symlink, we leave it alone under the assumption that it
            # is explicit user configuration. Otherwise we update it,
            # if the link points to the same target as the saved link.
            if os.path.islink(target_path) and os.path.islink(state_target_path) \
                and os.readlink(target_path) == os.readlink(state_target_path):
                os.unlink(target_path)
                os.unlink(state_target_path)
                os.symlink(source_path, state_target_path)
                os.symlink(source_path, target_path)
            else:
                # The user took posession of the link, so there is
                # no point to keep state of it around.
                if os.path.lexists(state_target_path):
                    os.unlink(state_target_path)

# Try really hard not to terminate before the given process
def cling_to_process(process):
    while True:
        try:
            process.terminate()
            r_code = process.wait()
        except:
            pass
        else:
            exit(r_code)

def run_df(run_dir, args):
    # libgraphics.so is missing the dependency on libz on i386, so we
    # have to help it a bit.
    df_env = dict(os.environb)
    libz = ctypes.util.find_library('z')
    try:
        df_env['LD_PRELOAD'] += ":{}".format(libz)
    except KeyError:
        df_env['LD_PRELOAD'] = libz

    cmd = [os.path.join(run_dir, 'df')] + args
    df_process = subprocess.Popen(cmd, env=df_env)
    try:
        exit(df_process.wait())
    except:
        cling_to_process(df_process)

def link_data_dir(target_directory, data_map):
    for local_path, source_path in data_map.items():
        target_path = os.path.join(target_directory, local_path)
        os.makedirs(os.path.dirname(target_path), exist_ok=True)
        if is_fake_writable_file(local_path):
            shutil.copyfile(source_path, target_path)
        else:
            os.symlink(source_path, target_path)

def run_df_in_linked_tmp_dir(data_map, args):
    with tempfile.TemporaryDirectory(prefix='dwarf-fortress-run-') as run_dir:
        link_data_dir(run_dir, data_map)
        run_df(run_dir, args)

def exit_handler(signal, frame):
    sys.exit(0)

def main():
    user_run_dir = get_user_run_dir()
    state_dir = get_state_path()
    user_config_dir = xdg.BaseDirectory.save_config_path('dwarf-fortress')
    data_dirs = get_data_dirs(user_run_dir, state_dir, user_config_dir)
    (data_map, config_map) = resolve_data_files(user_run_dir, user_config_dir, data_dirs)
    populate_user_config_dir(user_config_dir, state_dir, config_map)
    signal.signal(signal.SIGTERM, exit_handler)
    run_df_in_linked_tmp_dir(data_map, sys.argv[1:])

if __name__ == '__main__':
    main()
