// Copyright 2020 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.

// Present2md converts legacy-syntax present files to Markdown-syntax present files.
//
// Usage:
//
//	present2md [-w] [file ...]
//
// By default, present2md prints the Markdown-syntax form of each input file to standard output.
// If no input file is listed, standard input is used.
//
// The -w flag causes present2md to update the files in place, overwriting each with its
// Markdown-syntax equivalent.
//
// Examples
//
//	present2md your.article
//	present2md -w *.article
package main

import (
	"bytes"
	"flag"
	"fmt"
	"io"
	"log"
	"net/url"
	"os"
	"strings"
	"unicode"
	"unicode/utf8"

	"golang.org/x/tools/present"
)

func usage() {
	fmt.Fprintf(os.Stderr, "usage: present2md [-w] [file ...]\n")
	os.Exit(2)
}

var (
	writeBack  = flag.Bool("w", false, "write conversions back to original files")
	exitStatus = 0
)

func main() {
	log.SetPrefix("present2md: ")
	log.SetFlags(0)
	flag.Usage = usage
	flag.Parse()

	args := flag.Args()
	if len(args) == 0 {
		if *writeBack {
			log.Fatalf("cannot use -w with standard input")
		}
		convert(os.Stdin, "stdin", false)
		return
	}

	for _, arg := range args {
		f, err := os.Open(arg)
		if err != nil {
			log.Print(err)
			exitStatus = 1
			continue
		}
		err = convert(f, arg, *writeBack)
		f.Close()
		if err != nil {
			log.Print(err)
			exitStatus = 1
		}
	}
	os.Exit(exitStatus)
}

// convert reads the data from r, parses it as legacy present,
// and converts it to Markdown-enabled present.
// If any errors occur, the data is reported as coming from file.
// If writeBack is true, the converted version is written back to file.
// If writeBack is false, the converted version is printed to standard output.
func convert(r io.Reader, file string, writeBack bool) error {
	data, err := io.ReadAll(r)
	if err != nil {
		return err
	}
	if bytes.HasPrefix(data, []byte("# ")) {
		return fmt.Errorf("%v: already markdown", file)
	}

	// Convert all comments before parsing the document.
	// The '//' comment is treated as normal text and so
	// is passed through the translation unaltered.
	data = bytes.Replace(data, []byte("\n#"), []byte("\n//"), -1)

	doc, err := present.Parse(bytes.NewReader(data), file, 0)
	if err != nil {
		return err
	}

	// Title and Subtitle, Time, Tags.
	var md bytes.Buffer
	fmt.Fprintf(&md, "# %s\n", doc.Title)
	if doc.Subtitle != "" {
		fmt.Fprintf(&md, "%s\n", doc.Subtitle)
	}
	if !doc.Time.IsZero() {
		fmt.Fprintf(&md, "%s\n", doc.Time.Format("2 Jan 2006"))
	}
	if len(doc.Tags) > 0 {
		fmt.Fprintf(&md, "Tags: %s\n", strings.Join(doc.Tags, ", "))
	}

	// Summary, defaulting to first paragraph of section.
	// (Summaries must be explicit for Markdown-enabled present,
	// and the expectation is that they will be shorter than the
	// whole first paragraph. But this is what the blog does today.)
	if strings.HasSuffix(file, ".article") && len(doc.Sections) > 0 {
		for _, elem := range doc.Sections[0].Elem {
			text, ok := elem.(present.Text)
			if !ok || text.Pre {
				// skip everything but non-text elements
				continue
			}
			fmt.Fprintf(&md, "Summary:")
			for _, line := range text.Lines {
				fmt.Fprintf(&md, " ")
				printStyled(&md, line)
			}
			fmt.Fprintf(&md, "\n")
			break
		}
	}

	// Authors
	for _, a := range doc.Authors {
		fmt.Fprintf(&md, "\n")
		for _, elem := range a.Elem {
			switch elem := elem.(type) {
			default:
				// Can only happen if this type switch is incomplete, which is a bug.
				log.Fatalf("%s: unexpected author type %T", file, elem)
			case present.Text:
				for _, line := range elem.Lines {
					fmt.Fprintf(&md, "%s\n", markdownEscape(line))
				}
			case present.Link:
				fmt.Fprintf(&md, "%s\n", markdownEscape(elem.Label))
			}
		}
	}

	// Invariant: the output ends in non-blank line now,
	// and after printing any piece of the file below,
	// the output should still end in a non-blank line.
	// If a blank line separator is needed, it should be printed
	// before the block that needs separating, not after.

	if len(doc.TitleNotes) > 0 {
		fmt.Fprintf(&md, "\n")
		for _, line := range doc.TitleNotes {
			fmt.Fprintf(&md, ": %s\n", line)
		}
	}

	if len(doc.Sections) == 1 && strings.HasSuffix(file, ".article") {
		// Blog drops section headers when there is only one section.
		// Don't print a title in this case, to make clear that it's being dropped.
		fmt.Fprintf(&md, "\n##\n")
		printSectionBody(file, 1, &md, doc.Sections[0].Elem)
	} else {
		for _, s := range doc.Sections {
			fmt.Fprintf(&md, "\n")
			fmt.Fprintf(&md, "## %s\n", markdownEscape(s.Title))
			printSectionBody(file, 1, &md, s.Elem)
		}
	}

	if !writeBack {
		os.Stdout.Write(md.Bytes())
		return nil
	}
	return os.WriteFile(file, md.Bytes(), 0666)
}

