#!/usr/bin/env python3

import html
import os
import sys
import shutil

html_self_closing_tags = {"!DOCTYPE", "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", "command", "keygen", "menuitem", "frame"}
html_xml_content_tags = {"svg"}
text_url_match = {"/", "http://", "https://"}

traversed_links = set()
config_values = {
	"logo": "",
	"logo_url": "/",
	"blank": False,
	"icon": "/favicon.ico",
	"stylesheet": "/stylesheet.css",
	"title_format": "%t",
	"logo_width": "192",
	"logo_height": "32"
}

default_theme = {
	"background-color": "#f0f0f0",
	"text-color": "#7f687f",
	"link-color": "#af8faf",
	"header-color": "#e0e0e0",
	"footer-color": "#e0e0e0",
	"top-line-color": "#c0c0c0",
	"bottom-line-color": "#c0c0c0",
	"container-color": "#f0f0f0",
	"content-box-color": "#e8e8e8",
	"content-border-tl-color": "#f8f8f8",
	"content-border-br-color": "#c8c8c8",
	"container-padding": 80,
	"content-min-height": 36,
	"content-margin": 5,
	"content-line-pad": 5,
	"content-side-pad": 10,
	"header-height": 40,
	"header-padding-left": 10,
	"header-padding-right": 10,
	"header-gap": 5,
	"header-elem-size": 32,
	"header-line-pad": 4,
	"header-side-pad": 10,
	"footer-height": 32,
	"footer-padding-left": 15,
	"footer-padding-right": 15,
	"footer-gap": 8,
	"footer-elem-size": 20,
	"footer-line-pad": 2,
	"footer-side-pad": 7,
	"line-height": 20,
	"text-size": 14,
	"header-text-size": 14,
	"footer-text-size": 14
}

base_stylesheet = [
	("a", {
		"color": "[#link-color]",
		"text-decoration": "none"
	}),
	("body", {
		"box-sizing": "border-box",
		"line-height": "[#line-height]px",
		"display": "flex",
		"flex-direction": "column",
		"background-color": "[#background-color]",
		"margin": "0",
		"color": "[#text-color]",
		"font-size": "[#text-size]px"
	}),
	(".flex-container", {
		"box-sizing": "border-box",
		"display": "flex",
		"justify-content": "space-between",
		"margin": "0"
	}),
	("nav.flex-container", {
		"background-color": "[#header-color]",
		"border-style": "solid",
		"border-width": "0 0 1px 0",
		"border-color": "[#top-line-color]"
	}),
	("footer.flex-container", {
		"background-color": "[#footer-color]",
		"border-style": "solid",
		"border-width": "1px 0 0 0",
		"border-color": "[#bottom-line-color]"
	}),
	(".full-container", {
		"box-sizing": "border-box",
		"padding": "0 0 [#container-padding]px 0",
		"flex-grow": "1",
		"background-color": "[#container-color]",
		"margin": "0"
	}),
	(".base-container", {
		"min-height": "[#content-min-height]px",
		"align-items": "center",
		"box-sizing": "border-box",
		"padding": "[#content-line-pad]px [#content-side-pad]px",
		"background-color": "[#content-box-color]",
		"margin": "[#content-margin]px",
		"border-style": "solid",
		"border-width": "1px",
		"border-color": "[#content-border-tl-color] [#content-border-br-color] [#content-border-br-color] [#content-border-tl-color]"
	}),
	(".list-container", {
		"column-gap": "[#header-gap]px",
		"display": "flex",
		"min-height": "[#header-height]px",
		"row-gap": "[#header-gap]px",
		"align-items": "center",
		"box-sizing": "border-box",
		"flex-wrap": "wrap",
		"padding": "0 [#header-padding-right]px 0 [#header-padding-left]px",
		"margin": "0"
	}),
	(".compact-container", {
		"column-gap": "[#footer-gap]px",
		"display": "flex",
		"min-height": "[#footer-height]px",
		"row-gap": "[#footer-gap]px",
		"align-items": "center",
		"box-sizing": "border-box",
		"flex-wrap": "wrap",
		"padding": "0 [#footer-padding-right]px 0 [#footer-padding-left]px",
		"margin": "0"
	}),
	(".list-item", {
		"align-items": "center",
		"align-self": "center",
		"box-sizing": "border-box",
		"display": "flex",
		"flex-basis": "auto",
		"flex-grow": "0",
		"flex-shrink": "0",
		"min-height": "[#header-elem-size]px",
		"min-width": "[#header-elem-size]px",
		"padding": "[#header-line-pad]px [#header-side-pad]px",
		"margin": "0",
		"flex-wrap": "wrap",
		"font-size": "[#header-text-size]px"
	}),
	(".compact-item", {
		"align-items": "center",
		"align-self": "center",
		"box-sizing": "border-box",
		"display": "flex",
		"flex-basis": "auto",
		"flex-grow": "0",
		"flex-shrink": "0",
		"min-height": "[#footer-elem-size]px",
		"min-width": "[#footer-elem-size]px",
		"padding": "[#footer-line-pad]px [#footer-side-pad]px",
		"margin": "0",
		"flex-wrap": "wrap",
		"font-size": "[#footer-text-size]px"
	}),
	("a.nocolor", {
		"color": "inherit"
	})
]

