package framework

// Ported from github.com/openbao/openbao/sdk/v2/framework/openapi.go and
// adapted for warden's local types (logical.BackendClass, logical.Operation,
// logical.Response without Secret/WrapInfo, logical.Paths with only Root +
// Unauthenticated).

import (
	"errors"
	"fmt"
	"reflect"
	"regexp"
	"regexp/syntax"
	"sort"
	"strconv"
	"strings"

	"github.com/go-viper/mapstructure/v2"
	log "github.com/hashicorp/go-hclog"
	"github.com/stephnangue/warden/logical"
	"golang.org/x/text/cases"
	"golang.org/x/text/language"
)

// OpenAPI specification (OAS): https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md
const OASVersion = "3.0.2"

// NewOASDocument returns an empty OpenAPI document.
func NewOASDocument(version string) *OASDocument {
	return &OASDocument{
		Version: OASVersion,
		Info: OASInfo{
			Title:       "Warden API",
			Description: "HTTP API for the Warden identity-aware access fabric. All API routes are prefixed with `/v1/`.",
			Version:     version,
			License: OASLicense{
				Name: "Mozilla Public License 2.0",
				URL:  "https://www.mozilla.org/en-US/MPL/2.0",
			},
		},
		Paths: make(map[string]*OASPathItem),
		Components: OASComponents{
			Schemas: make(map[string]*OASSchema),
		},
	}
}

// NewOASDocumentFromMap builds an OASDocument from an existing map version of
// a document. After a JSON round-trip the document is a map[string]interface{}
// and needs special handling beyond the default mapstructure decoding.
func NewOASDocumentFromMap(input map[string]interface{}) (*OASDocument, error) {
	// The Responses map uses integer keys (the response code), but once translated into JSON
	// these become strings. mapstructure will not coerce these back to integers
	// without a custom decode hook.
	decodeHook := func(src reflect.Type, tgt reflect.Type, inputRaw interface{}) (interface{}, error) {
		// Only alter data if:
		//  1. going from string to int
		//  2. string represent an int in status code range (100-599)
		if src.Kind() == reflect.String && tgt.Kind() == reflect.Int {
			if input, ok := inputRaw.(string); ok {
				if intval, err := strconv.Atoi(input); err == nil {
					if intval >= 100 && intval < 600 {
						return intval, nil
					}
				}
			}
		}
		return inputRaw, nil
	}

	doc := new(OASDocument)

	config := &mapstructure.DecoderConfig{
		DecodeHook: decodeHook,
		Result:     doc,
	}

	decoder, err := mapstructure.NewDecoder(config)
	if err != nil {
		return nil, err
	}

	if err := decoder.Decode(input); err != nil {
		return nil, err
	}

	return doc, nil
}

type OASDocument struct {
	Version    string                  `json:"openapi" mapstructure:"openapi"`
	Info       OASInfo                 `json:"info"`
	Paths      map[string]*OASPathItem `json:"paths"`
	Components OASComponents           `json:"components"`
}

type OASComponents struct {
	Schemas map[string]*OASSchema `json:"schemas"`
}

type OASInfo struct {
	Title       string     `json:"title"`
	Description string     `json:"description"`
	Version     string     `json:"version"`
	License     OASLicense `json:"license"`
}

type OASLicense struct {
	Name string `json:"name"`
	URL  string `json:"url"`
}

type OASPathItem struct {
	Description       string             `json:"description,omitempty"`
	Parameters        []OASParameter     `json:"parameters,omitempty"`
	Sudo              bool               `json:"x-vault-sudo,omitempty" mapstructure:"x-vault-sudo"`
	Unauthenticated   bool               `json:"x-vault-unauthenticated,omitempty" mapstructure:"x-vault-unauthenticated"`
	CreateSupported   bool               `json:"x-vault-createSupported,omitempty" mapstructure:"x-vault-createSupported"`
	DisplayNavigation bool               `json:"x-vault-displayNavigation,omitempty" mapstructure:"x-vault-displayNavigation"`
	DisplayAttrs      *DisplayAttributes `json:"x-vault-displayAttrs,omitempty" mapstructure:"x-vault-displayAttrs"`

	Get    *OASOperation `json:"get,omitempty"`
	Post   *OASOperation `json:"post,omitempty"`
	Delete *OASOperation `json:"delete,omitempty"`
}

// NewOASOperation creates an empty OpenAPI Operations object.
func NewOASOperation() *OASOperation {
	return &OASOperation{
		Responses: make(map[int]*OASResponse),
	}
}

type OASOperation struct {
	Summary     string               `json:"summary,omitempty"`
	Description string               `json:"description,omitempty"`
	OperationID string               `json:"operationId,omitempty"`
	Tags        []string             `json:"tags,omitempty"`
	Parameters  []OASParameter       `json:"parameters,omitempty"`
	RequestBody *OASRequestBody      `json:"requestBody,omitempty"`
	Responses   map[int]*OASResponse `json:"responses"`
	Deprecated  bool                 `json:"deprecated,omitempty"`
}

type OASParameter struct {
	Name        string     `json:"name"`
	Description string     `json:"description,omitempty"`
	In          string     `json:"in"`
	Schema      *OASSchema `json:"schema,omitempty"`
	Required    bool       `json:"required,omitempty"`
	Deprecated  bool       `json:"deprecated,omitempty"`
}

