package goorgeous

import (
	"bufio"
	"bytes"
	"regexp"

	"github.com/russross/blackfriday"
	"github.com/shurcooL/sanitized_anchor_name"
)

type inlineParser func(p *parser, out *bytes.Buffer, data []byte, offset int) int

type footnotes struct {
	id  string
	def string
}

type parser struct {
	r              blackfriday.Renderer
	inlineCallback [256]inlineParser
	notes          []footnotes
}

// NewParser returns a new parser with the inlineCallbacks required for org content
func NewParser(renderer blackfriday.Renderer) *parser {
	p := new(parser)
	p.r = renderer

	p.inlineCallback['='] = generateVerbatim
	p.inlineCallback['~'] = generateCode
	p.inlineCallback['/'] = generateEmphasis
	p.inlineCallback['_'] = generateUnderline
	p.inlineCallback['*'] = generateBold
	p.inlineCallback['+'] = generateStrikethrough
	p.inlineCallback['['] = generateLinkOrImg

	return p
}

// OrgCommon is the easiest way to parse a byte slice of org content and makes assumptions
// that the caller wants to use blackfriday's HTMLRenderer with XHTML
func OrgCommon(input []byte) []byte {
	renderer := blackfriday.HtmlRenderer(blackfriday.HTML_USE_XHTML, "", "")
	return OrgOptions(input, renderer)
}

// Org is a convenience name for OrgOptions
func Org(input []byte, renderer blackfriday.Renderer) []byte {
	return OrgOptions(input, renderer)
}