func printSectionBody(file string, depth int, w *bytes.Buffer, elems []present.Elem) {
	for _, elem := range elems {
		switch elem := elem.(type) {
		default:
			// Can only happen if this type switch is incomplete, which is a bug.
			log.Fatalf("%s: unexpected present element type %T", file, elem)

		case present.Text:
			fmt.Fprintf(w, "\n")
			lines := elem.Lines
			for len(lines) > 0 && lines[0] == "" {
				lines = lines[1:]
			}
			if elem.Pre {
				for line := range strings.SplitSeq(strings.TrimRight(elem.Raw, "\n"), "\n") {
					if line == "" {
						fmt.Fprintf(w, "\n")
					} else {
						fmt.Fprintf(w, "\t%s\n", line)
					}
				}
			} else {
				for _, line := range elem.Lines {
					printStyled(w, line)
					fmt.Fprintf(w, "\n")
				}
			}

		case present.List:
			fmt.Fprintf(w, "\n")
			for _, item := range elem.Bullet {
				fmt.Fprintf(w, "  - ")
				for i, line := range strings.Split(item, "\n") {
					if i > 0 {
						fmt.Fprintf(w, "    ")
					}
					printStyled(w, line)
					fmt.Fprintf(w, "\n")
				}
			}

		case present.Section:
			fmt.Fprintf(w, "\n")
			sep := " "
			if elem.Title == "" {
				sep = ""
			}
			fmt.Fprintf(w, "%s%s%s\n", strings.Repeat("#", depth+2), sep, markdownEscape(elem.Title))
			printSectionBody(file, depth+1, w, elem.Elem)

		case interface{ PresentCmd() string }:
			// If there are multiple present commands in a row, don't print a blank line before the second etc.
			b := w.Bytes()
			sep := "\n"
			if len(b) > 0 {
				i := bytes.LastIndexByte(b[:len(b)-1], '\n')
				if b[i+1] == '.' {
					sep = ""
				}
			}
			fmt.Fprintf(w, "%s%s\n", sep, elem.PresentCmd())
		}
	}
}

func markdownEscape(s string) string {
	var b strings.Builder
	for i, r := range s {
		switch {
		case r == '#' && i == 0,
			r == '*',
			r == '_',
			r == '<' && (i == 0 || s[i-1] != ' ') && i+1 < len(s) && s[i+1] != ' ',
			r == '[' && strings.Contains(s[i:], "]("):
			b.WriteRune('\\')
		}
		b.WriteRune(r)
	}
	return b.String()
}

// Copy of ../../present/style.go adjusted to produce Markdown instead of HTML.

/*
	Fonts are demarcated by an initial and final char bracketing a
	space-delimited word, plus possibly some terminal punctuation.
	The chars are
		_ for italic
		* for bold
		` (back quote) for fixed width.
	Inner appearances of the char become spaces. For instance,
		_this_is_italic_!
	becomes
		<i>this is italic</i>!
*/

func printStyled(w *bytes.Buffer, text string) {
	w.WriteString(font(text))
}

