// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley
// SPDX-License-Identifier: MIT

package responses

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"reflect"
	"regexp"
	"strconv"

	"github.com/pb33f/libopenapi/datamodel/high/base"
	"github.com/pb33f/libopenapi/utils"
	"github.com/santhosh-tekuri/jsonschema/v6"
	"go.yaml.in/yaml/v4"
	"golang.org/x/text/language"
	"golang.org/x/text/message"

	"github.com/pb33f/libopenapi-validator/cache"
	"github.com/pb33f/libopenapi-validator/config"
	liberrors "github.com/pb33f/libopenapi-validator/errors"
	"github.com/pb33f/libopenapi-validator/helpers"
	"github.com/pb33f/libopenapi-validator/schema_validation"
	"github.com/pb33f/libopenapi-validator/strict"
)

var instanceLocationRegex = regexp.MustCompile(`^/(\d+)`)

// ValidateResponseSchemaInput contains parameters for response schema validation.
type ValidateResponseSchemaInput struct {
	Request  *http.Request   // Required: The HTTP request (for context)
	Response *http.Response  // Required: The HTTP response to validate
	Schema   *base.Schema    // Required: The OpenAPI schema to validate against
	Version  float32         // Required: OpenAPI version (3.0 or 3.1)
	Options  []config.Option // Optional: Functional options (defaults applied if empty/nil)
}

