#!/usr/bin/python3
#
# Generate documentation for how this machine works by
# parsing our bash scripts!

import cgi, re
import markdown
from modgrammar import *

def generate_documentation():
	print("""<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
        <meta name="viewport" content="width=device-width">

        <title>Build Your Own Mail Server From Scratch</title>

        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">

        <style>
		    @import url(https://fonts.googleapis.com/css?family=Iceland);
		    @import url(https://fonts.googleapis.com/css?family=Raleway:400,700);
			@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,500);
		   	body {
		    		font-family: Raleway, sans-serif;
		    		font-size: 16px;
					color: #555;
	    	}
	    	h2, h3 {
	    		margin-top: .25em;
	    		margin-bottom: .75em;
	    	}
	    	p {
	    		margin-bottom: 1em;
	    	}
		    	.intro p {
		    		margin: 1.5em 0;
		    	}
		    li {
		    	margin-bottom: .33em;
		    }

			.sourcefile {
	    		padding-top: 1.5em;
	    		padding-bottom: 1em;
	    		font-size: 90%;
	    		text-align: right;
			}
				.sourcefile a {
					color: red;
				}

	    	.instructions .row.contd {
	    		border-top: 1px solid #E0E0E0;
	    	}

	    	.prose {
	    		padding-top: 1em;    	
	    		padding-bottom: 1em;
	    	}
	    	.terminal {
	    		background-color: #EEE;
	    		padding-top: 1em;
	    		padding-bottom: 1em;
	    	}

	    	ul {
	    		padding-left: 1.25em;
	    	}

	    	pre {
	    		color: black;
	    		border: 0;
	    		background: none;
	    		font-size: 100%;
	    	}

	    	div.write-to {
	    		margin: 0 0 1em .5em;
	    	}
	    	div.write-to p {
	    		padding: .5em;
	    		margin: 0;
	    	}
	    	div.write-to .filename {
	    		padding: .25em .5em;
	    		background-color: #666;
	    		color: white;
	    		font-family: monospace;
	    		font-weight: bold;
	    	}
	    	div.write-to .filename span {
	    		font-family: sans-serif;
	    		font-weight: normal;
	    	}
	    	div.write-to pre {
	    		margin: 0;
	    		padding: .5em;
	    		border: 1px solid #999;
	    		border-radius: 0;
	    		font-size: 90%;
	    	}

	    	pre.shell > div:before {
	    		content: "$ ";
	    		color: #666;
	    	}
        </style>
    </head>
    <body>
    <div class="container">
      <div class="row intro">
        <div class="col-xs-12">
        <h1>Build Your Own Mail Server From Scratch</h1>
        <p>Here&rsquo;s how you can build your own mail server from scratch.</p>
        <p>This document is generated automatically from <a href="https://mailinabox.email">Mail-in-a-Box</a>&rsquo;s setup script <a href="https://github.com/mail-in-a-box/mailinabox">source code</a>.</p>
        <hr>
      </div>
    </div>
    <div class="container instructions">
 """)

	parser = Source.parser()
	for line in open("setup/start.sh"):
		try:
			fn = parser.parse_string(line).filename()
		except:
			continue
		if fn in ("setup/start.sh", "setup/preflight.sh", "setup/questions.sh", "setup/firstuser.sh", "setup/management.sh"):
			continue

		import sys
		print(fn, file=sys.stderr)

		print(BashScript.parse(fn))

	print("""
        <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.10.1/jquery.min.js"></script>
        <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>
        <script>
        $(function() {
			$('.terminal').each(function() {
			  $(this).outerHeight( $(this).parent().innerHeight() );
			});
        })
		</script>
    </body>
</html>
""")

class HashBang(Grammar):
	grammar = (L('#!'), REST_OF_LINE, EOL)
	def value(self):
		return ""

def strip_indent(s):
	s = s.replace("\t", "    ")
	lines = s.split("\n")
	try:
		min_indent = min(len(re.match(r"\s*", line).group(0)) for line in lines if len(line) > 0)
	except ValueError:
		# No non-empty lines.
		min_indent = 0
	lines = [line[min_indent:] for line in lines]
	return "\n".join(lines)

