package generator

import (
	"fmt"
	"go/ast"
	"go/importer"
	"go/parser"
	"go/token"
	"go/types"
	"log"
	"os"
	"strings"

	_ "embed"

	"github.com/pkg/errors"
	"github.com/projectdiscovery/nuclei/v3/pkg/js/compiler"
)

var (
	//go:embed templates/js_class.tmpl
	jsClassFile string
	//go:embed templates/go_class.tmpl
	goClassFile string
	//go:embed templates/markdown_class.tmpl
	markdownClassFile string
)

// TemplateData contains the parameters for the JS code generator
type TemplateData struct {
	PackageName             string
	PackagePath             string
	PackageFuncs            map[string]string
	PackageInterfaces       map[string]string
	PackageFuncsExtraNoType map[string]PackageFunctionExtra
	PackageFuncsExtra       map[string]PackageFuncExtra
	PackageVars             map[string]string
	PackageVarsValues       map[string]string
	PackageTypes            map[string]string
	PackageTypesExtra       map[string]PackageTypeExtra

	typesPackage *types.Package

	// NativeScripts contains the list of native scripts
	// that should be included in the package.
	NativeScripts []string
}

// PackageTypeExtra contains extra information about a type
type PackageTypeExtra struct {
	Fields map[string]string
}

// PackageFuncExtra contains extra information about a function
type PackageFuncExtra struct {
	Items map[string]PackageFunctionExtra
	Doc   string
}

// PackageFunctionExtra contains extra information about a function
type PackageFunctionExtra struct {
	Args    []string
	Name    string
	Returns []string
	Doc     string
}

// newTemplateData creates a new template data structure
func newTemplateData(packagePrefix, pkgName string) *TemplateData {
	return &TemplateData{
		PackageName:             pkgName,
		PackagePath:             packagePrefix + pkgName,
		PackageFuncs:            make(map[string]string),
		PackageFuncsExtraNoType: make(map[string]PackageFunctionExtra),
		PackageFuncsExtra:       make(map[string]PackageFuncExtra),
		PackageVars:             make(map[string]string),
		PackageVarsValues:       make(map[string]string),
		PackageTypes:            make(map[string]string),
		PackageInterfaces:       make(map[string]string),
		PackageTypesExtra:       make(map[string]PackageTypeExtra),
	}
}

// GetLibraryModules takes a directory and returns subdirectories as modules
func GetLibraryModules(directory string) ([]string, error) {
	dirs, err := os.ReadDir(directory)
	if err != nil {
		return nil, errors.Wrap(err, "could not read directory")
	}
	var modules []string
	for _, dir := range dirs {
		if dir.IsDir() {
			modules = append(modules, dir.Name())
		}
	}
	return modules, nil
}

// CreateTemplateData creates a TemplateData structure from a directory
// of go source code.
func CreateTemplateData(directory string, packagePrefix string) (*TemplateData, error) {
	fmt.Println(directory)
	fset := token.NewFileSet()

	pkgs, err := parser.ParseDir(fset, directory, nil, parser.ParseComments)
	if err != nil {
		return nil, errors.Wrap(err, "could not parse directory")
	}
	if len(pkgs) != 1 {
		return nil, fmt.Errorf("expected 1 package, got %d", len(pkgs))
	}

	config := &types.Config{
		Importer: importer.ForCompiler(fset, "source", nil),
	}
	var packageName string
	var files []*ast.File
	for k, v := range pkgs {
		packageName = k
		for _, f := range v.Files {
			files = append(files, f)
		}
		break
	}

	pkg, err := config.Check(packageName, fset, files, nil)
	if err != nil {
		return nil, errors.Wrap(err, "could not check package")
	}

	var pkgMain *ast.Package
	for _, p := range pkgs {
		pkgMain = p
		break
	}

	log.Printf("[create] [discover] Package: %s\n", pkgMain.Name)
	data := newTemplateData(packagePrefix, pkgMain.Name)
	data.typesPackage = pkg
	data.gatherPackageData(pkgMain, data)

	for item, v := range data.PackageFuncsExtra {
		if len(v.Items) == 0 {
			delete(data.PackageFuncsExtra, item)
		}
	}
	return data, nil
}

