/*
Copyright 2019 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package crd

import (
	"fmt"
	"reflect"
	"sort"
	"strings"
	"sync"

	apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"

	"sigs.k8s.io/controller-tools/pkg/loader"
)

// ErrorRecorder knows how to record errors.  It wraps the part of
// pkg/loader.Package that we need to record errors in places were it might not
// make sense to have a loader.Package
type ErrorRecorder interface {
	// AddError records that the given error occurred.
	// See the documentation on loader.Package.AddError for more information.
	AddError(error)
}

// isOrNil checks if val is nil if val is of a nillable type, otherwise,
// it compares val to valInt (which should probably be the zero value).
func isOrNil(val reflect.Value, valInt interface{}, zeroInt interface{}) bool {
	switch valKind := val.Kind(); valKind {
	case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
		return val.IsNil()
	default:
		return valInt == zeroInt
	}
}

// flattenAllOfInto copies properties from src to dst, then copies the properties
// of each item in src's allOf to dst's properties as well.
func flattenAllOfInto(dst *apiext.JSONSchemaProps, src apiext.JSONSchemaProps, errRec ErrorRecorder) {
	if len(src.AllOf) > 0 {
		for _, embedded := range src.AllOf {
			flattenAllOfInto(dst, embedded, errRec)
		}
	}

	dstVal := reflect.Indirect(reflect.ValueOf(dst))
	srcVal := reflect.ValueOf(src)
	typ := dstVal.Type()

	srcRemainder := apiext.JSONSchemaProps{}
	srcRemVal := reflect.Indirect(reflect.ValueOf(&srcRemainder))
	dstRemainder := apiext.JSONSchemaProps{}
	dstRemVal := reflect.Indirect(reflect.ValueOf(&dstRemainder))
	hoisted := false

	for i := 0; i < srcVal.NumField(); i++ {
		fieldName := typ.Field(i).Name
		switch fieldName {
		case "AllOf":
			// don't merge because we deal with it above
			continue
		case "Title", "Description", "Example", "ExternalDocs":
			// don't merge because we pre-merge to properly preserve field docs
			continue
		}
		srcField := srcVal.Field(i)
		fldTyp := srcField.Type()
		zeroVal := reflect.Zero(fldTyp)
		zeroInt := zeroVal.Interface()
		srcInt := srcField.Interface()

		if isOrNil(srcField, srcInt, zeroInt) {
			// nothing to copy from src, continue
			continue
		}

		dstField := dstVal.Field(i)
		dstInt := dstField.Interface()
		if isOrNil(dstField, dstInt, zeroInt) {
			// dst is empty, continue
			dstField.Set(srcField)
			continue
		}

		if fldTyp.Comparable() && srcInt == dstInt {
			// same value, continue
			continue
		}

		// resolve conflict
		switch fieldName {
		case "Properties":
			// merge if possible, use all of otherwise
			srcMap := srcInt.(map[string]apiext.JSONSchemaProps)
			dstMap := dstInt.(map[string]apiext.JSONSchemaProps)

			for k, v := range srcMap {
				dstProp, exists := dstMap[k]
				if !exists {
					dstMap[k] = v
					continue
				}
				flattenAllOfInto(&dstProp, v, errRec)
				dstMap[k] = dstProp
			}
		case "Required":
			// merge
			dstField.Set(reflect.AppendSlice(dstField, srcField))
		case "Type":
			if srcInt != dstInt {
				// TODO(directxman12): figure out how to attach this back to a useful point in the Go source or in the schema
				errRec.AddError(fmt.Errorf("conflicting types in allOf branches in schema: %s vs %s", dstInt, srcInt))
			}
			// keep the destination value, for now
		// TODO(directxman12): Default -- use field?
		// TODO(directxman12):
		// - Dependencies: if field x is present, then either schema validates or all props are present
		// - AdditionalItems: like AdditionalProperties
		// - Definitions: common named validation sets that can be references (merge, bail if duplicate)
		case "AdditionalProperties":
			// as of the time of writing, `allows: false` is not allowed, so we don't have to handle it
			srcProps := srcInt.(*apiext.JSONSchemaPropsOrBool)
			if srcProps.Schema == nil {
				// nothing to merge
				continue
			}
			dstProps := dstInt.(*apiext.JSONSchemaPropsOrBool)
			if dstProps.Schema == nil {
				dstProps.Schema = &apiext.JSONSchemaProps{}
			}
			flattenAllOfInto(dstProps.Schema, *srcProps.Schema, errRec)
		// NB(directxman12): no need to explicitly handle nullable -- false is considered to be the zero value
		// TODO(directxman12): src isn't necessarily the field value -- it's just the most recent allOf entry
		default:
			// hoist into allOf...
			hoisted = true

			srcRemVal.Field(i).Set(srcField)
			dstRemVal.Field(i).Set(dstField)
			// ...and clear the original
			dstField.Set(zeroVal)
		}
	}

	if hoisted {
		dst.AllOf = append(dst.AllOf, dstRemainder, srcRemainder)
	}

	// dedup required
	if len(dst.Required) > 0 {
		reqUniq := make(map[string]struct{})
		for _, req := range dst.Required {
			reqUniq[req] = struct{}{}
		}
		dst.Required = make([]string, 0, len(reqUniq))
		for req := range reqUniq {
			dst.Required = append(dst.Required, req)
		}
		// be deterministic
		sort.Strings(dst.Required)
	}
}

// allOfVisitor recursively visits allOf fields in the schema,
// merging nested allOf properties into the root schema.
type allOfVisitor struct {
	// errRec is used to record errors while flattening (like two conflicting
	// field values used in an allOf)
	errRec ErrorRecorder
}

func (v *allOfVisitor) Visit(schema *apiext.JSONSchemaProps) SchemaVisitor {
	if schema == nil {
		return v
	}

	// clear this now so that we can safely preserve edits made my flattenAllOfInto
	origAllOf := schema.AllOf
	schema.AllOf = nil

	for _, embedded := range origAllOf {
		flattenAllOfInto(schema, embedded, v.errRec)
	}
	return v
}

// NB(directxman12): FlattenEmbedded is separate from Flattener because
// some tooling wants to flatten out embedded fields, but only actually
// flatten a few specific types first.

// FlattenEmbedded flattens embedded fields (represented via AllOf) which have
// already had their references resolved into simple properties in the containing
// schema.
func FlattenEmbedded(schema *apiext.JSONSchemaProps, errRec ErrorRecorder) *apiext.JSONSchemaProps {
	outSchema := schema.DeepCopy()
	EditSchema(outSchema, &allOfVisitor{errRec: errRec})
	return outSchema
}

// Flattener knows how to take a root type, and flatten all references in it
// into a single, flat type.  Flattened types are cached, so it's relatively
// cheap to make repeated calls with the same type.
type Flattener struct {
	// Parser is used to lookup package and type details, and parse in new packages.
	Parser *Parser

	LookupReference func(ref string, contextPkg *loader.Package) (TypeIdent, error)

	// flattenedTypes hold the flattened version of each seen type for later reuse.
	flattenedTypes map[TypeIdent]apiext.JSONSchemaProps
	initOnce       sync.Once
}

func (f *Flattener) init() {
	f.initOnce.Do(func() {
		f.flattenedTypes = make(map[TypeIdent]apiext.JSONSchemaProps)
		if f.LookupReference == nil {
			f.LookupReference = identFromRef
		}
	})
}

// cacheType saves the flattened version of the given type for later reuse
func (f *Flattener) cacheType(typ TypeIdent, schema apiext.JSONSchemaProps) {
	f.init()
	f.flattenedTypes[typ] = schema
}

// loadUnflattenedSchema fetches a fresh, unflattened schema from the parser.
func (f *Flattener) loadUnflattenedSchema(typ TypeIdent) (*apiext.JSONSchemaProps, error) {
	f.Parser.NeedSchemaFor(typ)

	baseSchema, found := f.Parser.Schemata[typ]
	if !found {
		return nil, fmt.Errorf("unable to locate schema for type %s", typ)
	}
	return &baseSchema, nil
}

// FlattenType flattens the given pre-loaded type, removing any references from it.
// It deep-copies the schema first, so it won't affect the parser's version of the schema.
func (f *Flattener) FlattenType(typ TypeIdent) *apiext.JSONSchemaProps {
	f.init()
	if cachedSchema, isCached := f.flattenedTypes[typ]; isCached {
		return &cachedSchema
	}
	baseSchema, err := f.loadUnflattenedSchema(typ)
	if err != nil {
		typ.Package.AddError(err)
		return nil
	}
	resSchema := f.FlattenSchema(*baseSchema, typ.Package)
	f.cacheType(typ, *resSchema)
	return resSchema
}

// FlattenSchema flattens the given schema, removing any references.
// It deep-copies the schema first, so the input schema won't be affected.
func (f *Flattener) FlattenSchema(baseSchema apiext.JSONSchemaProps, currentPackage *loader.Package) *apiext.JSONSchemaProps {
	resSchema := baseSchema.DeepCopy()
	EditSchema(resSchema, &flattenVisitor{
		Flattener:      f,
		currentPackage: currentPackage,
	})

	return resSchema
}

// RefParts splits a reference produced by the schema generator into its component
// type name and package name (if it's a cross-package reference).  Note that
// referenced packages *must* be looked up relative to the current package.
func RefParts(ref string) (typ string, pkgName string, err error) {
	if !strings.HasPrefix(ref, defPrefix) {
		return "", "", fmt.Errorf("non-standard reference link %q", ref)
	}
	ref = ref[len(defPrefix):]
	// decode the json pointer encodings
	ref = strings.Replace(ref, "~1", "/", -1)
	ref = strings.Replace(ref, "~0", "~", -1)
	nameParts := strings.SplitN(ref, "~", 2)

	if len(nameParts) == 1 {
		// local reference
		return nameParts[0], "", nil
	}
	// cross-package reference
	return nameParts[1], nameParts[0], nil
}

// identFromRef converts the given schema ref from the given package back
// into the TypeIdent that it represents.
func identFromRef(ref string, contextPkg *loader.Package) (TypeIdent, error) {
	typ, pkgName, err := RefParts(ref)
	if err != nil {
		return TypeIdent{}, err
	}

	if pkgName == "" {
		// a local reference
		return TypeIdent{
			Name:    typ,
			Package: contextPkg,
		}, nil
	}

	// an external reference
	return TypeIdent{
		Name:    typ,
		Package: contextPkg.Imports()[pkgName],
	}, nil
}

// preserveFields copies documentation fields from src into dst, preserving
// field-level documentation when flattening, and preserving field-level validation
// as allOf entries.
func preserveFields(dst *apiext.JSONSchemaProps, src apiext.JSONSchemaProps) {
	srcDesc := src.Description
	srcTitle := src.Title
	srcExDoc := src.ExternalDocs
	srcEx := src.Example

	src.Description, src.Title, src.ExternalDocs, src.Example = "", "", nil, nil

	src.Ref = nil
	*dst = apiext.JSONSchemaProps{
		AllOf: []apiext.JSONSchemaProps{*dst, src},

		// keep these, in case the source field doesn't specify anything useful
		Description:  dst.Description,
		Title:        dst.Title,
		ExternalDocs: dst.ExternalDocs,
		Example:      dst.Example,
	}

	if srcDesc != "" {
		dst.Description = srcDesc
	}
	if srcTitle != "" {
		dst.Title = srcTitle
	}
	if srcExDoc != nil {
		dst.ExternalDocs = srcExDoc
	}
	if srcEx != nil {
		dst.Example = srcEx
	}
}

// flattenVisitor visits each node in the schema, recursively flattening references.
type flattenVisitor struct {
	*Flattener

	currentPackage *loader.Package
	currentType    *TypeIdent
	currentSchema  *apiext.JSONSchemaProps
	originalField  apiext.JSONSchemaProps
}

func (f *flattenVisitor) Visit(baseSchema *apiext.JSONSchemaProps) SchemaVisitor {
	if baseSchema == nil {
		// end-of-node marker, cache the results
		if f.currentType != nil {
			f.cacheType(*f.currentType, *f.currentSchema)
			// preserve field information *after* caching so that we don't
			// accidentally cache field-level information onto the schema for
			// the type in general.
			preserveFields(f.currentSchema, f.originalField)
		}
		return f
	}

	// if we get a type that's just a ref, resolve it
	if baseSchema.Ref != nil && len(*baseSchema.Ref) > 0 {
		// resolve this ref
		refIdent, err := f.LookupReference(*baseSchema.Ref, f.currentPackage)
		if err != nil {
			f.currentPackage.AddError(err)
			return nil
		}

		// load and potentially flatten the schema

		// check the cache first...
		if refSchemaCached, isCached := f.flattenedTypes[refIdent]; isCached {
			// shallow copy is fine, it's just to avoid overwriting the doc fields
			preserveFields(&refSchemaCached, *baseSchema)
			*baseSchema = refSchemaCached
			return nil // don't recurse, we're done
		}

		// ...otherwise, we need to flatten
		refSchema, err := f.loadUnflattenedSchema(refIdent)
		if err != nil {
			f.currentPackage.AddError(err)
			return nil
		}
		refSchema = refSchema.DeepCopy()

		// keep field around to preserve field-level validation, docs, etc
		origField := *baseSchema
		*baseSchema = *refSchema

		// avoid loops (which shouldn't exist, but just in case)
		// by marking a nil cached pointer before we start recursing
		f.cacheType(refIdent, apiext.JSONSchemaProps{})

		return &flattenVisitor{
			Flattener: f.Flattener,

			currentPackage: refIdent.Package,
			currentType:    &refIdent,
			currentSchema:  baseSchema,
			originalField:  origField,
		}
	}

	// otherwise, continue recursing...
	if f.currentType != nil {
		// ...but don't accidentally end this node early (for caching purposes)
		return &flattenVisitor{
			Flattener:      f.Flattener,
			currentPackage: f.currentPackage,
		}
	}

	return f
}
