package loader

import (
	"bytes"
	"crypto/sha512"
	"encoding/json"
	"errors"
	"fmt"
	"go/ast"
	"go/constant"
	"go/parser"
	"go/scanner"
	"go/token"
	"go/types"
	"io"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"unicode"

	"github.com/tinygo-org/tinygo/cgo"
	"github.com/tinygo-org/tinygo/compileopts"
	"github.com/tinygo-org/tinygo/goenv"
)

var initFileVersions = func(info *types.Info) {}

// Program holds all packages and some metadata about the program as a whole.
type Program struct {
	config      *compileopts.Config
	typeChecker types.Config
	goroot      string // synthetic GOROOT
	workingDir  string

	Packages map[string]*Package
	sorted   []*Package
	fset     *token.FileSet

	// Information obtained during parsing.
	LDFlags []string
}

// PackageJSON is a subset of the JSON struct returned from `go list`.
type PackageJSON struct {
	Dir        string
	ImportPath string
	Name       string
	ForTest    string
	Root       string
	Module     struct {
		Path      string
		Main      bool
		Dir       string
		GoMod     string
		GoVersion string
	}

	// Source files
	GoFiles  []string
	CgoFiles []string
	CFiles   []string

	// Embedded files
	EmbedFiles []string

	// Dependency information
	Imports   []string
	ImportMap map[string]string

	// Error information
	Error *struct {
		ImportStack []string
		Pos         string
		Err         string
	}
}

// Package holds a loaded package, its imports, and its parsed files.
type Package struct {
	PackageJSON

	program      *Program
	Files        []*ast.File
	FileHashes   map[string][]byte
	CFlags       []string // CFlags used during CGo preprocessing (only set if CGo is used)
	CGoHeaders   []string // text above 'import "C"' lines
	EmbedGlobals map[string][]*EmbedFile
	Pkg          *types.Package
	info         types.Info
}

type EmbedFile struct {
	Name      string
	Size      uint64
	Hash      string // hash of the file (as a hex string)
	NeedsData bool   // true if this file is embedded as a byte slice
	Data      []byte // contents of this file (only if NeedsData is set)
}