// OrgOptions takes an org content byte slice and a renderer to use
func OrgOptions(input []byte, renderer blackfriday.Renderer) []byte {
	// in the case that we need to render something in isEmpty but there isn't a new line char
	input = append(input, '\n')
	var output bytes.Buffer

	p := NewParser(renderer)

	scanner := bufio.NewScanner(bytes.NewReader(input))
	// used to capture code blocks
	marker := ""
	syntax := ""
	listType := ""
	inParagraph := false
	inList := false
	inTable := false
	inFixedWidthArea := false
	inFootNote := false
	curFootNoteId := ""
	var tmpBlock bytes.Buffer

	for scanner.Scan() {
		data := scanner.Bytes()

		if !isEmpty(data) && isComment(data) || IsKeyword(data) {
			switch {
			case inList:
				if tmpBlock.Len() > 0 {
					p.generateList(&output, tmpBlock.Bytes(), listType)
				}
				inList = false
				listType = ""
				tmpBlock.Reset()
			case inTable:
				if tmpBlock.Len() > 0 {
					p.generateTable(&output, tmpBlock.Bytes())
				}
				inTable = false
				tmpBlock.Reset()
			case inParagraph:
				if tmpBlock.Len() > 0 {
					p.generateParagraph(&output, tmpBlock.Bytes()[:len(tmpBlock.Bytes())-1])
				}
				inParagraph = false
				tmpBlock.Reset()
			case inFixedWidthArea:
				if tmpBlock.Len() > 0 {
					tmpBlock.WriteString("</pre>\n")
					output.Write(tmpBlock.Bytes())
				}
				inFixedWidthArea = false
				tmpBlock.Reset()
			case inFootNote:
				inFootNote = false
				curFootNoteId = ""
			}

		}

		switch {
		case isEmpty(data):
			switch {
			case inList:
				if tmpBlock.Len() > 0 {
					p.generateList(&output, tmpBlock.Bytes(), listType)
				}
				inList = false
				listType = ""
				tmpBlock.Reset()
			case inTable:
				if tmpBlock.Len() > 0 {
					p.generateTable(&output, tmpBlock.Bytes())
				}
				inTable = false
				tmpBlock.Reset()
			case inParagraph:
				if tmpBlock.Len() > 0 {
					p.generateParagraph(&output, tmpBlock.Bytes()[:len(tmpBlock.Bytes())-1])
				}
				inParagraph = false
				tmpBlock.Reset()
			case inFixedWidthArea:
				if tmpBlock.Len() > 0 {
					tmpBlock.WriteString("</pre>\n")
					output.Write(tmpBlock.Bytes())
				}
				inFixedWidthArea = false
				tmpBlock.Reset()
			case inFootNote:
				inFootNote = false
				curFootNoteId = ""
			case marker != "":
				tmpBlock.WriteByte('\n')
			default:
				continue
			}
		case isPropertyDrawer(data) || marker == "PROPERTIES":
			if marker == "" {
				marker = "PROPERTIES"
			}
			if bytes.Equal(data, []byte(":END:")) {
				marker = ""
			}
			continue
		case isBlock(data) || marker != "":
			matches := reBlock.FindSubmatch(data)
			if len(matches) > 0 {
				if string(matches[1]) == "END" {
					switch marker {
					case "QUOTE":
						var tmpBuf bytes.Buffer
						p.inline(&tmpBuf, tmpBlock.Bytes())
						p.r.BlockQuote(&output, tmpBuf.Bytes())
					case "CENTER":
						var tmpBuf bytes.Buffer
						output.WriteString("<center>\n")
						p.inline(&tmpBuf, tmpBlock.Bytes())
						output.Write(tmpBuf.Bytes())
						output.WriteString("</center>\n")
					default:
						tmpBlock.WriteByte('\n')
						p.r.BlockCode(&output, tmpBlock.Bytes(), syntax)
					}
					marker = ""
					tmpBlock.Reset()
					continue
				}

			}
			if marker != "" {
				if marker != "SRC" && marker != "EXAMPLE" {
					var tmpBuf bytes.Buffer
					tmpBuf.Write([]byte("<p>\n"))
					p.inline(&tmpBuf, data)
					tmpBuf.WriteByte('\n')
					tmpBuf.Write([]byte("</p>\n"))
					tmpBlock.Write(tmpBuf.Bytes())

				} else {
					if !(tmpBlock.Len() == 0 && (marker == "SRC" || marker == "EXAMPLE")) {
						tmpBlock.WriteByte('\n')
					}
					tmpBlock.Write(data)
				}

			} else {
				marker = string(matches[2])
				syntax = string(matches[3])
			}
		case isFootnoteDef(data) || inFootNote:
			if isFootnoteDef(data) {
				matches := reFootnoteDef.FindSubmatch(data)
				for i := range p.notes {
					if p.notes[i].id == string(matches[1]) {
						p.notes[i].def = string(matches[2])
						inFootNote = true
						curFootNoteId = string(matches[1])
					}
				}
			} else if inFootNote {
				for i := range p.notes {
					if p.notes[i].id == curFootNoteId {
						p.notes[i].def += " "
						p.notes[i].def += string(data)
					}
				}
			}
		case isTable(data):
			if inTable != true {
				inTable = true
			}
			tmpBlock.Write(data)
			tmpBlock.WriteByte('\n')
		case IsKeyword(data):
			continue
		case isComment(data):
			p.generateComment(&output, data)
		case isHeadline(data):
			p.generateHeadline(&output, data)
		case isDefinitionList(data):
			if inList != true {
				listType = "dl"
				inList = true
			}
			var work bytes.Buffer
			flags := blackfriday.LIST_TYPE_DEFINITION
			matches := reDefinitionList.FindSubmatch(data)
			flags |= blackfriday.LIST_TYPE_TERM
			p.inline(&work, matches[1])
			p.r.ListItem(&tmpBlock, work.Bytes(), flags)
			work.Reset()
			flags &= ^blackfriday.LIST_TYPE_TERM
			p.inline(&work, matches[2])
			p.r.ListItem(&tmpBlock, work.Bytes(), flags)
		case isUnorderedList(data):
			if inList != true {
				listType = "ul"
				inList = true
			}
			matches := reUnorderedList.FindSubmatch(data)
			var work bytes.Buffer
			p.inline(&work, matches[2])
			p.r.ListItem(&tmpBlock, work.Bytes(), 0)
		case isOrderedList(data):
			if inList != true {
				listType = "ol"
				inList = true
			}
			matches := reOrderedList.FindSubmatch(data)
			var work bytes.Buffer
			tmpBlock.WriteString("<li")
			if len(matches[2]) > 0 {
				tmpBlock.WriteString(" value=\"")
				tmpBlock.Write(matches[2])
				tmpBlock.WriteString("\"")
				matches[3] = matches[3][1:]
			}
			p.inline(&work, matches[3])
			tmpBlock.WriteString(">")
			tmpBlock.Write(work.Bytes())
			tmpBlock.WriteString("</li>\n")
		case isHorizontalRule(data):
			p.r.HRule(&output)
		case isExampleLine(data):
			if inParagraph == true {
				if len(tmpBlock.Bytes()) > 0 {
					p.generateParagraph(&output, tmpBlock.Bytes()[:len(tmpBlock.Bytes())-1])
					inParagraph = false
				}
				tmpBlock.Reset()
			}
			if inFixedWidthArea != true {
				tmpBlock.WriteString("<pre class=\"example\">\n")
				inFixedWidthArea = true
			}
			matches := reExampleLine.FindSubmatch(data)
			tmpBlock.Write(matches[1])
			tmpBlock.WriteString("\n")
			break
		default:
			if inParagraph == false {
				inParagraph = true
				if inFixedWidthArea == true {
					if tmpBlock.Len() > 0 {
						tmpBlock.WriteString("</pre>")
						output.Write(tmpBlock.Bytes())
					}
					inFixedWidthArea = false
					tmpBlock.Reset()
				}
			}
			tmpBlock.Write(data)
			tmpBlock.WriteByte('\n')
		}
	}

	if len(tmpBlock.Bytes()) > 0 {
		if inParagraph == true {
			p.generateParagraph(&output, tmpBlock.Bytes()[:len(tmpBlock.Bytes())-1])
		} else if inFixedWidthArea == true {
			tmpBlock.WriteString("</pre>\n")
			output.Write(tmpBlock.Bytes())
		}
	}

	// Writing footnote def. list
	if len(p.notes) > 0 {
		flags := blackfriday.LIST_ITEM_BEGINNING_OF_LIST
		p.r.Footnotes(&output, func() bool {
			for i := range p.notes {
				p.r.FootnoteItem(&output, []byte(p.notes[i].id), []byte(p.notes[i].def), flags)
			}
			return true
		})
	}

	return output.Bytes()
}

