// Copyright 2018 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 expect

import (
	"fmt"
	"go/ast"
	"go/parser"
	goscanner "go/scanner"
	"go/token"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"text/scanner"

	"golang.org/x/mod/modfile"
)

const commentStart = "@"
const commentStartLen = len(commentStart)

// Identifier is the type for an identifier in a Note argument list.
type Identifier string

// Parse collects all the notes present in a file.
// If content is nil, the filename specified is read and parsed, otherwise the
// content is used and the filename is used for positions and error messages.
// Each comment whose text starts with @ is parsed as a comma-separated
// sequence of notes.
// See the package documentation for details about the syntax of those
// notes.
func Parse(fset *token.FileSet, filename string, content []byte) ([]*Note, error) {
	if content == nil {
		data, err := os.ReadFile(filename)
		if err != nil {
			return nil, err
		}
		content = data
	}

	switch filepath.Ext(filename) {
	case ".s":
		// The assembler uses a custom scanner,
		// but the go/scanner package is close
		// enough: we only want the comments.
		file := fset.AddFile(filename, -1, len(content))
		var scan goscanner.Scanner
		scan.Init(file, content, nil, goscanner.ScanComments)

		var notes []*Note
		for {
			pos, tok, lit := scan.Scan()
			if tok == token.EOF {
				break
			}
			if tok == token.COMMENT {
				text, adjust := getAdjustedNote(lit)
				if text == "" {
					continue
				}
				parsed, err := parse(file, pos+token.Pos(adjust), text)
				if err != nil {
					return nil, err
				}
				notes = append(notes, parsed...)
			}
		}
		return notes, nil

	case ".go":
		// TODO: We should write this in terms of the scanner, like the .s case above.
		// there are ways you can break the parser such that it will not add all the
		// comments to the ast, which may result in files where the tests are silently
		// not run.
		file, err := parser.ParseFile(fset, filename, content, parser.ParseComments|parser.AllErrors|parser.SkipObjectResolution)
		if file == nil {
			return nil, err
		}
		return ExtractGo(fset.File(file.Pos()), file)

	case ".mod":
		file, err := modfile.Parse(filename, content, nil)
		if err != nil {
			return nil, err
		}
		f := fset.AddFile(filename, -1, len(content))
		f.SetLinesForContent(content)
		notes, err := extractModWork(fset, file.Syntax.Stmt)
		if err != nil {
			return nil, err
		}
		// Since modfile.Parse does not return an *ast, we need to add the offset
		// within the file's contents to the file's base relative to the fileset.
		for _, note := range notes {
			note.Pos += token.Pos(f.Base())
		}
		return notes, nil

	case ".work":
		file, err := modfile.ParseWork(filename, content, nil)
		if err != nil {
			return nil, err
		}
		f := fset.AddFile(filename, -1, len(content))
		f.SetLinesForContent(content)
		notes, err := extractModWork(fset, file.Syntax.Stmt)
		if err != nil {
			return nil, err
		}
		// As with go.mod files, we need to compute a synthetic token.Pos.
		for _, note := range notes {
			note.Pos += token.Pos(f.Base())
		}
		return notes, nil
	}
	return nil, nil
}

// extractModWork collects all the notes present in a go.mod file or go.work
// file, by way of the shared modfile.Expr statement node.
//
// Each comment whose text starts with @ is parsed as a comma-separated
// sequence of notes.
// See the package documentation for details about the syntax of those
// notes.
// Only allow notes to appear with the following format: "//@mark()" or // @mark()
func extractModWork(fset *token.FileSet, exprs []modfile.Expr) ([]*Note, error) {
	var notes []*Note
	for _, stmt := range exprs {
		comment := stmt.Comment()
		if comment == nil {
			continue
		}
		var allComments []modfile.Comment
		allComments = append(allComments, comment.Before...)
		allComments = append(allComments, comment.Suffix...)
		for _, cmt := range allComments {
			text, adjust := getAdjustedNote(cmt.Token)
			if text == "" {
				continue
			}
			pos := token.Pos(int(cmt.Start.Byte) + adjust)
			parsed, err := parse(fset.File(pos), pos, text)
			if err != nil {
				return nil, err
			}
			notes = append(notes, parsed...)
		}
	}
	return notes, nil
}

