package jsonschema

import (
	"bytes"
	"encoding/json"
	"errors"
	"net/url"
	"regexp"
	"strconv"

	"github.com/buger/jsonparser"
)

func New(schema []byte) (*Schema, error) {
	var nilSchema *Schema
	return nilSchema.Parse(schema)
	// return new(Schema).Parse(schema)
}

func NewFromString(schema string) (*Schema, error) {
	return New([]byte(schema))
}

func (s *Schema) SetCircularRefThresHold(threshold int) {
	s.circularThreshold = threshold
}

// This it to ensure MarshalJSON doesn't go haywire
type tmpSchema Schema

type Schema struct {
	// name contains e.g. the property name, under which this schema was found.
	// It may not always contain a name, but should at least for schemas with type object.
	name string

	// raw contains the raw json schema - necessary in some special cases like de-ref $refs
	raw []byte

	// Root schema is the top most schema.
	root *Schema

	// Parent schema is the nearest schema, up the stack
	parent *Schema

	// Base schema is the nearest schema, up the stack, with a non-pointer (#xxx) ($)id set
	base *Schema

	// baseURI is present on any schema with an $id
	baseURI *url.URL

	// pointers holds references to schemas with ($)id, collected during parsing - the map key is ($)id
	pointers *pointers

	// refs holds pointers to $ref objects to make de-ref'ing easier.
	// These should only be present on the root schema.
	refs *refs

	// circularThreshold is the threshold for when to stop resolving $refs and just print the $ref string
	// Should only be set on root
	circularThreshold int

	// Not sure this is the way to go
	// Array of validator functions.
	// These are added after checking for all possible constraints
	validators []validatorFunc

	// This is to make it easier to deal with true / false schemas
	boolean *bool

	// Unknown properties and their values are stored, so they can be marshalled
	unknownProps []*NamedValue

	/* Schema definition fields */

	Schema    *string `json:"$schema,omitempty"`
	ID        *string `json:"$id,omitempty"` // NOTE: draft-04 has id instead if $id
	IDDraft04 *string `json:"id,omitempty"`  // NOTE: draft-04 has id instead if $id
	Ref       *Ref    `json:"$ref,omitempty"`
	Comment   *string `json:"$comment,omitempty"`

	Title       *string `json:"title,omitempty"`
	Description *string `json:"description,omitempty"`

	Type *Type `json:"type,omitempty"`

	/* Common / shared */

	// Must have at least 1 value
	Enum     *Enum   `json:"enum,omitempty"`
	Default  *Value  `json:"default,omitempty"`
	Examples *Values `json:"examples,omitempty"`
	// Draft 6
	// Only allow 1 value
	Const *Value `json:"const,omitempty"`
	// Draft 7
	ReadOnly    *bool       `json:"readOnly,omitempty"`
	WriteOnly   *bool       `json:"writeOnly,omitempty"`
	Definitions *Properties `json:"definitions,omitempty"`
	// If schemas should look something like (const being the important part):
	//  { "if": { "properties": { "propertyX": { "const": "ValueX" } }, "required": ["propertyX"] } }
	If *Schema `json:"if,omitempty"`
	// One (or both?) of these can be omitted.
	// Both then and else will be ignore, if If is not defined.
	// If any of them are omitted, the value true is used in their place.
	// NOTE: It's not entirely obvious in the documentation, if both can be omitted:
	// https://json-schema.org/understanding-json-schema/reference/conditionals.html#if-then-else
	Then *Schema `json:"then,omitempty"`
	Else *Schema `json:"else,omitempty"`

	AllOf *Schemas `json:"allOf,omitempty"`
	AnyOf *Schemas `json:"anyOf,omitempty"`
	OneOf *Schemas `json:"oneOf,omitempty"`
	Not   *Schema  `json:"not,omitempty"`

	ContentEncoding  *string `json:"contentEncoding,omitempty"`  // e.g. base64
	ContentMediaType *string `json:"contentMediaType,omitempty"` // e.g. image/png

	/* Objects */

	Properties *Properties `json:"properties,omitempty"`
	// Draft 4 requires at least 1 string
	Required      *Strings `json:"required,omitempty"`
	MaxProperties *int64   `json:"maxProperties,omitempty"`
	MinProperties *int64   `json:"minProperties,omitempty"`
	// Dependencies is either:
	//  - if propertyX is set, then propertyY and propertyZ is required
	//     e.g.: { "propertyX": ["propertyY", "propertyZ"] }
	//  - if propertyX is set, then schemaX is also required to match
	//     e.g.: { "propertyX": { "properties": { "propertyY": { "type": "string" } }, "required": ["propertyY"] } }
	Dependencies *Dependencies `json:"dependencies,omitempty"`
	// patternProperties is used to match property names against a regex and for each a schema.
	// It's basically a map of schemas, but with regex instead of property names.
	PatternProperties        *Properties `json:"patternProperties,omitempty"`
	patternPropertiesRegexps *map[string]*regexp.Regexp
	// additionalProperties is a schema that will be used to validate any properties
	//  in the instance that are not matched by properties or patternProperties.
	// Setting it to false means no additional properties will be allowed.
	AdditionalProperties *Schema `json:"additionalProperties,omitempty"`
	//
	// Draft 6
	// Useful for enforcing a certain property name format
	// Property names implies { "type": "string" }
	// "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"}
	PropertyNames *Schema `json:"propertyNames,omitempty"`

	/* Arrays */

	// When items is an array of multiples Schemas, each refers to their own index.
	Items       *Items `json:"items,omitempty"` // TODO: Can actually also be boolean
	MaxItems    *int64 `json:"maxItems,omitempty"`
	MinItems    *int64 `json:"minItems,omitempty"`
	UniqueItems *bool  `json:"uniqueItems,omitempty"`
	// Should only be evaluated when items is multiple schemas.
	// Any values that does not have an explicit schmea (multi schema),
	//  will validate according to this schema.
	// Setting it to false, means that no other values are allowed.
	AdditionalItems *Schema `json:"additionalItems,omitempty"`
	// contains only need to match 1 item in the documents array
	Contains *Schema `json:"contains,omitempty"`

	/* String */

	MaxLength     *int64  `json:"maxLength,omitempty"`
	MinLength     *int64  `json:"minLength,omitempty"`
	Format        *string `json:"format,omitempty"`
	Pattern       *string `json:"pattern,omitempty"`
	patternRegexp *regexp.Regexp

	/* Integer / number */

	// The type (int/float) should of course match the type of the property
	MultipleOf *json.Number `json:"multipleOf,omitempty"`
	// Draft 4: x ≥ minimum unless exclusiveMinimum == true, x ≤ maximum unless exclusiveMaximum == true
	// Draft 6: x ≥ minimum, x > exclusiveMinimum, x ≤ maximum, x < exclusiveMaximum
	Maximum          *Value `json:"maximum,omitempty"`
	ExclusiveMaximum *Value `json:"exclusiveMaximum,omitempty"` // bool in draft 4
	Minimum          *Value `json:"minimum,omitempty"`
	ExclusiveMinimum *Value `json:"exclusiveMinimum,omitempty"` // bool in draft 4
}