// InitNativeScripts initializes the native scripts array
// with all the exported functions from the runtime
func (d *TemplateData) InitNativeScripts() {
	compiler := compiler.New()
	runtime := compiler.VM()

	exports := runtime.Get("exports")
	if exports == nil {
		return
	}
	exportsObj := exports.Export()
	if exportsObj == nil {
		return
	}
	for v := range exportsObj.(map[string]interface{}) {
		d.NativeScripts = append(d.NativeScripts, v)
	}
}

// gatherPackageData gathers data about the package
func (d *TemplateData) gatherPackageData(pkg *ast.Package, data *TemplateData) {
	ast.Inspect(pkg, func(node ast.Node) bool {
		switch node := node.(type) {
		case *ast.FuncDecl:
			extra := d.collectFuncDecl(node)
			if extra.Name == "" {
				return true
			}
			data.PackageFuncsExtraNoType[node.Name.Name] = extra
			data.PackageFuncs[node.Name.Name] = node.Name.Name
		case *ast.TypeSpec:
			if !node.Name.IsExported() {
				return true
			}
			if node.Type == nil {
				return true
			}
			structDecl, ok := node.Type.(*ast.StructType)
			if !ok {
				return true
			}

			packageTypes := PackageTypeExtra{
				Fields: make(map[string]string),
			}
			for _, field := range structDecl.Fields.List {
				fieldName := field.Names[0].Name

				var fieldTypeValue string
				switch fieldType := field.Type.(type) {
				case *ast.Ident: // Field type is a simple identifier
					fieldTypeValue = fieldType.Name
				case *ast.ArrayType:
					switch fieldType.Elt.(type) {
					case *ast.Ident:
						fieldTypeValue = fmt.Sprintf("[]%s", fieldType.Elt.(*ast.Ident).Name)
					case *ast.StarExpr:
						fieldTypeValue = fmt.Sprintf("[]%s", d.handleStarExpr(fieldType.Elt.(*ast.StarExpr)))
					}
				case *ast.SelectorExpr: // Field type is a qualified identifier
					fieldTypeValue = fmt.Sprintf("%s.%s", fieldType.X, fieldType.Sel)
				}
				packageTypes.Fields[fieldName] = fieldTypeValue
			}
			if len(packageTypes.Fields) == 0 {
				return true
			}
			data.PackageTypesExtra[node.Name.Name] = packageTypes
		case *ast.GenDecl:
			identifyGenDecl(pkg, node, data)
		}
		return true
	})
}

func identifyGenDecl(pkg *ast.Package, decl *ast.GenDecl, data *TemplateData) {
	for _, spec := range decl.Specs {
		switch spec := spec.(type) {
		case *ast.ValueSpec:
			if !spec.Names[0].IsExported() {
				continue
			}
			if spec.Values == nil || len(spec.Values) == 0 {
				continue
			}
			data.PackageVars[spec.Names[0].Name] = spec.Names[0].Name
			data.PackageVarsValues[spec.Names[0].Name] = spec.Values[0].(*ast.BasicLit).Value
		case *ast.TypeSpec:
			if !spec.Name.IsExported() {
				continue
			}
			if spec.Type == nil {
				continue
			}

			switch spec.Type.(type) {
			case *ast.InterfaceType:
				data.PackageInterfaces[spec.Name.Name] = convertCommentsToJavascript(decl.Doc.Text())

			case *ast.StructType:
				data.PackageFuncsExtra[spec.Name.Name] = PackageFuncExtra{
					Items: make(map[string]PackageFunctionExtra),
					Doc:   convertCommentsToJavascript(decl.Doc.Text()),
				}

				// Traverse the AST.
				collectStructFuncsFromAST(pkg, spec, data)
				data.PackageTypes[spec.Name.Name] = spec.Name.Name
			}
		}
	}
}

func collectStructFuncsFromAST(pkg *ast.Package, spec *ast.TypeSpec, data *TemplateData) {
	ast.Inspect(pkg, func(n ast.Node) bool {
		if fn, isFunc := n.(*ast.FuncDecl); isFunc && fn.Name.IsExported() {
			processFunc(fn, spec, data)
		}
		return true
	})
}

func processFunc(fn *ast.FuncDecl, spec *ast.TypeSpec, data *TemplateData) {
	if fn.Recv == nil || len(fn.Recv.List) == 0 {
		return
	}

	if t, ok := fn.Recv.List[0].Type.(*ast.StarExpr); ok {
		if ident, ok := t.X.(*ast.Ident); ok && spec.Name.Name == ident.Name {
			processFunctionDetails(fn, ident, data)
		}
	}
}

