#!/usr/bin/env python
#
# Copyright (c) 2006 Ondrej Palkovsky
# Copyright (c) 2009 Martin Decky
# Copyright (c) 2010 Jiri Svoboda
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
#
# - Redistributions of source code must retain the above copyright
#   notice, this list of conditions and the following disclaimer.
# - Redistributions in binary form must reproduce the above copyright
#   notice, this list of conditions and the following disclaimer in the
#   documentation and/or other materials provided with the distribution.
# - The name of the author may not be used to endorse or promote products
#   derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#

"""
HelenOS configuration system
"""

import sys
import os
import re
import time
import subprocess
import xtui
import random

RULES_FILE = sys.argv[1]
MAKEFILE = 'Makefile.config'
MACROS = 'config.h'
PRESETS_DIR = 'defaults'

def read_config(fname, config):
	"Read saved values from last configuration run or a preset file"
	
	inf = open(fname, 'r')
	
	for line in inf:
		res = re.match(r'^(?:#!# )?([^#]\w*)\s*=\s*(.*?)\s*$', line)
		if res:
			config[res.group(1)] = res.group(2)
	
	inf.close()

def check_condition(text, config, rules):
	"Check that the condition specified on input line is True (only CNF and DNF is supported)"
	
	ctype = 'cnf'
	
	if (')|' in text) or ('|(' in text):
		ctype = 'dnf'
	
	if ctype == 'cnf':
		conds = text.split('&')
	else:
		conds = text.split('|')
	
	for cond in conds:
		if cond.startswith('(') and cond.endswith(')'):
			cond = cond[1:-1]
		
		inside = check_inside(cond, config, ctype)
		
		if (ctype == 'cnf') and (not inside):
			return False
		
		if (ctype == 'dnf') and inside:
			return True
	
	if ctype == 'cnf':
		return True
	
	return False

def check_inside(text, config, ctype):
	"Check for condition"
	
	if ctype == 'cnf':
		conds = text.split('|')
	else:
		conds = text.split('&')
	
	for cond in conds:
		res = re.match(r'^(.*?)(!?=)(.*)$', cond)
		if not res:
			raise RuntimeError("Invalid condition: %s" % cond)
		
		condname = res.group(1)
		oper = res.group(2)
		condval = res.group(3)
		
		if not condname in config:
			varval = ''
		else:
			varval = config[condname]
			if (varval == '*'):
				varval = 'y'
		
		if ctype == 'cnf':
			if (oper == '=') and (condval == varval):
				return True
		
			if (oper == '!=') and (condval != varval):
				return True
		else:
			if (oper == '=') and (condval != varval):
				return False
			
			if (oper == '!=') and (condval == varval):
				return False
	
	if ctype == 'cnf':
		return False
	
	return True

def parse_rules(fname, rules):
	"Parse rules file"
	
	inf = open(fname, 'r')
	
	name = ''
	choices = []
	
	for line in inf:
		
		if line.startswith('!'):
			# Ask a question
			res = re.search(r'!\s*(?:\[(.*?)\])?\s*([^\s]+)\s*\((.*)\)\s*$', line)
			
			if not res:
				raise RuntimeError("Weird line: %s" % line)
			
			cond = res.group(1)
			varname = res.group(2)
			vartype = res.group(3)
			
			rules.append((varname, vartype, name, choices, cond))
			name = ''
			choices = []
			continue
		
		if line.startswith('@'):
			# Add new line into the 'choices' array
			res = re.match(r'@\s*(?:\[(.*?)\])?\s*"(.*?)"\s*(.*)$', line)
			
			if not res:
				raise RuntimeError("Bad line: %s" % line)
			
			choices.append((res.group(2), res.group(3)))
			continue
		
		if line.startswith('%'):
			# Name of the option
			name = line[1:].strip()
			continue
		
		if line.startswith('#') or (line == '\n'):
			# Comment or empty line
			continue
		
		
		raise RuntimeError("Unknown syntax: %s" % line)
	
	inf.close()