// Load loads the given package with all dependencies (including the runtime
// package). Call .Parse() afterwards to parse all Go files (including CGo
// processing, if necessary).
func Load(config *compileopts.Config, inputPkg string, typeChecker types.Config) (*Program, error) {
	goroot, err := GetCachedGoroot(config)
	if err != nil {
		return nil, err
	}
	var wd string
	if config.Options.Directory != "" {
		wd = config.Options.Directory
	} else {
		wd, err = os.Getwd()
		if err != nil {
			return nil, err
		}
	}
	p := &Program{
		config:      config,
		typeChecker: typeChecker,
		goroot:      goroot,
		workingDir:  wd,
		Packages:    make(map[string]*Package),
		fset:        token.NewFileSet(),
	}

	// List the dependencies of this package, in raw JSON format.
	extraArgs := []string{"-json", "-deps", "-e"}
	if config.TestConfig.CompileTestBinary {
		extraArgs = append(extraArgs, "-test")
	}
	cmd, err := List(config, extraArgs, []string{inputPkg})
	if err != nil {
		return nil, err
	}
	buf := &bytes.Buffer{}
	cmd.Stdout = buf
	cmd.Stderr = os.Stderr
	err = cmd.Run()
	if err != nil {
		if exitErr, ok := err.(*exec.ExitError); ok {
			os.Exit(exitErr.ExitCode())
		}
		return nil, fmt.Errorf("failed to run `go list`: %s", err)
	}

	// Parse the returned json from `go list`.
	decoder := json.NewDecoder(buf)
	var pkgErrors []error
	for {
		pkg := &Package{
			program:      p,
			FileHashes:   make(map[string][]byte),
			EmbedGlobals: make(map[string][]*EmbedFile),
			info: types.Info{
				Types:      make(map[ast.Expr]types.TypeAndValue),
				Instances:  make(map[*ast.Ident]types.Instance),
				Defs:       make(map[*ast.Ident]types.Object),
				Uses:       make(map[*ast.Ident]types.Object),
				Implicits:  make(map[ast.Node]types.Object),
				Scopes:     make(map[ast.Node]*types.Scope),
				Selections: make(map[*ast.SelectorExpr]*types.Selection),
			},
		}
		err := decoder.Decode(&pkg.PackageJSON)
		if err != nil {
			if err == io.EOF {
				break
			}
			return nil, err
		}
		if pkg.Error != nil {
			// There was an error while importing (for example, a circular
			// dependency).
			pos := token.Position{}
			fields := strings.Split(pkg.Error.Pos, ":")
			if len(fields) >= 2 {
				// There is some file/line/column information.
				if n, err := strconv.Atoi(fields[len(fields)-2]); err == nil {
					// Format: filename.go:line:column
					pos.Filename = strings.Join(fields[:len(fields)-2], ":")
					pos.Line = n
					pos.Column, _ = strconv.Atoi(fields[len(fields)-1])
				} else {
					// Format: filename.go:line
					pos.Filename = strings.Join(fields[:len(fields)-1], ":")
					pos.Line, _ = strconv.Atoi(fields[len(fields)-1])
				}
				if abs, err := filepath.Abs(pos.Filename); err == nil {
					// Make the path absolute, so that error messages will be
					// prettier (it will be turned back into a relative path
					// when printing the error).
					pos.Filename = abs
				}
				pos.Filename = p.getOriginalPath(pos.Filename)
			}
			err := scanner.Error{
				Pos: pos,
				Msg: pkg.Error.Err,
			}
			if len(pkg.Error.ImportStack) != 0 {
				pkgErrors = append(pkgErrors, Error{
					ImportStack: pkg.Error.ImportStack,
					Err:         err,
				})
				continue
			}
			return nil, err
		}
		if config.TestConfig.CompileTestBinary {
			// When creating a test binary, `go list` will list two or three
			// packages used for testing the package. The first is the original
			// package as if it were built normally, the second is the same
			// package but with the *_test.go files included. A possible third
			// may be included for _test packages (such as math_test), used to
			// test the external API with no access to internal functions.
			// All packages that are necessary for testing (including the to be
			// tested package with *_test.go files, but excluding the original
			// unmodified package) have a suffix added to the import path, for
			// example the math package has import path "math [math.test]" and
			// test dependencies such as fmt will have an import path of the
			// form "fmt [math.test]".
			// The code below removes this suffix, and if this results in a
			// duplicate (which happens with the to-be-tested package without
			// *.test.go files) the previous package is removed from the list of
			// packages included in this build.
			// This is necessary because the change in import paths results in
			// breakage to //go:linkname. Additionally, the duplicated package
			// slows down the build and so is best removed.
			if pkg.ForTest != "" && strings.HasSuffix(pkg.ImportPath, " ["+pkg.ForTest+".test]") {
				newImportPath := pkg.ImportPath[:len(pkg.ImportPath)-len(" ["+pkg.ForTest+".test]")]
				if _, ok := p.Packages[newImportPath]; ok {
					// Delete the previous package (that this package overrides).
					delete(p.Packages, newImportPath)
					for i, pkg := range p.sorted {
						if pkg.ImportPath == newImportPath {
							p.sorted = append(p.sorted[:i], p.sorted[i+1:]...) // remove element from slice
							break
						}
					}
				}
				pkg.ImportPath = newImportPath
			}
		}
		p.sorted = append(p.sorted, pkg)
		p.Packages[pkg.ImportPath] = pkg
	}

	if len(pkgErrors) != 0 {
		// TODO: use errors.Join in Go 1.20.
		return nil, Errors{
			Errs: pkgErrors,
		}
	}

	if config.TestConfig.CompileTestBinary && !strings.HasSuffix(p.sorted[len(p.sorted)-1].ImportPath, ".test") {
		// Trying to compile a test binary but there are no test files in this
		// package.
		return p, NoTestFilesError{p.sorted[len(p.sorted)-1].ImportPath}
	}

	return p, nil
}