// ExtractGo collects all the notes present in an AST.
// Each comment whose text starts with @ is parsed as a comma-separated
// sequence of notes.
// See the package documentation for details about the syntax of those
// notes.
func ExtractGo(tokFile *token.File, file *ast.File) ([]*Note, error) {
	var notes []*Note
	for _, g := range file.Comments {
		for _, c := range g.List {
			text, adjust := getAdjustedNote(c.Text)
			if text == "" {
				continue
			}
			parsed, err := parse(tokFile, token.Pos(int(c.Pos())+adjust), text)
			if err != nil {
				return nil, err
			}
			notes = append(notes, parsed...)
		}
	}
	return notes, nil
}

func getAdjustedNote(text string) (string, int) {
	if strings.HasPrefix(text, "/*") {
		text = strings.TrimSuffix(text, "*/")
	}
	text = text[2:] // remove "//" or "/*" prefix

	// Allow notes to appear within comments.
	// For example:
	// "// //@mark()" is valid.
	// "// @mark()" is not valid.
	// "// /*@mark()*/" is not valid.
	var adjust int
	if i := strings.Index(text, commentStart); i > 2 {
		// Get the text before the commentStart.
		pre := text[i-2 : i]
		if pre != "//" {
			return "", 0
		}
		text = text[i:]
		adjust = i
	}
	if !strings.HasPrefix(text, commentStart) {
		return "", 0
	}
	text = text[commentStartLen:]
	return text, commentStartLen + adjust + 1
}

const invalidToken rune = 0

type tokens struct {
	scanner scanner.Scanner
	current rune
	err     error
	base    token.Pos
}

func (t *tokens) Init(base token.Pos, text string) *tokens {
	t.base = base
	t.scanner.Init(strings.NewReader(text))
	t.scanner.Mode = scanner.GoTokens
	t.scanner.Whitespace ^= 1 << '\n' // don't skip new lines
	t.scanner.Error = func(s *scanner.Scanner, msg string) {
		t.Errorf("%v", msg)
	}
	return t
}

func (t *tokens) Consume() string {
	t.current = invalidToken
	return t.scanner.TokenText()
}

func (t *tokens) Token() rune {
	if t.err != nil {
		return scanner.EOF
	}
	if t.current == invalidToken {
		t.current = t.scanner.Scan()
	}
	return t.current
}

func (t *tokens) Skip(r rune) int {
	i := 0
	for t.Token() == '\n' {
		t.Consume()
		i++
	}
	return i
}

func (t *tokens) TokenString() string {
	return scanner.TokenString(t.Token())
}

func (t *tokens) Pos() token.Pos {
	return t.base + token.Pos(t.scanner.Position.Offset)
}

func (t *tokens) Errorf(msg string, args ...any) {
	if t.err != nil {
		return
	}
	t.err = fmt.Errorf(msg, args...)
}

func parse(tokFile *token.File, base token.Pos, text string) ([]*Note, error) {
	t := new(tokens).Init(base, text)
	notes := parseComment(t)
	if t.err != nil {
		return nil, fmt.Errorf("%v: %s", tokFile.Position(t.Pos()), t.err)
	}
	return notes, nil
}

func parseComment(t *tokens) []*Note {
	var notes []*Note
	for {
		t.Skip('\n')
		switch t.Token() {
		case scanner.EOF:
			return notes
		case scanner.Ident:
			notes = append(notes, parseNote(t))
		default:
			t.Errorf("unexpected %s parsing comment, expect identifier", t.TokenString())
			return nil
		}
		switch t.Token() {
		case scanner.EOF:
			return notes
		case ',', '\n':
			t.Consume()
		default:
			t.Errorf("unexpected %s parsing comment, expect separator", t.TokenString())
			return nil
		}
	}
}

