#!/usr/bin/env python
# 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>][--steps <steps>]
  functional client <test> [--dry] [--timeout <timeout>][--port <port>]
  functional server <test> [--dry] [--timeout <timeout>][--port <port>]
  functional run <test> [--timeout <timeout>][--port <port>]
  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: 20]
  --steps <steps>,  -s <steps>  number of test to run simultaneously [default: 0]
"""

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

%(exa)s


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

%(bgp)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):
	_next = 0
	_nl = 3

	@classmethod
	def get (cls):
		cls._next += 1
		return '%02d' % cls._next

	@classmethod
	def identifiers (cls):
		for n in range(1,cls._next+1):
			yield '%02d' % n, not n % 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'))

	@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('^\s*run\s+(.*)\s*?;\s*?$')
	_content = {}
	_tests = []
	completed = True

	@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',
					'status': Color.NONE,
					'port':   Port.get()
				}
		cls._tests.extend(sorted(cls._content.keys()))

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

	@classmethod
	def reset (cls,k):
		cls._content[k]['status'] = Color.NONE

	@classmethod
	def passed (cls,k):
		cls._content[k]['status'] = Color.SUCCESS

	@classmethod
	def failed (cls,k):
		cls._content[k]['status'] = Color.FAIL
		cls.completed = False

	@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:
			v = cls._content[k]
			status = v['status']
			if status == Color.SUCCESS:
				k = '--'
			sys.stdout.write('%s%s ' % (status,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.flush()


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


class Alarm (Exception):
	pass


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


class Command (object):
	@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 % {
			'exa': cls.client(index),
			'bgp': cls.server(index),
		})
		sys.exit(1)

	@staticmethod
	def client (index):
		test = CI.get(index)
		if not test:
			sys.exit('test not found')

		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'],
			]),
			'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('test not found')

		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'],
			]),
			'bgp': Path.BGP,
			'msg': test['msg'],
		}

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

	@staticmethod
	def dispatch (running):
		CI.listing()
		processes = {
			'bgp': {},
			'exa': {},
		}

		print('\nchecking\n--------\n')

		for name in running:
			test = CI.get(name)
			processes['bgp'][name] = subprocess.Popen([sys.argv[0],'server',name,'--port',str(test['port'])], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
			test['status'] = Color.STARTING
			CI.display()
			time.sleep(0.05)

		time.sleep(0.5)
		names = list(reversed(running))

		for name in names:
			test = CI.get(name)
			processes['exa'][name] = subprocess.Popen([sys.argv[0],'client',name,'--port',str(test['port'])], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
			test['status'] = Color.READY
			CI.display()
			time.sleep(0.2)

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

		while names and time.time() < exit_time:
			CI.display()
			for name in list(names):
				try:
					signal.alarm(1)
					rbgp = processes['bgp'][name].poll()
					signal.alarm(0)
				except Alarm:
					continue
				if rbgp is None:
					continue
				names.remove(name)
				try:
					stdout,stderr = processes['bgp'][name].communicate()
					if 'successful' in stdout:
						CI.passed(name)
					else:
						CI.failed(name)
				except (IOError,OSError,ValueError):
					names.remove(name)
			time.sleep(0.2)

		for name in names:
			CI.reset(name)

		if not CI.completed:
			for name in names:
				print('sending alarm signal to %s' % name)
				print('------------------------%s' % '-'*len(name))
				try:
					processes['bgp'][name].send_signal(signal.SIGTERM)
				except OSError:  # No such process, Errno 3
					pass
				try:
					processes['exa'][name].send_signal(signal.SIGTERM)
				except OSError:  # No such process, Errno 3
					pass
				try:
					signal.alarm(1)
					stdout,stderr = processes['bgp'][name].communicate()
					signal.alarm(0)
					print('\nbgp stdout\n------\n%s' % stdout)
					print('\nbgp stderr\n------\n%s' % stdout)
				except ValueError:  # I/O operation on closed file
					continue
				except Alarm:
					pass
				try:
					signal.alarm(1)
					stdout,stderr = processes['exa'][name].communicate()
					signal.alarm(0)
					print('\nexa stdout\n------\n%s' % stdout)
					print('\nexa stderr\n------\n%s' % stdout)
				except ValueError:  # I/O operation on closed file
					continue
				except Alarm:
					pass

		CI.display()
		print('\n')
		return CI.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']:
		to_run = [OPTIONS['<test>'],]
	elif OPTIONS['all']:
		to_run = [index for index,_ in Identifier.identifiers()]
	else:
		print(USAGE)
		sys.exit(1)

	chunk = len(to_run) if not int(OPTIONS['--steps']) else int(OPTIONS['--steps'])
	success = True
	while to_run and success:
		running, to_run = to_run[:chunk], to_run[chunk:]
		success = Command.dispatch(running)

	sys.exit(0 if success else 1)

	print(USAGE)
	sys.exit(1)