// getOriginalPath looks whether this path is in the generated GOROOT and if so,
// replaces the path with the original path (in GOROOT or TINYGOROOT). Otherwise
// the input path is returned.
func (p *Program) getOriginalPath(path string) string {
	originalPath := path
	if strings.HasPrefix(path, p.goroot+string(filepath.Separator)) {
		// If this file is part of the synthetic GOROOT, try to infer the
		// original path.
		relpath := path[len(filepath.Join(p.goroot, "src"))+1:]
		realgorootPath := filepath.Join(goenv.Get("GOROOT"), "src", relpath)
		if _, err := os.Stat(realgorootPath); err == nil {
			originalPath = realgorootPath
		}
		maybeInTinyGoRoot := false
		for prefix := range pathsToOverride(p.config.GoMinorVersion, needsSyscallPackage(p.config.BuildTags())) {
			if runtime.GOOS == "windows" {
				prefix = strings.ReplaceAll(prefix, "/", "\\")
			}
			if !strings.HasPrefix(relpath, prefix) {
				continue
			}
			maybeInTinyGoRoot = true
		}
		if maybeInTinyGoRoot {
			tinygoPath := filepath.Join(goenv.Get("TINYGOROOT"), "src", relpath)
			if _, err := os.Stat(tinygoPath); err == nil {
				originalPath = tinygoPath
			}
		}
	}
	return originalPath
}

// Sorted returns a list of all packages, sorted in a way that no packages come
// before the packages they depend upon.
func (p *Program) Sorted() []*Package {
	return p.sorted
}

// MainPkg returns the last package in the Sorted() slice. This is the main
// package of the program.
func (p *Program) MainPkg() *Package {
	return p.sorted[len(p.sorted)-1]
}

// Parse parses all packages and typechecks them.
//
// The returned error may be an Errors error, which contains a list of errors.
//
// Idempotent.
func (p *Program) Parse() error {
	// Parse all packages.
	// TODO: do this in parallel.
	for _, pkg := range p.sorted {
		err := pkg.Parse()
		if err != nil {
			return err
		}
	}

	// Typecheck all packages.
	for _, pkg := range p.sorted {
		err := pkg.Check()
		if err != nil {
			return err
		}
	}

	return nil
}

// OriginalDir returns the real directory name. It is the same as p.Dir except
// that if it is part of the cached GOROOT, its real location is returned.
func (p *Package) OriginalDir() string {
	return strings.TrimSuffix(p.program.getOriginalPath(p.Dir+string(os.PathSeparator)), string(os.PathSeparator))
}

// parseFile is a wrapper around parser.ParseFile.
func (p *Package) parseFile(path string, mode parser.Mode) (*ast.File, error) {
	originalPath := p.program.getOriginalPath(path)
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	sum := sha512.Sum512_224(data)
	p.FileHashes[originalPath] = sum[:]
	return parser.ParseFile(p.program.fset, originalPath, data, mode)
}

// Parse parses and typechecks this package.
//
// Idempotent.
func (p *Package) Parse() error {
	if len(p.Files) != 0 {
		return nil // nothing to do (?)
	}

	// Load the AST.
	if p.ImportPath == "unsafe" {
		// Special case for the unsafe package, which is defined internally by
		// the types package.
		p.Pkg = types.Unsafe
		return nil
	}

	files, err := p.parseFiles()
	if err != nil {
		return err
	}
	p.Files = files

	return nil
}

