// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package present

import (
	"bufio"
	"bytes"
	"errors"
	"fmt"
	"html/template"
	"io"
	"log"
	"net/url"
	"os"
	"regexp"
	"slices"
	"strings"
	"time"
	"unicode"
	"unicode/utf8"

	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/renderer/html"
	"github.com/yuin/goldmark/text"
)

var (
	parsers = make(map[string]ParseFunc)
	funcs   = template.FuncMap{}
)

// Template returns an empty template with the action functions in its FuncMap.
func Template() *template.Template {
	return template.New("").Funcs(funcs)
}

// Render renders the doc to the given writer using the provided template.
func (d *Doc) Render(w io.Writer, t *template.Template) error {
	data := struct {
		*Doc
		Template     *template.Template
		PlayEnabled  bool
		NotesEnabled bool
	}{d, t, PlayEnabled, NotesEnabled}
	return t.ExecuteTemplate(w, "root", data)
}

// Render renders the section to the given writer using the provided template.
func (s *Section) Render(w io.Writer, t *template.Template) error {
	data := struct {
		*Section
		Template    *template.Template
		PlayEnabled bool
	}{s, t, PlayEnabled}
	return t.ExecuteTemplate(w, "section", data)
}

type ParseFunc func(ctx *Context, fileName string, lineNumber int, inputLine string) (Elem, error)

// Register binds the named action, which does not begin with a period, to the
// specified parser to be invoked when the name, with a period, appears in the
// present input text.
func Register(name string, parser ParseFunc) {
	if len(name) == 0 || name[0] == ';' {
		panic("bad name in Register: " + name)
	}
	parsers["."+name] = parser
}

// Doc represents an entire document.
type Doc struct {
	Title      string
	Subtitle   string
	Summary    string
	Time       time.Time
	Authors    []Author
	TitleNotes []string
	Sections   []Section
	Tags       []string
	OldURL     []string
}

// Author represents the person who wrote and/or is presenting the document.
type Author struct {
	Elem []Elem
}

// TextElem returns the first text elements of the author details.
// This is used to display the author' name, job title, and company
// without the contact details.
func (p *Author) TextElem() (elems []Elem) {
	for _, el := range p.Elem {
		if _, ok := el.(Text); !ok {
			break
		}
		elems = append(elems, el)
	}
	return
}

// Section represents a section of a document (such as a presentation slide)
// comprising a title and a list of elements.
type Section struct {
	Number  []int
	Title   string
	ID      string // HTML anchor ID
	Elem    []Elem
	Notes   []string
	Classes []string
	Styles  []string
}

// HTMLAttributes for the section
func (s Section) HTMLAttributes() template.HTMLAttr {
	if len(s.Classes) == 0 && len(s.Styles) == 0 {
		return ""
	}

	var class string
	if len(s.Classes) > 0 {
		class = fmt.Sprintf(`class=%q`, strings.Join(s.Classes, " "))
	}
	var style string
	if len(s.Styles) > 0 {
		style = fmt.Sprintf(`style=%q`, strings.Join(s.Styles, " "))
	}
	return template.HTMLAttr(strings.Join([]string{class, style}, " "))
}

// Sections contained within the section.
func (s Section) Sections() (sections []Section) {
	for _, e := range s.Elem {
		if section, ok := e.(Section); ok {
			sections = append(sections, section)
		}
	}
	return
}

// Level returns the level of the given section.
// The document title is level 1, main section 2, etc.
func (s Section) Level() int {
	return len(s.Number) + 1
}

// FormattedNumber returns a string containing the concatenation of the
// numbers identifying a Section.
func (s Section) FormattedNumber() string {
	b := &bytes.Buffer{}
	for _, n := range s.Number {
		fmt.Fprintf(b, "%v.", n)
	}
	return b.String()
}

func (s Section) TemplateName() string { return "section" }

// Elem defines the interface for a present element. That is, something that
// can provide the name of the template used to render the element.
type Elem interface {
	TemplateName() string
}