def tree2html(tree, indent=0):
	lines = []
	for node in tree:
		if type(node) is not tuple or len(node) < 1 or len(node) > 3 or type(node[0]) is not str or (len(node) == 2 and type(node[1]) not in {dict, list}) or (len(node) == 3 and (type(node[1]) is not dict or type(node[2]) is not list)):
			raise Exception("element ist not a valid html tuple: (tag), (tag, properties), (tag, children) or (tag, properties, children), got " + str(type(node)) + " (" + str(node) + ")")
		props = ""
		inner = ""
		if len(node) > 1 and type(node[1]) is dict:
			for prop, value in node[1].items():
				props += ' ' + html.escape(prop) + (('="' + html.escape(value) + '"') if value is not None else "")
		if len(node) == 3 or (len(node) == 2 and not props):
			if node[0] in html_self_closing_tags:
				raise Exception("self closing tag <" + node[0] + "> cannot have children")
			lines.append((indent * "\t") + "<" + node[0] + props + ">")
			for child in node[2 if len(node) == 3 else 1]:
				if type(child) is list:
					lines.extend(tree2html(child, indent + 1))
				elif type(child) is tuple:
					lines.extend(tree2html([child], indent + 1))
				elif type(child) is str:
					lines.append(((indent + 1) * "\t") + (child if node[0] in html_xml_content_tags else html.escape(child)))
				else:
					raise Exception("element must be tuple, list or str, got " + str(type(child)) + " (" + str(child) + ")")
			lines.append((indent * "\t") + "</" + node[0] + ">")
		else:
			lines.append((indent * "\t") + "<" + node[0] + props + ">" + (("</" + node[0] + ">") if node[0] not in html_self_closing_tags else ""))
	return lines

def get_key_value(text, separator=" "):
	space = text.find(separator)
	if space < 0:
		return (text, None)
	else:
		return (text[:space], text[space + 1:])

def get_html_tags(line, variables):
	if not line:
		return None
	elif line.startswith("###"):
		return [("h1", text2tree(line[3:]))]
	elif line.startswith("##"):
		return [("h2", text2tree(line[2:]))]
	elif line.startswith("#"):
		return [("h3", text2tree(line[1:]))]
	elif line.startswith("[#") and line.endswith("]"):
		key, value = get_key_value(line[2:-1])
		match key:
			case "svg":
				svg = value.split(" ", 6)
				return [("svg", {"width": svg[0], "height": svg[1], "viewBox": svg[2] + " " + svg[3] + " " + svg[4] + " " + svg[5]}, [svg[6]])]
			case "img":
				img = value.split(" ", 2)
				src, alt = get_key_value(img[0], ":")
				props = {"src": src}
				if src.startswith("/"):
					global traversed_links
					traversed_links.add(src)
				if alt is not None:
					props["alt"] = alt
				if len(img) >= 2 and (img[1].startswith("w:") or img[1].startswith("h:")):
						props["width" if img[1].startswith("w:") else "height"] = img[1][2:]
				elif len(img) >= 3:
					props["width"] = img[1]
					props["height"] = img[2]
				return [("a", {"href": src}, [("img", props)])]
		if key in variables.keys():
			return variables[key]
	return None

