// Copyright 2022 The Dawn & Tint Authors
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
//  1. Redistributions of source code must retain the above copyright notice, this
//     list of conditions and the following disclaimer.
//
//  2. Redistributions in binary form must reproduce the above copyright notice,
//     this list of conditions and the following disclaimer in the documentation
//     and/or other materials provided with the distribution.
//
//  3. Neither the name of the copyright holder nor the names of its
//     contributors may be used to endorse or promote products derived from
//     this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

package expectations

import (
	"fmt"
	"strings"

	"dawn.googlesource.com/dawn/tools/src/cts/result"
)

const (
	tagHeaderStart = `BEGIN TAG HEADER`
	tagHeaderEnd   = `END TAG HEADER`
)

// Parse parses an expectations file, returning the Content
func Parse(path, body string) (Content, error) {
	// Normalize CRLF -> LF
	body = strings.ReplaceAll(body, "\r\n", "\n")

	// LineType is an enumerator classifying the 'type' of the line.
	type LineType int
	const (
		comment     LineType = iota // The line starts with the '#'
		expectation                 // The line declares an expectation
		blank                       // The line is blank
	)

	// classifyLine returns the LineType for the given line
	classifyLine := func(line string) LineType {
		line = strings.TrimSpace(line)
		switch {
		case line == "":
			return blank
		case strings.HasPrefix(line, "#"):
			return comment
		default:
			return expectation
		}
	}

	content := Content{} // The output content

	var pending Chunk // The current Chunk being parsed

	// flush completes the current chunk, appending it to 'content'
	flush := func() {
		parseTags(&content.Tags, pending.Comments)
		content.Chunks = append(content.Chunks, pending)
		pending = Chunk{}
	}

	lastLineType := blank                         // The type of the last parsed line
	for i, l := range strings.Split(body, "\n") { // For each line...
		lineIdx := i + 1 // line index
		lineType := classifyLine(l)

		// Compare the new line type to the last.
		// Flush the pending chunk if needed.
		if i > 0 {
			switch {
			case
				lastLineType != blank && lineType == blank,             // !blank -> blank
				lastLineType == expectation && lineType != expectation: // expectation -> comment
				flush()
			}
		}

		lastLineType = lineType

		// Handle blank lines and comments.
		switch lineType {
		case blank:
			continue
		case comment:
			pending.Comments = append(pending.Comments, l)
			continue
		}

		// Below this point, we're dealing with an expectation

		// Split the line by whitespace to form a list of tokens
		type Token struct {
			str        string
			start, end int // line offsets (0-based)
		}
		tokens := []Token{}
		if len(l) > 0 { // Parse the tokens
			inToken, s := false, 0
			for i, c := range l {
				if c == ' ' {
					if inToken {
						tokens = append(tokens, Token{l[s:i], s, i})
						inToken = false
					}
				} else if !inToken {
					s = i
					inToken = true
				}
			}
			if inToken {
				tokens = append(tokens, Token{l[s:], s, len(l)})
			}
		}

		// syntaxErr is a helper for returning a SyntaxError with the current
		// line and column index.
		syntaxErr := func(at Token, msg string) error {
			columnIdx := at.start + 1
			if columnIdx == 1 {
				columnIdx = len(l) + 1
			}
			return fmt.Errorf("%v:%v:%v error: %v", path, lineIdx, columnIdx, msg)
		}

		// peek returns the next token without consuming it.
		// If there are no more tokens then an empty Token is returned.
		peek := func() Token {
			if len(tokens) > 0 {
				return tokens[0]
			}
			return Token{}
		}

		// next returns the next token, consuming it and incrementing the
		// column index.
		// If there are no more tokens then an empty Token is returned.
		next := func() Token {
			if len(tokens) > 0 {
				tok := tokens[0]
				tokens = tokens[1:]
				return tok
			}
			return Token{}
		}

		match := func(str string) bool {
			if peek().str != str {
				return false
			}
			next()
			return true
		}

		// tags parses a [ tag ] block.
		tags := func(use string) (result.Tags, error) {
			if !match("[") {
				return result.Tags{}, nil
			}
			out := result.NewTags()
			for {
				t := next()
				switch t.str {
				case "]":
					return out, nil
				case "":
					return result.Tags{}, syntaxErr(t, "expected ']' for "+use)
				default:
					out.Add(t.str)
				}
			}
		}

		// Parse the optional bug
		var bug string
		if strings.HasPrefix(peek().str, "crbug.com") {
			bug = next().str
		}

		// Parse the optional test tags
		testTags, err := tags("tags")
		if err != nil {
			return Content{}, err
		}

		// Parse the query
		if t := peek(); t.str == "" || t.str[0] == '#' || t.str[0] == '[' {
			return Content{}, syntaxErr(t, "expected test query")
		}
		query := next().str

		// Parse the expected status
		if t := peek(); !strings.HasPrefix(t.str, "[") {
			return Content{}, syntaxErr(t, "expected status")
		}
		status, err := tags("status")
		if err != nil {
			return Content{}, err
		}

		// Parse any optional trailing comment
		comment := ""
		if t := peek(); strings.HasPrefix(t.str, "#") {
			comment = l[t.start:]
		}

		// Append the expectation to the list.
		pending.Expectations = append(pending.Expectations, Expectation{
			Line:    lineIdx,
			Bug:     bug,
			Tags:    testTags,
			Query:   query,
			Status:  status.List(),
			Comment: comment,
		})
	}

	if lastLineType != blank {
		flush()
	}

	return content, nil
}

