// This is free and unencumbered software released into the public
// domain.  For more information, see <http://unlicense.org> or the
// accompanying UNLICENSE file.

//go:generate hel

package mocks

import (
	"bytes"
	"go/ast"
	"go/format"
	"go/parser"
	"go/token"
	"io"
	"os"
	"strconv"
	"strings"

	"github.com/nelsam/hel/v2/types"
	"golang.org/x/tools/go/ast/astutil"
)

const commentHeader = `// This file was generated by github.com/nelsam/hel.  Do not
// edit this code by hand unless you *really* know what you're
// doing.  Expect any changes made manually to be overwritten
// the next time hel regenerates this file.

`

// TypeFinder represents a type which knows about types and dependencies.
type TypeFinder interface {
	ExportedTypes() (types []*ast.TypeSpec)
	Dependencies(inter *ast.InterfaceType) (dependencies []types.Dependency)
}

// Mocks is a slice of Mock values.
type Mocks []Mock

// Output writes the go code representing m to dest.  pkg will be the
// package name; dir is the destination directory (needed for formatting
// the file); chanSize is the buffer size of any channels created in
// constructors.
func (m Mocks) Output(pkg, dir string, chanSize int, dest io.Writer) error {
	if _, err := dest.Write([]byte(commentHeader)); err != nil {
		return err
	}

	fset := token.NewFileSet()

	f := &ast.File{
		Name:  &ast.Ident{Name: pkg},
		Decls: m.decls(chanSize),
	}

	var b bytes.Buffer
	format.Node(&b, fset, f)

	// TODO: Determine why adding imports without creating a new ast file
	// will only allow one import to be printed to the file.
	fset = token.NewFileSet()
	file, err := parser.ParseFile(fset, pkg, &b, 0)
	if err != nil {
		return err
	}

	file, fset, err = addImports(file, fset, dir)
	if err != nil {
		return err
	}

	return format.Node(dest, fset, file)
}

// PrependLocalPackage prepends name as the package name for local types
// in m's signature.  This is most often used when mocking types that are
// imported by the local package.
func (m Mocks) PrependLocalPackage(name string) {
	for _, m := range m {
		m.PrependLocalPackage(name)
	}
}

// SetBlockingReturn sets whether or not methods will include a blocking
// return channel, most often used for testing data races.
func (m Mocks) SetBlockingReturn(blockingReturn bool) {
	for _, m := range m {
		m.SetBlockingReturn(blockingReturn)
	}
}

func (m Mocks) decls(chanSize int) (decls []ast.Decl) {
	for _, mock := range m {
		decls = append(decls, mock.Ast(chanSize)...)
	}
	return decls
}

func addImports(file *ast.File, fset *token.FileSet, dirPath string) (*ast.File, *token.FileSet, error) {
	imports, err := getImports(dirPath, fset)
	if err != nil {
		return nil, nil, err
	}

	for _, s := range imports {
		unquotedPath, err := strconv.Unquote(s.Path.Value)
		if err != nil {
			return nil, nil, err
		}

		if s.Name != nil {
			astutil.AddNamedImport(fset, file, s.Name.Name, unquotedPath)
			continue
		}

		astutil.AddImport(fset, file, unquotedPath)
	}

	return file, fset, nil
}

func getImports(dirPath string, fset *token.FileSet) ([]*ast.ImportSpec, error) {
	// Grab imports from all files except helheim_test
	pkgs, err := parser.ParseDir(fset, dirPath, func(info os.FileInfo) bool {
		return !strings.Contains(info.Name(), "_test.go")
	}, parser.ImportsOnly)
	if err != nil {
		return nil, err
	}

	var imports []*ast.ImportSpec
	for _, p := range pkgs {
		files := p.Files
		for _, f := range files {
			imports = append(imports, f.Imports...)
		}
	}
	return imports, nil
}

// Generate generates a Mocks value for all exported interface
// types returned by finder.
func Generate(finder TypeFinder) (Mocks, error) {
	base := finder.ExportedTypes()
	var (
		typs []*ast.TypeSpec
		deps []types.Dependency
	)
	for _, typ := range base {
		typs = append(typs, typ)
		if inter, ok := typ.Type.(*ast.InterfaceType); ok {
			deps = append(deps, finder.Dependencies(inter)...)
		}
	}
	deps = deDupe(typs, deps)
	m := make(Mocks, 0, len(typs))
	for _, typ := range typs {
		newMock, err := For(typ)
		if err != nil {
			return nil, err
		}
		m = append(m, newMock)
	}
	for _, dep := range deps {
		newMock, err := For(dep.Type)
		if err != nil {
			return nil, err
		}
		newMock.PrependLocalPackage(dep.PkgName)
		m = append(m, newMock)
	}
	return m, nil
}

func deDupe(typs []*ast.TypeSpec, deps []types.Dependency) []types.Dependency {
	for _, typ := range typs {
		for i := 0; i < len(deps); i++ {
			if deps[i].Type.Name.Name != typ.Name.Name {
				continue
			}
			if deps[i].PkgName == "" {
				deps = append(deps[:i], deps[i+1:]...)
				i--
				continue
			}
			deps[i] = separate(deps[i], typ)
		}
	}
	for i := 0; i < len(deps); i++ {
		for j := i + 1; j < len(deps); j++ {
			if equal(deps[i], deps[j]) {
				deps = append(deps[:j], deps[j+1:]...)
				j--
				continue
			}
			deps[j] = separate(deps[j], deps[i].Type)
		}
	}
	return deps
}

func equal(a, b types.Dependency) bool {
	if a.PkgName != b.PkgName {
		return false
	}
	if a.Type.Name.Name != b.Type.Name.Name {
		return false
	}
	return true
}

func separate(dep types.Dependency, from *ast.TypeSpec) types.Dependency {
	if dep.Type.Name.Name != from.Name.Name {
		return dep
	}
	pkgTitle := strings.Title(dep.PkgName)
	if !strings.HasSuffix(dep.Type.Name.Name, pkgTitle) {
		dep.Type.Name.Name = pkgTitle + dep.Type.Name.Name
	}
	return dep
}
