// Copyright IBM Corp. 2016, 2025
// SPDX-License-Identifier: BUSL-1.1

package audit

import (
	"context"
	"fmt"
	"strings"
	"sync"

	"github.com/hashicorp/vault/sdk/logical"
)

// N.B.: While we could use textproto to get the canonical mime header, HTTP/2
// requires all headers to be converted to lower case, so we just do that.

const (
	// auditedHeadersEntry is the key used in storage to store and retrieve the header config
	auditedHeadersEntry = "audited-headers"

	// AuditedHeadersSubPath is the path used to create a sub view within storage.
	AuditedHeadersSubPath = "audited-headers-config/"
)

type durableStorer interface {
	Get(ctx context.Context, key string) (*logical.StorageEntry, error)
	Put(ctx context.Context, entry *logical.StorageEntry) error
}

// HeaderFormatter is an interface defining the methods of the
// vault.HeadersConfig structure needed in this package.
type HeaderFormatter interface {
	// ApplyConfig returns a map of header values that consists of the
	// intersection of the provided set of header values with a configured
	// set of headers and will hash headers that have been configured as such.
	ApplyConfig(context.Context, map[string][]string, Salter) (map[string][]string, error)
}

// AuditedHeadersKey returns the key at which audit header configuration is stored.
func AuditedHeadersKey() string {
	return AuditedHeadersSubPath + auditedHeadersEntry
}

type headerSettings struct {
	// HMAC is used to indicate whether the value of the header should be HMAC'd.
	HMAC bool `json:"hmac"`
}

// HeadersConfig is used by the Audit Broker to write only approved
// headers to the audit logs. It uses a BarrierView to persist the settings.
type HeadersConfig struct {
	// headerSettings stores the current headers that should be audited, and their settings.
	headerSettings map[string]*headerSettings

	// view is the barrier view which should be used to access underlying audit header config data.
	view durableStorer

	sync.RWMutex
}

// NewHeadersConfig should be used to create HeadersConfig.
func NewHeadersConfig(view durableStorer) (*HeadersConfig, error) {
	if view == nil {
		return nil, fmt.Errorf("barrier view cannot be nil")
	}

	// This should be the only place where the HeadersConfig struct is initialized.
	// Store the view so that we can reload headers when we 'Invalidate'.
	return &HeadersConfig{
		view:           view,
		headerSettings: make(map[string]*headerSettings),
	}, nil
}

// Header attempts to retrieve a copy of the settings associated with the specified header.
// The second boolean return parameter indicates whether the header existed in configuration,
// it should be checked as when 'false' the returned settings will have the default values.
func (a *HeadersConfig) Header(name string) (headerSettings, bool) {
	a.RLock()
	defer a.RUnlock()

	var s headerSettings
	v, ok := a.headerSettings[strings.ToLower(name)]

	if ok {
		s.HMAC = v.HMAC
	}

	return s, ok
}

// Headers returns all existing headers along with a copy of their current settings.
func (a *HeadersConfig) Headers() map[string]headerSettings {
	a.RLock()
	defer a.RUnlock()

	// We know how many entries the map should have.
	headers := make(map[string]headerSettings, len(a.headerSettings))

	// Clone the headers
	for name, setting := range a.headerSettings {
		headers[name] = headerSettings{HMAC: setting.HMAC}
	}

	return headers
}

// Add adds or overwrites a header in the config and updates the barrier view
// NOTE: Add will acquire a write lock in order to update the underlying headers.
func (a *HeadersConfig) Add(ctx context.Context, header string, hmac bool) error {
	if header == "" {
		return fmt.Errorf("header value cannot be empty")
	}

	// Grab a write lock
	a.Lock()
	defer a.Unlock()

	if a.headerSettings == nil {
		a.headerSettings = make(map[string]*headerSettings, 1)
	}

	a.headerSettings[strings.ToLower(header)] = &headerSettings{hmac}
	entry, err := logical.StorageEntryJSON(auditedHeadersEntry, a.headerSettings)
	if err != nil {
		return fmt.Errorf("failed to persist audited headers config: %w", err)
	}

	if err := a.view.Put(ctx, entry); err != nil {
		return fmt.Errorf("failed to persist audited headers config: %w", err)
	}

	return nil
}