// parseTags parses the tag information found between tagHeaderStart and
// tagHeaderEnd comments.
func parseTags(tags *Tags, lines []string) {
	// Flags for whether we're currently parsing a TAG HEADER and whether we're
	// also within a tag-set.
	inTagsHeader, inTagSet := false, false
	tagSet := TagSet{} // The currently parsed tag-set
	for _, line := range lines {
		line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "#"))
		if strings.Contains(line, tagHeaderStart) {
			if tags.ByName == nil {
				*tags = Tags{
					ByName: map[string]TagSetAndPriority{},
					Sets:   []TagSet{},
				}
			}
			inTagsHeader = true
			continue
		}
		if strings.Contains(line, tagHeaderEnd) {
			return // Reached the end of the TAG HEADER
		}
		if !inTagsHeader {
			continue // Still looking for a tagHeaderStart
		}

		// Below this point, we're in a TAG HEADER.
		tokens := removeEmpty(strings.Split(line, " "))
		for len(tokens) > 0 {
			if inTagSet {
				// Parsing tags in a tag-set (between the '[' and ']')
				if tokens[0] == "]" {
					// End of the tag-set.
					tags.Sets = append(tags.Sets, tagSet)
					inTagSet = false
					break
				} else {
					// Still inside the tag-set. Consume the tag.
					tag := tokens[0]
					tags.ByName[tag] = TagSetAndPriority{
						Set:      tagSet.Name,
						Priority: len(tagSet.Tags),
					}
					tagSet.Tags.Add(tag)
				}
				tokens = tokens[1:]
			} else {
				// Outside of tag-set. Scan for 'tags: ['
				if len(tokens) > 2 && tokens[0] == "tags:" && tokens[1] == "[" {
					inTagSet = true
					tagSet.Tags = result.NewTags()
					tokens = tokens[2:] // Skip 'tags:' and '['
				} else {
					// Tag set names are on their own line.
					// Remember the content of the line, in case the next line
					// starts a tag-set.
					tagSet.Name = strings.Join(tokens, " ")
					break
				}
			}
		}
	}
}

// removeEmpty returns the list of strings with all empty strings removed.
func removeEmpty(in []string) []string {
	out := make([]string, 0, len(in))
	for _, s := range in {
		if s != "" {
			out = append(out, s)
		}
	}
	return out
}