func processFunctionDetails(fn *ast.FuncDecl, ident *ast.Ident, data *TemplateData) {
	extra := PackageFunctionExtra{
		Name:    fn.Name.Name,
		Args:    extractArgs(fn),
		Doc:     convertCommentsToJavascript(fn.Doc.Text()),
		Returns: data.extractReturns(fn),
	}
	data.PackageFuncsExtra[ident.Name].Items[fn.Name.Name] = extra
}

func extractArgs(fn *ast.FuncDecl) []string {
	args := make([]string, 0)
	for _, arg := range fn.Type.Params.List {
		for _, name := range arg.Names {
			args = append(args, name.Name)
		}
	}
	return args
}

func (d *TemplateData) extractReturns(fn *ast.FuncDecl) []string {
	returns := make([]string, 0)
	if fn.Type.Results == nil {
		return returns
	}
	for _, ret := range fn.Type.Results.List {
		returnType := d.extractReturnType(ret)
		if returnType != "" {
			returns = append(returns, returnType)
		}
	}
	return returns
}

func (d *TemplateData) extractReturnType(ret *ast.Field) string {
	switch v := ret.Type.(type) {
	case *ast.ArrayType:
		if v, ok := v.Elt.(*ast.Ident); ok {
			return fmt.Sprintf("[]%s", v.Name)
		}
		if v, ok := v.Elt.(*ast.StarExpr); ok {
			return fmt.Sprintf("[]%s", d.handleStarExpr(v))
		}
	case *ast.Ident:
		return v.Name
	case *ast.StarExpr:
		return d.handleStarExpr(v)
	}
	return ""
}

func (d *TemplateData) handleStarExpr(v *ast.StarExpr) string {
	switch vk := v.X.(type) {
	case *ast.Ident:
		return vk.Name
	case *ast.SelectorExpr:
		if vk.X != nil {
			d.collectTypeFromExternal(d.typesPackage, vk.X.(*ast.Ident).Name, vk.Sel.Name)
		}
		return vk.Sel.Name
	}
	return ""
}

func (d *TemplateData) collectTypeFromExternal(pkg *types.Package, pkgName, name string) {
	extra := PackageTypeExtra{
		Fields: make(map[string]string),
	}

	for _, importValue := range pkg.Imports() {
		if importValue.Name() != pkgName {
			continue
		}
		obj := importValue.Scope().Lookup(name)
		if obj == nil || !obj.Exported() {
			continue
		}
		typeName, ok := obj.(*types.TypeName)
		if !ok {
			continue
		}
		underlying, ok := typeName.Type().Underlying().(*types.Struct)
		if !ok {
			continue
		}
		for i := 0; i < underlying.NumFields(); i++ {
			field := underlying.Field(i)
			fieldType := field.Type().String()

			if val, ok := field.Type().Underlying().(*types.Pointer); ok {
				fieldType = field.Name()
				d.collectTypeFromExternal(pkg, pkgName, val.Elem().(*types.Named).Obj().Name())
			}
			if _, ok := field.Type().Underlying().(*types.Struct); ok {
				fieldType = field.Name()
				d.collectTypeFromExternal(pkg, pkgName, field.Name())
			}
			extra.Fields[field.Name()] = fieldType
		}
		if len(extra.Fields) > 0 {
			d.PackageTypesExtra[name] = extra
		}
	}
}

func (d *TemplateData) collectFuncDecl(decl *ast.FuncDecl) (extra PackageFunctionExtra) {
	if decl.Recv != nil {
		return
	}
	if !decl.Name.IsExported() {
		return
	}
	extra.Name = decl.Name.Name
	extra.Doc = convertCommentsToJavascript(decl.Doc.Text())

	for _, arg := range decl.Type.Params.List {
		for _, name := range arg.Names {
			extra.Args = append(extra.Args, name.Name)
		}
	}
	extra.Returns = d.extractReturns(decl)
	return extra
}

// convertCommentsToJavascript converts comments to javascript comments.
func convertCommentsToJavascript(comments string) string {
	suffix := strings.Trim(strings.TrimSuffix(strings.ReplaceAll(comments, "\n", "\n// "), "// "), "\n")
	return fmt.Sprintf("// %s", suffix)
}
