#!/usr/bin/python

import argparse
import glob
from distutils.dir_util import mkpath as mkdir_p
import os
import shutil
import string
import subprocess
import sys

sys.path.append('/usr/share/l.v.e-manager/cpanel/lib')
from extensions import get_user_path, user_check

CAGEFS_CUSTOM_ETC = '/etc/cagefs/custom.etc'
CAGEFSCTL_TOOL    = '/usr/sbin/cagefsctl'
TOOL_LIST_GLOB    = '/usr/share/examine-lsphp/*-lsphp.sh'


class OopsError (RuntimeError): pass


def call(cmd):
    rc = 0
    if not g_dry_run:
        rc = subprocess.call(cmd)
    if g_debug:
        print 'subprocess.call(%s) = %s' % (str(cmd), rc)
    return rc


class Tool (object):

    def __init__(self, path):
        self._path = path

    def name(self):
        return os.path.split(self._path)[1].split('-')[0]

    def path(self):
        return self._path


class ToolList (object):

    def __init__(self):
        self._list = [Tool(path) for path in glob.glob(TOOL_LIST_GLOB)]

    def __str__(self):
        name_list = [x.name() for x in self._list]
        if len(name_list) < 3:
            return ' or '.join(name_list)
        elif len(name_list) < 5:
            return "%s or %s" % (', '.join(name_list[:-1]), name_list[-1])
        else:
            return 'available tools'

    def __contains__(self, item):
        return any(x.path() == item for x in self._list)

    def __iter__(self):
        return iter(self._list)

    def lookup_tool_path(self, tool_name):
        found = [x for x in self._list if x.name() == tool_name]
        if not found:
            raise OopsError("Invalid argument: " + tool_name)
        result, = found
        return result.path()


class CageFSContext (object):
    "Give CageFS a chance to catch up environment changes"

    class UpdateUserEtc (object):

        DO_ALL_THRESHOLD = 10
        pending_changes = dict()
        num_errors = 0

        def __init__(self, user): self._user = user

        def do_one(self):
            if g_debug:
                print '# propagate %s modifications' % CAGEFS_CUSTOM_ETC

            cmd = [CAGEFSCTL_TOOL, '--update-etc', self._user]
            rc = call(cmd)
            if rc:
                self.__class__.num_errors += 1

        @classmethod
        def do_all(cls):
            if g_debug:
                print '# propagate %s modifications' % CAGEFS_CUSTOM_ETC

            cmd = [CAGEFSCTL_TOOL, '--update-etc']
            rc = call(cmd)
            if rc:
                cls._num_erros += 1

    @classmethod
    def notify_filechange(cls, filepath):

        custom_etc = CAGEFS_CUSTOM_ETC.split('/')
        tokenized_fp = os.path.abspath(filepath).split('/')
        if tokenized_fp[ : len(custom_etc)] == custom_etc:
            try:
                user = tokenized_fp[len(custom_etc)]
            except IndexError:
                raise RuntimeError('Invalid argument: %s' % filepath)
            else:
                cls.UpdateUserEtc.pending_changes[user] = cls.UpdateUserEtc(user)

    @classmethod
    def flush(cls):
        "Apply pending operations"

        if len(cls.UpdateUserEtc.pending_changes) >= cls.UpdateUserEtc.DO_ALL_THRESHOLD:
            cls.UpdateUserEtc.do_all()
        else:
            for cmd in cls.UpdateUserEtc.pending_changes.values():
                try:
                    cmd.do_one()
                except EnvironmentError as e:
                    # be robust, not just bailout
                    print >>sys.stderr, str(e)
                    cls.UpdateUserEtc.num_errors += 1

        if cls.UpdateUserEtc.num_errors:
            raise OopsError('cagefsctl --update-etc : %d error(s)'
                            % cls.UpdateUserEtc.num_errors)

    def __enter__(self): return self
    def __exit__(self, exc_type, exc_value, traceback):
        try:
            self.flush()
        except (OopsError, EnvironmentError) as e:
            if exc_type is not Nune:
                raise
            else:
                print >>sys.stderr, e


