package main

import (
	"bytes"
	"flag"
	"fmt"
	"go/ast"
	"io"
	"log"
	"os"
	"sort"
	"strconv"
	"strings"

	"golang.org/x/tools/go/packages"
)

func main() {
	out := flag.String("o", "", "output file for hash constants and methods")
	flag.Parse()

	if len(flag.Args()) != 1 {
		log.Fatalln("Must pass one package to produce block hashes for.")
	}
	cfg := &packages.Config{
		Mode: packages.NeedName | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedFiles,
	}
	pkgs, err := packages.Load(cfg, flag.Args()[0])
	if err != nil {
		log.Fatalln(err)
	}
	f, err := os.OpenFile(*out, os.O_CREATE|os.O_TRUNC|os.O_WRONLY|os.O_APPEND, 0644)
	if err != nil {
		log.Fatalln(err)
	}
	for _, pkg := range pkgs {
		procPackage(pkg, f)
	}
	_ = f.Close()
}

func procPackage(pkg *packages.Package, w io.Writer) {
	b := &hashBuilder{
		pkg:         pkg,
		fields:      make(map[string][]*ast.Field),
		aliases:     make(map[string]string),
		handled:     map[string]struct{}{},
		funcs:       map[string]*ast.FuncDecl{},
		blockFields: map[string][]*ast.Field{},
	}
	b.readStructFields(pkg)
	b.readFuncs(pkg)
	b.resolveBlocks()
	b.sortNames()

	b.writePackage(w)
	b.writeConstants(w)
	b.writeNextHash(w)
	b.writeMethods(w)
}

var (
	packageFormat = "// Code generated by cmd/blockhash; DO NOT EDIT.\n\npackage %v\n\n"
	methodFormat  = "\nfunc (%v%v) Hash() (uint64, uint64) {\n\treturn %v, %v\n}\n"
	constFormat   = "\thash%v"
)

type hashBuilder struct {
	pkg         *packages.Package
	fields      map[string][]*ast.Field
	funcs       map[string]*ast.FuncDecl
	aliases     map[string]string
	handled     map[string]struct{}
	blockFields map[string][]*ast.Field
	names       []string
}

// sortNames sorts the names of the blockFields map and stores them in a slice.
func (b *hashBuilder) sortNames() {
	b.names = make([]string, 0, len(b.blockFields))
	for name := range b.blockFields {
		b.names = append(b.names, name)
	}
	sort.Slice(b.names, func(i, j int) bool {
		return b.names[i] < b.names[j]
	})
}

// writePackage writes the package at the top of the file.
func (b *hashBuilder) writePackage(w io.Writer) {
	if _, err := fmt.Fprintf(w, packageFormat, b.pkg.Name); err != nil {
		log.Fatalln(err)
	}
	if _, err := fmt.Fprintf(w, "import \"github.com/df-mc/dragonfly/server/world\"\n\n"); err != nil {
		log.Fatalln(err)
	}
}

// writeConstants writes hash constants for every block to a file.
func (b *hashBuilder) writeConstants(w io.Writer) {
	if _, err := fmt.Fprintln(w, "const ("); err != nil {
		log.Fatalln(err)
	}

	for i, name := range b.names {
		c := constFormat
		if i == 0 {
			c += " = iota"
		}

		if _, err := fmt.Fprintf(w, c+"\n", name); err != nil {
			log.Fatalln(err)
		}
	}

	if _, err := fmt.Fprintln(w, "\thashCustomBlockBase\n)"); err != nil {
		log.Fatalln(err)
	}
}

func (b *hashBuilder) writeNextHash(w io.Writer) {
	if _, err := fmt.Fprintln(w, "\n// customBlockBase represents the base hash for all custom blocks."); err != nil {
		log.Fatalln(err)
	}
	if _, err := fmt.Fprintln(w, "var customBlockBase = uint64(hashCustomBlockBase - 1)"); err != nil {
		log.Fatalln(err)
	}
	if _, err := fmt.Fprintln(w, "\n// NextHash returns the next free hash for custom blocks."); err != nil {
		log.Fatalln(err)
	}
	if _, err := fmt.Fprintln(w, "func NextHash() uint64 {\n\tcustomBlockBase++\n\treturn customBlockBase\n}"); err != nil {
		log.Fatalln(err)
	}
}