// renderElem implements the elem template function, used to render
// sub-templates.
func renderElem(t *template.Template, e Elem) (template.HTML, error) {
	var data any = e
	if s, ok := e.(Section); ok {
		data = struct {
			Section
			Template *template.Template
		}{s, t}
	}
	return execTemplate(t, e.TemplateName(), data)
}

// pageNum derives a page number from a section.
func pageNum(s Section, offset int) int {
	if len(s.Number) == 0 {
		return offset
	}
	return s.Number[0] + offset
}

func init() {
	funcs["elem"] = renderElem
	funcs["pagenum"] = pageNum
}

// execTemplate is a helper to execute a template and return the output as a
// template.HTML value.
func execTemplate(t *template.Template, name string, data any) (template.HTML, error) {
	b := new(bytes.Buffer)
	err := t.ExecuteTemplate(b, name, data)
	if err != nil {
		return "", err
	}
	return template.HTML(b.String()), nil
}

// Text represents an optionally preformatted paragraph.
type Text struct {
	Lines []string
	Pre   bool
	Raw   string // original text, for Pre==true
}

func (t Text) TemplateName() string { return "text" }

// List represents a bulleted list.
type List struct {
	Bullet []string
}

func (l List) TemplateName() string { return "list" }

// Lines is a helper for parsing line-based input.
type Lines struct {
	line    int // 0 indexed, so has 1-indexed number of last line returned
	text    []string
	comment string
}

func readLines(r io.Reader) (*Lines, error) {
	var lines []string
	s := bufio.NewScanner(r)
	for s.Scan() {
		lines = append(lines, s.Text())
	}
	if err := s.Err(); err != nil {
		return nil, err
	}
	return &Lines{0, lines, "#"}, nil
}

func (l *Lines) next() (text string, ok bool) {
	for {
		current := l.line
		l.line++
		if current >= len(l.text) {
			return "", false
		}
		text = l.text[current]
		// Lines starting with l.comment are comments.
		if l.comment == "" || !strings.HasPrefix(text, l.comment) {
			ok = true
			break
		}
	}
	return
}

func (l *Lines) back() {
	l.line--
}

func (l *Lines) nextNonEmpty() (text string, ok bool) {
	for {
		text, ok = l.next()
		if !ok {
			return
		}
		if len(text) > 0 {
			break
		}
	}
	return
}

// A Context specifies the supporting context for parsing a presentation.
type Context struct {
	// ReadFile reads the file named by filename and returns the contents.
	ReadFile func(filename string) ([]byte, error)
}

// ParseMode represents flags for the Parse function.
type ParseMode int

const (
	// If set, parse only the title and subtitle.
	TitlesOnly ParseMode = 1
)

// Parse parses a document from r.
func (ctx *Context) Parse(r io.Reader, name string, mode ParseMode) (*Doc, error) {
	doc := new(Doc)
	lines, err := readLines(r)
	if err != nil {
		return nil, err
	}

	// Detect Markdown-enabled vs legacy present file.
	// Markdown-enabled files have a title line beginning with "# "
	// (like preprocessed C files of yore).
	isMarkdown := false
	for i := lines.line; i < len(lines.text); i++ {
		line := lines.text[i]
		if line == "" {
			continue
		}
		isMarkdown = strings.HasPrefix(line, "# ")
		break
	}

	sectionPrefix := "*"
	if isMarkdown {
		sectionPrefix = "##"
		lines.comment = "//"
	}

	for i := lines.line; i < len(lines.text); i++ {
		if strings.HasPrefix(lines.text[i], sectionPrefix) {
			break
		}

		if isSpeakerNote(lines.text[i]) {
			doc.TitleNotes = append(doc.TitleNotes, trimSpeakerNote(lines.text[i]))
		}
	}

	err = parseHeader(doc, isMarkdown, lines)
	if err != nil {
		return nil, err
	}
	if mode&TitlesOnly != 0 {
		return doc, nil
	}

	// Authors
	if doc.Authors, err = parseAuthors(name, sectionPrefix, lines); err != nil {
		return nil, err
	}

	// Sections
	if doc.Sections, err = parseSections(ctx, name, sectionPrefix, lines, []int{}); err != nil {
		return nil, err
	}

	return doc, nil
}