func (s *Schema) SetUnknown(name string, val *Value) error {
	for i, up := range s.unknownProps {
		if up.Name == name {
			s.unknownProps[i].Value = val
			return nil
		}
	}
	s.unknownProps = append(s.unknownProps, &NamedValue{Name: name, Value: val})
	return nil
}

func (s *Schema) GetUnknown(name string) (*Value, error) {
	if s.unknownProps == nil {
		return nil, errors.New("unknown property not found")
	}

	for _, up := range s.unknownProps {
		if up.Name == name {
			return up.Value, nil
		}
	}

	return nil, errors.New("unknown property not found")
}

func (s Schema) MarshalJSON() ([]byte, error) {
	if s.boolean != nil {
		return []byte(strconv.FormatBool(*s.boolean)), nil
	}

	// Reset the refs' counters, if this is the root object being marshalled
	if s.root == nil && s.refs != nil {
		for _, ref := range *s.refs {
			ref.marshalled = 0
		}
	}

	circularThreshold := s.circularThreshold
	if s.root != nil {
		circularThreshold = s.root.circularThreshold
	}

	if s.Ref != nil && s.Ref.Schema != nil && s.Ref.marshalled < circularThreshold {
		s.Ref.marshalled++
		b, err := json.Marshal(tmpSchema(*s.Ref.Schema))
		// TODO: Why is this set to 0???
		s.Ref.marshalled = 0
		return b, err

	} else if s.Ref != nil && s.Ref.String != nil {
		// All of the following is basically to make it possible to keep ignored properties
		// and marshal them back - otherwise we could just do this:
		// return []byte(fmt.Sprintf(`{"$ref": "%s"}`, *s.Ref.String)), nil

		// Make a copy of the schema
		newSchema, err := New(s.raw)
		if err != nil {
			return nil, err
		}

		// Remove any items, properties and definitions, that might hold more $refs
		// newSchema.Items = nil
		// newSchema.Properties = nil
		// newSchema.Definitions = nil

		// Set the ref again, but without the Schema part
		newSchema.Ref = &Ref{
			String: s.Ref.String,
		}

		// Now marshal the new schema without refs
		return json.Marshal(tmpSchema(*newSchema))
	}

	b, err := json.Marshal(tmpSchema(s))

	// Set unknown properties
	for _, up := range s.unknownProps {
		val, err := up.Value.MarshalJSON()
		if err != nil {
			return nil, err
		}
		b, err = jsonparser.Set(b, val, up.Name)
		if err != nil {
			return nil, err
		}
	}

	return b, err
}