// Remove deletes a header out of the header config and updates the barrier view
// NOTE: Remove will acquire a write lock in order to update the underlying headers.
func (a *HeadersConfig) Remove(ctx context.Context, header string) error {
	if header == "" {
		return fmt.Errorf("header value cannot be empty")
	}

	// Grab a write lock
	a.Lock()
	defer a.Unlock()

	// Nothing to delete
	if len(a.headerSettings) == 0 {
		return nil
	}

	delete(a.headerSettings, strings.ToLower(header))
	entry, err := logical.StorageEntryJSON(auditedHeadersEntry, a.headerSettings)
	if err != nil {
		return fmt.Errorf("failed to persist audited headers config: %w", err)
	}

	if err := a.view.Put(ctx, entry); err != nil {
		return fmt.Errorf("failed to persist audited headers config: %w", err)
	}

	return nil
}

// DefaultHeaders can be used to retrieve the set of default headers that will be
// added to HeadersConfig in order to allow them to appear in audit logs in a raw
// format. If the Vault Operator adds their own setting for any of the defaults,
// their setting will be honored.
func (a *HeadersConfig) DefaultHeaders() map[string]*headerSettings {
	// Support deprecated 'x-' prefix (https://datatracker.ietf.org/doc/html/rfc6648)
	const correlationID = "correlation-id"
	xCorrelationID := fmt.Sprintf("x-%s", correlationID)

	return map[string]*headerSettings{
		correlationID:  {},
		xCorrelationID: {},
		"user-agent":   {},
	}
}

// Invalidate attempts to refresh the allowed audit headers and their settings.
// NOTE: Invalidate will acquire a write lock in order to update the underlying headers.
func (a *HeadersConfig) Invalidate(ctx context.Context) error {
	a.Lock()
	defer a.Unlock()

	// Get the actual headers entries, e.g. sys/audited-headers-config/audited-headers
	out, err := a.view.Get(ctx, auditedHeadersEntry)
	if err != nil {
		return fmt.Errorf("failed to read config: %w", err)
	}

	// If we cannot update the stored 'new' headers, we will clear the existing
	// ones as part of invalidation.
	headers := make(map[string]*headerSettings)
	if out != nil {
		err = out.DecodeJSON(&headers)
		if err != nil {
			return fmt.Errorf("failed to parse config: %w", err)
		}
	}

	// Ensure that we are able to case-sensitively access the headers;
	// necessary for the upgrade case
	lowerHeaders := make(map[string]*headerSettings, len(headers))
	for k, v := range headers {
		lowerHeaders[strings.ToLower(k)] = v
	}

	// Ensure that we have default headers configured to appear in the audit log.
	// Add them if they're missing.
	for header, setting := range a.DefaultHeaders() {
		if _, ok := lowerHeaders[header]; !ok {
			lowerHeaders[header] = setting
		}
	}

	a.headerSettings = lowerHeaders
	return nil
}

// ApplyConfig returns a map of approved headers and their values, either HMAC'd or plaintext.
// If the supplied headers are empty or nil, an empty set of headers will be returned.
func (a *HeadersConfig) ApplyConfig(ctx context.Context, headers map[string][]string, salter Salter) (result map[string][]string, retErr error) {
	// Return early if we don't have headers.
	if len(headers) < 1 {
		return map[string][]string{}, nil
	}

	// Grab a read lock
	a.RLock()
	defer a.RUnlock()

	// Make a copy of the incoming headers with everything lower so we can
	// case-insensitively compare
	lowerHeaders := make(map[string][]string, len(headers))
	for k, v := range headers {
		lowerHeaders[strings.ToLower(k)] = v
	}

	result = make(map[string][]string, len(a.headerSettings))
	for key, settings := range a.headerSettings {
		if val, ok := lowerHeaders[key]; ok {
			// copy the header values so we don't overwrite them
			hVals := make([]string, len(val))
			copy(hVals, val)

			// Optionally hmac the values
			if settings.HMAC {
				for i, el := range hVals {
					hVal, err := hashString(ctx, salter, el)
					if err != nil {
						return nil, err
					}
					hVals[i] = hVal
				}
			}

			result[key] = hVals
		}
	}

	return result, nil
}