def text2tree(line, nocolor=False):
	blank = config_values["blank"]
	tree = []
	tpos = pos = 0
	while True:
		lpos = line.find("[", pos)
		if lpos < 0:
			break
		epos = line.find("]", lpos + 1)
		if epos < 0:
			break
		sub = line[lpos + 1:epos]
		for prefix in text_url_match:
			if sub.startswith(prefix):
				url, alt = get_key_value(sub)
				props = {"href": url}
				if nocolor:
					props["class"] = "nocolor"
				if prefix == "/":
					global traversed_links
					traversed_links.add(url)
				if blank:
					props["target"] = "_blank"
				if tpos < lpos:
					tree.append(line[tpos:lpos])
				tree.append(("a", props, [alt if alt else url]))
				tpos = epos + 1;
				break
		pos = epos + 1;
	if tpos < len(line):
		tree.append(line[tpos:])
	return tree

def lines2tree(text, variables):
	tree = []
	paragraph = []
	for line in text:
		tags = get_html_tags(line, variables)
		if tags:
			if paragraph:
				tree.append(("p", paragraph))
				paragraph = []
			tree.extend(tags)
			continue
		if paragraph:
			paragraph.append(("br",))
		paragraph.extend(text2tree(line))
	if paragraph:
		tree.append(("p", paragraph))
	return tree

def line2tree(line, variables, nocolor):
	tags = get_html_tags(line, variables)
	if tags:
		return tags
	return text2tree(line, nocolor)

def filter_text(data):
	for elem in data:
		if type(elem) is tuple and elem[0] in {"svg", "img"}:
			return [("span", [elem]) if type(elem) is str else elem for elem in data]
	return [("span", data)]

def filter_entry(entry, clazz):
	elem = entry if type(entry) is not list or len(entry) == 0 else entry[0]
	if type(elem) == tuple and len(elem) >= 2 and type(elem[1]) == dict and "class" in elem[1].keys() and clazz + "-item" in elem[1]["class"].split(" "):
		return entry if type(entry) == list else [entry]
	else:
		return [("p", {"class": clazz + "-item"}, filter_text(entry))]

def make_element(elems, variables, clazz):
	tree = []
	arr = None
	for elem in elems:
		if type(elem) is list:
			data = elem
		elif type(elem) is tuple:
			data = [elem]
		elif type(elem) is str:
			if elem == "[":
				if arr is not None:
					data = arr
					arr = []
				else:
					arr = []
					continue
			elif elem == "]":
				if arr is None:
					continue
				data = arr
				arr = None
			else:
				data = line2tree(elem, variables, clazz == "list")
				if arr is not None:
					arr.extend(data)
					continue
		else:
			data = str(elem)
		tree.extend(filter_entry(data, clazz))
	if arr is not None:
		tree.extend(filter_entry(arr, clazz))
	return ("div", {"class": clazz + "-container"}, tree)

def make_head(title):
	return [
		("meta", {"http-equiv": "content-type", "content": "text/html; charset=UTF-8"}),
		("title", [config_values["title_format"].replace("%t", title)]),
		("link", {"rel": "icon", "type": "image/x-icon", "href": config_values["icon"]}),
		("link", {"rel": "stylesheet", "href": config_values["stylesheet"]})
	]

def make_links(pages, clazz):
	logo = config_values["logo"]
	logo_url = config_values["logo_url"]
	logo_width = config_values["logo_width"]
	logo_height = config_values["logo_height"]
	nav = []
	if logo:
		alt = "Logo"
		for page in pages:
			if page["url"] is logo_url:
				if page["name"]:
					alt = page["name"]
				break
		nav.append(("a", {"href": logo_url, "class": clazz + "-item nocolor"}, [("img", {"width": logo_width, "height": logo_height, "src": logo, "alt": "Logo"})]))
	nav.extend([("a", {"href": page["url"], "class": clazz + "-item nocolor"}, [page["name"]]) for page in pages if page["name"] and (not logo or page["url"] is not logo_url)])
	return nav