type OASRequestBody struct {
	Description string     `json:"description,omitempty"`
	Required    bool       `json:"required,omitempty"`
	Content     OASContent `json:"content,omitempty"`
}

type OASContent map[string]*OASMediaTypeObject

type OASMediaTypeObject struct {
	Schema *OASSchema `json:"schema,omitempty"`
}

type OASSchema struct {
	Ref         string                `json:"$ref,omitempty"`
	Type        string                `json:"type,omitempty"`
	Description string                `json:"description,omitempty"`
	Properties  map[string]*OASSchema `json:"properties,omitempty"`

	// Required is a list of keys in Properties that are required to be present. This is a different
	// approach than OASParameter (unfortunately), but is how JSONSchema handles 'required'.
	Required []string `json:"required,omitempty"`

	Items        *OASSchema    `json:"items,omitempty"`
	Format       string        `json:"format,omitempty"`
	Pattern      string        `json:"pattern,omitempty"`
	Enum         []interface{} `json:"enum,omitempty"`
	Default      interface{}   `json:"default,omitempty"`
	Example      interface{}   `json:"example,omitempty"`
	Deprecated   bool          `json:"deprecated,omitempty"`
	DisplayValue interface{}   `json:"x-vault-displayValue,omitempty" mapstructure:"x-vault-displayValue,omitempty"`
	DisplaySensitive bool               `json:"x-vault-displaySensitive,omitempty" mapstructure:"x-vault-displaySensitive,omitempty"`
	DisplayGroup     string             `json:"x-vault-displayGroup,omitempty" mapstructure:"x-vault-displayGroup,omitempty"`
	DisplayAttrs     *DisplayAttributes `json:"x-vault-displayAttrs,omitempty" mapstructure:"x-vault-displayAttrs,omitempty"`
}

type OASResponse struct {
	Description string     `json:"description"`
	Content     OASContent `json:"content,omitempty"`
}

var OASStdRespOK = &OASResponse{
	Description: "OK",
}

var OASStdRespNoContent = &OASResponse{
	Description: "empty body",
}

// Regex for handling fields in paths, and string cleanup.
// Predefined here to avoid substantial recompilation.

var (
	nonWordRe    = regexp.MustCompile(`[^\w]+`)  // Match a sequence of non-word characters
	pathFieldsRe = regexp.MustCompile(`{(\w+)}`) // Capture OpenAPI-style named parameters, e.g. "lookup/{urltoken}",
	wsRe         = regexp.MustCompile(`\s+`)     // Match whitespace, to be compressed during cleaning
)

// ExtractBackend returns the *framework.Backend embedded in `b`, walking
// through arbitrary anonymous-field chains to find it. Provider wrappers
// (e.g. *awsBackend) embed *StreamingBackend, which itself embeds *Backend;
// auth methods embed *Backend directly; the system backend embeds *Backend.
// All of these are reachable.
//
// Returns nil for values that don't ultimately wrap a *framework.Backend
// (audit devices, custom logical.Backend implementations).
//
// Used by the sys/schema endpoint to document every framework-based mount
// without forcing every wrapper to implement an extraction method.
func ExtractBackend(b any) *Backend {
	if b == nil {
		return nil
	}
	return findBackend(reflect.ValueOf(b))
}

func findBackend(v reflect.Value) *Backend {
	if v.Kind() == reflect.Pointer && !v.IsNil() {
		if fb, ok := v.Interface().(*Backend); ok {
			return fb
		}
	}
	for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
		if v.IsNil() {
			return nil
		}
		v = v.Elem()
	}
	if v.Kind() != reflect.Struct {
		return nil
	}
	t := v.Type()
	for i := 0; i < t.NumField(); i++ {
		if !t.Field(i).Anonymous {
			continue
		}
		if fb := findBackend(v.Field(i)); fb != nil {
			return fb
		}
	}
	return nil
}

// DocumentMount documents a mounted backend's regular Paths into `doc`,
// prepending `mountPrefix` to every path key. Accepts any logical.Backend
// value: provider wrappers, *StreamingBackend, *Backend itself; the
// framework-level extraction is handled internally.
//
// StreamingPaths (e.g. gateway/.* proxies) are intentionally NOT documented:
// they accept arbitrary HTTP methods, sub-paths, and bodies, so any OAS
// fragment we'd emit would be fictional and would mislead agents and codegen
// tooling about what methods are actually supported.
func DocumentMount(b any, mountPrefix string, doc *OASDocument) error {
	fb := ExtractBackend(b)
	if fb == nil {
		return nil
	}
	return DocumentPathsWithMountPrefix(fb, mountPrefix, doc)
}