func (s *Schema) UnmarshalJSON(schema []byte) error {
	newSchema, err := New(schema)
	*s = *newSchema
	return err
}

func (s Schema) String() string {
	schema, err := s.MarshalJSON()
	if err != nil {
		return ""
	}

	return string(schema)
}

func (s Schema) Pretty() string {
	schema := s.String()
	var out bytes.Buffer
	json.Indent(&out, []byte(schema), "", "  ")
	return out.String()
}

func (s *Schema) findPatternProperties(key []byte) []*Schema {
	if s.patternPropertiesRegexps == nil {
		return nil
	}

	schemas := []*Schema{}
	for reStr, re := range *s.patternPropertiesRegexps {
		if re.Match(key) {
			prop, ok := (*s.PatternProperties).GetProperty(reStr)
			if ok {
				schemas = append(schemas, prop.Property)
			}
		}
	}

	if len(schemas) > 0 {
		return schemas
	}

	return nil
}

func (s Schema) IsDraft4() bool {
	if s.Schema != nil {
		if *s.Schema == "http://json-schema.org/draft-04/schema#" {
			return true
		}
		if *s.Schema == "http://json-schema.org/draft-05/schema#" {
			// Draft 5 was a no-change patch for Draft 4
			return true
		}
		if *s.Schema == "http://json-schema.org/schema#" {
			// Means "latest schema", this was deprectaed after Draft 4
			return true
		}
	}

	return (s.Schema != nil && *s.Schema == "http://json-schema.org/draft-04/schema#")
}

func (s Schema) IsDraft6() bool {
	return (s.Schema != nil && *s.Schema == "http://json-schema.org/draft-06/schema#")
}

func (s Schema) IsDraft7() bool {
	return (s.Schema != nil && *s.Schema == "http://json-schema.org/draft-07/schema#")
}

func (s Schema) GetID() string {
	if s.ID != nil {
		return *s.ID
	}
	if s.IDDraft04 != nil {
		return *s.IDDraft04
	}

	return ""
}

func (s *Schema) SetID(id string) {
	if s.IsDraft4() {
		*s.IDDraft04 = id
	} else {
		*s.ID = id
	}
}

// Checks if everything is nil and thereby an empty schema, similar to a "true" schema
// TODO: Update with 20xx-xx props
func (s Schema) IsEmpty() bool {
	return ((s.boolean == nil) &&
		(s.unknownProps == nil) &&
		(s.Schema == nil) &&
		(s.ID == nil) &&
		(s.IDDraft04 == nil) &&
		(s.Ref == nil) &&
		(s.Comment == nil) &&
		(s.Title == nil) &&
		(s.Description == nil) &&
		(s.Type == nil) &&
		(s.Enum == nil) &&
		(s.Default == nil) &&
		(s.Const == nil) &&
		(s.Examples == nil) &&
		(s.ReadOnly == nil) &&
		(s.WriteOnly == nil) &&
		(s.Definitions == nil) &&
		(s.If == nil) &&
		(s.Then == nil) &&
		(s.Else == nil) &&
		(s.AllOf == nil) &&
		(s.AnyOf == nil) &&
		(s.OneOf == nil) &&
		(s.Not == nil) &&
		(s.ContentEncoding == nil) &&
		(s.ContentMediaType == nil) &&
		(s.Properties == nil) &&
		(s.Required == nil) &&
		(s.MaxProperties == nil) &&
		(s.MinProperties == nil) &&
		(s.Dependencies == nil) &&
		(s.PatternProperties == nil) &&
		(s.AdditionalProperties == nil) &&
		(s.PropertyNames == nil) &&
		(s.Items == nil) &&
		(s.MaxItems == nil) &&
		(s.MinItems == nil) &&
		(s.UniqueItems == nil) &&
		(s.AdditionalItems == nil) &&
		(s.Contains == nil) &&
		(s.MaxLength == nil) &&
		(s.MinLength == nil) &&
		(s.Format == nil) &&
		(s.Pattern == nil) &&
		(s.MultipleOf == nil) &&
		(s.Maximum == nil) &&
		(s.ExclusiveMaximum == nil) &&
		(s.Minimum == nil) &&
		(s.ExclusiveMinimum == nil))
}
