#!/usr/bin/env python3
# encoding: utf-8
"""
cache.py

Created by Thomas Mangin
Copyright (c) 2013-2017 Exa Networks. All rights reserved.
License: 3-clause BSD. (See the COPYRIGHT file)
"""

import os
import re
import sys
import glob
import time
import signal
import itertools
import subprocess

PROGRAM = os.path.realpath(__file__)
ROOT = os.path.abspath(os.path.join(os.path.dirname(PROGRAM), os.path.join('..', '..')))
LIBRARY = os.path.join(ROOT, 'lib')

try:
    import docopt
except ImportError as exc:
    # PROGRAM = os.path.abspath(os.path.join(os.getcwd(),sys.argv[0]))
    LIBRARY = os.path.abspath(os.path.join(os.path.dirname(PROGRAM), os.path.join('..', '..', 'lib')))
    sys.path.append(LIBRARY)
    from exabgp.vendoring import docopt


USAGE = """\
usage:
  functional help|--help
  functional listing
  functional all [--timeout <timeout>][--port <port>]
  functional client <test> [--dry] [--timeout <timeout>][--port <port>]
  functional server <test> [--dry] [--timeout <timeout>][--port <port>]
  functional run [<test>] [--timeout <timeout>][--port <port>][--steps <steps>]
  functional explain <test> [--timeout <timeout>][--port <port>]
  functional decode <test> <payload>
  functional edit <test>

The BGP swiss army knife of networking functional testing tool

commands:
  listing    list all functional test available
  all        run all available test
  explain    show what command for a test are run
  run        run a particular test
  client     start the client for a specific test
  server     start the server for a specific test
  decode     use the cond
  edit       start $EDITOR to edit a specific test

optional arguments:
  --help,           -h          this page
  --dry,            -d          show what command would be run but does nothing
  --port <port>,    -p <port>   base port to use [default: 1790]
  --timeout <time>, -t <time>   timeout for test failure [default: 60]
  --steps <steps>,  -s <steps>  number of test to run simultaneously [default: 0]
"""

EXPLAIN = """
ExaBGP command line
=======================================================

%(client)s


bgp daemon command line
=======================================================

%(server)s


The following extra configuration options could be used
=======================================================

export exabgp_debug_rotate=true
export exabgp_debug_defensive=true
"""

OPTIONS = docopt.docopt(USAGE, help=False)


class Color(object):
    NONE = '\033[0m' + '\033[0m' + ' '  # NONE
    STARTING = '\033[0m' + '\033[96m' + '~'  # LIGHT BLUE
    READY = '\033[0m' + '\033[94m' + '='  # PENDING
    FAIL = '\033[0m' + '\033[91m' + '-'  # RED
    SUCCESS = '\033[1m' + '\033[92m' + '+'  # GREEN


class Identifier(object):
    _listing = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
    _next = 0
    _nl = 3

    @classmethod
    def get(cls):
        cls._next += 1
        return cls._listing[cls._next - 1]

    @classmethod
    def identifiers(cls):
        for n in range(0, cls._next):
            yield cls._listing[n], not (n + 1) % cls._nl


class Port(object):
    _next = int(OPTIONS['--port']) - 1

    @classmethod
    def get(cls):
        cls._next += 1
        return cls._next


class Path(object):
    ETC = os.path.join(ROOT, 'etc', 'exabgp')
    EXABGP = os.path.join(ROOT, 'sbin', 'exabgp')
    BGP = os.path.join(ROOT, 'qa', 'sbin', 'bgp')
    CI = os.path.join(os.path.join(ROOT, 'qa', 'ci'))
    ALL_CI = glob.glob(os.path.join(CI, '*.ci'))
    ALL_CI.sort()

    @classmethod
    def validate(cls):
        if not os.path.isdir(cls.ETC):
            sys.exit('could not find etc folder')

        if not os.path.isdir(cls.CI):
            sys.exit('could not find tests in the qa/ci folder')

        if not os.path.isfile(cls.EXABGP):
            sys.exit('could not find exabgp')

        if not os.path.isfile(cls.BGP):
            sys.exit('could not find the sequence daemon')


