#!/usr/bin/python3

import argparse
import glob
import os
import re
import sys

DEFAULTS = {
        'config_dir': '/etc/asterisk/modules.d',
        'links_dir': '/var/lib/asterisk/modules',
        'ast_mod_dirs': ['/usr/lib/asterisk/modules',
                         '/usr/lib64/asterisk/modules'],
        'suffix': '.so',
        'restart_command': "systemctl restart asterisk.service",
        }


def log(msg):
    print(msg, file=sys.stderr)


def warn(msg):
    log("W: " + msg)


class ConfigParseError(RuntimeError):
    def __init__(self, arg):
        self.args = arg


class Action(object):
    def __init__(self, actions, action_char, module_name):
        self.module = module_name
        if action_char == '+':
            self.action = actions.do_add
            self.action_name = 'add'
        elif action_char == '-':
            self.action = actions.do_del
            self.action_name = 'del'
        else:
            msg = "Invalid action \"{}\" (module_name: \"{}\"".format(action,
                                                                      module)
            raise ConfigParseError(msg)

    def run(self):
        self.action(self.module)

    def __str__(self):
        return self.action_name + ' ' + self.module


class Actions(object):
    """ Set of module linking actions to run """

    def __init__(self, cfg):
        self.config_dir = cfg.config_dir
        self.modules_dir = cfg.modules_dir
        self.symlinks_dir = cfg.symlinks_dir
        self.is_twinstar = cfg.is_twinstar
        self.dry_run = cfg.dry_run
        self.verbose = cfg.verbose
        # Required action by module:
        self.actions = {}
        # Last type of action on each module. To help avoid duplicates:
        self.modules = {}
        self.read_conf()

    def verb(self, msg):
        if self.verbose:
            log("V: " + msg)

    def add_action(self, action_char, module_name):
        """ Add an action to the TODO list after expanding wildcards.

        Only the last action on the same module counts. Thus previous
        ones are silently dropped from the list.
        """
        full_path = os.path.join(self.modules_dir, module_name)
        for file_name in glob.glob(full_path):
            name = os.path.basename(file_name)
            action = Action(self, action_char, name)
            self.actions[name] = action

    def read_conf(self):
        conf = ConfFiles(self.config_dir)
        config_str = conf.read()
        for line in config_str.split("\n"):
            if len(line) == 0:
                continue
            action_char, module_name = line.split(' ')
            self.add_action(action_char, module_name)

    def apply(self):
        for action in list(self.actions.values()):
            action.run()

    def do_add(self, module_name):
        """ Symlink a module """
        target_full = os.path.join(self.modules_dir, module_name)
        lnk_name = os.path.join(self.symlinks_dir, module_name)
        target_rel = os.path.relpath(target_full, self.symlinks_dir)
        if self.is_twinstar:
            # Workaround: in the Twin-Star setup, the symlinks directory
            # is on a directory symlinked from /replica . Thus relpath()
            # gets fulled.
            target_rel = os.path.join("..", target_rel)
        if not os.path.exists(target_full):
            self.verb("Cannot symlink to non existing module " + module_name)
            return False
        if os.path.exists(lnk_name):
            self.verb("Valid symbolic link already exists. Nothing to create: " + module_name)
            return False
        if os.path.lexists(lnk_name):
            if self.dry_run:
                log("I: Dandling symbolic link. Will remove: " + module_name)
            else:
                self.verb("Dandling symbolic link. Remove: " + module_name)
                os.unlink(lnk_name)
        if self.dry_run:
            log("I: Will create the symlink {} -> {}".format(lnk_name, target_rel))
        else:
            self.verb("Symlinking {} -> {}".format(lnk_name, target_rel))
            # file, linkname
            os.symlink(target_rel, lnk_name)
        return True

    def do_del(self, module_name):
        """ Remove module symlink """
        lnk_name = os.path.join(self.symlinks_dir, module_name)
        if not os.path.lexists(lnk_name):
            self.verb("Symlink does not exist. Nothing to delete: " + module_name)
            return False
        if self.dry_run:
            log("I: Will remove the symlink {}".format(lnk_name))
        else:
            self.verb("Deleting symlink {}".format(lnk_name))
            os.unlink(lnk_name)
        return True

    def __str__(self):
        modules = sorted(self.actions.keys())
        return "\n".join([str(self.actions[m]) for m in modules])