// font returns s with font indicators turned into HTML font tags.
func font(s string) string {
	if !strings.ContainsAny(s, "[`_*") {
		return markdownEscape(s)
	}
	words := split(s)
	var b bytes.Buffer
Word:
	for w, word := range words {
		words[w] = markdownEscape(word) // for all the continue Word
		if len(word) < 2 {
			continue Word
		}
		if link, _ := parseInlineLink(word); link != "" {
			words[w] = link
			continue Word
		}
		const marker = "_*`"
		// Initial punctuation is OK but must be peeled off.
		first := strings.IndexAny(word, marker)
		if first == -1 {
			continue Word
		}
		// Opening marker must be at the beginning of the token or else preceded by punctuation.
		if first != 0 {
			r, _ := utf8.DecodeLastRuneInString(word[:first])
			if !unicode.IsPunct(r) {
				continue Word
			}
		}
		open, word := markdownEscape(word[:first]), word[first:]
		char := word[0] // ASCII is OK.
		close := ""
		switch char {
		default:
			continue Word
		case '_':
			open += "_"
			close = "_"
		case '*':
			open += "**"
			close = "**"
		case '`':
			open += "`"
			close = "`"
		}
		// Closing marker must be at the end of the token or else followed by punctuation.
		last := strings.LastIndex(word, word[:1])
		if last == 0 {
			continue Word
		}
		if last+1 != len(word) {
			r, _ := utf8.DecodeRuneInString(word[last+1:])
			if !unicode.IsPunct(r) {
				continue Word
			}
		}
		head, tail := word[:last+1], word[last+1:]
		b.Reset()
		var wid int
		for i := 1; i < len(head)-1; i += wid {
			var r rune
			r, wid = utf8.DecodeRuneInString(head[i:])
			if r != rune(char) {
				// Ordinary character.
				b.WriteRune(r)
				continue
			}
			if head[i+1] != char {
				// Inner char becomes space.
				b.WriteRune(' ')
				continue
			}
			// Doubled char becomes real char.
			// Not worth worrying about "_x__".
			b.WriteByte(char)
			wid++ // Consumed two chars, both ASCII.
		}
		text := b.String()
		if close == "`" {
			for strings.Contains(text, close) {
				open += "`"
				close += "`"
			}
		} else {
			text = markdownEscape(text)
		}
		words[w] = open + text + close + tail
	}
	return strings.Join(words, "")
}

// split is like strings.Fields but also returns the runs of spaces
// and treats inline links as distinct words.
func split(s string) []string {
	var (
		words = make([]string, 0, 10)
		start = 0
	)

	// appendWord appends the string s[start:end] to the words slice.
	// If the word contains the beginning of a link, the non-link portion
	// of the word and the entire link are appended as separate words,
	// and the start index is advanced to the end of the link.
	appendWord := func(end int) {
		if j := strings.Index(s[start:end], "[["); j > -1 {
			if _, l := parseInlineLink(s[start+j:]); l > 0 {
				// Append portion before link, if any.
				if j > 0 {
					words = append(words, s[start:start+j])
				}
				// Append link itself.
				words = append(words, s[start+j:start+j+l])
				// Advance start index to end of link.
				start = start + j + l
				return
			}
		}
		// No link; just add the word.
		words = append(words, s[start:end])
		start = end
	}

	wasSpace := false
	for i, r := range s {
		isSpace := unicode.IsSpace(r)
		if i > start && isSpace != wasSpace {
			appendWord(i)
		}
		wasSpace = isSpace
	}
	for start < len(s) {
		appendWord(len(s))
	}
	return words
}

// parseInlineLink parses an inline link at the start of s, and returns
// a rendered Markdown link and the total length of the raw inline link.
// If no inline link is present, it returns all zeroes.
func parseInlineLink(s string) (link string, length int) {
	if !strings.HasPrefix(s, "[[") {
		return
	}
	end := strings.Index(s, "]]")
	if end == -1 {
		return
	}
	urlEnd := strings.Index(s, "]")
	rawURL := s[2:urlEnd]
	const badURLChars = `<>"{}|\^[] ` + "`" // per RFC2396 section 2.4.3
	if strings.ContainsAny(rawURL, badURLChars) {
		return
	}
	if urlEnd == end {
		simpleURL := ""
		url, err := url.Parse(rawURL)
		if err == nil {
			// If the URL is http://foo.com, drop the http://
			// In other words, render [[http://golang.org]] as:
			//   <a href="http://golang.org">golang.org</a>
			if after, ok := strings.CutPrefix(rawURL, url.Scheme+"://"); ok {
				simpleURL = after
			} else if after, ok := strings.CutPrefix(rawURL, url.Scheme+":"); ok {
				simpleURL = after
			}
		}
		return renderLink(rawURL, simpleURL), end + 2
	}
	if s[urlEnd:urlEnd+2] != "][" {
		return
	}
	text := s[urlEnd+2 : end]
	return renderLink(rawURL, text), end + 2
}

func renderLink(href, text string) string {
	text = font(text)
	if text == "" {
		text = markdownEscape(href)
	}
	return "[" + text + "](" + href + ")"
}