// Org Syntax has been broken up into 4 distinct sections based on
// the org-syntax draft (http://orgmode.org/worg/dev/org-syntax.html):
// - Headlines
// - Greater Elements
// - Elements
// - Objects

// Headlines
func isHeadline(data []byte) bool {
	if !charMatches(data[0], '*') {
		return false
	}
	level := 0
	for level < 6 && charMatches(data[level], '*') {
		level++
	}
	return charMatches(data[level], ' ')
}

func (p *parser) generateHeadline(out *bytes.Buffer, data []byte) {
	level := 1
	status := ""
	priority := ""

	for level < 6 && data[level] == '*' {
		level++
	}

	start := skipChar(data, level, ' ')

	data = data[start:]
	i := 0

	// Check if has a status so it can be rendered as a separate span that can be hidden or
	// modified with CSS classes
	if hasStatus(data[i:4]) {
		status = string(data[i:4])
		i += 5 // one extra character for the next whitespace
	}

	// Check if the next byte is a priority marker
	if data[i] == '[' && hasPriority(data[i+1]) {
		priority = string(data[i+1])
		i += 4 // for "[c]" + ' '
	}

	tags, tagsFound := findTags(data, i)

	headlineID := sanitized_anchor_name.Create(string(data[i:]))

	generate := func() bool {
		dataEnd := len(data)
		if tagsFound > 0 {
			dataEnd = tagsFound
		}

		headline := bytes.TrimRight(data[i:dataEnd], " \t")

		if status != "" {
			out.WriteString("<span class=\"todo " + status + "\">" + status + "</span>")
			out.WriteByte(' ')
		}

		if priority != "" {
			out.WriteString("<span class=\"priority " + priority + "\">[" + priority + "]</span>")
			out.WriteByte(' ')
		}

		p.inline(out, headline)

		if tagsFound > 0 {
			for _, tag := range tags {
				out.WriteByte(' ')
				out.WriteString("<span class=\"tags " + tag + "\">" + tag + "</span>")
				out.WriteByte(' ')
			}
		}
		return true
	}

	p.r.Header(out, generate, level, headlineID)
}

func hasStatus(data []byte) bool {
	return bytes.Contains(data, []byte("TODO")) || bytes.Contains(data, []byte("DONE"))
}

func hasPriority(char byte) bool {
	return (charMatches(char, 'A') || charMatches(char, 'B') || charMatches(char, 'C'))
}