// Check runs the package through the typechecker. The package must already be
// loaded and all dependencies must have been checked already.
//
// Idempotent.
func (p *Package) Check() error {
	if p.Pkg != nil {
		return nil // already typechecked
	}

	// Prepare some state used during type checking.
	var typeErrors []error
	checker := p.program.typeChecker // make a copy, because it will be modified
	checker.Error = func(err error) {
		typeErrors = append(typeErrors, err)
	}
	checker.Importer = p
	if p.Module.GoVersion != "" {
		// Setting the Go version for a module makes sure the type checker
		// errors out on language features not supported in that particular
		// version.
		checker.GoVersion = "go" + p.Module.GoVersion
	} else {
		// Version is not known, so use the currently installed Go version.
		// This is needed for `tinygo run` for example.
		// Normally we'd use goenv.GorootVersionString(), but for compatibility
		// with Go 1.20 and below we need a version in the form of "go1.12" (no
		// patch version).
		major, minor, err := goenv.GetGorootVersion()
		if err != nil {
			return err
		}
		checker.GoVersion = fmt.Sprintf("go%d.%d", major, minor)
	}
	initFileVersions(&p.info)

	// Do typechecking of the package.
	packageName := p.ImportPath
	if p == p.program.MainPkg() {
		if p.Name != "main" {
			return Errors{p, []error{
				scanner.Error{
					Pos: p.program.fset.Position(p.Files[0].Name.Pos()),
					Msg: fmt.Sprintf("expected main package to have name \"main\", not %#v", p.Name),
				},
			}}
		}
		packageName = "main"
	}
	typesPkg, err := checker.Check(packageName, p.program.fset, p.Files, &p.info)
	if err != nil {
		if err, ok := err.(Errors); ok {
			return err
		}
		if len(typeErrors) != 0 {
			// Got type errors, so return them.
			return Errors{p, typeErrors}
		}
		// This can happen in some weird cases.
		// The only case I know is when compiling a Go 1.23 program, with a
		// TinyGo version that supports Go 1.23 but is compiled using Go 1.22.
		// So this should be pretty rare.
		return Errors{p, []error{err}}
	}
	p.Pkg = typesPkg

	p.extractEmbedLines(checker.Error)
	if len(typeErrors) != 0 {
		return Errors{p, typeErrors}
	}

	return nil
}

// parseFiles parses the loaded list of files and returns this list.
func (p *Package) parseFiles() ([]*ast.File, error) {
	var files []*ast.File
	var fileErrs []error

	// Parse all files (including CgoFiles).
	parseFile := func(file string) {
		if !filepath.IsAbs(file) {
			file = filepath.Join(p.Dir, file)
		}
		f, err := p.parseFile(file, parser.ParseComments)
		if err != nil {
			fileErrs = append(fileErrs, err)
			return
		}
		files = append(files, f)
	}
	for _, file := range p.GoFiles {
		parseFile(file)
	}
	for _, file := range p.CgoFiles {
		parseFile(file)
	}

	// Do CGo processing.
	// This is done when there are any CgoFiles at all. In that case, len(files)
	// should be non-zero. However, if len(GoFiles) == 0 and len(CgoFiles) == 1
	// and there is a syntax error in a CGo file, len(files) may be 0. Don't try
	// to call cgo.Process in that case as it will only cause issues.
	if len(p.CgoFiles) != 0 && len(files) != 0 {
		var initialCFlags []string
		initialCFlags = append(initialCFlags, p.program.config.CFlags(true)...)
		initialCFlags = append(initialCFlags, "-I"+p.Dir)
		generated, headerCode, cflags, ldflags, accessedFiles, errs := cgo.Process(files, p.program.workingDir, p.ImportPath, p.program.fset, initialCFlags)
		p.CFlags = append(initialCFlags, cflags...)
		p.CGoHeaders = headerCode
		for path, hash := range accessedFiles {
			p.FileHashes[path] = hash
		}
		if errs != nil {
			fileErrs = append(fileErrs, errs...)
		}
		files = append(files, generated...)
		p.program.LDFlags = append(p.program.LDFlags, ldflags...)
	}

	// Only return an error after CGo processing, so that errors in parsing and
	// CGo can be reported together.
	if len(fileErrs) != 0 {
		return nil, Errors{p, fileErrs}
	}

	return files, nil
}

