// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley
// SPDX-License-Identifier: MIT

package renderer

import (
	cryptoRand "crypto/rand"
	"encoding/base64"
	"fmt"
	"io"
	"math/rand"
	"os"
	"slices"
	"strings"
	"time"

	"github.com/lucasjones/reggen"
	"github.com/pb33f/libopenapi/datamodel/high/base"
	"github.com/pb33f/libopenapi/orderedmap"
)

const (
	rootType         = "rootType"
	stringType       = "string"
	numberType       = "number"
	integerType      = "integer"
	bigIntType       = "bigint"
	decimalType      = "decimal"
	booleanType      = "boolean"
	objectType       = "object"
	arrayType        = "array"
	int32Type        = "int32"
	floatType        = "float"
	doubleType       = "double"
	byteType         = "byte"
	binaryType       = "binary"
	passwordType     = "password"
	dateType         = "date"
	dateTimeType     = "date-time"
	timeType         = "time"
	emailType        = "email"
	hostnameType     = "hostname"
	ipv4Type         = "ipv4"
	ipv6Type         = "ipv6"
	uriType          = "uri"
	uriReferenceType = "uri-reference"
	uuidType         = "uuid"
	allOfType        = "allOf"
	anyOfType        = "anyOf"
	oneOfType        = "oneOf"
	itemsType        = "items"
)

// used to generate random words if there is no dictionary applied.
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func init() {
	// create a new random seed
	rand.New(rand.NewSource(time.Now().UnixNano()))
}

// SchemaRenderer is a renderer that will generate random words, numbers and values based on a dictionary file.
// The dictionary is just a slice of strings that is used to generate random words.
type SchemaRenderer struct {
	words           []string
	disableRequired bool
}

// CreateRendererUsingDictionary will create a new SchemaRenderer using a custom dictionary file.
// The location of a text file with one word per line is expected.
func CreateRendererUsingDictionary(dictionaryLocation string) *SchemaRenderer {
	// try and read in the dictionary file
	words := ReadDictionary(dictionaryLocation)
	return &SchemaRenderer{words: words}
}

// CreateRendererUsingDefaultDictionary will create a new SchemaRenderer using the default dictionary file.
// The default dictionary is located at /usr/share/dict/words on most systems.
// Windows users will need to use CreateRendererUsingDictionary to specify a custom dictionary.
func CreateRendererUsingDefaultDictionary() *SchemaRenderer {
	wr := new(SchemaRenderer)
	wr.words = ReadDictionary("/usr/share/dict/words")
	return wr
}

// RenderSchema takes a schema and renders it into an interface, ready to be converted to JSON or YAML.
func (wr *SchemaRenderer) RenderSchema(schema *base.Schema) any {
	// dive into the schema and render it
	structure := make(map[string]any)
	wr.DiveIntoSchema(schema, rootType, structure, 0)
	return structure[rootType]
}

// DisableRequiredCheck will disable the required check when rendering a schema. This means that all properties
// will be rendered, not just the required ones.
// https://github.com/pb33f/libopenapi/issues/200
func (wr *SchemaRenderer) DisableRequiredCheck() {
	wr.disableRequired = true
}