def make_bar(left, center, right, pages, variables, clazz, tag):
	variables = variables.copy()
	variables["nav"] = make_links(pages, clazz)
	bar = [make_element(left, variables, clazz), make_element(right, variables, clazz)]
	if center:
		bar = [bar[0], make_element(center, variables, clazz), bar[1]]
	return (tag, {"class": "flex-container"}, bar)

def make_content(sections, variables):
	tree = []
	for section in sections:
		tree.append(("div", {"class": "base-container"}, lines2tree(section, variables)))
	return ("div", {"class": "full-container"}, tree)

def compile_page(page, header, footer, variables):
	return [("!DOCTYPE", {"html": None}), ("html", [("head", make_head(page["title"])), ("body", [header, make_content(page["sections"], variables), footer])])]

def sort_pages(page):
	return page["sort"]

def compile_pages(pages):
	pages.sort(key=sort_pages)
	variables = {
	}
	header = make_bar(read_list("header/left", ["[#nav]"]), read_list("header/center"), read_list("header/right"), pages, variables, "list", "nav")
	footer = make_bar(read_list("footer/left"), read_list("footer/center"), read_list("footer/right"), pages, variables, "compact", "footer")
	return [(page["file"], compile_page(page, header, footer, variables)) for page in pages]

def read_page(filename):
	path = os.path.join(config_values["indir"], filename + ".sml")
	print("Reading " + path)
	with open(path, "r") as fd:
		data = fd.read().splitlines()
	page = {
		"name": None,
		"title": filename,
		"sort": 0
	}
	sections = []
	subpage = []
	for line in data:
		if line.startswith("[#") and line.endswith("]"):
			key, value = get_key_value(line[2:-1])
			match key:
				case "name":
					page["name"] = value
				case "title":
					page["title"] = value
				case "sort":
					page["sort"] = int(value)
				case "index":
					if os.path.basename(filename) != "index":
						filename = os.path.join(filename, "index")
				case "":
					sections.append(subpage)
					subpage = []
				case _:
					subpage.append(line)
		else:
			subpage.append(line)
	if subpage:
		sections.append(subpage)
	page["sections"] = sections
	page["url"] = get_file_url(filename)
	page["file"] = filename
	return page

def read_list(filename, default=[], directory=None):
	path = os.path.join(config_values["indir"] if directory is None else directory, filename + ".sml")
	if not os.path.exists(path):
		if default is not None:
			print("Writing default " + path)
			base = os.path.dirname(path)
			if not os.path.exists(base):
				os.makedirs(base)
			with open(path, "w") as fd:
				fd.write('\n'.join(default))
			return default
		return []
	print("Reading " + path)
	with open(path, "r") as fd:
		data = fd.read().splitlines()
	return data

def write_page(filename, data, extension="html"):
	filename += (("." + extension) if extension else "")
	path = os.path.join(config_values["outdir"], filename)
	print("Writing " + path)
	base = os.path.dirname(path)
	if not os.path.exists(base):
		os.makedirs(base)
	with open(path, "w") as fd:
		fd.write('\n'.join(data))
	return filename

def read_pages():
	directory = config_values["indir"]
	pages = []
	for root, dirs, files in os.walk(directory):
		for filename in files:
			if not filename.endswith(".sml"):
				continue
			path = os.path.join(os.path.relpath(root, directory), filename)
			path = path[2:-4] if path.split(os.sep)[0] == "." else path[:-4]
			if os.path.dirname(path) not in {"header", "footer"} and path != "config" and path != "theme":
				pages.append(read_page(path))
	return pages

