// Copyright (C) 2022-2023  Luke Shumaker <lukeshu@lukeshu.com>
//
// SPDX-License-Identifier: GPL-2.0-or-later

package lowmemjson

import (
	"reflect"

	"git.lukeshu.com/go/typedsync"

	"git.lukeshu.com/go/lowmemjson/internal"
)

type structField struct {
	Name      string
	Path      []int
	Tagged    bool
	OmitEmpty bool
	Quote     bool
}

// A structIndex is used by Decoder.Decode() and Encoder.Encode() when
// decoding-to or encoding-from a struct.
type structIndex struct {
	byPos  []structField
	byName map[string]int
}

var structIndexCache typedsync.CacheMap[reflect.Type, structIndex]

// indexStruct takes a struct Type, and indexes its fields for use by
// Decoder.Decode() and Encoder.Encode().  indexStruct caches its
// results.
func indexStruct(typ reflect.Type) structIndex {
	ret, _ := structIndexCache.LoadOrCompute(typ, indexStructReal)
	return ret
}

// indexStructReal is like indexStruct, but is the real indexer,
// bypassing the cache.
func indexStructReal(typ reflect.Type) structIndex {
	var byPos []structField
	byName := make(map[string][]int)

	indexStructInner(typ, &byPos, byName, nil, map[reflect.Type]struct{}{})

	ret := structIndex{
		byName: make(map[string]int),
	}

	for curPos, _field := range byPos {
		name := _field.Name
		fieldPoss := byName[name]
		switch len(fieldPoss) {
		case 0:
			// do nothing
		case 1:
			ret.byName[name] = len(ret.byPos)
			ret.byPos = append(ret.byPos, _field)
		default:
			// To quote the encoding/json docs (version 1.18.4):
			//
			//    If there are multiple fields at the same level, and that level is the
			//    least nested (and would therefore be the nesting level selected by the
			//    usual Go rules), the following extra rules apply:
			//
			//    1) Of those fields, if any are JSON-tagged, only tagged fields are
			//       considered, even if there are multiple untagged fields that would
			//       otherwise conflict.
			//
			//    2) If there is exactly one field (tagged or not according to the first
			//       rule), that is selected.
			//
			//    3) Otherwise there are multiple fields, and all are ignored; no error
			//       occurs.
			leastLevel := len(byPos[fieldPoss[0]].Path)
			for _, fieldPos := range fieldPoss[1:] {
				field := byPos[fieldPos]
				if len(field.Path) < leastLevel {
					leastLevel = len(field.Path)
				}
			}
			var numUntagged, numTagged int
			var untaggedPos, taggedPos int
			for _, fieldPos := range fieldPoss {
				field := byPos[fieldPos]
				if len(field.Path) != leastLevel {
					continue
				}
				if field.Tagged {
					numTagged++
					taggedPos = fieldPos
					if numTagged > 1 {
						break // optimization
					}
				} else {
					numUntagged++
					untaggedPos = fieldPos
				}
			}
			switch numTagged {
			case 0:
				switch numUntagged {
				case 0:
					// do nothing
				case 1:
					if curPos == untaggedPos {
						ret.byName[name] = len(ret.byPos)
						ret.byPos = append(ret.byPos, byPos[curPos])
					}
				}
			case 1:
				if curPos == taggedPos {
					ret.byName[name] = len(ret.byPos)
					ret.byPos = append(ret.byPos, byPos[curPos])
				}
			}
		}
	}

	return ret
}

// indexStructInner crawls the struct `typ`, storing information on
// all struct fields foun in to `byPos` and `byName`.  If `typ`
// contains other structs as fields, indexStructInner will recurse and
// call itself; keeping track of stack information with `stackPath`
// (which identifies where we are in the parent struct) and
// `stackSeen` (which is used for detecting loops).
func indexStructInner(typ reflect.Type, byPos *[]structField, byName map[string][]int, stackPath []int, stackSeen map[reflect.Type]struct{}) {
	if _, ok := stackSeen[typ]; ok {
		return
	}
	stackSeen[typ] = struct{}{}
	defer delete(stackSeen, typ)

	n := typ.NumField()
	for i := 0; i < n; i++ {
		stackPath := append(stackPath, i)

		fTyp := typ.Field(i)
		var embed bool
		if fTyp.Anonymous {
			t := fTyp.Type
			if t.Kind() == reflect.Pointer {
				t = t.Elem()
			}
			if !fTyp.IsExported() && t.Kind() != reflect.Struct {
				continue
			}
			embed = t.Kind() == reflect.Struct
		} else if !fTyp.IsExported() {
			continue
		}
		tag := fTyp.Tag.Get("json")
		if tag == "-" {
			continue
		}
		tagName, opts := internal.ParseTag(tag)
		name := tagName
		if !isValidTag(name) {
			name = ""
		}
		if name == "" {
			name = fTyp.Name
		}

		if embed && tagName == "" {
			t := fTyp.Type
			if t.Kind() == reflect.Pointer {
				t = t.Elem()
			}
			indexStructInner(t, byPos, byName, stackPath, stackSeen)
		} else {
			byName[name] = append(byName[name], len(*byPos))
			*byPos = append(*byPos, structField{
				Name:      name,
				Path:      append([]int(nil), stackPath...),
				Tagged:    tagName != "",
				OmitEmpty: opts.Contains("omitempty"),
				Quote:     opts.Contains("string") && isQuotable(fTyp.Type),
			})
		}
	}
}

// isQuotable returns whether a type is eligible for `json:,string`
// quoting.
func isQuotable(typ reflect.Type) bool {
	for typ.Kind() == reflect.Pointer {
		typ = typ.Elem()
	}
	switch typ.Kind() {
	case reflect.Bool,
		reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
		reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
		reflect.Uintptr,
		reflect.Float32, reflect.Float64,
		reflect.String:
		return true
	default:
		return false
	}
}