class ConfFiles(object):
    """ Configuration files with the module symlinking instructions """
    def __init__(self, directory):
        self.directory = directory
        self.files = self.get_files()

    def get_files(self):
        """ Returns an ordered list of files """
        return sorted(glob.glob("{}/*.conf".format(self.directory)))

    def read(self):
        """ Returns a string of the contents of all the files.

        Returns the configuration already partially parsed. It is safe
        to further split every line on a space to exactly two parts.
        """
        config_str = ''
        for file_name in self.get_files():
            with open(file_name) as f:
                for line in f:
                    line_no_comment = re.sub(r'#.*', '', line)
                    if re.match(r'\s*$', line_no_comment):
                        continue
                    m = re.match(r'([+-])\s+([\w*?.-]+)\s?$', line_no_comment)
                    if m is None:
                        warn("Configuration: file {}: invalid line: {}".
                             format(file_name, line))
                        continue
                    # Just the relevant parts of the line:
                    config_str += " ".join(m.groups()[0:2]) + "\n"
        return config_str


class AsteriskModules(object):
    """ The Asterisk modules directory """
    def __init__(self, directory, suffix):
        self.directory = directory
        self.suffix = suffix

    def list(self):
        """ List modules.

        Initial implementation: no check for anything. Just use glob """
        full_names = sorted(glob.glob("{}/*{}".format(self.directory,
                                                      self.suffix)))
        return [os.path.basename(fn) for fn in full_names]


class Symlink(object):
    """ Information about a symbolic link. Source: real module """
    def __init__(self, symlink, realfile):
        self.realfile = realfile
        self.symlink = symlink

    def get(self):
        return (self.symlink, self.realfile)

    def names(self):
        return [os.path.basename(p) for p in self.get()]

    def __eq__(self, other):
        return self.realfile == other.realfile and \
               self.symlink == other.symlink

    def __ne__(self, other):
        return not self.__eq__(other)

    def __str__(self):
        return '{} -> {}'.format(self.symlink, self.realfile)


class SymlinksState(object):
    """ State of the symlinks """
    def __init__(self, symlinks, extra_mods):
        self.symlinks = symlinks
        self.extra_mods = extra_mods

    def __str__(self):
        s = ""
        s += "# Symlinks\n"
        for link in self.symlinks:
            s += "{}\n".format(link)
        s += "# Extra Modules\n"
        for mod in self.extra_mods:
            s += "{}\n".format(mod)
        return s

    def __eq__(self, other):
        if len(self.symlinks) != len(other.symlinks):
            return False
        if len(self.extra_mods) != len(other.extra_mods):
            return False
        for i in range(len(self.symlinks)):
            if self.symlinks[i] != other.symlinks[i]:
                return False
        for i in range(len(self.extra_mods)):
            if self.extra_mods[i] != other.extra_mods[i]:
                return False
        return True

    def __ne__(self, other):
        return not self.__eq__(other)


class SymlinksDirectory(object):
    """ The directory with symbolic links """
    def __init__(self, cfg):
        self.directory = cfg.symlinks_dir
        self.modules_directory = cfg.modules_dir
        self.suffix = cfg.suffix
        self.dry_run = cfg.dry_run

    def full_names(self):
        return sorted(glob.glob("{}/*{}".format(self.directory,
                                                self.suffix)))

    def state(self):
        """ Get current state of symlinks """
        files = self.full_names()
        symlinks = []
        linked_modules = {}
        for symlink in files:
            if not os.path.islink(symlink):
                continue
            if not os.path.exists(symlink):
                continue
            target = os.readlink(symlink)
            if not target.endswith(self.suffix):
                continue
            target_file = os.path.realpath(symlink)
            if not os.path.samefile(os.path.dirname(target_file),
                                    self.modules_directory):
                continue
            mod_name = os.path.basename(target)
            linked_modules[mod_name] = 1
            symlinks.append(Symlink(symlink, target))
        all_modules = AsteriskModules(self.modules_directory,
                                      self.suffix).list()
        extra_modules = [m for m in all_modules if m not in linked_modules]
        return SymlinksState(symlinks, extra_modules)

    def del_dead_links(self):
        """ Delete the dead symlinks in the symlinks dir """
        files = self.full_names()
        for symlink in files:
            if not os.path.exists(symlink):
                log("I: Dead link {} will be deleted.".format(symlink))
                if not self.dry_run:
                    os.unlink(symlink)

    def num_links(self):
        """ Returns the number of symlinks in the directory  """
        return len(self.full_names())


def get_links_count(cfg):
    """ Returns the number of symlinks in symlinks dir that are not broken """
    d = SymlinksDirectory(cfg)
    return d.num_links()