def yes_no(default):
	"Return '*' if yes, ' ' if no"
	
	if default == 'y':
		return '*'
	
	return ' '

def subchoice(screen, name, choices, default):
	"Return choice of choices"
	
	maxkey = 0
	for key, val in choices:
		length = len(key)
		if (length > maxkey):
			maxkey = length
	
	options = []
	position = None
	cnt = 0
	for key, val in choices:
		if (default) and (key == default):
			position = cnt
		
		options.append(" %-*s  %s " % (maxkey, key, val))
		cnt += 1
	
	(button, value) = xtui.choice_window(screen, name, 'Choose value', options, position)
	
	if button == 'cancel':
		return None
	
	return choices[value][0]

## Infer and verify configuration values.
#
# Augment @a config with values that can be inferred, purge invalid ones
# and verify that all variables have a value (previously specified or inferred).
#
# @param config Configuration to work on
# @param rules  Rules
#
# @return True if configuration is complete and valid, False
#         otherwise.
#
def infer_verify_choices(config, rules):
	"Infer and verify configuration values."
	
	for rule in rules:
		varname, vartype, name, choices, cond = rule
		
		if cond and (not check_condition(cond, config, rules)):
			continue
		
		if not varname in config:
			value = None
		else:
			value = config[varname]
		
		if not validate_rule_value(rule, value):
			value = None
		
		default = get_default_rule(rule)
		
		#
		# If we don't have a value but we do have
		# a default, use it.
		#
		if value == None and default != None:
			value = default
			config[varname] = default
		
		if not varname in config:
			return False
	
	return True

## Fill the configuration with random (but valid) values.
#
# The random selection takes next rule and if the condition does
# not violate existing configuration, random value of the variable
# is selected.
# This happens recursively as long as there are more rules.
# If a conflict is found, we backtrack and try other settings of the
# variable or ignoring the variable altogether.
#
# @param config Configuration to work on
# @param rules  Rules
# @param start_index With which rule to start (initial call must specify 0 here).
# @return True if able to find a valid configuration 
def random_choices(config, rules, start_index):
	"Fill the configuration with random (but valid) values."
	if start_index >= len(rules):
		return True
	
	varname, vartype, name, choices, cond = rules[start_index]

	# First check that this rule would make sense	
	if cond:
		if not check_condition(cond, config, rules):
			return random_choices(config, rules, start_index + 1)
	
	# Remember previous choices for backtracking
	yes_no = 0
	choices_indexes = range(0, len(choices))
	random.shuffle(choices_indexes)
	
	# Remember current configuration value
	old_value = None
	try:
		old_value = config[varname]
	except KeyError:
		old_value = None
	
	# For yes/no choices, we ran the loop at most 2 times, for select
	# choices as many times as there are options.
	try_counter = 0
	while True:
		if vartype == 'choice':
			if try_counter >= len(choices_indexes):
				break
			value = choices[choices_indexes[try_counter]][0]
		elif vartype == 'y' or vartype == 'n':
			if try_counter > 0:
				break
			value = vartype
		elif vartype == 'y/n' or vartype == 'n/y':
			if try_counter == 0:
				yes_no = random.randint(0, 1)
			elif try_counter == 1:
				yes_no = 1 - yes_no
			else:
				break
			if yes_no == 0:
				value = 'n'
			else:
				value = 'y'
		else:
			raise RuntimeError("Unknown variable type: %s" % vartype)
	
		config[varname] = value
		
		ok = random_choices(config, rules, start_index + 1)
		if ok:
			return True
		
		try_counter = try_counter + 1
	
	# Restore the old value and backtrack
	# (need to delete to prevent "ghost" variables that do not exist under
	# certain configurations)
	config[varname] = old_value
	if old_value is None:
		del config[varname]
	
	return random_choices(config, rules, start_index + 1)
	

