#! /usr/bin/env python3
# -----------------------------------------------------------------------------
# Copyright (c) 2012 Ben Blazak <benblazak.dev@gmail.com>
# Released under The MIT License (MIT) (see "license.md")
# Project located at <https://github.com/benblazak/ergodox-firmware>
# -----------------------------------------------------------------------------

"""
Generate UI info file (in JSON)

Depends on:
- the project source code
- the project '.map' file (generated by the compiler)
"""

_FORMAT_DESCRIPTION = ("""
/* ----------------------------------------------------------------------------
 * Version 0
 * ----------------------------------------------------------------------------
 * Hopefully the add-hoc conventions are clear enough...  I didn't feel like
 * investing the time in making it a real JSON Schema when there aren't many
 * validators, and the most current completed draft at the moment (draft 3) is
 * expired...
 * ----------------------------------------------------------------------------
 * Please note that in general, fields may be added without changing the
 * version number, and that programs using this format are not required to fill
 * (or read) any of the given fields.
 * ------------------------------------------------------------------------- */

var ui_info = {
    ".meta-data": {                    // for the JSON file
        "version": "<number>",
        "date-generated": "<string>",  // format: RFC 3339
		"description": "<string>",
    },
    "keyboard-functions": {
        "<(function name)>": {
            "position": "<number>",  // as given by the .map file
            "length": "<number>",    // as given by the .map file
            "comments": {
                "name": "<string>",  // more user friendly name
                "description": "<string>",
                "notes": [
                    "<string>",
                    "..."
                ],
                "..."
            }
        },
        "..."
    },
    "layout-matrices": {
        "<(matrix name)>": {
            "position": "<number>",  // as given by the .map file
            "length": "<number>"     // as given by the .map file
        },
        "..."
    },
    "mappings": {
        /* 
         * The mappings prefixed with 'matrix' have their elements in the same
         * order as the .hex file (whatever order that is).  The mappings
         * prefixed with 'physical' will have their elements in an order
         * corresponding to thier physical position on the keyboard.  You can
         * convert between the two using the relative positions of the key-ids
         * in 'physical-positions' and 'matrix-positions'.
         *
         * The current order of 'physical' mappings is:
         * --------------------------------------------
         * // left hand, spatial positions
         * 00, 01, 02, 03, 04, 05, 06,
         * 07, 08, 09, 10, 11, 12, 13,
         * 14, 15, 16, 17, 18, 19,
         * 20, 21, 22, 23, 24, 25, 26,
         * 27, 28, 29, 30, 31,
         *                     32, 33,
         *                     34, 35, 36,
         *                     37, 38, 39,

         * // right hand, spatial positions
         *     40, 41, 42, 43, 44, 45, 46,
         *     47, 48, 49, 50, 51, 52, 53,
         *         54, 55, 56, 57, 58, 59,
         *     60, 61, 62, 63, 64, 65, 66,
         *             67, 68, 69, 70, 71,
         *     72, 73,
         * 74, 75, 76,
         * 77, 78, 79,
         * --------------------------------------------
         */

        "physical-positions": [  // list of key-ids
            "<string>", "..."
        ],
        "matrix-positions": [    // list of key-ids
            "<string>", "..."
        ],
        "matrix-layout": [
            [  // begin layer
                [  // begin key
                    "<number>",  // keycode
                    "<string>",  // press function name (ex: 'kbfun_...')
                    "<string>"   // release function name (ex: 'NULL')
                ],
                "..."  // more keys
            ],
            "..."  // more layers
        ]
    },
    "miscellaneous": {
        "git-commit-date": "<string>",  // format: RFC 3339
        "git-commit-id": "<string>",
        "number-of-layers": "<number>"
    }
}
""")[1:-1]

# -----------------------------------------------------------------------------

import argparse
import json
import os
import re
import subprocess
import sys

# -----------------------------------------------------------------------------

def gen_static(current_date=None, git_commit_date=None, git_commit_id=None):
	"""Generate static information"""

	return {
		'.meta-data': {
			'version': 0,  # the format version number
			'date-generated': current_date,
			'description': _FORMAT_DESCRIPTION,
		},
		'miscellaneous': {
			'git-commit-date': git_commit_date, # should be passed by makefile
			'git-commit-id': git_commit_id, # should be passed by makefile
		},
	}

def gen_derived(data):
    return {}  # don't really need this info anymore
# 	"""
# 	Generate derived information
# 	Should be called last
# 	"""
# 	return {
# 		'miscellaneous': {
# 			'number-of-layers':
# 				int( data['layout-matrices']['_kb_layout']['length']/(6*14) ),
# 				# because 6*14 is the number of bytes/layer for '_kb_layout'
# 				# (which is a uint8_t matrix)
# 		},
# 	}