// DocumentPathsWithMountPrefix is the warden-facing entry point for assembling
// a server-wide OAS doc. It documents `backend` into `doc`, prepending
// `mountPrefix` to every path key so multiple mounts don't collide. The
// upstream `requestResponsePrefix` argument controls operation-ID and
// component-schema naming and is set to a slug derived from the mount.
//
// `mountPrefix` must be non-empty; passing "" returns an error rather than
// silently producing malformed `//<path>` keys that would conflict across
// mounts.
//
// Use DocumentPaths (no prefix) when you want a single backend's spec
// without mount-relative path prefixing, e.g. for the per-backend
// `?warden-help=1` response.
func DocumentPathsWithMountPrefix(backend *Backend, mountPrefix string, doc *OASDocument) error {
	mp := strings.Trim(mountPrefix, "/")
	if mp == "" {
		return errors.New("DocumentPathsWithMountPrefix: mountPrefix must be non-empty")
	}

	tmp := NewOASDocument(doc.Info.Version)
	requestResponsePrefix := strings.ReplaceAll(mp, "/", "-")
	if err := DocumentPaths(backend, requestResponsePrefix, tmp); err != nil {
		return err
	}
	for path, item := range tmp.Paths {
		// `path` looks like "/config" or "/role/{name}"; produce
		// "/aws/config", "/aws/role/{name}".
		full := "/" + mp + "/" + strings.TrimPrefix(path, "/")
		doc.Paths[full] = item
	}
	for name, schema := range tmp.Components.Schemas {
		if doc.Components.Schemas == nil {
			doc.Components.Schemas = map[string]*OASSchema{}
		}
		doc.Components.Schemas[name] = schema
	}
	return nil
}

// DocumentPaths parses all paths in a framework.Backend into OpenAPI paths.
// Path keys in the result are relative to the backend (not mount-prefixed).
// For a server-wide assembly, prefer DocumentPathsWithMountPrefix.
func DocumentPaths(backend *Backend, requestResponsePrefix string, doc *OASDocument) error {
	for _, p := range backend.Paths {
		if err := documentPath(p, backend.SpecialPaths(), requestResponsePrefix, backend.BackendClass, doc); err != nil {
			return err
		}
	}

	return nil
}