// ValidateResponseSchema will validate the response body for a http.Response pointer. The request is used to
// locate the operation in the specification, the response is used to ensure the response code, media type and the
// schema of the response body are valid.
//
// This function is used by the ValidateResponseBody function, but can be used independently.
// The schema will be compiled from cache if available, otherwise it will be compiled and cached.
func ValidateResponseSchema(input *ValidateResponseSchemaInput) (bool, []*liberrors.ValidationError) {
	validationOptions := config.NewValidationOptions(input.Options...)
	var validationErrors []*liberrors.ValidationError
	var renderedSchema, jsonSchema []byte
	var referenceSchema string
	var compiledSchema *jsonschema.Schema
	var cachedNode *yaml.Node

	if input.Schema == nil {
		return false, []*liberrors.ValidationError{{
			ValidationType:    helpers.ResponseBodyValidation,
			ValidationSubType: helpers.Schema,
			Message:           "schema is nil",
			Reason:            "The schema to validate against is nil",
		}}
	} else if input.Schema.GoLow() == nil {
		return false, []*liberrors.ValidationError{{
			ValidationType:    helpers.ResponseBodyValidation,
			ValidationSubType: helpers.Schema,
			Message:           "schema cannot be rendered",
			Reason:            "The schema does not have low-level information and cannot be rendered. Please ensure the schema is loaded from a document.",
		}}
	}

	if validationOptions.SchemaCache != nil {
		hash := input.Schema.GoLow().Hash()
		if cached, ok := validationOptions.SchemaCache.Load(hash); ok && cached != nil && cached.CompiledSchema != nil {
			renderedSchema = cached.RenderedInline
			referenceSchema = cached.ReferenceSchema
			compiledSchema = cached.CompiledSchema
			cachedNode = cached.RenderedNode
		}
	}

	// Cache miss or no cache - render and compile
	if compiledSchema == nil {
		renderCtx := base.NewInlineRenderContextForValidation()
		var renderErr error
		renderedSchema, renderErr = input.Schema.RenderInlineWithContext(renderCtx)
		referenceSchema = string(renderedSchema)

		// If rendering failed (e.g., circular reference), return the render error
		if renderErr != nil {
			violation := &liberrors.SchemaValidationFailure{
				Reason:          renderErr.Error(),
				ReferenceSchema: referenceSchema,
			}
			validationErrors = append(validationErrors, &liberrors.ValidationError{
				ValidationType:    helpers.ResponseBodyValidation,
				ValidationSubType: helpers.Schema,
				Message: fmt.Sprintf("%d response body for '%s' failed schema rendering",
					input.Response.StatusCode, input.Request.URL.Path),
				Reason: fmt.Sprintf("The response schema for status code '%d' failed to render: %s",
					input.Response.StatusCode, renderErr.Error()),
				SpecLine:               1,
				SpecCol:                0,
				SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation},
				HowToFix:               "check the response schema for circular references or invalid structures",
				Context:                referenceSchema,
			})
			return false, validationErrors
		}

		jsonSchema, _ = utils.ConvertYAMLtoJSON(renderedSchema)

		var err error
		schemaName := fmt.Sprintf("%x", input.Schema.GoLow().Hash())
		compiledSchema, err = helpers.NewCompiledSchemaWithVersion(
			schemaName,
			jsonSchema,
			validationOptions,
			input.Version,
		)
		if err != nil {
			validationErrors = append(validationErrors, &liberrors.ValidationError{
				ValidationType:    helpers.ResponseBodyValidation,
				ValidationSubType: helpers.Schema,
				Message: fmt.Sprintf("%d response body for '%s' failed schema compilation",
					input.Response.StatusCode, input.Request.URL.Path),
				Reason: fmt.Sprintf("The response schema for status code '%d' failed to compile: %s",
					input.Response.StatusCode, err.Error()),
				SpecLine: 1,
				SpecCol:  0,
				HowToFix: "check the response schema for invalid JSON Schema syntax, complex regex patterns, or unsupported schema constructs",
				Context:  input.Schema,
			})
			return false, validationErrors
		}

		if validationOptions.SchemaCache != nil {
			hash := input.Schema.GoLow().Hash()
			validationOptions.SchemaCache.Store(hash, &cache.SchemaCacheEntry{
				Schema:          input.Schema,
				RenderedInline:  renderedSchema,
				ReferenceSchema: referenceSchema,
				RenderedJSON:    jsonSchema,
				CompiledSchema:  compiledSchema,
			})
		}
	}

	request := input.Request
	response := input.Response
	schema := input.Schema

	if response == nil || response.Body == http.NoBody {

		// skip response body validation for head request after processing schema
		if response != nil && request != nil && request.Method == http.MethodHead {
			return len(validationErrors) == 0, validationErrors
		}
		// cannot decode the response body, so it's not valid
		validationErrors = append(validationErrors, &liberrors.ValidationError{
			ValidationType:    "response",
			ValidationSubType: "object",
			Message: fmt.Sprintf("%s response object is missing for '%s'",
				request.Method, request.URL.Path),
			Reason:   "The response object is completely missing",
			SpecLine: 1,
			SpecCol:  0,
			HowToFix: "ensure response object has been set",
			Context:  schema,
		})
		return false, validationErrors
	}

	responseBody, ioErr := io.ReadAll(response.Body)
	if ioErr != nil {
		// cannot decode the response body, so it's not valid
		validationErrors = append(validationErrors, &liberrors.ValidationError{
			ValidationType:    helpers.ResponseBodyValidation,
			ValidationSubType: helpers.Schema,
			Message: fmt.Sprintf("%s response body for '%s' cannot be read, it's empty or malformed",
				request.Method, request.URL.Path),
			Reason:   fmt.Sprintf("The response body cannot be decoded: %s", ioErr.Error()),
			SpecLine: 1,
			SpecCol:  0,
			HowToFix: "ensure body is not empty",
			Context:  schema,
		})
		return false, validationErrors
	}

	// close the request body, so it can be re-read later by another player in the chain
	_ = response.Body.Close()
	response.Body = io.NopCloser(bytes.NewBuffer(responseBody))

	var decodedObj interface{}

	if len(responseBody) > 0 {
		// Per RFC7231, a response to a HEAD request MUST NOT include a message body.
		if request != nil && request.Method == http.MethodHead {
			violation := &liberrors.SchemaValidationFailure{
				Reason:          "HEAD responses must not include a message body",
				ReferenceObject: string(responseBody),
				ReferenceSchema: referenceSchema,
			}
			validationErrors = append(validationErrors, &liberrors.ValidationError{
				ValidationType:    helpers.ResponseBodyValidation,
				ValidationSubType: helpers.Schema,
				Message: fmt.Sprintf("%s response for '%s' must not include a body",
					request.Method, request.URL.Path),
				Reason:                 "The response to a HEAD request must not contain a body",
				SpecLine:               1,
				SpecCol:                0,
				SchemaValidationErrors: []*liberrors.SchemaValidationFailure{violation},
				HowToFix:               "ensure no response body is present for HEAD requests",
				Context:                referenceSchema,
			})
			return false, validationErrors
		}
		err := json.Unmarshal(responseBody, &decodedObj)
		if err != nil {
			// cannot decode the response body, so it's not valid
			validationErrors = append(validationErrors, &liberrors.ValidationError{
				ValidationType:    helpers.ResponseBodyValidation,
				ValidationSubType: helpers.Schema,
				Message: fmt.Sprintf("%s response body for '%s' failed to validate schema",
					request.Method, request.URL.Path),
				Reason:   fmt.Sprintf("The response body cannot be decoded: %s", err.Error()),
				SpecLine: 1,
				SpecCol:  0,
				HowToFix: liberrors.HowToFixInvalidSchema,
				Context:  schema,
			})
			return false, validationErrors
		}
	}

	// no response body? failed to decode anything? nothing to do here.
	if responseBody == nil || decodedObj == nil {
		return true, nil
	}

	// validate the object against the schema
	scErrs := compiledSchema.Validate(decodedObj)
	if scErrs != nil {
		var jk *jsonschema.ValidationError
		var schemaValidationErrors []*liberrors.SchemaValidationFailure

		if errors.As(scErrs, &jk) {
			// flatten the validationErrors
			schFlatErrs := jk.BasicOutput().Errors

			renderedNode := cachedNode
			if renderedNode == nil {
				renderedNode = new(yaml.Node)
				_ = yaml.Unmarshal(renderedSchema, renderedNode)
			}

			for q := range schFlatErrs {
				er := schFlatErrs[q]

				errMsg := er.Error.Kind.LocalizedString(message.NewPrinter(language.Tag{}))
				if er.KeywordLocation == "" || helpers.IgnoreRegex.MatchString(errMsg) {
					continue // ignore this error, it's useless tbh, utter noise.
				}
				if er.Error != nil {
					// locate the violated property in the schema
					var located *yaml.Node
					if len(renderedNode.Content) > 0 {
						located = schema_validation.LocateSchemaPropertyNodeByJSONPath(renderedNode.Content[0], er.KeywordLocation)
					}

					// extract the element specified by the instance
					val := instanceLocationRegex.FindStringSubmatch(er.InstanceLocation)
					var referenceObject string

					if len(val) > 0 {
						referenceIndex, _ := strconv.Atoi(val[1])
						if reflect.ValueOf(decodedObj).Type().Kind() == reflect.Slice {
							found := decodedObj.([]any)[referenceIndex]
							recoded, _ := json.MarshalIndent(found, "", "  ")
							referenceObject = string(recoded)
						}
					}
					if referenceObject == "" {
						referenceObject = string(responseBody)
					}

					violation := &liberrors.SchemaValidationFailure{
						Reason:                  errMsg,
						FieldName:               helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation),
						FieldPath:               helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation),
						InstancePath:            helpers.ConvertStringLocationToPathSegments(er.InstanceLocation),
						KeywordLocation:         er.KeywordLocation,
						ReferenceSchema:         referenceSchema,
						ReferenceObject:         referenceObject,
						OriginalJsonSchemaError: jk,
					}
					// if we have a location within the schema, add it to the error
					if located != nil {

						line := located.Line
						// if the located node is a map or an array, then the actual human interpretable
						// line on which the violation occurred is the line of the key, not the value.
						if located.Kind == yaml.MappingNode || located.Kind == yaml.SequenceNode {
							if line > 0 {
								line--
							}
						}

						// location of the violation within the rendered schema.
						violation.Line = line
						violation.Column = located.Column
					}
					schemaValidationErrors = append(schemaValidationErrors, violation)
				}
			}
		}

		line := 1
		col := 0
		if low := schema.GoLow(); low != nil && low.Type.KeyNode != nil {
			line = low.Type.KeyNode.Line
			col = low.Type.KeyNode.Column
		}

		// add the error to the list
		validationErrors = append(validationErrors, &liberrors.ValidationError{
			ValidationType:    helpers.ResponseBodyValidation,
			ValidationSubType: helpers.Schema,
			Message: fmt.Sprintf("%d response body for '%s' failed to validate schema",
				response.StatusCode, request.URL.Path),
			Reason: fmt.Sprintf("The response body for status code '%d' is defined as an object. "+
				"However, it does not meet the schema requirements of the specification", response.StatusCode),
			SpecLine:               line,
			SpecCol:                col,
			SchemaValidationErrors: schemaValidationErrors,
			HowToFix:               liberrors.HowToFixInvalidSchema,
			Context:                schema,
		})
	}
	if len(validationErrors) > 0 {
		return false, validationErrors
	}

	// strict mode: check for undeclared properties in response body
	if validationOptions.StrictMode && decodedObj != nil {
		strictValidator := strict.NewValidator(validationOptions, input.Version)
		strictResult := strictValidator.Validate(strict.Input{
			Schema:    schema,
			Data:      decodedObj,
			Direction: strict.DirectionResponse,
			Options:   validationOptions,
			BasePath:  "$.body",
			Version:   input.Version,
		})

		if !strictResult.Valid {
			for _, undeclared := range strictResult.UndeclaredValues {
				switch undeclared.Type {
				case strict.TypeWriteOnlyProperty:
					validationErrors = append(validationErrors,
						liberrors.WriteOnlyPropertyError(
							undeclared.Path, undeclared.Name, undeclared.Value,
							request.URL.Path, request.Method,
							undeclared.SpecLine, undeclared.SpecCol,
						))
				default:
					validationErrors = append(validationErrors,
						liberrors.UndeclaredPropertyError(
							undeclared.Path,
							undeclared.Name,
							undeclared.Value,
							undeclared.DeclaredProperties,
							undeclared.Direction.String(),
							request.URL.Path,
							request.Method,
							undeclared.SpecLine,
							undeclared.SpecCol,
						))
				}
			}
		}
	}

	if len(validationErrors) > 0 {
		return false, validationErrors
	}
	return true, nil
}