func (b *hashBuilder) writeMethods(w io.Writer) {
	for _, name := range b.names {
		fields := b.blockFields[name]

		var h string
		var bitSize int

		fun := b.funcs[name]
		var recvName string
		for _, n := range fun.Recv.List[0].Names {
			recvName = n.Name
		}
		pos := b.pkg.Fset.Position(fun.Body.Pos())
		f, err := os.Open(pos.Filename)
		if err != nil {
			log.Fatalln(err)
		}
		body := make([]byte, fun.Body.End()-fun.Body.Pos())

		if _, err := f.ReadAt(body, int64(pos.Offset)); err != nil {
			log.Fatalln(err)
		}
		_ = f.Close()

		for _, field := range fields {
			for _, fieldName := range field.Names {
				if !bytes.Contains(body, []byte(fieldName.Name)) {
					// Field was not used in the EncodeBlock method, so we can assume it's not a property and thus
					// should not be in the Hash method.
					continue
				}
				if !fieldName.IsExported() {
					continue
				}
				directives := make(map[string]string)
				if field.Doc != nil {
					for _, d := range field.Doc.List {
						const k = "//blockhash:"
						if index := strings.Index(d.Text, k); index != -1 {
							dir := strings.Split(d.Text[index+len(k):], " ")
							directives[dir[0]] = strings.Join(dir[1:], " ")
						}
					}
				}
				str, v := b.ftype(name, recvName+"."+fieldName.Name, field.Type, directives)
				if v == 0 {
					// Assume this field is not used in the hash.
					continue
				}

				if bitSize > 64 {
					log.Println("Hash size of block properties of", name, "exceeds 64 bits. Please look at this manually.")
				} else {
					if h == "" {
						h += str
					} else {
						h += " | " + str
					}
					if bitSize > 0 {
						h += "<<" + strconv.Itoa(bitSize)
					}
				}
				bitSize += v
			}
		}
		if bitSize == 0 {
			// No need to have a receiver name if we don't use any of the fields of the block.
			recvName = ""
		}

		if recvName != "" {
			recvName += " "
		}
		if h == "" {
			h = "0"
		}

		if _, err := fmt.Fprintf(w, methodFormat, recvName, name, "hash"+name, h); err != nil {
			log.Fatalln(err)
		}
	}
}

func (b *hashBuilder) ftype(structName, s string, expr ast.Expr, directives map[string]string) (string, int) {
	var name string
	switch t := expr.(type) {
	case *ast.BasicLit:
		name = t.Value
	case *ast.Ident:
		name = t.Name
	case *ast.SelectorExpr:
		name = t.Sel.Name
	case *ast.StarExpr:
		return "", 0 // Ignore this field
	default:
		log.Fatalf("unknown field type %#v\n", expr)
		return "", 0
	}
	switch name {
	case "bool":
		return "uint64(boolByte(" + s + "))", 1
	case "int":
		return "uint64(" + s + ")", 8
	case "Block":
		return "world.BlockHash(" + s + ")", 32
	case "Attachment":
		if _, ok := directives["facing_only"]; ok {
			log.Println("Found directive: 'facing_only'")
			return "uint64(" + s + ".FaceUint8())", 3
		}
		return "uint64(" + s + ".Uint8())", 5
	case "GrindstoneAttachment":
		return "uint64(" + s + ".Uint8())", 2
	case "WoodType", "LeavesType", "FlowerType", "DoubleFlowerType", "Colour":
		// Assuming these were all based on metadata, it should be safe to assume a bit size of 4 for this.
		return "uint64(" + s + ".Uint8())", 4
	case "CoralType", "SkullType":
		return "uint64(" + s + ".Uint8())", 3
	case "AnvilType", "SandstoneType", "PrismarineType", "StoneBricksType", "NetherBricksType", "FroglightType",
		"WallConnectionType", "BlackstoneType", "DeepslateType", "TallGrassType", "CopperType", "OxidationType":
		return "uint64(" + s + ".Uint8())", 2
	case "OreType", "FireType", "DoubleTallGrassType":
		return "uint64(" + s + ".Uint8())", 1
	case "Direction", "Axis":
		return "uint64(" + s + ")", 2
	case "Face":
		return "uint64(" + s + ")", 3
	default:
		log.Println("Found unhandled field type", "'"+name+"'", "in block", structName+".", "Assuming this field is not included in block states. Please make sure this is correct or add the type to cmd/blockhash.")
	}
	return "", 0
}