// documentPath parses a framework.Path into one or more OpenAPI paths.
func documentPath(p *Path, specialPaths *logical.Paths, requestResponsePrefix string, backendClass logical.BackendClass, doc *OASDocument) error {
	var sudoPaths []string
	var unauthPaths []string

	if specialPaths != nil {
		sudoPaths = specialPaths.Root
		unauthPaths = specialPaths.Unauthenticated
	}

	// Convert optional parameters into distinct patterns to be processed independently.
	forceUnpublished := false
	paths, err := expandPattern(p.Pattern)
	if err != nil {
		if errors.Is(err, errUnsupportableRegexpOperationForOpenAPI) {
			// Pattern cannot be transformed into sensible OpenAPI paths. In this case, we override the later
			// processing to use the regexp, as is, as the path, and behave as if Unpublished was set on every
			// operation (meaning the operations will not be represented in the OpenAPI document).
			//
			// This allows a human reading the OpenAPI document to notice that, yes, a path handler does exist,
			// even though it was not able to contribute actual OpenAPI operations.
			forceUnpublished = true
			paths = []string{p.Pattern}
		} else {
			return err
		}
	}

	for pathIndex, path := range paths {
		// Construct a top level PathItem which will be populated as the path is processed.
		pi := OASPathItem{
			Description: cleanString(p.HelpSynopsis),
		}

		pi.Sudo = specialPathMatch(path, sudoPaths)
		pi.Unauthenticated = specialPathMatch(path, unauthPaths)
		pi.DisplayAttrs = withoutOperationHints(p.DisplayAttrs)

		// If the newer style Operations map isn't defined, create one from the legacy fields.
		operations := p.Operations
		if operations == nil {
			operations = make(map[logical.Operation]OperationHandler)

			for opType, cb := range p.Callbacks {
				operations[opType] = &PathOperation{
					Callback: cb,
					Summary:  p.HelpSynopsis,
				}
			}
		}

		// Process path and header parameters, which are common to all operations.
		// Body fields will be added to individual operations.
		pathFields, bodyFields := splitFields(p.Fields, path)

		for name, field := range pathFields {
			location := "path"
			required := true

			if field == nil {
				continue
			}

			if field.Query {
				location = "query"
				required = false
			}

			t := convertType(field.Type)
			p := OASParameter{
				Name:        name,
				Description: cleanString(field.Description),
				In:          location,
				Schema: &OASSchema{
					Type:         t.baseType,
					Pattern:      t.pattern,
					Enum:         field.AllowedValues,
					Default:      field.Default,
					DisplayAttrs: withoutOperationHints(field.DisplayAttrs),
				},
				Required:   required,
				Deprecated: field.Deprecated,
			}
			pi.Parameters = append(pi.Parameters, p)
		}

		// Sort parameters for a stable output
		sort.Slice(pi.Parameters, func(i, j int) bool {
			return strings.ToLower(pi.Parameters[i].Name) < strings.ToLower(pi.Parameters[j].Name)
		})

		// Process each supported operation by building up an Operation object
		// with descriptions, properties and examples from the framework.Path data.
		for opType, opHandler := range operations {
			props := opHandler.Properties()
			if props.Unpublished || forceUnpublished {
				continue
			}

			if opType == logical.CreateOperation {
				pi.CreateSupported = true

				// If both Create and Update are defined, only process Update.
				if operations[logical.UpdateOperation] != nil {
					continue
				}
			}

			// If both List and Read are defined, only process Read.
			if opType == logical.ListOperation && operations[logical.ReadOperation] != nil {
				continue
			}

			op := NewOASOperation()

			operationID := constructOperationID(
				path,
				pathIndex,
				p.DisplayAttrs,
				opType,
				props.DisplayAttrs,
				requestResponsePrefix,
			)

			op.Summary = props.Summary
			op.Description = props.Description
			op.Deprecated = props.Deprecated
			op.OperationID = operationID

			// Add any fields not present in the path as body parameters for POST.
			if opType == logical.CreateOperation || opType == logical.UpdateOperation {
				s := &OASSchema{
					Type:       "object",
					Properties: make(map[string]*OASSchema),
					Required:   make([]string, 0),
				}

				for name, field := range bodyFields {
					openapiField := convertType(field.Type)
					if field.Required {
						s.Required = append(s.Required, name)
					}

					p := OASSchema{
						Type:         openapiField.baseType,
						Description:  cleanString(field.Description),
						Format:       openapiField.format,
						Pattern:      openapiField.pattern,
						Enum:         field.AllowedValues,
						Default:      field.Default,
						Deprecated:   field.Deprecated,
						DisplayAttrs: withoutOperationHints(field.DisplayAttrs),
					}
					if openapiField.baseType == "array" {
						p.Items = &OASSchema{
							Type: openapiField.items,
						}
					}
					s.Properties[name] = &p
				}

				// Make the ordering deterministic, so that the generated OpenAPI spec document, observed over several
				// versions, doesn't contain spurious non-semantic changes.
				sort.Strings(s.Required)

				// If examples were given, use the first one as the sample
				// of this schema.
				if len(props.Examples) > 0 {
					s.Example = props.Examples[0].Data
				}

				// Set the final request body. Only JSON request data is supported.
				if len(s.Properties) > 0 || s.Example != nil {
					requestName := hyphenatedToTitleCase(operationID) + "Request"
					doc.Components.Schemas[requestName] = s
					op.RequestBody = &OASRequestBody{
						Required: true,
						Content: OASContent{
							"application/json": &OASMediaTypeObject{
								Schema: &OASSchema{Ref: fmt.Sprintf("#/components/schemas/%s", requestName)},
							},
						},
					}
				}
			}

			// LIST is represented as GET with a `list` query parameter.
			if opType == logical.ListOperation {
				// Only accepts List (due to the above skipping of ListOperations that also have ReadOperations)
				op.Parameters = append(op.Parameters, OASParameter{
					Name:        "list",
					Description: "Must be set to `true`",
					Required:    true,
					In:          "query",
					Schema:      &OASSchema{Type: "string", Enum: []interface{}{"true"}},
				})
			} else if opType == logical.ScanOperation {
				// Only accepts List (due to the above skipping of ListOperations that also have ReadOperations)
				op.Parameters = append(op.Parameters, OASParameter{
					Name:        "scan",
					Description: "Must be set to `true`",
					Required:    true,
					In:          "query",
					Schema:      &OASSchema{Type: "string", Enum: []interface{}{"true"}},
				})
			} else if opType == logical.ReadOperation && operations[logical.ListOperation] != nil {
				// Accepts both Read and List
				op.Parameters = append(op.Parameters, OASParameter{
					Name:        "list",
					Description: "Return a list if `true`",
					In:          "query",
					Schema:      &OASSchema{Type: "string"},
				})
			} else if opType == logical.ReadOperation && operations[logical.ScanOperation] != nil {
				// Accepts both Read and List
				op.Parameters = append(op.Parameters, OASParameter{
					Name:        "scan",
					Description: "Return a recursive list if `true`",
					In:          "query",
					Schema:      &OASSchema{Type: "string"},
				})
			}

			// Add tags based on backend class.
			var tags []string
			switch backendClass {
			case logical.ClassProvider:
				tags = []string{"providers"}
			case logical.ClassAuth:
				tags = []string{"auth"}
			case logical.ClassSystem:
				tags = []string{"system"}
			}

			op.Tags = append(op.Tags, tags...)

			// Set default responses.
			if len(props.Responses) == 0 {
				if opType == logical.DeleteOperation {
					op.Responses[204] = OASStdRespNoContent
				} else {
					op.Responses[200] = OASStdRespOK
				}
			}

			// Add any defined response details.
			for code, responses := range props.Responses {
				var description string
				content := make(OASContent)

				for i, resp := range responses {
					if i == 0 {
						description = resp.Description
					}
					if resp.Example != nil {
						mediaType := resp.MediaType
						if mediaType == "" {
							mediaType = "application/json"
						}

						// create a version of the response that will not emit null items
						cr := cleanResponse(resp.Example)

						// Only one example per media type is allowed, so first one wins
						if _, ok := content[mediaType]; !ok {
							content[mediaType] = &OASMediaTypeObject{
								Schema: &OASSchema{
									Example: cr,
								},
							}
						}
					}

					responseSchema := &OASSchema{
						Type:       "object",
						Properties: make(map[string]*OASSchema),
					}

					for name, field := range resp.Fields {
						openapiField := convertType(field.Type)
						p := OASSchema{
							Type:         openapiField.baseType,
							Description:  cleanString(field.Description),
							Format:       openapiField.format,
							Pattern:      openapiField.pattern,
							Enum:         field.AllowedValues,
							Default:      field.Default,
							Deprecated:   field.Deprecated,
							DisplayAttrs: withoutOperationHints(field.DisplayAttrs),
						}
						if openapiField.baseType == "array" {
							p.Items = &OASSchema{
								Type: openapiField.items,
							}
						}
						responseSchema.Properties[name] = &p
					}

					if len(resp.Fields) != 0 {
						// Use custom schema name if provided, otherwise generate from operationID
						responseName := resp.SchemaName
						if responseName == "" {
							responseName = hyphenatedToTitleCase(operationID) + "Response"
						}

						// Only add to schemas if not already present (allows schema reuse)
						if _, exists := doc.Components.Schemas[responseName]; !exists {
							doc.Components.Schemas[responseName] = responseSchema
						}

						content = OASContent{
							"application/json": &OASMediaTypeObject{
								Schema: &OASSchema{Ref: fmt.Sprintf("#/components/schemas/%s", responseName)},
							},
						}
					}
				}

				op.Responses[code] = &OASResponse{
					Description: description,
					Content:     content,
				}
			}

			switch opType {
			case logical.CreateOperation, logical.UpdateOperation:
				pi.Post = op
			case logical.ReadOperation, logical.ListOperation:
				pi.Get = op
			case logical.DeleteOperation:
				pi.Delete = op
			}
		}

		doc.Paths["/"+path] = &pi
	}

	return nil
}