func findTags(data []byte, start int) ([]string, int) {
	tags := []string{}
	tagOpener := 0
	tagMarker := tagOpener
	for tIdx := start; tIdx < len(data); tIdx++ {
		if tagMarker > 0 && data[tIdx] == ':' {
			tags = append(tags, string(data[tagMarker+1:tIdx]))
			tagMarker = tIdx
		}
		if data[tIdx] == ':' && tagOpener == 0 && data[tIdx-1] == ' ' {
			tagMarker = tIdx
			tagOpener = tIdx
		}
	}
	return tags, tagOpener
}

// Greater Elements
// ~~ Definition Lists
var reDefinitionList = regexp.MustCompile(`^\s*-\s+(.+?)\s+::\s+(.*)`)

func isDefinitionList(data []byte) bool {
	return reDefinitionList.Match(data)
}

// ~~ Example lines
var reExampleLine = regexp.MustCompile(`^\s*:\s(\s*.*)|^\s*:$`)

func isExampleLine(data []byte) bool {
	return reExampleLine.Match(data)
}

// ~~ Ordered Lists
var reOrderedList = regexp.MustCompile(`^(\s*)\d+\.\s+\[?@?(\d*)\]?(.+)`)

func isOrderedList(data []byte) bool {
	return reOrderedList.Match(data)
}

// ~~ Unordered Lists
var reUnorderedList = regexp.MustCompile(`^(\s*)[-\+]\s+(.+)`)

func isUnorderedList(data []byte) bool {
	return reUnorderedList.Match(data)
}

// ~~ Tables
var reTableHeaders = regexp.MustCompile(`^[|+-]*$`)

func isTable(data []byte) bool {
	return charMatches(data[0], '|')
}

func (p *parser) generateTable(output *bytes.Buffer, data []byte) {
	var table bytes.Buffer
	rows := bytes.Split(bytes.Trim(data, "\n"), []byte("\n"))
	hasTableHeaders := len(rows) > 1
	if len(rows) > 1 {
		hasTableHeaders = reTableHeaders.Match(rows[1])
	}
	tbodySet := false

	for idx, row := range rows {
		var rowBuff bytes.Buffer
		if hasTableHeaders && idx == 0 {
			table.WriteString("<thead>")
			for _, cell := range bytes.Split(row[1:len(row)-1], []byte("|")) {
				p.r.TableHeaderCell(&rowBuff, bytes.Trim(cell, " \t"), 0)
			}
			p.r.TableRow(&table, rowBuff.Bytes())
			table.WriteString("</thead>\n")
		} else if hasTableHeaders && idx == 1 {
			continue
		} else {
			if !tbodySet {
				table.WriteString("<tbody>")
				tbodySet = true
			}
			if !reTableHeaders.Match(row) {
				for _, cell := range bytes.Split(row[1:len(row)-1], []byte("|")) {
					var cellBuff bytes.Buffer
					p.inline(&cellBuff, bytes.Trim(cell, " \t"))
					p.r.TableCell(&rowBuff, cellBuff.Bytes(), 0)
				}
				p.r.TableRow(&table, rowBuff.Bytes())
			}
			if tbodySet && idx == len(rows)-1 {
				table.WriteString("</tbody>\n")
				tbodySet = false
			}
		}
	}

	output.WriteString("\n<table>\n")
	output.Write(table.Bytes())
	output.WriteString("</table>\n")
}

// ~~ Property Drawers

func isPropertyDrawer(data []byte) bool {
	return bytes.Equal(data, []byte(":PROPERTIES:"))
}

// ~~ Dynamic Blocks
var reBlock = regexp.MustCompile(`^\s*#\+(BEGIN|END)_(\w+)\s*([0-9A-Za-z_\-]*)?`)

func isBlock(data []byte) bool {
	return reBlock.Match(data)
}

// ~~ Footnotes
var reFootnoteDef = regexp.MustCompile(`^\[fn:([\w]+)\] +(.+)`)

func isFootnoteDef(data []byte) bool {
	return reFootnoteDef.Match(data)
}

// Elements
// ~~ Keywords
func IsKeyword(data []byte) bool {
	return len(data) > 2 && charMatches(data[0], '#') && charMatches(data[1], '+') && !charMatches(data[2], ' ')
}

