package basicauth

import (
	"encoding/base64"
	"errors"
	"strings"
	"sync"
	"unicode"
	"unicode/utf8"

	"github.com/gofiber/fiber/v3"
	"github.com/gofiber/fiber/v3/middleware/logger"
	"github.com/gofiber/utils/v2"
	"golang.org/x/text/unicode/norm"
)

// The contextKey type is unexported to prevent collisions with context keys defined in
// other packages.
type contextKey int

// The key for the username value stored in the context
const (
	usernameKey contextKey = iota
)

const basicScheme = "Basic"

var registerLogContextTagsOnce sync.Once

// New creates a new middleware handler
func New(config ...Config) fiber.Handler {
	registerLogContextTagsOnce.Do(registerLogContextTags)

	// Set default config
	cfg := configDefault(config...)

	var cerr base64.CorruptInputError

	// Return new handler
	return func(c fiber.Ctx) error {
		// Don't execute middleware if Next returns true
		if cfg.Next != nil && cfg.Next(c) {
			return c.Next()
		}

		// Get authorization header and ensure it matches the Basic scheme
		rawAuth := c.Get(fiber.HeaderAuthorization)
		if rawAuth == "" {
			return cfg.Unauthorized(c)
		}
		if len(rawAuth) > cfg.HeaderLimit {
			return c.SendStatus(fiber.StatusRequestHeaderFieldsTooLarge)
		}
		if containsInvalidHeaderChars(rawAuth) {
			return cfg.BadRequest(c)
		}
		auth := utils.TrimSpace(rawAuth)
		if auth == "" {
			return cfg.Unauthorized(c)
		}
		if len(auth) < len(basicScheme) || !utils.EqualFold(auth[:len(basicScheme)], basicScheme) {
			return cfg.Unauthorized(c)
		}
		rest := auth[len(basicScheme):]
		if len(rest) < 2 || rest[0] != ' ' || rest[1] == ' ' {
			return cfg.BadRequest(c)
		}
		rest = rest[1:]
		if strings.IndexFunc(rest, unicode.IsSpace) != -1 {
			return cfg.BadRequest(c)
		}

		// Decode the header contents
		raw, err := base64.StdEncoding.DecodeString(rest)
		if err != nil {
			if errors.As(err, &cerr) {
				raw, err = base64.RawStdEncoding.DecodeString(rest)
			}
			if err != nil {
				return cfg.BadRequest(c)
			}
		}
		if !utf8.Valid(raw) {
			return cfg.BadRequest(c)
		}
		if !norm.NFC.IsNormal(raw) {
			raw = norm.NFC.Bytes(raw)
		}

		// Get the credentials
		var creds string
		if c.App().Config().Immutable {
			creds = string(raw)
		} else {
			creds = utils.UnsafeString(raw)
		}

		// Check if the credentials are in the correct form
		// which is "username:password".
		username, password, found := strings.Cut(creds, ":")
		if !found {
			return cfg.BadRequest(c)
		}

		if containsCTL(username) || containsCTL(password) {
			return cfg.BadRequest(c)
		}

		if cfg.Authorizer(username, password, c) {
			fiber.StoreInContext(c, usernameKey, username)
			return c.Next()
		}

		// Authentication failed
		return cfg.Unauthorized(c)
	}
}

// registerLogContextTags exposes the authenticated username under the
// ${username} tag for both middleware/logger access logs and fiberlog
// WithContext lines. The full clear-text username is intentionally written
// (not redacted) because the primary use case for ${username} is auditability:
// who hit which endpoint, when. Username strings have already passed
// containsCTL stripping, so they are safe with respect to log injection.
//
// If full usernames are PII in your jurisdiction (GDPR, CCPA, etc.), do not
// include ${username} in your log format and obtain the value via
// UsernameFromContext at the application layer where you can hash, prefix,
// or otherwise minimize it before emitting.
func registerLogContextTags() {
	logger.RegisterContextTag("username", UsernameFromContext)
}

func containsCTL(s string) bool {
	return strings.IndexFunc(s, unicode.IsControl) != -1
}

func containsInvalidHeaderChars(s string) bool {
	return strings.IndexFunc(s, func(r rune) bool {
		return (r < 0x20 && r != '\t') || r == 0x7F || r >= 0x80
	}) != -1
}

// UsernameFromContext returns the username found in the context.
// It accepts fiber.CustomCtx, fiber.Ctx, *fasthttp.RequestCtx, and context.Context.
// It returns an empty string if the username does not exist.
func UsernameFromContext(ctx any) string {
	if username, ok := fiber.ValueFromContext[string](ctx, usernameKey); ok {
		return username
	}

	return ""
}