class Comment(Grammar):
	grammar = ONE_OR_MORE(ZERO_OR_MORE(SPACE), L('#'), REST_OF_LINE, EOL)
	def value(self):
		if self.string.replace("#", "").strip() == "":
			return "\n"
		lines = [x[2].string for x in self[0]]
		content = "\n".join(lines)
		content = strip_indent(content)
		return markdown.markdown(content, output_format="html4") + "\n\n"

FILENAME = WORD('a-z0-9-/.')

class Source(Grammar):
	grammar = ((L('.') | L('source')), L(' '), FILENAME, Comment | EOL)
	def filename(self):
		return self[2].string.strip()
	def value(self):
		return BashScript.parse(self.filename())

class CatEOF(Grammar):
	grammar = (ZERO_OR_MORE(SPACE), L('cat '), L('>') | L('>>'), L(' '), ANY_EXCEPT(WHITESPACE), L(" <<"), OPTIONAL(SPACE), L("EOF"), EOL, REPEAT(ANY, greedy=False), EOL, L("EOF"), EOL)
	def value(self):
		content = self[9].string
		content = re.sub(r"\\([$])", r"\1", content) # un-escape bash-escaped characters
		return "<div class='write-to'><div class='filename'>%s <span>(%s)</span></div><pre>%s</pre></div>\n" \
			% (self[4].string,
			   "overwrite" if ">>" not in self[2].string else "append to",
			   cgi.escape(content))

class HideOutput(Grammar):
	grammar = (L("hide_output "), REF("BashElement"))
	def value(self):
		return self[1].value()

class EchoLine(Grammar):
	grammar = (OPTIONAL(SPACE), L("echo "), REST_OF_LINE, EOL)
	def value(self):
		if "|" in self.string  or ">" in self.string:
			return "<pre class='shell'><div>" + recode_bash(self.string.strip()) + "</div></pre>\n"
		return ""

class EditConf(Grammar):
	grammar = (
		L('tools/editconf.py '),
		FILENAME,
		SPACE,
		OPTIONAL((LIST_OF(
			L("-w") | L("-s") | L("-c ;"),
			sep=SPACE,
		), SPACE)),
		REST_OF_LINE,
		OPTIONAL(SPACE),
		EOL
		)
	def value(self):
		conffile = self[1]
		options = []
		eq = "="
		if self[3] and "-s" in self[3].string: eq = " "
		for opt in re.split("\s+", self[4].string):
			k, v = opt.split("=", 1)
			v = re.sub(r"\n+", "", fixup_tokens(v)) # not sure why newlines are getting doubled
			options.append("%s%s%s" % (k, eq, v))
		return "<div class='write-to'><div class='filename'>" + self[1].string + " <span>(change settings)</span></div><pre>" + "\n".join(cgi.escape(s) for s in options) + "</pre></div>\n"

class CaptureOutput(Grammar):
	grammar = OPTIONAL(SPACE), WORD("A-Za-z_"), L('=$('), REST_OF_LINE, L(")"), OPTIONAL(L(';')), EOL
	def value(self):
		cmd = self[3].string
		cmd = cmd.replace("; ", "\n")
		return "<div class='write-to'><div class='filename'>$" + self[1].string + "=</div><pre>" + cgi.escape(cmd) + "</pre></div>\n"

class SedReplace(Grammar):
	grammar = OPTIONAL(SPACE), L('sed -i "s/'), OPTIONAL(L('^')), ONE_OR_MORE(WORD("-A-Za-z0-9 #=\\{};.*$_!()")), L('/'), ONE_OR_MORE(WORD("-A-Za-z0-9 #=\\{};.*$_!()")), L('/"'), SPACE, FILENAME, EOL
	def value(self):
		return "<div class='write-to'><div class='filename'>edit<br>" + self[8].string + "</div><p>replace</p><pre>" + cgi.escape(self[3].string.replace(".*", ". . .")) + "</pre><p>with</p><pre>" + cgi.escape(self[5].string.replace("\\n", "\n").replace("\\t", "\t")) + "</pre></div>\n"