// ~~ Comments
func isComment(data []byte) bool {
	return len(data) > 1 && charMatches(data[0], '#') && charMatches(data[1], ' ')
}

func (p *parser) generateComment(out *bytes.Buffer, data []byte) {
	var work bytes.Buffer
	work.WriteString("<!-- ")
	work.Write(data[2:])
	work.WriteString(" -->")
	work.WriteByte('\n')
	out.Write(work.Bytes())
}

// ~~ Horizontal Rules
var reHorizontalRule = regexp.MustCompile(`^\s*?-----\s?$`)

func isHorizontalRule(data []byte) bool {
	return reHorizontalRule.Match(data)
}

// ~~ Paragraphs
func (p *parser) generateParagraph(out *bytes.Buffer, data []byte) {
	generate := func() bool {
		p.inline(out, bytes.Trim(data, " "))
		return true
	}
	p.r.Paragraph(out, generate)
}

func (p *parser) generateList(output *bytes.Buffer, data []byte, listType string) {
	generateList := func() bool {
		output.WriteByte('\n')
		p.inline(output, bytes.Trim(data, " "))
		return true
	}
	switch listType {
	case "ul":
		p.r.List(output, generateList, 0)
	case "ol":
		p.r.List(output, generateList, blackfriday.LIST_TYPE_ORDERED)
	case "dl":
		p.r.List(output, generateList, blackfriday.LIST_TYPE_DEFINITION)
	}
}

// Objects

func (p *parser) inline(out *bytes.Buffer, data []byte) {
	i, end := 0, 0

	for i < len(data) {
		for end < len(data) && p.inlineCallback[data[end]] == nil {
			end++
		}

		p.r.Entity(out, data[i:end])

		if end >= len(data) {
			break
		}
		i = end

		handler := p.inlineCallback[data[i]]

		if consumed := handler(p, out, data, i); consumed > 0 {
			i += consumed
			end = i
			continue
		}

		end = i + 1
	}
}

func isAcceptablePreOpeningChar(dataIn, data []byte, offset int) bool {
	if len(dataIn) == len(data) {
		return true
	}

	char := dataIn[offset-1]
	return charMatches(char, ' ') || isPreChar(char)
}

func isPreChar(char byte) bool {
	return charMatches(char, '>') || charMatches(char, '(') || charMatches(char, '{') || charMatches(char, '[')
}

func isAcceptablePostClosingChar(char byte) bool {
	return charMatches(char, ' ') || isTerminatingChar(char)
}

func isTerminatingChar(char byte) bool {
	return charMatches(char, '.') || charMatches(char, ',') || charMatches(char, '?') || charMatches(char, '!') || charMatches(char, ')') || charMatches(char, '}') || charMatches(char, ']')
}

func findLastCharInInline(data []byte, char byte) int {
	timesFound := 0
	last := 0
	// Start from character after the inline indicator
	for i := 1; i < len(data); i++ {
		if timesFound == 1 {
			break
		}
		if data[i] == char {
			if len(data) == i+1 || (len(data) > i+1 && isAcceptablePostClosingChar(data[i+1])) {
				last = i
				timesFound += 1
			}
		}
	}
	return last
}

func generator(p *parser, out *bytes.Buffer, dataIn []byte, offset int, char byte, doInline bool, renderer func(*bytes.Buffer, []byte)) int {
	data := dataIn[offset:]
	c := byte(char)
	start := 1
	i := start
	if len(data) <= 1 {
		return 0
	}

	lastCharInside := findLastCharInInline(data, c)

	// Org mode spec says a non-whitespace character must immediately follow.
	// if the current char is the marker, then there's no text between, not a candidate
	if isSpace(data[i]) || lastCharInside == i || !isAcceptablePreOpeningChar(dataIn, data, offset) {
		return 0
	}

	if lastCharInside > 0 {
		var work bytes.Buffer
		if doInline {
			p.inline(&work, data[start:lastCharInside])
			renderer(out, work.Bytes())
		} else {
			renderer(out, data[start:lastCharInside])
		}
		next := lastCharInside + 1
		return next
	}

	return 0
}