// Parse parses a document from r. Parse reads assets used by the presentation
// from the file system using os.ReadFile.
func Parse(r io.Reader, name string, mode ParseMode) (*Doc, error) {
	ctx := Context{ReadFile: os.ReadFile}
	return ctx.Parse(r, name, mode)
}

// isHeading matches any section heading.
var (
	isHeadingLegacy   = regexp.MustCompile(`^\*+( |$)`)
	isHeadingMarkdown = regexp.MustCompile(`^\#+( |$)`)
)

// lesserHeading returns true if text is a heading of a lesser or equal level
// than that denoted by prefix.
func lesserHeading(isHeading *regexp.Regexp, text, prefix string) bool {
	return isHeading.MatchString(text) && !strings.HasPrefix(text, prefix+prefix[:1])
}

// parseSections parses Sections from lines for the section level indicated by
// number (a nil number indicates the top level).
func parseSections(ctx *Context, name, prefix string, lines *Lines, number []int) ([]Section, error) {
	isMarkdown := prefix[0] == '#'
	isHeading := isHeadingLegacy
	if isMarkdown {
		isHeading = isHeadingMarkdown
	}
	var sections []Section
	for i := 1; ; i++ {
		// Next non-empty line is title.
		text, ok := lines.nextNonEmpty()
		for ok && text == "" {
			text, ok = lines.next()
		}
		if !ok {
			break
		}
		if text != prefix && !strings.HasPrefix(text, prefix+" ") {
			lines.back()
			break
		}
		// Markdown sections can end in {#id} to set the HTML anchor for the section.
		// This is nicer than the default #TOC_1_2-style anchor.
		title := strings.TrimSpace(text[len(prefix):])
		id := ""
		if isMarkdown && strings.HasSuffix(title, "}") {
			j := strings.LastIndex(title, "{#")
			if j >= 0 {
				id = title[j+2 : len(title)-1]
				title = strings.TrimSpace(title[:j])
			}
		}
		section := Section{
			Number: append(slices.Clone(number), i),
			Title:  title,
			ID:     id,
		}
		text, ok = lines.nextNonEmpty()
		for ok && !lesserHeading(isHeading, text, prefix) {
			var e Elem
			r, _ := utf8.DecodeRuneInString(text)
			switch {
			case !isMarkdown && unicode.IsSpace(r):
				i := strings.IndexFunc(text, func(r rune) bool {
					return !unicode.IsSpace(r)
				})
				if i < 0 {
					break
				}
				indent := text[:i]
				var s []string
				for ok && (strings.HasPrefix(text, indent) || text == "") {
					if text != "" {
						text = text[i:]
					}
					s = append(s, text)
					text, ok = lines.next()
				}
				lines.back()
				pre := strings.Join(s, "\n")
				raw := pre
				pre = strings.Replace(pre, "\t", "    ", -1) // browsers treat tabs badly
				pre = strings.TrimRightFunc(pre, unicode.IsSpace)
				e = Text{Lines: []string{pre}, Pre: true, Raw: raw}
			case !isMarkdown && strings.HasPrefix(text, "- "):
				var b []string
				for {
					if strings.HasPrefix(text, "- ") {
						b = append(b, text[2:])
					} else if len(b) > 0 && strings.HasPrefix(text, " ") {
						b[len(b)-1] += "\n" + strings.TrimSpace(text)
					} else {
						break
					}
					if text, ok = lines.next(); !ok {
						break
					}
				}
				lines.back()
				e = List{Bullet: b}
			case isSpeakerNote(text):
				section.Notes = append(section.Notes, trimSpeakerNote(text))
			case strings.HasPrefix(text, prefix+prefix[:1]+" ") || text == prefix+prefix[:1]:
				lines.back()
				subsecs, err := parseSections(ctx, name, prefix+prefix[:1], lines, section.Number)
				if err != nil {
					return nil, err
				}
				for _, ss := range subsecs {
					section.Elem = append(section.Elem, ss)
				}
			case strings.HasPrefix(text, prefix+prefix[:1]):
				return nil, fmt.Errorf("%s:%d: badly nested section inside %s: %s", name, lines.line, prefix, text)
			case strings.HasPrefix(text, "."):
				args := strings.Fields(text)
				if args[0] == ".background" {
					section.Classes = append(section.Classes, "background")
					section.Styles = append(section.Styles, "background-image: url('"+args[1]+"')")
					break
				}
				parser := parsers[args[0]]
				if parser == nil {
					return nil, fmt.Errorf("%s:%d: unknown command %q", name, lines.line, text)
				}
				t, err := parser(ctx, name, lines.line, text)
				if err != nil {
					return nil, err
				}
				e = t

			case isMarkdown:
				// Collect Markdown lines, including blank lines and indented text.
				var block []string
				endLine, endBlock := lines.line-1, -1 // end is last non-empty line
				for ok {
					trim := strings.TrimSpace(text)
					if trim != "" {
						// Command breaks text block.
						// Section heading breaks text block in markdown.
						if text[0] == '.' || text[0] == '#' || isSpeakerNote(text) {
							break
						}
						if strings.HasPrefix(text, `\.`) { // Backslash escapes initial period.
							text = text[1:]
						}
						endLine, endBlock = lines.line, len(block)
					}
					block = append(block, text)
					text, ok = lines.next()
				}
				block = block[:endBlock+1]
				lines.line = endLine + 1
				if len(block) == 0 {
					break
				}

				// Replace all leading tabs with 4 spaces,
				// which render better in code blocks.
				// CommonMark defines that for parsing the structure of the file
				// a tab is equivalent to 4 spaces, so this change won't
				// affect the later parsing at all.
				// An alternative would be to apply this to code blocks after parsing,
				// at the same time that we update <a> targets, but that turns out
				// to be quite difficult to modify in the AST.
				for i, line := range block {
					if len(line) > 0 && line[0] == '\t' {
						short := strings.TrimLeft(line, "\t")
						line = strings.Repeat("    ", len(line)-len(short)) + short
						block[i] = line
					}
				}
				html, err := renderMarkdown([]byte(strings.Join(block, "\n")))
				if err != nil {
					return nil, err
				}
				e = HTML{HTML: html}

			default:
				// Collect text lines.
				var block []string
				for ok && strings.TrimSpace(text) != "" {
					// Command breaks text block.
					// Section heading breaks text block in markdown.
					if text[0] == '.' || isSpeakerNote(text) {
						lines.back()
						break
					}
					if strings.HasPrefix(text, `\.`) { // Backslash escapes initial period.
						text = text[1:]
					}
					block = append(block, text)
					text, ok = lines.next()
				}
				if len(block) == 0 {
					break
				}
				e = Text{Lines: block}
			}
			if e != nil {
				section.Elem = append(section.Elem, e)
			}
			text, ok = lines.nextNonEmpty()
		}
		if isHeading.MatchString(text) {
			lines.back()
		}
		sections = append(sections, section)
	}

	if len(sections) == 0 {
		return nil, fmt.Errorf("%s:%d: unexpected line: %s", name, lines.line+1, lines.text[lines.line])
	}
	return sections, nil
}