// DiveIntoSchema will dive into a schema and inject values from examples into a map. If there are no examples in
// the schema, then the renderer will attempt to generate a value based on the schema type, format and pattern.
func (wr *SchemaRenderer) DiveIntoSchema(schema *base.Schema, key string, structure map[string]any, depth int) {
	// got an example? use it, we're done here.
	if schema.Example != nil {
		var example any
		_ = schema.Example.Decode(&example)

		structure[key] = example
		return
	}

	// emergency break to prevent stack overflow from ever occurring
	if depth > 100 {
		structure[key] = "to deep to continue rendering..."
		return
	}

	// render out a string.
	if slices.Contains(schema.Type, stringType) {
		// check for an enum, if there is one, then pick a random value from it.
		if schema.Enum != nil && len(schema.Enum) > 0 {
			enum := schema.Enum[rand.Int()%len(schema.Enum)]

			var example any
			_ = enum.Decode(&example)

			structure[key] = example
		} else {

			// generate a random value based on the schema format, pattern and length values.
			var minLength int64 = 3
			var maxLength int64 = 10

			if schema.MinLength != nil {
				minLength = *schema.MinLength
			}
			if schema.MaxLength != nil {
				maxLength = *schema.MaxLength
			}

			// if there are examples, use them.
			if schema.Examples != nil && len(schema.Examples) > 0 {
				var renderedExample any

				// multi examples and the type is an array? then render all examples.
				if len(schema.Examples) > 1 && key == itemsType {
					renderedExamples := make([]any, len(schema.Examples))
					for i, exmp := range schema.Examples {
						if exmp != nil {
							var ex any
							_ = exmp.Decode(&ex)
							renderedExamples[i] = fmt.Sprint(ex)
						}
					}
					structure[key] = renderedExamples
					return
				} else {
					// render the first example
					exmp := schema.Examples[0]
					if exmp != nil {
						var ex any
						_ = exmp.Decode(&ex)
						renderedExample = fmt.Sprint(ex)
					}
					structure[key] = renderedExample
					return
				}
			}

			switch schema.Format {
			case dateTimeType:
				structure[key] = time.Now().Format(time.RFC3339)
			case dateType:
				structure[key] = time.Now().Format("2006-01-02")
			case timeType:
				structure[key] = time.Now().Format("15:04:05")
			case emailType:
				structure[key] = fmt.Sprintf("%s@%s.com",
					wr.RandomWord(minLength, maxLength, 0),
					wr.RandomWord(minLength, maxLength, 0))
			case hostnameType:
				structure[key] = fmt.Sprintf("%s.com", wr.RandomWord(minLength, maxLength, 0))
			case ipv4Type:
				structure[key] = fmt.Sprintf("%d.%d.%d.%d",
					rand.Int()%255, rand.Int()%255, rand.Int()%255, rand.Int()%255)
			case ipv6Type:
				structure[key] = fmt.Sprintf("%04x:%04x:%04x:%04x:%04x:%04x:%04x:%04x",
					rand.Intn(65535), rand.Intn(65535), rand.Intn(65535), rand.Intn(65535),
					rand.Intn(65535), rand.Intn(65535), rand.Intn(65535), rand.Intn(65535),
				)
			case uriType:
				structure[key] = fmt.Sprintf("https://%s-%s-%s.com/%s",
					wr.RandomWord(minLength, maxLength, 0),
					wr.RandomWord(minLength, maxLength, 0),
					wr.RandomWord(minLength, maxLength, 0),
					wr.RandomWord(minLength, maxLength, 0))
			case uriReferenceType:
				structure[key] = fmt.Sprintf("/%s/%s",
					wr.RandomWord(minLength, maxLength, 0),
					wr.RandomWord(minLength, maxLength, 0))
			case uuidType:
				structure[key] = wr.PseudoUUID()
			case byteType:
				structure[key] = wr.RandomWord(minLength, maxLength, 0)
			case passwordType:
				structure[key] = wr.RandomWord(minLength, maxLength, 0)
			case binaryType:
				structure[key] = base64.StdEncoding.EncodeToString([]byte(wr.RandomWord(minLength, maxLength, 0)))
			case bigIntType:
				structure[key] = fmt.Sprint(wr.RandomInt(minLength, maxLength))
			case decimalType:
				structure[key] = fmt.Sprint(wr.RandomFloat64())
			default:
				// if there is a pattern supplied, then try and generate a string from it.
				if schema.Pattern != "" {
					str, err := reggen.Generate(schema.Pattern, int(maxLength))
					if err == nil {
						structure[key] = str
					}
				} else {
					// last resort, generate a random value
					structure[key] = wr.RandomWord(minLength, maxLength, 0)
				}
			}
		}
		return
	}

	// handle numbers
	if slices.Contains(schema.Type, numberType) ||
		slices.Contains(schema.Type, integerType) ||
		slices.Contains(schema.Type, bigIntType) ||
		slices.Contains(schema.Type, decimalType) {

		if schema.Enum != nil && len(schema.Enum) > 0 {
			enum := schema.Enum[rand.Int()%len(schema.Enum)]

			var example any
			_ = enum.Decode(&example)

			structure[key] = example
		} else {

			var minimum int64 = 1
			var maximum int64 = 100

			if schema.Minimum != nil {
				minimum = int64(*schema.Minimum)
			}
			if schema.Maximum != nil {
				maximum = int64(*schema.Maximum)
			}

			if schema.Examples != nil {
				if len(schema.Examples) > 0 {
					var renderedExample any
					exmp := schema.Examples[0]
					if exmp != nil {
						var ex any
						_ = exmp.Decode(&ex)
						renderedExample = ex
					}
					structure[key] = renderedExample
					return
				}
			}

			switch schema.Format {
			case floatType:
				structure[key] = rand.Float32()
			case doubleType:
				structure[key] = rand.Float64()
			case int32Type:
				structure[key] = int(wr.RandomInt(minimum, maximum))
			case bigIntType:
				structure[key] = wr.RandomInt(minimum, maximum)
			case decimalType:
				structure[key] = wr.RandomFloat64()
			default:
				structure[key] = wr.RandomInt(minimum, maximum)
			}
		}
		return
	}

	// handle booleans
	if slices.Contains(schema.Type, booleanType) {
		structure[key] = true
	}

	// handle objects
	if slices.Contains(schema.Type, objectType) {
		properties := schema.Properties
		propertyMap := make(map[string]any)

		if properties != nil {
			// check if this schema has required properties, if so, then only render required props, if not
			// render everything in the schema.
			checkProps := orderedmap.New[string, *base.SchemaProxy]()
			if !wr.disableRequired && len(schema.Required) > 0 {
				for _, requiredProp := range schema.Required {
					checkProps.Set(requiredProp, properties.GetOrZero(requiredProp))
				}
			} else {
				checkProps = properties
			}
			for propName, propValue := range checkProps.FromOldest() {
				// render property
				propertySchema := propValue.Schema()
				wr.DiveIntoSchema(propertySchema, propName, propertyMap, depth+1)
			}
		}

		// handle allOf
		allOf := schema.AllOf
		if allOf != nil {
			allOfMap := make(map[string]any)
			for _, allOfSchema := range allOf {
				allOfCompiled := allOfSchema.Schema()
				wr.DiveIntoSchema(allOfCompiled, allOfType, allOfMap, depth+1)
				if m, ok := allOfMap[allOfType].(map[string]any); ok {
					for k, v := range m {
						propertyMap[k] = v
					}
				}
				if m, ok := allOfMap[allOfType].(string); ok {
					propertyMap[allOfType] = m
				}
			}
		}

		// handle dependentSchemas
		dependentSchemas := schema.DependentSchemas
		if dependentSchemas != nil {
			dependentSchemasMap := make(map[string]any)
			for k, dependentSchema := range dependentSchemas.FromOldest() {
				// only map if the property exists
				if propertyMap[k] != nil {
					dependentSchemaCompiled := dependentSchema.Schema()
					wr.DiveIntoSchema(dependentSchemaCompiled, k, dependentSchemasMap, depth+1)
					for i, v := range dependentSchemasMap[k].(map[string]any) {
						propertyMap[k].(map[string]any)[i] = v
					}
				}
			}
		}

		// handle oneOf
		oneOf := schema.OneOf
		if len(oneOf) > 0 {
			oneOfMap := make(map[string]any)
			oneOfCompiled := oneOf[0].Schema()
			wr.DiveIntoSchema(oneOfCompiled, oneOfType, oneOfMap, depth+1)
			if m, ok := oneOfMap[oneOfType].(map[string]any); ok {
				for k, v := range m {
					propertyMap[k] = v
				}
			}
			if m, ok := oneOfMap[oneOfType].(string); ok {
				propertyMap[oneOfType] = m
			}
		}

		// handle anyOf
		anyOf := schema.AnyOf
		if len(anyOf) > 0 {
			anyOfMap := make(map[string]any)
			anyOfCompiled := anyOf[0].Schema()
			wr.DiveIntoSchema(anyOfCompiled, anyOfType, anyOfMap, depth+1)
			if m, ok := anyOfMap[anyOfType].(map[string]any); ok {
				for k, v := range m {
					propertyMap[k] = v
				}
			}
			if m, ok := anyOfMap[anyOfType].(string); ok {
				propertyMap[anyOfType] = m
			}
		}
		structure[key] = propertyMap
		return
	}

	if slices.Contains(schema.Type, arrayType) {

		// an array needs an items schema
		itemsSchema := schema.Items
		if itemsSchema != nil {
			// otherwise the items value is a schema, so we need to dive into it
			if itemsSchema.IsA() {

				// check if the schema contains a minItems value and render up to that number.
				var minItems int64 = 1
				if schema.MinItems != nil {
					minItems = *schema.MinItems
				}

				var renderedItems []any
				// build up the array
				for i := int64(0); i < minItems; i++ {
					itemMap := make(map[string]any)
					itemsSchemaCompiled := itemsSchema.A.Schema()
					wr.DiveIntoSchema(itemsSchemaCompiled, itemsType, itemMap, depth+1)
					if multipleItems, ok := itemMap[itemsType].([]any); ok {
						renderedItems = multipleItems
					} else {
						renderedItems = append(renderedItems, itemMap[itemsType])
					}
				}
				structure[key] = renderedItems
				return
			}
		}
	}
}

