// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

package analysis

import (
	"encoding/json"

	"github.com/go-openapi/spec"
	"github.com/go-openapi/strfmt"
	"github.com/go-openapi/swag/loading"
)

// SchemaOpts configures the schema analyzer.
type SchemaOpts struct {
	Schema   *spec.Schema
	Root     any
	BasePath string

	// PathLoaderWithOptions injects the document loader (with loading options) used to resolve
	// remote and relative $ref while analyzing a schema.
	//
	// Security: set this to a confined loader — for example one built with loading.WithRoot and
	// loading.WithHTTPClient, or a restricted loader from go-openapi/loads — when the schema may
	// derive from an untrusted source. Left nil, the spec package default (unsandboxed) loader is
	// used.
	PathLoaderWithOptions func(string, ...loading.Option) (json.RawMessage, error)

	_ struct{}
}

// Schema analysis, will classify the schema according to known
// patterns.
func Schema(opts SchemaOpts) (*AnalyzedSchema, error) {
	if opts.Schema == nil {
		return nil, ErrNoSchema
	}

	a := &AnalyzedSchema{
		schema:                opts.Schema,
		root:                  opts.Root,
		basePath:              opts.BasePath,
		pathLoaderWithOptions: opts.PathLoaderWithOptions,
	}

	a.initializeFlags()
	a.inferKnownType()
	a.inferEnum()
	a.inferBaseType()

	if err := a.inferMap(); err != nil {
		return nil, err
	}
	if err := a.inferArray(); err != nil {
		return nil, err
	}

	a.inferTuple()

	if err := a.inferFromRef(); err != nil {
		return nil, err
	}

	a.inferSimpleSchema()

	return a, nil
}

// AnalyzedSchema indicates what the schema represents.
type AnalyzedSchema struct {
	schema                *spec.Schema
	root                  any
	basePath              string
	pathLoaderWithOptions func(string, ...loading.Option) (json.RawMessage, error)

	hasProps           bool
	hasAllOf           bool
	hasItems           bool
	hasAdditionalProps bool
	hasAdditionalItems bool
	hasRef             bool

	IsKnownType      bool
	IsSimpleSchema   bool
	IsArray          bool
	IsSimpleArray    bool
	IsMap            bool
	IsSimpleMap      bool
	IsExtendedObject bool
	IsTuple          bool
	IsTupleWithExtra bool
	IsBaseType       bool
	IsEnum           bool
}

// Inherits copies value fields from other onto this schema.
func (a *AnalyzedSchema) inherits(other *AnalyzedSchema) {
	if other == nil {
		return
	}
	a.hasProps = other.hasProps
	a.hasAllOf = other.hasAllOf
	a.hasItems = other.hasItems
	a.hasAdditionalItems = other.hasAdditionalItems
	a.hasAdditionalProps = other.hasAdditionalProps
	a.hasRef = other.hasRef

	a.IsKnownType = other.IsKnownType
	a.IsSimpleSchema = other.IsSimpleSchema
	a.IsArray = other.IsArray
	a.IsSimpleArray = other.IsSimpleArray
	a.IsMap = other.IsMap
	a.IsSimpleMap = other.IsSimpleMap
	a.IsExtendedObject = other.IsExtendedObject
	a.IsTuple = other.IsTuple
	a.IsTupleWithExtra = other.IsTupleWithExtra
	a.IsBaseType = other.IsBaseType
	a.IsEnum = other.IsEnum
}

// subSchemaOpts builds SchemaOpts for a nested schema, propagating the root, base path and the
// injected document loader so that confinement applies throughout the recursive analysis.
func (a *AnalyzedSchema) subSchemaOpts(sch *spec.Schema) SchemaOpts {
	return SchemaOpts{
		Schema:                sch,
		Root:                  a.root,
		BasePath:              a.basePath,
		PathLoaderWithOptions: a.pathLoaderWithOptions,
	}
}

// expandOpts builds the spec expand options for this analysis, carrying the injected loader so
// remote/relative $ref are resolved through it (rather than the unsandboxed package default).
func (a *AnalyzedSchema) expandOpts() *spec.ExpandOptions {
	return &spec.ExpandOptions{PathLoaderWithOptions: a.pathLoaderWithOptions}
}

func (a *AnalyzedSchema) inferFromRef() error {
	if a.hasRef {
		sch := new(spec.Schema)
		sch.Ref = a.schema.Ref
		err := spec.ExpandSchemaWithOptions(sch, a.root, nil, a.expandOpts())
		if err != nil {
			return err
		}
		rsch, err := Schema(a.subSchemaOpts(sch))
		if err != nil {
			// NOTE(fredbi): currently the only cause for errors is
			// unresolved ref. Since spec.ExpandSchema() expands the
			// schema recursively, there is no chance to get there,
			// until we add more causes for error in this schema analysis.
			return err
		}
		a.inherits(rsch)
	}

	return nil
}