func parseHeader(doc *Doc, isMarkdown bool, lines *Lines) error {
	var ok bool
	// First non-empty line starts header.
	doc.Title, ok = lines.nextNonEmpty()
	if !ok {
		return errors.New("unexpected EOF; expected title")
	}
	if isMarkdown {
		doc.Title = strings.TrimSpace(strings.TrimPrefix(doc.Title, "#"))
	}

	for {
		text, ok := lines.next()
		if !ok {
			return errors.New("unexpected EOF")
		}
		if text == "" {
			break
		}
		if isSpeakerNote(text) {
			continue
		}
		if strings.HasPrefix(text, "Tags:") {
			tags := strings.Split(text[len("Tags:"):], ",")
			for i := range tags {
				tags[i] = strings.TrimSpace(tags[i])
			}
			doc.Tags = append(doc.Tags, tags...)
		} else if strings.HasPrefix(text, "Summary:") {
			doc.Summary = strings.TrimSpace(text[len("Summary:"):])
		} else if strings.HasPrefix(text, "OldURL:") {
			doc.OldURL = append(doc.OldURL, strings.TrimSpace(text[len("OldURL:"):]))
		} else if t, ok := parseTime(text); ok {
			doc.Time = t
		} else if doc.Subtitle == "" {
			doc.Subtitle = text
		} else {
			return fmt.Errorf("unexpected header line: %q", text)
		}
	}
	return nil
}