class EchoPipe(Grammar):
	grammar = OPTIONAL(SPACE), L("echo "), REST_OF_LINE, L(' | '), REST_OF_LINE, EOL
	def value(self):
		text = " ".join("\"%s\"" % s for s in self[2].string.split(" "))
		return "<pre class='shell'><div>echo " + recode_bash(text) + " \<br> | " + recode_bash(self[4].string) + "</div></pre>\n"

def shell_line(bash):
	return "<pre class='shell'><div>" + recode_bash(bash.strip()) + "</div></pre>\n"

class AptGet(Grammar):
	grammar = (ZERO_OR_MORE(SPACE), L("apt_install "), REST_OF_LINE, EOL)
	def value(self):
		return shell_line("apt-get install -y " + re.sub(r"\s+", " ", self[2].string))
class UfwAllow(Grammar):
	grammar = (ZERO_OR_MORE(SPACE), L("ufw_allow "), REST_OF_LINE, EOL)
	def value(self):
		return shell_line("ufw allow " + self[2].string)
class RestartService(Grammar):
	grammar = (ZERO_OR_MORE(SPACE), L("restart_service "), REST_OF_LINE, EOL)
	def value(self):
		return shell_line("service " + self[2].string + " restart")

class OtherLine(Grammar):
	grammar = (REST_OF_LINE, EOL)
	def value(self):
		if self.string.strip() == "": return ""
		if "source setup/functions.sh" in self.string: return ""
		if "source /etc/mailinabox.conf" in self.string: return ""
		return "<pre class='shell'><div>" + recode_bash(self.string.strip()) + "</div></pre>\n"

class BashElement(Grammar):
	grammar = Comment | CatEOF | EchoPipe | EchoLine | HideOutput | EditConf | SedReplace | AptGet | UfwAllow | RestartService | OtherLine
	def value(self):
		return self[0].value()

# Make some special characters to private use Unicode code points.
bash_special_characters1 = {
	"\n": "\uE000",
	" ": "\uE001",
}
bash_special_characters2 = {
	"$": "\uE010",
}
bash_escapes = {
	"n": "\uE020",
	"t": "\uE021",
}

def quasitokenize(bashscript):
	# Make a parse of bash easier by making the tokenization easy.
	newscript = ""
	quote_mode = None
	escape_next = False
	line_comment = False
	subshell = 0
	for c in bashscript:
		if line_comment:
			# We're in a comment until the end of the line.
			newscript += c
			if c == '\n':
				line_comment = False
		elif escape_next:
			# Previous character was a \. Normally the next character
			# comes through literally, but escaped newlines are line
			# continuations and some escapes are for special characters
			# which we'll recode and then turn back into escapes later.
			if c == "\n":
				c = " "
			elif c in bash_escapes:
				c = bash_escapes[c]
			newscript += c
			escape_next = False
		elif c == "\\":
			# Escaping next character.
			escape_next = True
		elif quote_mode is None and c in ('"', "'"):
			# Starting a quoted word.
			quote_mode = c
		elif c == quote_mode:
			# Ending a quoted word.
			quote_mode = None
		elif quote_mode is not None and quote_mode != "EOF" and c in bash_special_characters1:
			# Replace special tokens within quoted words so that they
			# don't interfere with tokenization later.
			newscript += bash_special_characters1[c]
		elif quote_mode is None and c == '#':
			# Start of a line comment.
			newscript += c
			line_comment = True
		elif quote_mode is None and c == ';' and subshell == 0:
			# End of a statement.
			newscript += "\n"
		elif quote_mode is None and c == '(':
			# Start of a subshell.
			newscript += c
			subshell += 1
		elif quote_mode is None and c == ')':
			# End of a subshell.
			newscript += c
			subshell -= 1
		elif quote_mode is None and c == '\t':
			# Make these just spaces.
			if newscript[-1] != " ":
				newscript += " "
		elif quote_mode is None and c == ' ':
			# Collapse consecutive spaces.
			if newscript[-1] != " ":
				newscript += " "
		elif c in bash_special_characters2:
			newscript += bash_special_characters2[c]
		else:
			# All other characters.
			newscript += c

		# "<< EOF" escaping.
		if quote_mode is None and re.search("<<\s*EOF\n$", newscript):
			quote_mode = "EOF"
		elif quote_mode == "EOF" and re.search("\nEOF\n$", newscript):
			quote_mode = None

	return newscript