## Get default value from a rule.
def get_default_rule(rule):
	varname, vartype, name, choices, cond = rule
	
	default = None
	
	if vartype == 'choice':
		# If there is just one option, use it
		if len(choices) == 1:
			default = choices[0][0]
	elif vartype == 'y':
		default = '*'
	elif vartype == 'n':
		default = 'n'
	elif vartype == 'y/n':
		default = 'y'
	elif vartype == 'n/y':
		default = 'n'
	else:
		raise RuntimeError("Unknown variable type: %s" % vartype)
	
	return default

## Get option from a rule.
#
# @param rule  Rule for a variable
# @param value Current value of the variable
#
# @return Option (string) to ask or None which means not to ask.
#
def get_rule_option(rule, value):
	varname, vartype, name, choices, cond = rule
	
	option = None
	
	if vartype == 'choice':
		# If there is just one option, don't ask
		if len(choices) != 1:
			if (value == None):
				option = "?     %s --> " % name
			else:
				option = "      %s [%s] --> " % (name, value)
	elif vartype == 'y':
		pass
	elif vartype == 'n':
		pass
	elif vartype == 'y/n':
		option = "  <%s> %s " % (yes_no(value), name)
	elif vartype == 'n/y':
		option ="  <%s> %s " % (yes_no(value), name)
	else:
		raise RuntimeError("Unknown variable type: %s" % vartype)
	
	return option

## Check if variable value is valid.
#
# @param rule  Rule for the variable
# @param value Value of the variable
#
# @return True if valid, False if not valid.
#
def validate_rule_value(rule, value):
	varname, vartype, name, choices, cond = rule
	
	if value == None:
		return True
	
	if vartype == 'choice':
		if not value in [choice[0] for choice in choices]:
			return False
	elif vartype == 'y':
		if value != 'y':
			return False
	elif vartype == 'n':
		if value != 'n':
			return False
	elif vartype == 'y/n':
		if not value in ['y', 'n']:
			return False
	elif vartype == 'n/y':
		if not value in ['y', 'n']:
			return False
	else:
		raise RuntimeError("Unknown variable type: %s" % vartype)
	
	return True

def preprocess_config(config, rules):
	"Preprocess configuration"
	
	varname_mode = 'CONFIG_BFB_MODE'
	varname_width = 'CONFIG_BFB_WIDTH'
	varname_height = 'CONFIG_BFB_HEIGHT'
	
	if varname_mode in config:
		mode = config[varname_mode].partition('x')
		
		config[varname_width] = mode[0]
		rules.append((varname_width, 'choice', 'Default framebuffer width', None, None))
		
		config[varname_height] = mode[2]
		rules.append((varname_height, 'choice', 'Default framebuffer height', None, None))