func parseAuthors(name, sectionPrefix string, lines *Lines) (authors []Author, err error) {
	// This grammar demarcates authors with blanks.

	// Skip blank lines.
	if _, ok := lines.nextNonEmpty(); !ok {
		return nil, errors.New("unexpected EOF")
	}
	lines.back()

	var a *Author
	for {
		text, ok := lines.next()
		if !ok {
			return nil, errors.New("unexpected EOF")
		}

		// If we find a section heading, we're done.
		if strings.HasPrefix(text, sectionPrefix) {
			lines.back()
			break
		}

		if isSpeakerNote(text) {
			continue
		}

		// If we encounter a blank we're done with this author.
		if a != nil && len(text) == 0 {
			authors = append(authors, *a)
			a = nil
			continue
		}
		if a == nil {
			a = new(Author)
		}

		// Parse the line. Those that
		// - begin with @ are twitter names,
		// - contain slashes are links, or
		// - contain an @ symbol are an email address.
		// The rest is just text.
		var el Elem
		switch {
		case strings.HasPrefix(text, "@"):
			el = parseAuthorURL(name, "http://twitter.com/"+text[1:])
		case strings.Contains(text, ":"):
			el = parseAuthorURL(name, text)
		case strings.Contains(text, "@"):
			el = parseAuthorURL(name, "mailto:"+text)
		}
		if l, ok := el.(Link); ok {
			l.Label = text
			el = l
		}
		if el == nil {
			el = Text{Lines: []string{text}}
		}
		a.Elem = append(a.Elem, el)
	}
	if a != nil {
		authors = append(authors, *a)
	}
	return authors, nil
}

func parseAuthorURL(name, text string) Elem {
	u, err := url.Parse(text)
	if err != nil {
		log.Printf("parsing %s author block: invalid URL %q: %v", name, text, err)
		return nil
	}
	return Link{URL: u}
}

func parseTime(text string) (t time.Time, ok bool) {
	t, err := time.Parse("15:04 2 Jan 2006", text)
	if err == nil {
		return t, true
	}
	t, err = time.Parse("2 Jan 2006", text)
	if err == nil {
		// at 11am UTC it is the same date everywhere
		t = t.Add(time.Hour * 11)
		return t, true
	}
	return time.Time{}, false
}

func isSpeakerNote(s string) bool {
	return strings.HasPrefix(s, ": ") || s == ":"
}

func trimSpeakerNote(s string) string {
	if s == ":" {
		return ""
	}
	return strings.TrimPrefix(s, ": ")
}

func renderMarkdown(input []byte) (template.HTML, error) {
	md := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
	reader := text.NewReader(input)
	doc := md.Parser().Parse(reader)
	fixupMarkdown(doc)
	var b strings.Builder
	if err := md.Renderer().Render(&b, input, doc); err != nil {
		return "", err
	}
	return template.HTML(b.String()), nil
}

func fixupMarkdown(n ast.Node) {
	ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		if entering {
			switch n := n.(type) {
			case *ast.Link:
				n.SetAttributeString("target", []byte("_blank"))
				// https://developers.google.com/web/tools/lighthouse/audits/noopener
				n.SetAttributeString("rel", []byte("noopener"))
			}
		}
		return ast.WalkContinue, nil
	})
}