class FSModifyingCallDecorator (object):
    """
    The decorator is recommended to be used for FS operation
    that modify something (files, directories, symlinks creation)
    """

    @staticmethod
    def unlink(link_name):
        if g_debug:
            print 'rm', link_name
        if not g_dry_run:
            os.unlink(link_name)

        CageFSContext.notify_filechange(link_name)

    @staticmethod
    def symlink(source, link_name):
        if not g_dry_run:
            os.symlink(source, link_name)

        CageFSContext.notify_filechange(link_name)

    @staticmethod
    def mkdir_p(dirs):
        if g_debug:
            print 'mkdir -p', dirs
        if not g_dry_run:
            mkdir_p(dirs)

        CageFSContext.notify_filechange(dirs)


def get_lsphp_selector_path(username):
    user_path = get_user_path(username)
    if user_path is None:
        raise OopsError("OopsError user '%s'" % username)
    return os.path.join(user_path, 'lsphp')


def get_lsphp_selector_backup_path(username):
    selector_backup_path = os.path.join(
        CAGEFS_CUSTOM_ETC,
        username,
        'examine-lsphp',
        'cl.selector.lsphp'
    )
    return selector_backup_path


def get_lsphp_selector_old_backup_path(username):

    cagefs_user_path = user_check(username)
    if cagefs_user_path is None:
        raise OopsError("OopsError user '%s'" % username)

    selector_backup_path = os.path.join(
        '/var/cagefs',
        cagefs_user_path,
        username,
        'etc',
        'examine-lsphp',
        'cl.selector.lsphp'
    )
    return selector_backup_path


def _symlink(source, link_name):

    if os.path.islink(link_name):
        FSModifyingCallDecorator.unlink(link_name)

    print 'ln -sv', source, link_name
    FSModifyingCallDecorator.symlink(source, link_name)


def set_user_current(username, tool_name):

    lsphp_selector_path = get_lsphp_selector_path(username)
    tool_path = g_tool_list.lookup_tool_path(tool_name)
    if os.readlink(lsphp_selector_path) == tool_path:
        print lsphp_selector_path, "is already symlinked to", tool_path
        return 1

    if g_debug:
        print '# create backup link'

    source, link_name = os.readlink(lsphp_selector_path), get_lsphp_selector_backup_path(username)
    if not source in g_tool_list:
        FSModifyingCallDecorator.mkdir_p(os.path.dirname(link_name))
        _symlink(source, link_name)

    if g_debug:
        print '# symlink user lsphp to', tool_path

    source, link_name = tool_path, lsphp_selector_path
    _symlink(source, link_name)

    return 0


def revert_user(username, verbose):

    lsphp_selector_backup_path = get_lsphp_selector_backup_path(username)
    if not os.path.islink(lsphp_selector_backup_path):
        if verbose:
            print "File %s does not exist or is not a symlink" % get_lsphp_selector_backup_path(username)
            print "Nothing to do."
        return 1

    # restore backup link
    source, link_name = os.readlink(lsphp_selector_backup_path), get_lsphp_selector_path(username)
    _symlink(source, link_name)
    FSModifyingCallDecorator.unlink(lsphp_selector_backup_path)

    return 0


def purge_user(username):

    revert_user(username, verbose=False)

    log_dir = os.path.join(os.path.expanduser("~%s/.examine-lsphp" % username))
    if os.path.isdir(log_dir):
        print "rm -rf", log_dir
        if not g_dry_run:
            shutil.rmtree(log_dir)

    return 0


def fixup_modls_199(username, **kwarg):

    old_backup_link = get_lsphp_selector_old_backup_path(username)
    if not os.path.islink(old_backup_link):
        return

    new_backup_link = get_lsphp_selector_backup_path(username)

    _symlink(os.readlink(old_backup_link), new_backup_link)
    FSModifyingCallDecorator.unlink(old_backup_link)