// ~~ Text Markup
func generateVerbatim(p *parser, out *bytes.Buffer, data []byte, offset int) int {
	return generator(p, out, data, offset, '=', false, p.r.CodeSpan)
}

func generateCode(p *parser, out *bytes.Buffer, data []byte, offset int) int {
	return generator(p, out, data, offset, '~', false, p.r.CodeSpan)
}

func generateEmphasis(p *parser, out *bytes.Buffer, data []byte, offset int) int {
	return generator(p, out, data, offset, '/', true, p.r.Emphasis)
}

func generateUnderline(p *parser, out *bytes.Buffer, data []byte, offset int) int {
	underline := func(out *bytes.Buffer, text []byte) {
		out.WriteString("<span style=\"text-decoration: underline;\">")
		out.Write(text)
		out.WriteString("</span>")
	}

	return generator(p, out, data, offset, '_', true, underline)
}

func generateBold(p *parser, out *bytes.Buffer, data []byte, offset int) int {
	return generator(p, out, data, offset, '*', true, p.r.DoubleEmphasis)
}

func generateStrikethrough(p *parser, out *bytes.Buffer, data []byte, offset int) int {
	return generator(p, out, data, offset, '+', true, p.r.StrikeThrough)
}

// ~~ Images and Links (inc. Footnote)
var reLinkOrImg = regexp.MustCompile(`\[\[(.+?)\]\[?(.*?)\]?\]`)

func generateLinkOrImg(p *parser, out *bytes.Buffer, data []byte, offset int) int {
	data = data[offset+1:]
	start := 1
	i := start
	var hyperlink []byte
	isImage := false
	isFootnote := false
	closedLink := false
	hasContent := false

	if bytes.Equal(data[0:3], []byte("fn:")) {
		isFootnote = true
	} else if data[0] != '[' {
		return 0
	}

	if bytes.Equal(data[1:6], []byte("file:")) {
		isImage = true
	}

	for i < len(data) {
		currChar := data[i]
		switch {
		case charMatches(currChar, ']') && closedLink == false:
			if isImage {
				hyperlink = data[start+5 : i]
			} else if isFootnote {
				refid := data[start+2 : i]
				if bytes.Equal(refid, bytes.Trim(refid, " ")) {
					p.notes = append(p.notes, footnotes{string(refid), "DEFINITION NOT FOUND"})
					p.r.FootnoteRef(out, refid, len(p.notes))
					return i + 2
				} else {
					return 0
				}
			} else if bytes.Equal(data[i-4:i], []byte(".org")) {
				orgStart := start
				if bytes.Equal(data[orgStart:orgStart+2], []byte("./")) {
					orgStart = orgStart + 1
				}
				hyperlink = data[orgStart : i-4]
			} else {
				hyperlink = data[start:i]
			}
			closedLink = true
		case charMatches(currChar, '['):
			start = i + 1
			hasContent = true
		case charMatches(currChar, ']') && closedLink == true && hasContent == true && isImage == true:
			p.r.Image(out, hyperlink, data[start:i], data[start:i])
			return i + 3
		case charMatches(currChar, ']') && closedLink == true && hasContent == true:
			var tmpBuf bytes.Buffer
			p.inline(&tmpBuf, data[start:i])
			p.r.Link(out, hyperlink, tmpBuf.Bytes(), tmpBuf.Bytes())
			return i + 3
		case charMatches(currChar, ']') && closedLink == true && hasContent == false && isImage == true:
			p.r.Image(out, hyperlink, hyperlink, hyperlink)
			return i + 2
		case charMatches(currChar, ']') && closedLink == true && hasContent == false:
			p.r.Link(out, hyperlink, hyperlink, hyperlink)
			return i + 2
		}
		i++
	}

	return 0
}

// Helpers
func skipChar(data []byte, start int, char byte) int {
	i := start
	for i < len(data) && charMatches(data[i], char) {
		i++
	}
	return i
}

func isSpace(char byte) bool {
	return charMatches(char, ' ')
}

func isEmpty(data []byte) bool {
	if len(data) == 0 {
		return true
	}

	for i := 0; i < len(data) && !charMatches(data[i], '\n'); i++ {
		if !charMatches(data[i], ' ') && !charMatches(data[i], '\t') {
			return false
		}
	}
	return true
}

func charMatches(a byte, b byte) bool {
	return a == b
}