# -----------------------------------------------------------------------------

def parse_mapfile(map_file_path):
    return {}  # don't really need this info anymore
# 	"""Parse the '.map' file"""
# 
# 	def parse_keyboard_function(f, line):
# 		"""Parse keyboard-functions in the '.map' file"""
# 
# 		search = re.search(r'(0x\S+)\s+(0x\S+)', next(f))
# 		position = int( search.group(1), 16 )
# 		length = int( search.group(2), 16 )
# 
# 		search = re.search(r'0x\S+\s+(\S+)', next(f))
# 		name = search.group(1)
# 
# 		return {
# 			'keyboard-functions': {
# 				name: {
# 					'position': position,
# 					'length': length,
# 				},
# 			},
# 		}
# 
# 	def parse_layout_matrices(f, line):
# 		"""Parse layout matrix information in the '.map' file"""
# 
# 		name = re.search(r'.progmem.data.(_kb_layout\S*)', line).group(1)
# 
# 		search = re.search(r'(0x\S+)\s+(0x\S+)', next(f))
# 		position = int( search.group(1), 16 )
# 		length = int( search.group(2), 16 )
# 
# 		return {
# 			'layout-matrices': {
# 				name: {
# 					'position': position,
# 					'length': length,
# 				},
# 			},
# 		}
# 
# 	# --- parse_mapfile() ---
# 
# 	# normalize paths
# 	map_file_path = os.path.abspath(map_file_path)
# 	# check paths
# 	if not os.path.exists(map_file_path):
# 		raise ValueError("invalid 'map_file_path' given")
# 
# 	output = {}
# 
# 	f = open(map_file_path)
# 
# 	for line in f:
# 		if re.search(r'^\s*\.text\.kbfun_', line):
# 			dict_merge(output, parse_keyboard_function(f, line))
# 		elif re.search(r'^\s*\.progmem\.data.*layout', line):
# 			dict_merge(output, parse_layout_matrices(f, line))
# 
# 	return output


def find_keyboard_functions(source_code_path):
	"""Parse all files in the source directory"""

	def read_comments(f, line):
		"""
		Read in properly formatted multi-line comments
		- Comments must start with '/*' and end with '*/', each on their own
		  line
		"""
		comments = ''
		while(line.strip() != r'*/'):
			comments += line[2:].strip()+'\n'
			line = next(f)
		return comments

	def parse_comments(comments):
		"""
		Parse an INI style comment string
		- Fields begin with '[field-name]', and continue until the next field,
		  or the end of the comment
		- Fields '[name]', '[description]', and '[note]' are treated specially
		"""

		def add_field(output, field, value):
			"""Put a field+value pair in 'output', the way we want it, if the
			pair is valid"""

			value = value.strip()

			if field is not None:
				if field in ('name', 'description'):
					if field not in output:
						output[field] = value
				else:
					if field == 'note':
						field = 'notes'

					if field not in output:
						output[field] = []

					output[field] += [value]

		# --- parse_comments() ---

		output = {}

		field = None
		value = None
		for line in comments.split('\n'):
			line = line.strip()

			if re.search(r'^\[.*\]$', line):
				add_field(output, field, value)
				field = line[1:-1]
				value = None

			else:
				if value is None:
					value = ''
				if len(value) > 0 and value[-1] == '.':
					line = ' '+line
				value += ' '+line

		add_field(output, field, value)

		return output

	def parse_keyboard_function(f, line, comments):
		"""Parse keyboard-functions in the source code"""

		search = re.search(r'void\s+(kbfun_\S+)\s*\(void\)', line)
		name = search.group(1)

		return {
			'keyboard-functions': {
				name: {
					'comments': parse_comments(comments),
				},
			},
		}

	# --- find_keyboard_functions() ---

	# normalize paths
	source_code_path = os.path.abspath(source_code_path)
	# check paths
	if not os.path.exists(source_code_path):
		raise ValueError("invalid 'source_code_path' given")

	output = {}

	for tup in os.walk(source_code_path):
		for file_name in tup[2]:
			# normalize paths
			file_name = os.path.abspath( os.path.join( tup[0], file_name ) )

			# ignore non '.c' files
			if file_name[-2:] != '.c':
				continue

			f = open(file_name)

			comments = ''
			for line in f:
				if line.strip() == r'/*':
					comments = read_comments(f, line)
				elif re.search(r'void\s+kbfun_\S+\s*\(void\)', line):
					dict_merge(
							output,
							parse_keyboard_function(f, line, comments) )

	return output