func parseNote(t *tokens) *Note {
	n := &Note{
		Pos:  t.Pos(),
		Name: t.Consume(),
	}

	switch t.Token() {
	case ',', '\n', scanner.EOF:
		// no argument list present
		return n
	case '(':
		n.Args, n.NamedArgs = parseArgumentList(t)
		return n
	default:
		t.Errorf("unexpected %s parsing note", t.TokenString())
		return nil
	}
}

func parseArgumentList(t *tokens) (args []any, named map[string]any) {
	args = []any{} // @name() is represented by a non-nil empty slice.
	t.Consume()    // '('
	t.Skip('\n')
	for t.Token() != ')' {
		name, arg := parseArgument(t)
		if name != "" {
			// f(k=v)
			if named == nil {
				named = make(map[string]any)
			}
			if _, dup := named[name]; dup {
				t.Errorf("duplicate named argument %q", name)
				return nil, nil
			}
			named[name] = arg
		} else {
			// f(v)
			if named != nil {
				t.Errorf("positional argument follows named argument")
				return nil, nil
			}
			args = append(args, arg)
		}
		if t.Token() != ',' {
			break
		}
		t.Consume()
		t.Skip('\n')
	}
	if t.Token() != ')' {
		t.Errorf("unexpected %s parsing argument list", t.TokenString())
		return nil, nil
	}
	t.Consume() // ')'
	return args, named
}

// parseArgument returns the value of the argument ("f(value)"),
// and its name if named "f(name=value)".
func parseArgument(t *tokens) (name string, value any) {
again:
	switch t.Token() {
	case scanner.Ident:
		v := t.Consume()
		switch v {
		case "true":
			value = true
		case "false":
			value = false
		case "nil":
			value = nil
		case "re":
			if t.Token() != scanner.String && t.Token() != scanner.RawString {
				t.Errorf("re must be followed by string, got %s", t.TokenString())
				return
			}
			pattern, _ := strconv.Unquote(t.Consume()) // can't fail
			re, err := regexp.Compile(pattern)
			if err != nil {
				t.Errorf("invalid regular expression %s: %v", pattern, err)
				return
			}
			value = re
		default:
			// f(name=value)?
			if name == "" && t.Token() == '=' {
				t.Consume() // '='
				name = v
				goto again
			}
			value = Identifier(v)
		}

	case scanner.String, scanner.RawString:
		value, _ = strconv.Unquote(t.Consume()) // can't fail

	case scanner.Int:
		s := t.Consume()
		v, err := strconv.ParseInt(s, 0, 0)
		if err != nil {
			t.Errorf("cannot convert %v to int: %v", s, err)
		}
		value = v

	case scanner.Float:
		s := t.Consume()
		v, err := strconv.ParseFloat(s, 64)
		if err != nil {
			t.Errorf("cannot convert %v to float: %v", s, err)
		}
		value = v

	case '-':
		t.Consume()
		switch t.Token() {
		case scanner.Int:
			s := "-" + t.Consume()
			v, err := strconv.ParseInt(s, 0, 0)
			if err != nil {
				t.Errorf("cannot convert %v to int: %v", s, err)
			}
			value = v

		case scanner.Float:
			s := "-" + t.Consume()
			v, err := strconv.ParseFloat(s, 64)
			if err != nil {
				t.Errorf("cannot convert %v to float: %v", s, err)
			}
			value = v

		default:
			t.Errorf("unexpected '-' not followed by number, got %s", t.TokenString())
		}

	case scanner.Char:
		t.Errorf("unexpected char literal %s", t.Consume())

	default:
		t.Errorf("unexpected %s parsing argument", t.TokenString())
	}
	return
}