def create_output(mkname, mcname, config, rules):
	"Create output configuration"

	timestamp_unix = int(time.time())
	timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp_unix))
	
	sys.stderr.write("Fetching current revision identifier ... ")
	
	try:
		version = subprocess.Popen(['bzr', 'version-info', '--custom', '--template={clean}:{revno}:{revision_id}'], stdout = subprocess.PIPE).communicate()[0].decode().split(':')
		sys.stderr.write("ok\n")
	except:
		version = [1, "unknown", "unknown"]
		sys.stderr.write("failed\n")
	
	if len(version) == 3:
		revision = version[1]
		if version[0] != 1:
			revision += 'M'
		revision += ' (%s)' % version[2]
	else:
		revision = None
	
	outmk = open(mkname, 'w')
	outmc = open(mcname, 'w')
	
	outmk.write('#########################################\n')
	outmk.write('## AUTO-GENERATED FILE, DO NOT EDIT!!! ##\n')
	outmk.write('## Generated by: tools/config.py       ##\n')
	outmk.write('#########################################\n\n')
	
	outmc.write('/***************************************\n')
	outmc.write(' * AUTO-GENERATED FILE, DO NOT EDIT!!! *\n')
	outmc.write(' * Generated by: tools/config.py       *\n')
	outmc.write(' ***************************************/\n\n')
	
	defs = 'CONFIG_DEFS ='
	
	for varname, vartype, name, choices, cond in rules:
		if cond and (not check_condition(cond, config, rules)):
			continue
		
		if not varname in config:
			value = ''
		else:
			value = config[varname]
			if (value == '*'):
				value = 'y'
		
		outmk.write('# %s\n%s = %s\n\n' % (name, varname, value))
		
		if vartype in ["y", "n", "y/n", "n/y"]:
			if value == "y":
				outmc.write('/* %s */\n#define %s\n\n' % (name, varname))
				defs += ' -D%s' % varname
		else:
			outmc.write('/* %s */\n#define %s %s\n#define %s_%s\n\n' % (name, varname, value, varname, value))
			defs += ' -D%s=%s -D%s_%s' % (varname, value, varname, value)
	
	if revision is not None:
		outmk.write('REVISION = %s\n' % revision)
		outmc.write('#define REVISION %s\n' % revision)
		defs += ' "-DREVISION=%s"' % revision
	
	outmk.write('TIMESTAMP_UNIX = %d\n' % timestamp_unix)
	outmc.write('#define TIMESTAMP_UNIX %d\n' % timestamp_unix)
	defs += ' "-DTIMESTAMP_UNIX=%d"\n' % timestamp_unix
	
	outmk.write('TIMESTAMP = %s\n' % timestamp)
	outmc.write('#define TIMESTAMP %s\n' % timestamp)
	defs += ' "-DTIMESTAMP=%s"\n' % timestamp
	
	outmk.write(defs)
	
	outmk.close()
	outmc.close()

def sorted_dir(root):
	list = os.listdir(root)
	list.sort()
	return list

## Ask user to choose a configuration profile.
#
def choose_profile(root, fname, screen, config):
	options = []
	opt2path = {}
	cnt = 0
	
	# Look for profiles
	for name in sorted_dir(root):
		path = os.path.join(root, name)
		canon = os.path.join(path, fname)
		
		if os.path.isdir(path) and os.path.exists(canon) and os.path.isfile(canon):
			subprofile = False
			
			# Look for subprofiles
			for subname in sorted_dir(path):
				subpath = os.path.join(path, subname)
				subcanon = os.path.join(subpath, fname)
				
				if os.path.isdir(subpath) and os.path.exists(subcanon) and os.path.isfile(subcanon):
					subprofile = True
					options.append("%s (%s)" % (name, subname))
					opt2path[cnt] = [name, subname]
					cnt += 1
			
			if not subprofile:
				options.append(name)
				opt2path[cnt] = [name]
				cnt += 1
	
	(button, value) = xtui.choice_window(screen, 'Load preconfigured defaults', 'Choose configuration profile', options, None)
	
	if button == 'cancel':
		return None
	
	return opt2path[value]

## Read presets from a configuration profile.
#
# @param profile Profile to load from (a list of string components)
# @param config  Output configuration
#
def read_presets(profile, config):
	path = os.path.join(PRESETS_DIR, profile[0], MAKEFILE)
	read_config(path, config)
	
	if len(profile) > 1:
		path = os.path.join(PRESETS_DIR, profile[0], profile[1], MAKEFILE)
		read_config(path, config)

## Parse profile name (relative OS path) into a list of components.
#
# @param profile_name Relative path (using OS separator)
# @return             List of components
#
def parse_profile_name(profile_name):
	profile = []
	
	head, tail = os.path.split(profile_name)
	if head != '':
		profile.append(head)
	
	profile.append(tail)
	return profile