func readFile(file io.Reader) []string {
	bytes, err := io.ReadAll(file)
	if err != nil {
		return []string{}
	}
	return strings.Split(string(bytes), "\n")
}

// ReadDictionary will read a dictionary file and return a slice of strings.
func ReadDictionary(dictionaryLocation string) []string {
	file, err := os.Open(dictionaryLocation)
	if err != nil {
		return []string{}
	}
	return readFile(file)
}

// RandomWord will return a random word from the dictionary file between the min and max values. The depth is used
// to prevent a stack overflow, the maximum depth is 100 (anything more than this is probably a bug).
// set the values to 0 to return the first word returned, essentially ignore the min and max values.
func (wr *SchemaRenderer) RandomWord(min, max int64, depth int) string {
	// break out if we've gone too deep
	if depth > 100 {
		return fmt.Sprintf("no-word-found-%d-%d", min, max)
	}

	// no dictionary? then just return a random string.
	if len(wr.words) == 0 {
		if min == 0 {
			min = 7 // seems like a good default
		}
		b := make([]byte, min)
		for i := range b {
			b[i] = letterBytes[rand.Intn(len(letterBytes))]
		}
		return string(b)
	}

	word := wr.words[rand.Int()%len(wr.words)]
	if min == 0 && max == 0 {
		return word
	}
	if len(word) < int(min) || len(word) > int(max) {
		return wr.RandomWord(min, max, depth+1)
	}
	return word
}

// RandomInt will return a random int between the min and max values.
func (wr *SchemaRenderer) RandomInt(min, max int64) int64 {
	return rand.Int63n(max-min) + min
}

// RandomFloat64 will return a random float64 between 0 and 1.
func (wr *SchemaRenderer) RandomFloat64() float64 {
	return rand.Float64()
}

// PseudoUUID will return a random UUID, it's not a real UUID, but it's good enough for mock /example data.
func (wr *SchemaRenderer) PseudoUUID() string {
	b := make([]byte, 16)
	_, _ = cryptoRand.Read(b)
	return strings.ToLower(fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]))
}