// specialPathMatch checks whether the given path matches one of the special
// paths, taking into account * and + wildcards (e.g. foo/+/bar/*)
func specialPathMatch(path string, specialPaths []string) bool {
	// pathMatchesByParts determines if the path matches the special path's
	// pattern, accounting for the '+' and '*' wildcards
	pathMatchesByParts := func(pathParts []string, specialPathParts []string) bool {
		if len(pathParts) < len(specialPathParts) {
			return false
		}
		for i := 0; i < len(specialPathParts); i++ {
			var (
				part    = pathParts[i]
				pattern = specialPathParts[i]
			)
			if pattern == "+" {
				continue
			}
			if pattern == "*" {
				return true
			}
			if strings.HasSuffix(pattern, "*") && strings.HasPrefix(part, pattern[0:len(pattern)-1]) {
				return true
			}
			if pattern != part {
				return false
			}
		}
		return len(pathParts) == len(specialPathParts)
	}

	pathParts := strings.Split(path, "/")

	for _, sp := range specialPaths {
		// exact match
		if sp == path {
			return true
		}

		// match *
		if strings.HasSuffix(sp, "*") && strings.HasPrefix(path, sp[0:len(sp)-1]) {
			return true
		}

		// match +
		if strings.Contains(sp, "+") && pathMatchesByParts(pathParts, strings.Split(sp, "/")) {
			return true
		}
	}

	return false
}

// constructOperationID joins the given inputs into a hyphen-separated
// lower-case operation id, which is also used as a prefix for request and
// response names.
//
// The OperationPrefix / -Verb / -Suffix found in display attributes will be
// used, if provided. Otherwise, the function falls back to using the path and
// the operation.
//
// Examples of generated operation identifiers:
//   - kvv2-write
//   - kvv2-read
//   - google-cloud-login
//   - google-cloud-write-role
func constructOperationID(
	path string,
	pathIndex int,
	pathAttributes *DisplayAttributes,
	operation logical.Operation,
	operationAttributes *DisplayAttributes,
	defaultPrefix string,
) string {
	var (
		prefix string
		verb   string
		suffix string
	)

	if operationAttributes != nil {
		prefix = operationAttributes.OperationPrefix
		verb = operationAttributes.OperationVerb
		suffix = operationAttributes.OperationSuffix
	}

	if pathAttributes != nil {
		if prefix == "" {
			prefix = pathAttributes.OperationPrefix
		}
		if verb == "" {
			verb = pathAttributes.OperationVerb
		}
		if suffix == "" {
			suffix = pathAttributes.OperationSuffix
		}
	}

	// A single suffix string can contain multiple pipe-delimited strings. To
	// determine the actual suffix, we attempt to match it by the index of the
	// paths returned from `expandPattern(...)`. For example:
	//
	//  pki/
	//  	Pattern: "keys/generate/(internal|exported|kms)",
	//      DisplayAttrs: {
	//          ...
	//          OperationSuffix: "internal-key|exported-key|kms-key",
	//      },
	//
	//  will expand into three paths and corresponding suffixes:
	//
	//      path 0: "keys/generate/internal"  suffix: internal-key
	//      path 1: "keys/generate/exported"  suffix: exported-key
	//      path 2: "keys/generate/kms"       suffix: kms-key
	//
	pathIndexOutOfRange := false

	if suffixes := strings.Split(suffix, "|"); len(suffixes) > 1 || pathIndex > 0 {
		// if the index is out of bounds, fall back to the old logic
		if pathIndex >= len(suffixes) {
			suffix = ""
			pathIndexOutOfRange = true
		} else {
			suffix = suffixes[pathIndex]
		}
	}

	// a helper that hyphenates & lower-cases the slice except the empty elements
	toLowerHyphenate := func(parts []string) string {
		filtered := make([]string, 0, len(parts))
		for _, e := range parts {
			if e != "" {
				filtered = append(filtered, e)
			}
		}
		return strings.ToLower(strings.Join(filtered, "-"))
	}

	// fall back to using the path + operation to construct the operation id
	var (
		needPrefix = prefix == "" && verb == ""
		needVerb   = verb == ""
		needSuffix = suffix == "" && (verb == "" || pathIndexOutOfRange)
	)

	if needPrefix {
		prefix = defaultPrefix
	}

	if needVerb {
		if operation == logical.UpdateOperation {
			verb = "write"
		} else {
			verb = string(operation)
		}
	}

	if needSuffix {
		suffix = toLowerHyphenate(nonWordRe.Split(path, -1))
	}

	return toLowerHyphenate([]string{prefix, verb, suffix})
}