def copy_file(filename):
	ipath = os.path.join(config_values["indir"], filename)
	opath = os.path.join(config_values["outdir"], filename)
	if not os.path.exists(ipath):
		print("Warning: file " + ipath + " does not exist")
		return
	print("Copying " + ipath + " to " + opath)
	base = os.path.dirname(opath)
	if not os.path.exists(base):
		os.makedirs(base)
	shutil.copy(ipath, opath)

def read_config(directory, output):
	global config_values
	config = read_list("config", [" [#" + (prop if type(value) is bool else prop + " " + str(value)) + "]" for prop, value in config_values.items()], directory)
	for cfg in config:
		if cfg.startswith("[#") and cfg.endswith("]"):
			prop, value = get_key_value(cfg[2:-1])
			if prop not in config_values.keys():
				print("Warning: config value '" + prop + "' is unknown")
				continue
			ovalue = config_values[prop]
			if value is None:
				if type(ovalue) is not bool:
					print("Warning: config value '" + prop + "' needs an argument")
					continue
				value = True
			elif type(ovalue) is bool:
				if value.lower() != "true" and value.lower() != "false":
					print("Warning: unknown bool value for config value '" + prop + "'")
					continue
				value = value.lower() == "true"
				print("Warning: redundant bool value for config value '" + prop + "'")
			config_values[prop] = value
	config_values["indir"] = directory
	config_values["outdir"] = output
	for prop, value in config_values.items():
		print(prop + " = " + str(value))

def add_default_links():
	global traversed_links
	for key in "logo", "icon", "stylesheet":
		if config_values[key].startswith("/"):
			traversed_links.add(config_values[key])

def read_theme():
	theme = read_list("theme", ["[#" + prop + " " + str(value) + "]" for prop, value in default_theme.items()])
	style = {prop: str(value) for prop, value in default_theme.items()}
	for line in theme:
		if line.startswith("[#") and line.endswith("]"):
			prop, value = get_key_value(line[2:-1])
			if prop not in default_theme.keys():
				print("Warning: theme property '" + prop + "' is unknown")
				continue
			if value is None:
				continue
			if type(default_theme[prop]) is int:
				test = int(value)
			style[prop] = value
	return style

def compile_stylesheet(classes, variables):
	lines = []
	for clazz, props in classes:
		lines.append(clazz + " {")
		for prop, value in props.items():
			plain = ""
			tpos = pos = 0
			while True:
				lpos = value.find("[#", pos)
				if lpos < 0:
					break
				epos = value.find("]", lpos + 2)
				if epos < 0:
					break
				key, _ = get_key_value(value[lpos + 2:epos])
				if key in variables.keys():
					if tpos < lpos:
						plain += value[tpos:lpos]
					plain += variables[key]
					tpos = epos + 1;
				pos = epos + 1;
			if tpos < len(value):
				plain += value[tpos:]
			lines.append("\t" + prop + ": " + plain + ";")
		lines.append("}")
	return lines

def write_stylesheet(style):
	return write_page(config_values["stylesheet"][1:], style, extension=None)

def write_pages(pages):
	written = set()
	for path, tree in pages:
		written.add(write_page(path, tree2html(tree)))
	return written

def get_url_file(url):
	url = url[1:]
	return url if "." in url else ("index.html" if not url else os.path.join(url[:-1] if url.endswith("/") else url, "index.html"))

def get_file_url(filename):
	return "/" + (("" if filename == "index" else os.path.dirname(filename)) if os.path.basename(filename) == "index" else (filename + ".html"))

def copy_linked_files(written):
	for url in traversed_links:
		filename = get_url_file(url)
		if filename not in written:
			copy_file(filename)

def clean_directory():
	output = config_values["outdir"]
	if os.path.exists(output):
		shutil.rmtree(output)

def main():
	if len(sys.argv) < 2:
		print(sys.argv[0] + " <indir> <outdir>")
		return
	read_config(sys.argv[1], sys.argv[2])
	add_default_links()
	pages = read_pages()
	theme = read_theme()
	pages = compile_pages(pages)
	style = compile_stylesheet(base_stylesheet, theme)
	clean_directory()
	written = write_pages(pages)
	written.add(write_stylesheet(style))
	copy_linked_files(written)

if __name__ == "__main__":
	main()