def do_apply_actions(cfg):
    """ Parse configuration, run the symlink-changing actions """
    count = get_links_count(cfg)
    actions = Actions(cfg)
    rc = actions.apply()
    SymlinksDirectory(cfg).del_dead_links()
    if cfg.restart_if_changed:
        count_after = get_links_count(cfg)
        if count_after != count:
            # The command's exit status is ignored:
            os.system(cfg.restart_command)
    return rc


def do_list_links(cfg):
    symlinks_directory = SymlinksDirectory(cfg)
    state = symlinks_directory.state()
    print("## Enabled modules ##")
    for symlink in state.symlinks:
        link_name, mod_name = symlink.names()
        if mod_name == link_name:
            print(mod_name)
        else:
            print("{} => {}".format(link_name, mod_name))

    print("")
    print("## Disabled modules ##")
    for mod in state.extra_mods:
        print(mod)


def do_list_modules(cfg):
    mods = AsteriskModules(cfg.modules_dir, cfg.suffix)
    modules_list = mods.list()
    for m in modules_list:
        print(m)


def check_if_twinstar(cfg):
    """ Check if the system is a twinstar one.

    chdirs to the symlinks directory and tries to see if it can get to
    the modules directory in the relative path.

    On a Twin Star system exactly one extra level of directory up in
    a relative symlink because the links directory is in /replica and
    the modules directory (the targets of the symbolic links) are
    outside.
    """
    result = False
    cwd = os.getcwd()
    try:
        os.chdir(cfg.symlinks_dir)
        symlink = os.path.relpath(cfg.modules_dir, cfg.symlinks_dir)
        symlink = os.path.join("..", symlink)
        os.chdir(symlink)
        result = True
    except:
        pass
    finally:
        os.chdir(cwd)
    return result


def parse_cmd_line(defaults, args=sys.argv[1:]):
    """ Parse command-line arguments """
    parser = argparse.ArgumentParser()
    parser.description = """
    A script to enable / disable modules in an asterisk modules
    directory. It does so according to files in the configuration
    directory (--config-dir). This directory of symbolic links
    (symlinks directory) can be used as Asterisk astmoddir.
    Without further arguments, the script will apply the settings
    from the config dir.
    """
    parser.add_argument("-c", "--config-dir", action="store", type=str,
                        default=defaults['config_dir'],
                        help="Directory with generated symbolic links. Default: %(default)s.")
    parser.add_argument("-m", "--modules-dir", action="store", type=str,
                        default=defaults['mods_dir'],
                        help="Directory with all Asterisk modules. Default: %(default)s.")
    parser.add_argument("-s", "--symlinks-dir", action="store", type=str,
                        default=defaults['links_dir'],
                        help="Directory with generated symbolic links. Default: %(default)s.")
    parser.add_argument("-d", "--dry-run", action="store_true",
                        help="Do nothing. Just print what is to be run")
    parser.add_argument("-v", "--verbose", action="store_true",
                        help="Verbose execution")
    parser.add_argument("-r", "--restart-if-changed", action="store_true",
                        help="Restart Asterisk if links changed")
    parser.add_argument("-l", "--list-modules", action="store_true",
                        help="Do nothing. Just list available modules")
    parser.add_argument("-L", "--list-links", action="store_true",
                        help="Do nothing. Just list enabled/disabled modules")
    parser.add_argument("--suffix", action="store",
                        default=defaults['suffix'],
                        help="Module name suffix. Default: %(default)s.")
    parser.add_argument("--restart-command", action="store", type=str,
                        default=defaults['restart_command'],
                        help="Command to run to restart asterisk, if needed. Default: %(default)s.")
    parser.add_argument("--is-twinstar", action="store_true",
                        default=False, help=argparse.SUPPRESS)
    cfg = parser.parse_args(args)
    cfg.is_twinstar = check_if_twinstar(cfg)
    return cfg


def set_defaults(base_defaults):
    """ Set default values for directories and such.

    Mostly copies the built-in defaults. But also detects the modules
    directory.
    """
    defaults = {}
    for key in base_defaults:
        defaults[key] = base_defaults[key]
    for d in base_defaults['ast_mod_dirs']:
        if os.path.exists(d):
            defaults['mods_dir'] = d
    if 'mods_dir' not in defaults:
        defaults['mods_dir'] = base_defaults['ast_mod_dirs'][0]
    return defaults


def main():
    """ Main function """
    defaults = set_defaults(DEFAULTS)
    cfg = parse_cmd_line(defaults)
    if cfg.list_modules:
        return do_list_modules(cfg)
    if cfg.list_links:
        return do_list_links(cfg)
    else:
        return do_apply_actions(cfg)


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