def main():
	profile = None
	config = {}
	rules = []
	
	# Parse rules file
	parse_rules(RULES_FILE, rules)
	
	# Input configuration file can be specified on command line
	# otherwise configuration from previous run is used.
	if len(sys.argv) >= 4:
		profile = parse_profile_name(sys.argv[3])
		read_presets(profile, config)
	elif os.path.exists(MAKEFILE):
		read_config(MAKEFILE, config)
	
	# Default mode: check values and regenerate configuration files
	if (len(sys.argv) >= 3) and (sys.argv[2] == 'default'):
		if (infer_verify_choices(config, rules)):
			preprocess_config(config, rules)
			create_output(MAKEFILE, MACROS, config, rules)
			return 0
	
	# Hands-off mode: check values and regenerate configuration files,
	# but no interactive fallback
	if (len(sys.argv) >= 3) and (sys.argv[2] == 'hands-off'):
		# We deliberately test sys.argv >= 4 because we do not want
		# to read implicitly any possible previous run configuration
		if len(sys.argv) < 4:
			sys.stderr.write("Configuration error: No presets specified\n")
			return 2
		
		if (infer_verify_choices(config, rules)):
			preprocess_config(config, rules)
			create_output(MAKEFILE, MACROS, config, rules)
			return 0
		
		sys.stderr.write("Configuration error: The presets are ambiguous\n")
		return 1
	
	# Check mode: only check configuration
	if (len(sys.argv) >= 3) and (sys.argv[2] == 'check'):
		if infer_verify_choices(config, rules):
			return 0
		return 1
	
	# Random mode
	if (len(sys.argv) == 3) and (sys.argv[2] == 'random'):
		ok = random_choices(config, rules, 0)
		if not ok:
			sys.stderr.write("Internal error: unable to generate random config.\n")
			return 2
		if not infer_verify_choices(config, rules):
			sys.stderr.write("Internal error: random configuration not consistent.\n")
			return 2
		preprocess_config(config, rules)
		create_output(MAKEFILE, MACROS, config, rules)
		
		return 0	
	
	screen = xtui.screen_init()
	try:
		selname = None
		position = None
		while True:
			
			# Cancel out all values which have to be deduced
			for varname, vartype, name, choices, cond in rules:
				if (vartype == 'y') and (varname in config) and (config[varname] == '*'):
					config[varname] = None
			
			options = []
			opt2row = {}
			cnt = 1
			
			options.append("  --- Load preconfigured defaults ... ")
			
			for rule in rules:
				varname, vartype, name, choices, cond = rule
				
				if cond and (not check_condition(cond, config, rules)):
					continue
				
				if varname == selname:
					position = cnt
				
				if not varname in config:
					value = None
				else:
					value = config[varname]
				
				if not validate_rule_value(rule, value):
					value = None
				
				default = get_default_rule(rule)
				
				#
				# If we don't have a value but we do have
				# a default, use it.
				#
				if value == None and default != None:
					value = default
					config[varname] = default
				
				option = get_rule_option(rule, value)
				if option != None:
					options.append(option)
				else:
					continue
				
				opt2row[cnt] = (varname, vartype, name, choices)
				
				cnt += 1
			
			if (position != None) and (position >= len(options)):
				position = None
			
			(button, value) = xtui.choice_window(screen, 'HelenOS configuration', 'Choose configuration option', options, position)
			
			if button == 'cancel':
				return 'Configuration canceled'
			
			if button == 'done':
				if (infer_verify_choices(config, rules)):
					break
				else:
					xtui.error_dialog(screen, 'Error', 'Some options have still undefined values. These options are marked with the "?" sign.')
					continue
			
			if value == 0:
				profile = choose_profile(PRESETS_DIR, MAKEFILE, screen, config)
				if profile != None:
					read_presets(profile, config)
				position = 1
				continue
			
			position = None
			if not value in opt2row:
				raise RuntimeError("Error selecting value: %s" % value)
			
			(selname, seltype, name, choices) = opt2row[value]
			
			if not selname in config:
				value = None
			else:
				value = config[selname]
			
			if seltype == 'choice':
				config[selname] = subchoice(screen, name, choices, value)
			elif (seltype == 'y/n') or (seltype == 'n/y'):
				if config[selname] == 'y':
					config[selname] = 'n'
				else:
					config[selname] = 'y'
	finally:
		xtui.screen_done(screen)
	
	preprocess_config(config, rules)
	create_output(MAKEFILE, MACROS, config, rules)
	return 0

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