def all_users_do(func, **kwarg):

    LAST = -1
    GLOB = CAGEFS_CUSTOM_ETC + "/*/examine-lsphp"
    GLOB_USER_OFFSET = LAST - 1

    num_err = 0

    for path in glob.glob(kwarg.get('glob', GLOB)):

        username = path.split('/')[kwarg.get('glob_user_offset', GLOB_USER_OFFSET)]
        try:
            func(username, **kwarg)
        except (OopsError, EnvironmentError) as e:
            print >>sys.stderr, "User '%s': %s" % (username, e)
            num_err += 1

    if num_err:
        raise OopsError('%d error(s)' % num_err)

    return 0


def all_users_fixup_modls_199():

    OLD_LSPHP_SELECTOR_BACKUP_DIR_GLOB = "/var/cagefs/*/*/etc/examine-lsphp"
    OLD_LSPHP_SELECTOR_BACKUP_DIR_USER_OFFSET = -3

    all_users_do(fixup_modls_199,
                 glob=OLD_LSPHP_SELECTOR_BACKUP_DIR_GLOB,
                 glob_user_offset=OLD_LSPHP_SELECTOR_BACKUP_DIR_USER_OFFSET)

    return 0


def show_user_details(username):

    lsphp_selector_path = get_lsphp_selector_path(username)
    lsphp_selector_backup_path = get_lsphp_selector_backup_path(username)

    tool_path = os.readlink(lsphp_selector_path)
    if tool_path in g_tool_list:
        print "User:", username
        print "  %s -> %s" % (lsphp_selector_path, tool_path)
        print "  %s -> %s" % (tool_path, os.readlink(lsphp_selector_backup_path))
        return True
    else:
        return False


def show_details_all_users():

    # trick to workaround python 2.7 limits for closure functions
    class ScopeTrick (object): pass
    _ = ScopeTrick()
    _.count = 0

    def func(user):
        if show_user_details(user):
            _.count += 1
        return 0

    all_users_do(func)
    if not _.count:
        print "No users have lsphp linked to %s." % g_tool_list

    return 0


def list_tools():
    for tool in g_tool_list:
        print "%s: %s" % (tool.name(), tool.path())
    return 0


def main():

    global g_tool_list
    g_tool_list = ToolList()

    parser = argparse.ArgumentParser()
    parser.add_argument('--set-user-current', nargs=2, metavar=('username',
                                                                'tool'),
                        help="substitute user's lsphp alternative with %s symlink"
                            % g_tool_list)
    parser.add_argument('--revert-user', metavar="username",
                        help="revert user alternative to the previous state")
    parser.add_argument('--list', action='store_true',
                        help="list users have lsphp symlinked to %s"
                            % g_tool_list)
    parser.add_argument('--list-tools', action='store_true',
                        help="list tools available")
    parser.add_argument('--revert-all', action='store_true',
                        help="revert back all users")
    parser.add_argument('--purge-all', action='store_true',
                        help="revert and purge logs")
    parser.add_argument('--fixup-199', action='store_true',
                        help="fixup examine-lsphp-0.1-2 created symlinks (MODLS-199)")
    parser.add_argument('--debug', action='store_true',
                        help="verbose file operations and command invokations")
    parser.add_argument('--dry-run', action='store_true',
                        help="similar to '--debug' but do not touch anything")
    args = parser.parse_args()

    global g_debug
    global g_dry_run

    if args.dry_run:
        g_debug = True
        g_dry_run = True
    else:
        g_debug = args.debug
        g_dry_run = False

    with CageFSContext():
        if args.set_user_current:
            return set_user_current(*args.set_user_current)
        elif args.revert_user:
            return revert_user(args.revert_user, verbose=True)
        elif args.revert_all:
            return all_users_do(revert_user, verbose=False)
        elif args.list:
            return show_details_all_users()
        elif args.list_tools:
            return list_tools()
        elif args.purge_all:
            return all_users_do(purge_user)
        elif args.fixup_199:
            return all_users_fixup_modls_199()
        else:
            parser.print_help()  # sys.exit() is called by parser.print_help() implicitly


if __name__ == "__main__":
    try:
        err = main()
    except OopsError as e:
        print >>sys.stderr, e
        err = 1
    if not err:
        print "Done"
    sys.exit(err)