// expandPattern expands a regex pattern by generating permutations of any optional parameters
// and changing named parameters into their {openapi} equivalents.
func expandPattern(pattern string) ([]string, error) {
	// Happily, the Go regexp library exposes its underlying "parse to AST" functionality, so we can rely on that to do
	// the hard work of interpreting the regexp syntax.
	rx, err := syntax.Parse(pattern, syntax.Perl)
	if err != nil {
		// This should be impossible to reach, since regexps have previously been compiled with MustCompile in
		// Backend.init.
		panic(err)
	}

	paths, err := collectPathsFromRegexpAST(rx)
	if err != nil {
		return nil, err
	}

	return paths, nil
}

type pathCollector struct {
	strings.Builder
	conditionalSlashAppendedAtLength int
}

// collectPathsFromRegexpAST performs a depth-first recursive walk through a regexp AST, collecting an OpenAPI-style
// path as it goes.
//
// Each time it encounters alternation (a|b) or an optional part (a?), it forks its processing to produce additional
// results, to account for each possibility. A pattern with many such regex features can therefore produce many
// distinct OpenAPI endpoints — for example a pattern like
//
//	"issuer/" + framework.GenericNameRegex("ref") + "/crl(/pem|/der|/delta(/pem|/der)?)?"
//
// expands to six separate paths.
//
// Each named capture group - i.e. (?P<name>something here) - is replaced with an OpenAPI parameter - i.e. {name} - and
// the subtree of regexp AST inside the parameter is completely skipped.
func collectPathsFromRegexpAST(rx *syntax.Regexp) ([]string, error) {
	pathCollectors, err := collectPathsFromRegexpASTInternal(rx, []*pathCollector{{}})
	if err != nil {
		return nil, err
	}
	paths := make([]string, 0, len(pathCollectors))
	for _, collector := range pathCollectors {
		if collector.conditionalSlashAppendedAtLength != collector.Len() {
			paths = append(paths, collector.String())
		}
	}
	return paths, nil
}

var errUnsupportableRegexpOperationForOpenAPI = errors.New("path regexp uses an operation that cannot be translated to an OpenAPI pattern")