class CI(dict):
    API = re.compile(r'^\s*run\s+(.*)\s*?;\s*?$')
    _content = {}
    _status = {}
    _tests = []

    @classmethod
    def make(cls):
        for filename in Path.ALL_CI:
            index = Identifier.get()
            name, extension = os.path.splitext(filename.split('/')[-1])
            with open(filename, 'r') as reader:
                content = reader.readline()
                cls._content[index] = {
                    'name': name,
                    'confs': [os.path.join(Path.ETC, _) for _ in content.split()],
                    'ci': os.path.join(Path.CI, name) + '.ci',
                    'msg': os.path.join(Path.CI, name) + '.msg',
                    'port': Port.get(),
                }
        cls._tests.extend(sorted(cls._content.keys()))

    @classmethod
    def get(cls, k):
        return cls._content.get(k, None)

    @classmethod
    def state(cls, name):
        if name not in cls._status:
            cls._status[name] = Color.NONE
        elif cls._status[name] == Color.NONE:
            cls._status[name] = Color.STARTING
        elif cls._status[name] == Color.STARTING:
            cls._status[name] = Color.READY

    @classmethod
    def color(cls, name):
        return cls._status.get(name, Color.NONE)

    @classmethod
    def reset(cls, name):
        cls._status[name] = Color.NONE

    @classmethod
    def passed(cls, name):
        cls._status[name] = Color.SUCCESS

    @classmethod
    def failed(cls, name):
        cls._status[name] = Color.FAIL

    @classmethod
    def files(cls, k):
        test = cls._content.get(k, None)
        if not test:
            return []
        files = [
            test['msg'],
        ]
        for f in test['confs']:
            files.append(f)
            with open(f) as reader:
                for line in reader:
                    found = cls.API.match(line)
                    if not found:
                        continue
                    name = found.group(1)
                    if not name.startswith('/'):
                        name = os.path.abspath(os.path.join(Path.ETC, name))
                    if name not in files:
                        files.append(name)
        return [f for f in files if os.path.isfile(f)]

    @classmethod
    def display(cls):
        sys.stdout.write('\r')
        for k in cls._tests:
            sys.stdout.write('%s%s ' % (CI.color(k), k))
        sys.stdout.write(Color.NONE)
        sys.stdout.flush()

    @classmethod
    def listing(cls):
        sys.stdout.write('\n')
        sys.stdout.write('The available functional tests are:\n')
        sys.stdout.write('\n')
        for index, nl in Identifier.identifiers():
            name = cls._content[index]['name']
            sys.stdout.write(' %-2s %s%s' % (index, name, ' ' * (25 - len(name))))
            sys.stdout.write('\n' if nl else '')
        sys.stdout.write('\n')
        sys.stdout.write('\n')
        sys.stdout.write('\n')
        sys.stdout.write('checking\n')
        sys.stdout.write('\n')
        sys.stdout.flush()


Path.validate()
CI.make()
# CI.display()


class Alarm(Exception):
    pass


def alarm_handler(number, frame):  # pylint: disable=W0613
    raise Alarm()


class Process(object):
    _running = {}
    _result = {}

    @classmethod
    def add(cls, name, side, process):
        cls._running.setdefault(name, {})[side] = process
        for std in ('in', 'out'):
            cls._result.setdefault(name, {}).setdefault(side, {})[std] = b''

    @classmethod
    def success(cls, name):
        return b'successful' in cls._result[name]['server']['out']

    @classmethod
    def _ready(cls, side, name):
        try:
            signal.alarm(1)
            polled = cls._running[side][name].poll()
            signal.alarm(0)
        except Alarm:
            return False
        except (IOError, OSError, ValueError):
            return True
        if polled is None:
            return False
        return True

    @classmethod
    def collect(cls, name, side):
        try:
            signal.alarm(1)
            stdout, stderr = cls._running[name][side].communicate()
            signal.alarm(0)
            cls._result[name][side]['out'] = stdout
            cls._result[name][side]['err'] = stderr
        except ValueError:  # I/O operation on closed file
            pass
        except Alarm:
            pass

    @classmethod
    def output(cls, name, side):
        return cls._result[name][side]['out']

    def error(cls, name, side):
        return cls._result[name][side]['err']

    @classmethod
    def _terminate(cls, name, side):
        try:
            cls._running[name][side].send_signal(signal.SIGTERM)
        except OSError:  # No such process, Errno 3
            pass

    @classmethod
    def terminate(cls):
        for name in cls._running:
            for side in cls._running[name]:
                if cls.output(name, side) != '' or cls.error(name, side) != '':
                    continue
                cls._terminate(name, side)
                cls.collect(name, side)