def gen_mappings(matrix_file_path, layout_file_path):
	# normalize paths
	matrix_file_path = os.path.abspath(matrix_file_path)
	layout_file_path = os.path.abspath(layout_file_path)

	def parse_matrix_file(matrix_file_path):
		match = re.search(  # find the whole 'KB_MATRIX_LAYER' macro
				r'#define\s+KB_MATRIX_LAYER\s*\(([^)]+)\)[^{]*\{\{([^#]+)\}\}',
				open(matrix_file_path).read() )

		return {
			"mappings": {
				"physical-positions": re.findall(r'k..', match.group(1)),
				"matrix-positions": re.findall(r'k..|na', match.group(2)),
			},
		}

	def parse_layout_file(layout_file_path):
		match = re.findall(  # find each whole '_kb_layout*' matrix definition
				r'(_kb_layout\w*)[^=]*=((?:[^{}]*\{){3}[^=]*(?:[^{}]*\}){3})',
				subprocess.getoutput("gcc -E '"+layout_file_path+"'") )

		layout = {}
		# collect all the values
		for (name, matrix) in match:
			layout[name] = [
					re.findall(  # find all numbers and function pointers
						r'[x0-9A-F]+|&\w+|NULL',
						re.sub(  # replace '((void *) 0)' with 'NULL'
							r'\(\s*\(\s*void\s*\*\s*\)\s*0\s*\)',
							'NULL',
							el ) )
					for el in
						re.findall(  # find each whole layer
							r'(?:[^{}]*\{){2}((?:[^}]|\}\s*,)+)(?:[^{}]*\}){2}',
							matrix ) ]

		# make the numbers into actual numbers
		layout['_kb_layout'] = \
				[[eval(el) for el in layer] for layer in layout['_kb_layout']]
		# remove the preceeding '&' from function pointers
		for matrix in ('_kb_layout_press', '_kb_layout_release'):
			layout[matrix] = \
					[ [re.sub(r'&', '', el) for el in layer]
					  for layer in layout[matrix] ]

		return {
			"mappings": {
				"matrix-layout":
					# group them all properly
					[ [[c, p, r] for (c, p, r) in zip(code, press, release)]
					  for (code, press, release) in
						  zip( layout['_kb_layout'],
							   layout['_kb_layout_press'],
							   layout['_kb_layout_release'] ) ]
			},
		}

	return dict_merge(
			parse_matrix_file(matrix_file_path),
			parse_layout_file(layout_file_path) )


# -----------------------------------------------------------------------------

def dict_merge(a, b):
	"""
	Recursively merge two dictionaries
	- I was looking around for an easy way to do this, and found something
	  [here]
	  (http://www.xormedia.com/recursively-merge-dictionaries-in-python.html).
	  This is pretty close, but i didn't copy it exactly.
	"""

	if not isinstance(a, dict) or not isinstance(b, dict):
		return b

	for (key, value) in b.items():
		if key in a:
			a[key] = dict_merge(a[key], value)
		else:
			a[key] = value

	return a

# -----------------------------------------------------------------------------

def main():
	arg_parser = argparse.ArgumentParser(
			description = 'Generate project data for use with the UI' )

	arg_parser.add_argument(
			'--current-date',
			help = ( "should be in the format rfc-3339 "
				   + "(e.g. 2006-08-07 12:34:56-06:00)" ),
			required = True )
	arg_parser.add_argument(
			'--git-commit-date',
			help = ( "should be in the format rfc-3339 "
				   + "(e.g. 2006-08-07 12:34:56-06:00)" ),
			required = True )
	arg_parser.add_argument(
			'--git-commit-id',
			help = "the git commit ID",
			required = True )
	arg_parser.add_argument(
			'--map-file-path',
			help = "the path to the '.map' file",
			required = True )
	arg_parser.add_argument(
			'--source-code-path',
			help = "the path to the source code directory",
			required = True )
	arg_parser.add_argument(
			'--matrix-file-path',
			help = "the path to the matrix file we're using",
			required = True )
	arg_parser.add_argument(
			'--layout-file-path',
			help = "the path to the layout file we're using",
			required = True )

	args = arg_parser.parse_args(sys.argv[1:])

	output = {}
	dict_merge( output, gen_static( args.current_date,
									args.git_commit_date,
									args.git_commit_id ) )
	dict_merge(output, parse_mapfile(args.map_file_path))
	dict_merge(output, find_keyboard_functions(args.source_code_path))
	dict_merge(output, gen_mappings( args.matrix_file_path,
									  args.layout_file_path ))
	dict_merge(output, gen_derived(output))

	print(json.dumps(output, sort_keys=True, indent=4))

# -----------------------------------------------------------------------------

if __name__ == '__main__':
	main()