// extractEmbedLines finds all //go:embed lines in the package and matches them
// against EmbedFiles from `go list`.
func (p *Package) extractEmbedLines(addError func(error)) {
	for _, file := range p.Files {
		// Check for an `import "embed"` line at the start of the file.
		// //go:embed lines are only valid if the given file itself imports the
		// embed package. It is not valid if it is only imported in a separate
		// Go file.
		hasEmbed := false
		for _, importSpec := range file.Imports {
			if importSpec.Path.Value == `"embed"` {
				hasEmbed = true
			}
		}

		for _, decl := range file.Decls {
			switch decl := decl.(type) {
			case *ast.GenDecl:
				if decl.Tok != token.VAR {
					continue
				}
				for _, spec := range decl.Specs {
					spec := spec.(*ast.ValueSpec)
					var doc *ast.CommentGroup
					if decl.Lparen == token.NoPos {
						// Plain 'var' declaration, like:
						//   //go:embed hello.txt
						//   var hello string
						doc = decl.Doc
					} else {
						// Bigger 'var' declaration like:
						//   var (
						//       //go:embed hello.txt
						//       hello string
						//   )
						doc = spec.Doc
					}
					if doc == nil {
						continue
					}

					// Look for //go:embed comments.
					var allPatterns []string
					for _, comment := range doc.List {
						if comment.Text != "//go:embed" && !strings.HasPrefix(comment.Text, "//go:embed ") {
							continue
						}
						if !hasEmbed {
							addError(types.Error{
								Fset: p.program.fset,
								Pos:  comment.Pos() + 2,
								Msg:  "//go:embed only allowed in Go files that import \"embed\"",
							})
							// Continue, because otherwise we might run into
							// issues below.
							continue
						}
						patterns, err := p.parseGoEmbed(comment.Text[len("//go:embed"):], comment.Slash)
						if err != nil {
							addError(err)
							continue
						}
						if len(patterns) == 0 {
							addError(types.Error{
								Fset: p.program.fset,
								Pos:  comment.Pos() + 2,
								Msg:  "usage: //go:embed pattern...",
							})
							continue
						}
						for _, pattern := range patterns {
							// Check that the pattern is well-formed.
							// It must be valid: the Go toolchain has already
							// checked for invalid patterns. But let's check
							// anyway to be sure.
							if _, err := path.Match(pattern, ""); err != nil {
								addError(types.Error{
									Fset: p.program.fset,
									Pos:  comment.Pos(),
									Msg:  "invalid pattern syntax",
								})
								continue
							}
							allPatterns = append(allPatterns, pattern)
						}
					}

					if len(allPatterns) != 0 {
						// This is a //go:embed global. Do a few more checks.
						if len(spec.Names) != 1 {
							addError(types.Error{
								Fset: p.program.fset,
								Pos:  spec.Names[1].NamePos,
								Msg:  "//go:embed cannot apply to multiple vars",
							})
						}
						if spec.Values != nil {
							addError(types.Error{
								Fset: p.program.fset,
								Pos:  spec.Values[0].Pos(),
								Msg:  "//go:embed cannot apply to var with initializer",
							})
						}
						globalName := spec.Names[0].Name
						globalType := p.Pkg.Scope().Lookup(globalName).Type()
						valid, byteSlice := isValidEmbedType(globalType)
						if !valid {
							addError(types.Error{
								Fset: p.program.fset,
								Pos:  spec.Type.Pos(),
								Msg:  "//go:embed cannot apply to var of type " + globalType.String(),
							})
						}

						// Match all //go:embed patterns against the embed files
						// provided by `go list`.
						for _, name := range p.EmbedFiles {
							for _, pattern := range allPatterns {
								if matchPattern(pattern, name) {
									p.EmbedGlobals[globalName] = append(p.EmbedGlobals[globalName], &EmbedFile{
										Name:      name,
										NeedsData: byteSlice,
									})
									break
								}
							}
						}
					}
				}
			}
		}
	}
}