func (b *hashBuilder) resolveBlocks() {
	for bl, fields := range b.fields {
		if _, ok := b.funcs[bl]; ok {
			b.blockFields[bl] = fields
		}
	}
}

func (b *hashBuilder) readFuncs(pkg *packages.Package) {
	for _, f := range pkg.Syntax {
		ast.Inspect(f, b.readFuncDecls)
	}
}

func (b *hashBuilder) readFuncDecls(node ast.Node) bool {
	if fun, ok := node.(*ast.FuncDecl); ok {
		// If the function is called 'EncodeBlock' and the receiver is not nil, meaning the function is a method, this
		// is an implementation of the world.Block interface.
		if fun.Name.Name == "EncodeBlock" && fun.Recv != nil {
			b.funcs[fun.Recv.List[0].Type.(*ast.Ident).Name] = fun
		}
	}
	return true
}

func (b *hashBuilder) readStructFields(pkg *packages.Package) {
	for _, f := range pkg.Syntax {
		ast.Inspect(f, b.readStructs)
	}
	b.resolveEmbedded()
	b.resolveAliases()
}

func (b *hashBuilder) resolveAliases() {
	for name, alias := range b.aliases {
		b.fields[name] = b.findFields(alias)
	}
}

func (b *hashBuilder) findFields(structName string) []*ast.Field {
	for {
		if fields, ok := b.fields[structName]; ok {
			// Alias found in the fields map, so it referred to a struct directly.
			return fields
		}
		if nested, ok := b.aliases[structName]; ok {
			// The alias itself was an alias, so continue with the next.
			structName = nested
			continue
		}
		// Neither an alias nor a struct: Break as this isn't going to go anywhere.
		return nil
	}
}

func (b *hashBuilder) resolveEmbedded() {
	for name, fields := range b.fields {
		if _, ok := b.handled[name]; ok {
			// Don't handle if a previous run already handled this struct.
			continue
		}
		newFields := make([]*ast.Field, 0, len(fields))
		for _, f := range fields {
			if len(f.Names) == 0 {
				// We're dealing with an embedded struct here. They're of the type ast.Ident.
				if ident, ok := f.Type.(*ast.Ident); ok {
					for _, af := range b.findFields(ident.Name) {
						if len(af.Names) == 0 {
							// The struct this referred is embedding a struct itself which hasn't yet been processed,
							// so we need to rerun and hope that struct is handled next. This isn't a very elegant way,
							// and could lead to a lot of runs, but in general it's fast enough and does the job.
							b.resolveEmbedded()
							return
						}
					}
					newFields = append(newFields, b.findFields(ident.Name)...)
				}
			} else {
				newFields = append(newFields, f)
			}
		}
		// Make sure a next run doesn't end up handling this struct again.
		b.handled[name] = struct{}{}
		b.fields[name] = newFields
	}
}

func (b *hashBuilder) readStructs(node ast.Node) bool {
	if s, ok := node.(*ast.TypeSpec); ok {
		switch t := s.Type.(type) {
		case *ast.StructType:
			b.fields[s.Name.Name] = t.Fields.List
		case *ast.Ident:
			// This is a type created something like 'type Andesite polishable': A type alias. We need to handle
			// these later, first parse all struct types.
			b.aliases[s.Name.Name] = t.Name
		}
	}
	return true
}