def recode_bash(s):
	def requote(tok):
		tok = tok.replace("\\", "\\\\")
		for c in bash_special_characters2:
			tok = tok.replace(c, "\\" + c)
		tok = fixup_tokens(tok)
		if " " in tok or '"' in tok:
			tok = tok.replace("\"", "\\\"")
			tok = '"' + tok +'"'
		else:
			tok = tok.replace("'", "\\'")
		return tok
	return cgi.escape(" ".join(requote(tok) for tok in s.split(" ")))

def fixup_tokens(s):
	for c, enc in bash_special_characters1.items():
		s = s.replace(enc, c)
	for c, enc in bash_special_characters2.items():
		s = s.replace(enc, c)
	for esc, c in bash_escapes.items():
		s = s.replace(c, "\\" + esc)
	return s

class BashScript(Grammar):
	grammar = (OPTIONAL(HashBang), REPEAT(BashElement))
	def value(self):
		return [line.value() for line in self[1]]

	@staticmethod
	def parse(fn):
		if fn in ("setup/functions.sh", "/etc/mailinabox.conf"): return ""
		string = open(fn).read()

		# tokenize
		string = re.sub(".* #NODOC\n", "", string)
		string = re.sub("\n\s*if .*then.*|\n\s*fi|\n\s*else|\n\s*elif .*", "", string)
		string = quasitokenize(string)
		string = re.sub("hide_output ", "", string)

		parser = BashScript.parser()
		result = parser.parse_string(string)

		v = "<div class='row'><div class='col-xs-12 sourcefile'>view the bash source for the following section at <a href=\"%s\">%s</a></div></div>\n" \
			 % ("https://github.com/mail-in-a-box/mailinabox/tree/master/" + fn, fn)

		mode = 0
		for item in result.value():
			if item.strip() == "":
				pass
			elif item.startswith("<p") and not item.startswith("<pre"):
				clz = ""
				if mode == 2:
					v += "</div>\n" # col
					v += "</div>\n" # row
					mode = 0
					clz = "contd"
				if mode == 0:
					v += "<div class='row %s'>\n" % clz
					v += "<div class='col-md-6 prose'>\n"
				v += item
				mode = 1
			elif item.startswith("<h"):
				if mode != 0:
					v += "</div>\n" # col
					v += "</div>\n" # row
				v += "<div class='row'>\n"
				v += "<div class='col-md-6 header'>\n"
				v += item
				v += "</div>\n" # col
				v += "<div class='col-md-6 terminal'> </div>\n"
				v += "</div>\n" # row
				mode = 0
			else:
				if mode == 0:
					v += "<div class='row'>\n"
					v += "<div class='col-md-offset-6 col-md-6 terminal'>\n"
				elif mode == 1:
					v += "</div>\n"
					v += "<div class='col-md-6 terminal'>\n"
				mode = 2
				v += item

		v += "</div>\n" # col
		v += "</div>\n" # row

		v = fixup_tokens(v)

		v = v.replace("</pre>\n<pre class='shell'>", "")
		v = re.sub("<pre>([\w\W]*?)</pre>", lambda m : "<pre>" + strip_indent(m.group(1)) + "</pre>", v)

		v = re.sub(r"(\$?)PRIMARY_HOSTNAME", r"<b>box.yourdomain.com</b>", v)
		v = re.sub(r"\$STORAGE_ROOT", r"<b>$STORE</b>", v)
		v = v.replace("`pwd`",  "<code><b>/path/to/mailinabox</b></code>")

		return v

def wrap_lines(text, cols=60):
	ret = ""
	words = re.split("(\s+)", text)
	linelen = 0
	for w in words:
		if linelen + len(w) > cols-1:
			ret += " \\\n"
			ret += "   "
			linelen = 0
		if linelen == 0 and w.strip() == "": continue
		ret += w
		linelen += len(w)
	return ret

if __name__ == '__main__':
	generate_documentation()