// matchPattern returns true if (and only if) the given pattern would match the
// filename. The pattern could also match a parent directory of name, in which
// case hidden files do not match.
func matchPattern(pattern, name string) bool {
	// Match this file.
	matched, _ := path.Match(pattern, name)
	if matched {
		return true
	}

	// Match parent directories.
	dir := name
	for {
		dir, _ = path.Split(dir)
		if dir == "" {
			return false
		}
		dir = path.Clean(dir)
		if matched, _ := path.Match(pattern, dir); matched {
			// Pattern matches the directory.
			suffix := name[len(dir):]
			if strings.Contains(suffix, "/_") || strings.Contains(suffix, "/.") {
				// Pattern matches a hidden file.
				// Hidden files are included when listed directly as a
				// pattern, but not when they are part of a directory tree.
				// Source:
				// > If a pattern names a directory, all files in the
				// > subtree rooted at that directory are embedded
				// > (recursively), except that files with names beginning
				// > with ‘.’ or ‘_’ are excluded.
				return false
			}
			return true
		}
	}
}

// parseGoEmbed is like strings.Fields but for a //go:embed line. It parses
// regular fields and quoted fields (that may contain spaces).
func (p *Package) parseGoEmbed(args string, pos token.Pos) (patterns []string, err error) {
	args = strings.TrimSpace(args)
	initialLen := len(args)
	for args != "" {
		patternPos := pos + token.Pos(initialLen-len(args))
		switch args[0] {
		case '`', '"', '\\':
			// Parse the next pattern using the Go scanner.
			// This is perhaps a bit overkill, but it does correctly implement
			// parsing of the various Go strings.
			var sc scanner.Scanner
			fset := &token.FileSet{}
			file := fset.AddFile("", 0, len(args))
			sc.Init(file, []byte(args), nil, 0)
			_, tok, lit := sc.Scan()
			if tok != token.STRING || sc.ErrorCount != 0 {
				// Calculate start of token
				return nil, types.Error{
					Fset: p.program.fset,
					Pos:  patternPos,
					Msg:  "invalid quoted string in //go:embed",
				}
			}
			pattern := constant.StringVal(constant.MakeFromLiteral(lit, tok, 0))
			patterns = append(patterns, pattern)
			args = strings.TrimLeftFunc(args[len(lit):], unicode.IsSpace)
		default:
			// The value is just a regular value.
			// Split it at the first white space.
			index := strings.IndexFunc(args, unicode.IsSpace)
			if index < 0 {
				index = len(args)
			}
			pattern := args[:index]
			patterns = append(patterns, pattern)
			args = strings.TrimLeftFunc(args[len(pattern):], unicode.IsSpace)
		}
		if _, err := path.Match(patterns[len(patterns)-1], ""); err != nil {
			return nil, types.Error{
				Fset: p.program.fset,
				Pos:  patternPos,
				Msg:  "invalid pattern syntax",
			}
		}
	}
	return patterns, nil
}

// isValidEmbedType returns whether the given Go type can be used as a
// //go:embed type. This is only true for embed.FS, strings, and byte slices.
// The second return value indicates that this is a byte slice, and therefore
// the contents of the file needs to be passed to the compiler.
func isValidEmbedType(typ types.Type) (valid, byteSlice bool) {
	if typ.Underlying() == types.Typ[types.String] {
		// string type
		return true, false
	}
	if sliceType, ok := typ.Underlying().(*types.Slice); ok {
		if elemType, ok := sliceType.Elem().Underlying().(*types.Basic); ok && elemType.Kind() == types.Byte {
			// byte slice type
			return true, true
		}
	}
	if namedType, ok := typ.(*types.Named); ok && namedType.String() == "embed.FS" {
		// embed.FS type
		return true, false
	}
	return false, false
}

// Import implements types.Importer. It loads and parses packages it encounters
// along the way, if needed.
func (p *Package) Import(to string) (*types.Package, error) {
	if to == "unsafe" {
		return types.Unsafe, nil
	}
	if newTo, ok := p.ImportMap[to]; ok && !strings.HasSuffix(newTo, ".test]") {
		to = newTo
	}
	if imported, ok := p.program.Packages[to]; ok {
		return imported.Pkg, nil
	} else {
		return nil, errors.New("package not imported: " + to)
	}
}