func collectPathsFromRegexpASTInternal(rx *syntax.Regexp, appendingTo []*pathCollector) ([]*pathCollector, error) {
	var err error

	// Depending on the type of this regexp AST node (its Op, i.e. operation), figure out whether it contributes any
	// characters to the URL path, and whether we need to recurse through child AST nodes.
	//
	// Each element of the appendingTo slice tracks a separate path, defined by the alternatives chosen when traversing
	// the | and ? conditional regexp features, and new elements are added as each of these features are traversed.
	//
	// To share this slice across multiple recursive calls of this function, it is passed down as a parameter to each
	// recursive call, potentially modified throughout this switch block, and passed back up as a return value at the
	// end of this function - the parent call uses the return value to update its own local variable.
	switch rx.Op {

	// These AST operations are leaf nodes (no children), that match zero characters, so require no processing at all
	case syntax.OpEmptyMatch: // e.g. (?:)
	case syntax.OpBeginLine: // i.e. ^ when (?m)
	case syntax.OpEndLine: // i.e. $ when (?m)
	case syntax.OpBeginText: // i.e. \A, or ^ when (?-m)
	case syntax.OpEndText: // i.e. \z, or $ when (?-m)
	case syntax.OpWordBoundary: // i.e. \b
	case syntax.OpNoWordBoundary: // i.e. \B

	// OpConcat simply represents multiple parts of the pattern appearing one after the other, so just recurse through
	// those pieces.
	case syntax.OpConcat:
		for _, child := range rx.Sub {
			appendingTo, err = collectPathsFromRegexpASTInternal(child, appendingTo)
			if err != nil {
				return nil, err
			}
		}

	// OpLiteral is a literal string in the pattern - append it to the paths we are building.
	case syntax.OpLiteral:
		for _, collector := range appendingTo {
			collector.WriteString(string(rx.Rune))
		}

	// OpAlternate, i.e. a|b, means we clone all of the pathCollector instances we are currently accumulating paths
	// into, and independently recurse through each alternate option.
	case syntax.OpAlternate: // i.e |
		var totalAppendingTo []*pathCollector
		lastIndex := len(rx.Sub) - 1
		for index, child := range rx.Sub {
			var childAppendingTo []*pathCollector
			if index == lastIndex {
				// Optimization: last time through this loop, we can simply re-use the existing set of pathCollector
				// instances, as we no longer need to preserve them unmodified to make further copies of.
				childAppendingTo = appendingTo
			} else {
				for _, collector := range appendingTo {
					newCollector := new(pathCollector)
					newCollector.WriteString(collector.String())
					newCollector.conditionalSlashAppendedAtLength = collector.conditionalSlashAppendedAtLength
					childAppendingTo = append(childAppendingTo, newCollector)
				}
			}
			childAppendingTo, err = collectPathsFromRegexpASTInternal(child, childAppendingTo)
			if err != nil {
				return nil, err
			}
			totalAppendingTo = append(totalAppendingTo, childAppendingTo...)
		}
		appendingTo = totalAppendingTo

	// OpQuest, i.e. a?, is much like an alternation between exactly two options, one of which is the empty string.
	case syntax.OpQuest:
		child := rx.Sub[0]
		var childAppendingTo []*pathCollector
		for _, collector := range appendingTo {
			newCollector := new(pathCollector)
			newCollector.WriteString(collector.String())
			newCollector.conditionalSlashAppendedAtLength = collector.conditionalSlashAppendedAtLength
			childAppendingTo = append(childAppendingTo, newCollector)
		}
		childAppendingTo, err = collectPathsFromRegexpASTInternal(child, childAppendingTo)
		if err != nil {
			return nil, err
		}
		appendingTo = append(appendingTo, childAppendingTo...)

		// Many path patterns end with `/?` to accept paths that end with or without a slash. Our current
		// convention for generating the OpenAPI is to strip away these slashes. To do that, this very special case
		// detects when we just appended a single conditional slash, and records the length of the path at this point,
		// so we can later discard this path variant, if nothing else is appended to it later.
		if child.Op == syntax.OpLiteral && string(child.Rune) == "/" {
			for _, collector := range childAppendingTo {
				collector.conditionalSlashAppendedAtLength = collector.Len()
			}
		}

	// OpCapture, i.e. ( ) or (?P<name> ), a capturing group
	case syntax.OpCapture:
		if rx.Name == "" {
			// An unnamed capturing group is not actually used for capturing.
			// Treat it exactly the same as OpConcat.
			for _, child := range rx.Sub {
				appendingTo, err = collectPathsFromRegexpASTInternal(child, appendingTo)
				if err != nil {
					return nil, err
				}
			}
		} else {
			// A named capturing group is replaced with the OpenAPI parameter syntax, and the regexp inside the group
			// is NOT added to the OpenAPI path.
			for _, builder := range appendingTo {
				builder.WriteRune('{')
				builder.WriteString(rx.Name)
				builder.WriteRune('}')
			}
		}

	// Any other kind of operation is a problem, and will trigger an error, resulting in the pattern being left out of
	// the OpenAPI entirely - that's better than generating a path which is incorrect.
	//
	// The Op types we expect to hit the default condition are:
	//
	//     OpCharClass    - i.e. [something]
	//     OpAnyCharNotNL - i.e. .
	//     OpAnyChar      - i.e. (?s:.)
	//     OpStar         - i.e. *
	//     OpPlus         - i.e. +
	//     OpRepeat       - i.e. {N}, {N,M}, etc.
	//
	// In any of these conditions, there is no sensible translation of the path to OpenAPI syntax. (Note, this only
	// applies to these appearing outside of a named capture group, otherwise they are handled in the previous case.)
	//
	// Such patterns are typically catch-alls used for custom error handling and are normally marked Unpublished,
	// so they are withheld from the OpenAPI doc anyway.
	//
	// For completeness, one other Op type exists, OpNoMatch, which is never generated by syntax.Parse - only by
	// subsequent Simplify in preparation to Compile, which is not used here.
	default:
		return nil, errUnsupportableRegexpOperationForOpenAPI
	}

	return appendingTo, nil
}

// schemaType is a subset of the JSON Schema elements used as a target
// for conversions from the framework's standard FieldTypes.
type schemaType struct {
	baseType string
	items    string
	format   string
	pattern  string
}

// convertType translates a FieldType into an OpenAPI type.
// In the case of arrays, a subtype is returned as well.
func convertType(t FieldType) schemaType {
	ret := schemaType{}

	switch t {
	case TypeString, TypeHeader:
		ret.baseType = "string"
	case TypeNameString:
		ret.baseType = "string"
		ret.pattern = `\w([\w-.]*\w)?`
	case TypeLowerCaseString:
		ret.baseType = "string"
		ret.format = "lowercase"
	case TypeInt:
		ret.baseType = "integer"
	case TypeInt64:
		ret.baseType = "integer"
		ret.format = "int64"
	case TypeDurationSecond, TypeSignedDurationSecond:
		ret.baseType = "integer"
		ret.format = "seconds"
	case TypeBool:
		ret.baseType = "boolean"
	case TypeMap:
		ret.baseType = "object"
		ret.format = "map"
	case TypeKVPairs:
		ret.baseType = "object"
		ret.format = "kvpairs"
	case TypeSlice:
		ret.baseType = "array"
		ret.items = "object"
	case TypeStringSlice, TypeCommaStringSlice:
		ret.baseType = "array"
		ret.items = "string"
	case TypeCommaIntSlice:
		ret.baseType = "array"
		ret.items = "integer"
	case TypeTime:
		ret.baseType = "string"
		ret.format = "date-time"
	case TypeFloat:
		ret.baseType = "number"
		ret.format = "float"
	default:
		log.L().Warn("error parsing field type", "type", t)
		ret.format = "unknown"
	}

	return ret
}