func (a *AnalyzedSchema) inferSimpleSchema() {
	a.IsSimpleSchema = a.IsKnownType || a.IsSimpleArray || a.IsSimpleMap
}

func (a *AnalyzedSchema) inferKnownType() {
	tpe := a.schema.Type
	format := a.schema.Format
	a.IsKnownType = tpe.Contains("boolean") ||
		tpe.Contains("integer") ||
		tpe.Contains("number") ||
		tpe.Contains("string") ||
		(format != "" && strfmt.Default.ContainsName(format)) ||
		(a.isObjectType() && !a.hasProps && !a.hasAllOf && !a.hasAdditionalProps && !a.hasAdditionalItems)
}

func (a *AnalyzedSchema) inferMap() error {
	if !a.isObjectType() {
		return nil
	}

	hasExtra := a.hasProps || a.hasAllOf
	a.IsMap = a.hasAdditionalProps && !hasExtra
	a.IsExtendedObject = a.hasAdditionalProps && hasExtra

	if !a.IsMap {
		return nil
	}

	// maps
	if a.schema.AdditionalProperties.Schema != nil {
		msch, err := Schema(a.subSchemaOpts(a.schema.AdditionalProperties.Schema))
		if err != nil {
			return err
		}
		a.IsSimpleMap = msch.IsSimpleSchema
	} else if a.schema.AdditionalProperties.Allows {
		a.IsSimpleMap = true
	}

	return nil
}

func (a *AnalyzedSchema) inferArray() error {
	// an array has Items defined as an object schema, otherwise we qualify this JSON array as a tuple
	// (yes, even if the Items array contains only one element).
	// arrays in JSON schema may be unrestricted (i.e no Items specified).
	// Note that arrays in Swagger MUST have Items. Nonetheless, we analyze unrestricted arrays.
	//
	// NOTE: the spec package misses the distinction between:
	// items: [] and items: {}, so we consider both arrays here.
	a.IsArray = a.isArrayType() && (a.schema.Items == nil || a.schema.Items.Schemas == nil)
	if a.IsArray && a.hasItems {
		if a.schema.Items.Schema != nil {
			itsch, err := Schema(a.subSchemaOpts(a.schema.Items.Schema))
			if err != nil {
				return err
			}

			a.IsSimpleArray = itsch.IsSimpleSchema
		}
	}

	if a.IsArray && !a.hasItems {
		a.IsSimpleArray = true
	}

	return nil
}

func (a *AnalyzedSchema) inferTuple() {
	tuple := a.hasItems && a.schema.Items.Schemas != nil
	a.IsTuple = tuple && !a.hasAdditionalItems
	a.IsTupleWithExtra = tuple && a.hasAdditionalItems
}

func (a *AnalyzedSchema) inferBaseType() {
	if a.isObjectType() {
		a.IsBaseType = a.schema.Discriminator != ""
	}
}

func (a *AnalyzedSchema) inferEnum() {
	a.IsEnum = len(a.schema.Enum) > 0
}

func (a *AnalyzedSchema) initializeFlags() {
	a.hasProps = len(a.schema.Properties) > 0
	a.hasAllOf = len(a.schema.AllOf) > 0
	a.hasRef = a.schema.Ref.String() != ""

	a.hasItems = a.schema.Items != nil &&
		(a.schema.Items.Schema != nil || len(a.schema.Items.Schemas) > 0)

	a.hasAdditionalProps = a.schema.AdditionalProperties != nil &&
		(a.schema.AdditionalProperties.Schema != nil || a.schema.AdditionalProperties.Allows)

	a.hasAdditionalItems = a.schema.AdditionalItems != nil &&
		(a.schema.AdditionalItems.Schema != nil || a.schema.AdditionalItems.Allows)
}

func (a *AnalyzedSchema) isObjectType() bool {
	return !a.hasRef && (a.schema.Type == nil || a.schema.Type.Contains("") || a.schema.Type.Contains("object"))
}

func (a *AnalyzedSchema) isArrayType() bool {
	return !a.hasRef && (a.schema.Type != nil && a.schema.Type.Contains("array"))
}

// isAnalyzedAsComplex determines if an analyzed schema is eligible to flattening (i.e. it is "complex").
//
// Complex means the schema is any of:
//   - a simple type (primitive)
//   - an array of something (items are possibly complex ; if this is the case, items will generate a definition)
//   - a map of something (additionalProperties are possibly complex ; if this is the case, additionalProperties will
//     generate a definition)
func (a *AnalyzedSchema) isAnalyzedAsComplex() bool {
	return !a.IsSimpleSchema && !a.IsArray && !a.IsMap
}