class Command(dict):
    @staticmethod
    def execute(cmd):
        print('starting: %s' % ' '.join(cmd))
        popen = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
        for line in itertools.chain(iter(popen.stdout.readline, ''), iter(popen.stderr.readline, '')):
            yield line
        popen.stdout.close()
        return_code = popen.wait()
        if return_code:
            raise subprocess.CalledProcessError(return_code, cmd)

    @classmethod
    def explain(cls, index):
        print(
            EXPLAIN % {'client': cls.client(index), 'server': cls.server(index),}
        )
        sys.exit(1)

    @staticmethod
    def client(index):
        test = CI.get(index)
        if not test:
            sys.exit("can not find any test called '%s'" % index)

        if os.getuid() and os.getgid() and test['port'] <= 1024:
            sys.exit('you need to have root privileges to bind to port 79')

        config = {
            'env': ' \\\n  '.join(
                [
                    'exabgp_tcp_once=true',
                    'exabgp_api_cli=false',
                    'exabgp_debug_rotate=true',
                    'exabgp_debug_configuration=true',
                    'exabgp_tcp_bind=\'\'',
                    'exabgp_tcp_port=%d' % test['port'],
                    'INTERPRETER=%s ' % os.environ.get('__PYVENV_LAUNCHER__', sys.executable),
                ]
            ),
            'exabgp': Path.EXABGP,
            'confs': ' \\\n    '.join(test['confs']),
        }
        return 'env \\\n  %(env)s \\\n %(exabgp)s -d -p \\\n    %(confs)s' % config

    @staticmethod
    def server(index):
        test = CI.get(index)

        if not test:
            sys.exit("can not find any test called '%s'" % index)

        if os.getuid() and os.getgid() and test['port'] <= 1024:
            sys.exit('you need to have root privileges to bind to port 79')

        config = {
            'env': ' \\\n  '.join(['exabgp_tcp_port=%d' % test['port'],]),
            'interpreter': os.environ.get('__PYVENV_LAUNCHER__', sys.executable),
            'bgp': Path.BGP,
            'msg': test['msg'],
        }

        return 'env \\\n  %(env)s \\\n  %(interpreter)s %(bgp)s \\\n    %(msg)s' % config

    @staticmethod
    def dispatch(running):
        completed = True
        names = []
        for name in running:
            if CI.get(name) is None:
                sys.exit("can not find any test called '%s'" % name)
            CI.state(name)
            names.append(name)

        for side in ['server', 'client']:
            for name in running:
                process = subprocess.Popen(
                    [sys.argv[0], side, name, '--port', str(CI.get(name)['port'])],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                )
                Process.add(name, side, process)
                CI.state(name)
                CI.display()
                time.sleep(0.02)

        exit_time = time.time() + int(OPTIONS['--timeout'])

        while names and time.time() < exit_time:
            CI.display()
            for name in list(names):
                for side in ('server', 'client'):
                    if not Process._ready(name, side):
                        continue

                    Process.collect(name, side)

                    if side == 'server':
                        names.remove(name)

                    if Process.success(name):
                        CI.passed(name)
                    else:
                        CI.failed(name)
                        completed = False

                    CI.display()
            time.sleep(0.2)

        Process.terminate()

        for name in names:
            print('\server stderr\n------\n%s' % str(Process.output(name, 'server')).replace('\\n', '\n'))
            print('\client stdout\n------\n%s' % str(Process.output(name, 'client')).replace('\\n', '\n'))

        CI.display()
        return completed


if __name__ == '__main__':

    if OPTIONS['help'] or OPTIONS['--help']:
        print(USAGE)
        sys.exit(0)

    if OPTIONS['listing']:
        CI.listing()
        sys.exit(0)

    if OPTIONS['explain']:
        Command.explain(OPTIONS['<test>'])
        sys.exit(0)

    if OPTIONS['edit']:
        files = CI.files(OPTIONS['<test>'])
        if not files:
            sys.exit('no such test')
        editor = os.environ.get('EDITOR', 'vi')
        os.system('%s %s' % (editor, ' '.join(files)))
        sys.exit(0)

    if OPTIONS['decode']:
        test = CI.get(OPTIONS['<test>'])
        command = '%s -d %s --decode "%s"' % (Path.EXABGP, test['confs'][0], OPTIONS['<payload>'])
        print('> %s' % command)
        os.system(command)
        sys.exit(0)

    if OPTIONS['client']:
        command = Command.client(OPTIONS['<test>'])
        print('> ' + command)
        if not OPTIONS['--dry']:
            sys.exit(os.system(command))
        sys.exit(0)

    if OPTIONS['server']:
        command = Command.server(OPTIONS['<test>'])
        print('> ' + command)
        if not OPTIONS['--dry']:
            sys.exit(os.system(command))
        sys.exit(0)

    if OPTIONS['run']:
        test = OPTIONS['<test>']
        to_run = (
            [index for index, _ in Identifier.identifiers()] if test is None else [test,]
        )
        chunk = len(to_run) if not int(OPTIONS['--steps']) else int(OPTIONS['--steps'])
    elif OPTIONS['all']:
        to_run = [index for index, _ in Identifier.identifiers()]
        chunk = 1
    else:
        print(USAGE)
        sys.exit(1)

    CI.listing()
    success = True
    while to_run and success:
        running, to_run = to_run[:chunk], to_run[chunk:]
        success = Command.dispatch(running)
    sys.stdout.write('\n')

    sys.exit(0 if success else 1)

    print(USAGE)
    sys.exit(1)