// cleanString prepares s for inclusion in the output
func cleanString(s string) string {
	// clean leading/trailing whitespace, and replace whitespace runs into a single space
	s = strings.TrimSpace(s)
	s = wsRe.ReplaceAllString(s, " ")
	return s
}

// splitFields partitions fields into path and body groups
// The input pattern is expected to have been run through expandPattern,
// with paths parameters denotes in {braces}.
func splitFields(allFields map[string]*FieldSchema, pattern string) (pathFields, bodyFields map[string]*FieldSchema) {
	pathFields = make(map[string]*FieldSchema)
	bodyFields = make(map[string]*FieldSchema)

	for _, match := range pathFieldsRe.FindAllStringSubmatch(pattern, -1) {
		name := match[1]
		pathFields[name] = allFields[name]
	}

	for name, field := range allFields {
		if _, ok := pathFields[name]; !ok {
			if field.Query {
				pathFields[name] = field
			} else {
				bodyFields[name] = field
			}
		}
	}

	return pathFields, bodyFields
}

// withoutOperationHints returns a copy of the given DisplayAttributes without
// OperationPrefix / OperationVerb / OperationSuffix since we don't need these
// fields in the final output.
func withoutOperationHints(in *DisplayAttributes) *DisplayAttributes {
	if in == nil {
		return nil
	}

	copy := *in

	copy.OperationPrefix = ""
	copy.OperationVerb = ""
	copy.OperationSuffix = ""

	// return nil if all fields are empty to avoid empty JSON objects
	if copy == (DisplayAttributes{}) {
		return nil
	}

	return &copy
}

func hyphenatedToTitleCase(in string) string {
	var b strings.Builder

	title := cases.Title(language.English, cases.NoLower)

	for _, word := range strings.Split(in, "-") {
		b.WriteString(title.String(word))
	}

	return b.String()
}

// cleanedResponse mirrors warden's logical.Response with nulls omitted from
// JSON encoding. Warden's Response has no Secret, Redirect, or WrapInfo
// fields (those upstream concepts don't apply here), so they're not included.
type cleanedResponse struct {
	Auth     *logical.Auth          `json:"auth,omitempty"`
	Data     map[string]interface{} `json:"data,omitempty"`
	Warnings []string               `json:"warnings,omitempty"`
	Headers  map[string][]string    `json:"headers,omitempty"`
}

func cleanResponse(resp *logical.Response) *cleanedResponse {
	return &cleanedResponse{
		Auth:     resp.Auth,
		Data:     resp.Data,
		Warnings: resp.Warnings,
		Headers:  resp.Headers,
	}
}

// CreateOperationIDs generates unique operationIds for all paths/methods.
// The transform will convert path/method into camelcase. e.g.:
//
// /sys/tools/random/{urlbytes} -> postSysToolsRandomUrlbytes
//
// In the unlikely case of a duplicate ids, a numeric suffix is added:
//
//	postSysToolsRandomUrlbytes_2
//
// An optional user-provided suffix ("context") may also be appended.
//
// Deprecated: operationID's are now populated using `constructOperationID`.
// This function is here for backwards compatibility with older OpenAPI consumers.
func (d *OASDocument) CreateOperationIDs(context string) {
	opIDCount := make(map[string]int)
	var paths []string

	// traverse paths in a stable order to ensure stable output
	for path := range d.Paths {
		paths = append(paths, path)
	}
	sort.Strings(paths)

	titleCaser := cases.Title(language.English, cases.NoLower)

	for _, path := range paths {
		pi := d.Paths[path]
		for _, method := range []string{"get", "post", "delete"} {
			var oasOperation *OASOperation
			switch method {
			case "get":
				oasOperation = pi.Get
			case "post":
				oasOperation = pi.Post
			case "delete":
				oasOperation = pi.Delete
			}

			if oasOperation == nil {
				continue
			}

			if oasOperation.OperationID != "" {
				continue
			}

			// Discard "_mount_path" from any {thing_mount_path} parameters
			path = strings.Replace(path, "_mount_path", "", 1)

			// Space-split on non-words, title case everything, recombine
			opID := nonWordRe.ReplaceAllString(strings.ToLower(path), " ")
			opID = titleCaser.String(opID)
			opID = method + strings.ReplaceAll(opID, " ", "")

			// deduplicate operationIds. This is a safeguard, since generated IDs should
			// already be unique given our current path naming conventions.
			opIDCount[opID]++
			if opIDCount[opID] > 1 {
				opID = fmt.Sprintf("%s_%d", opID, opIDCount[opID])
			}

			if context != "" {
				opID += "_" + context
			}

			oasOperation.OperationID = opID
		}
	}
}
