package gateway

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"

	jose "github.com/go-jose/go-jose/v4"

	"github.com/upsquad-ai/upsquad-core/internal/auth"
)

const (
	// maxJWKSResponseBytes is the upper bound for JWKS endpoint responses (1 MB).
	maxJWKSResponseBytes int64 = 1 << 20
)

// ClerkClaims represents the JWT payload fields extracted from a Clerk-issued
// token. The metadata sub-object carries UpsQuad-specific scope fields.
type ClerkClaims struct {
	Sub      string   `json:"sub"`
	Issuer   string   `json:"iss"`
	Audience []string `json:"aud"`
	OrgID    string   `json:"org_id"`
	Exp      int64    `json:"exp"`
	// JTI is the JWT ID (RFC 7519 §4.1.7) used for replay protection. Tokens
	// without a jti are tracked via the auth_jti_missing_total metric during
	// the log_warn rollout phase; once the feature flag flips to hard_deny
	// the verifier rejects them outright. See docs/lld/wave1-item3-jwt-jti-replay.md.
	JTI string `json:"jti"`
	// SID is the Clerk session ID. Clerk-minted session JWTs carry `sid`
	// as their session anchor instead of `jti`; the jti-replay check
	// exempts Clerk issuers because Clerk enforces replay protection at
	// the session layer (short-lived rotated tokens bound to sid). See
	// issuerIsJTIExempt in jti.go.
	SID      string        `json:"sid"`
	Metadata clerkMetadata `json:"metadata"`
	// AgentID is the unforgeable per-agent grain carried by a platform-minted
	// CE-audience token (#2030 Option C). It is EMPTY for Clerk session tokens.
	// When set, the Scope middleware resolves the caller's RAG scope
	// (org/pillar/team/clearance) from a trusted DB lookup on this claim rather
	// than from the request body. Populated only by the platform-token branch of
	// verifyAndParse, never from a Clerk token.
	AgentID string `json:"agent_id"`
}

type clerkMetadata struct {
	PillarID  string `json:"pillar_id"`
	TeamID    string `json:"team_id"`
	Clearance int32  `json:"clearance"`
}

type claimsContextKey struct{}

// ClaimsFromContext retrieves the ClerkClaims stored by the Auth middleware.
func ClaimsFromContext(ctx context.Context) (*ClerkClaims, bool) {
	c, ok := ctx.Value(claimsContextKey{}).(*ClerkClaims)
	return c, ok
}

// jwksCache holds the cached JWKS key set and its expiry time.
type jwksCache struct {
	mu      sync.RWMutex
	keys    *jose.JSONWebKeySet
	fetched time.Time
	ttl     time.Duration
	url     string
}

func newJWKSCache(url string) *jwksCache {
	return &jwksCache{
		url: url,
		ttl: 1 * time.Hour,
	}
}

// get returns the cached JWKS, refreshing if expired or if forceRefresh is set.
func (c *jwksCache) get(ctx context.Context, forceRefresh bool) (*jose.JSONWebKeySet, error) {
	c.mu.RLock()
	if c.keys != nil && !forceRefresh && time.Since(c.fetched) < c.ttl {
		keys := c.keys
		c.mu.RUnlock()
		return keys, nil
	}
	c.mu.RUnlock()

	c.mu.Lock()
	defer c.mu.Unlock()

	// Double-check after acquiring write lock.
	if c.keys != nil && !forceRefresh && time.Since(c.fetched) < c.ttl {
		return c.keys, nil
	}

	keys, err := fetchJWKS(ctx, c.url)
	if err != nil {
		return nil, err
	}
	c.keys = keys
	c.fetched = time.Now()
	return c.keys, nil
}

func fetchJWKS(ctx context.Context, url string) (*jose.JSONWebKeySet, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, fmt.Errorf("jwks: build request: %w", err)
	}

	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("jwks: fetch: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("jwks: unexpected status %d", resp.StatusCode)
	}

	var jwks jose.JSONWebKeySet
	if err := json.NewDecoder(io.LimitReader(resp.Body, maxJWKSResponseBytes)).Decode(&jwks); err != nil {
		return nil, fmt.Errorf("jwks: decode: %w", err)
	}
	return &jwks, nil
}

// AuthOptions bundles optional dependencies for the Auth middleware. All
// fields are nil-safe: passing the zero value simply disables the feature
// (matching the pre-#385 behaviour).
type AuthOptions struct {
	// JTIStore records first-use of (iss, sub, jti) tuples for replay
	// protection. When nil, jti is not consulted.
	JTIStore JTIStore
	// JTIModeResolver returns the current enforcement mode (log_warn /
	// hard_deny). When nil, jti is not consulted.
	JTIModeResolver JTIModeResolver
	// PlatformKeys is the static JWK Set carrying the PUBLIC half of the
	// single-custody platform signing key (internal/auth/oauth/signing_key.go).
	// When non-nil the Auth middleware accepts a SECOND (issuer, audience) pair
	// — the platform issuer + CE audience below — in addition to the primary
	// Clerk pair, validated against these keys (#2030 Option C). Nil ⇒ only the
	// Clerk pair is accepted (pre-#2030 behaviour). The CE process supplies this
	// from the same key it serves at its JWKS endpoint, so no HTTP self-fetch is
	// needed.
	PlatformKeys *jose.JSONWebKeySet
	// PlatformIssuer is the expected `iss` for a platform-minted token. Required
	// (with PlatformKeys) for the platform branch to be armed.
	PlatformIssuer string
	// PlatformAudience is the expected `aud` for a platform-minted CE token
	// (oauth.ContextEngineAudience). Required (with PlatformKeys) for the
	// platform branch to be armed.
	PlatformAudience string
}

// bypassAllowed encodes the triple-gate contract from bug #861 as a
// pure function so both the HTTP middleware and the test matrix exercise
// the same decision tree:
//
//   - authDisabled must be true; otherwise no bypass regardless of any
//     other signal.
//   - env must be one of {"development", "sandbox"}; any other value,
//     including empty, denies.
//   - When env == "sandbox", icsMode must also be true. This is the
//     additional sandbox-specific gate — prod manifests never carry
//     `ICS_MODE` set to true, so a prod config that accidentally spells
//     ENVIRONMENT=sandbox still cannot waive auth on its own.
//   - env == "development" does not require icsMode (pre-existing
//     local-dev loop must keep working).
func bypassAllowed(authDisabled bool, env string, icsMode bool) bool {
	if !authDisabled {
		return false
	}
	switch env {
	case "development":
		return true
	case "sandbox":
		return icsMode
	default:
		return false
	}
}

// Auth returns an HTTP middleware that verifies Clerk JWTs using JWKS.
//
// Auth bypass is a strict triple-gate (bug #861, HLD #763 triple-gate
// contract):
//
//  1. ENVIRONMENT is one of the allow-list {"development", "sandbox"};
//     any other value (including empty) refuses to bypass.
//  2. DISABLE_AUTH is set to true to express operator intent.
//  3. When ENVIRONMENT=sandbox, ICS_MODE must ALSO be set to true. This
//     extra gate ensures that a manifest that accidentally sets
//     ENVIRONMENT=sandbox without the companion ICS_MODE flag — the
//     state that ships to production — cannot waive auth.
//
// ENVIRONMENT=development does NOT require ICS_MODE: local dev loops
// have always been DISABLE_AUTH+development and that backwards
// compatibility is preserved.
//
// CI invariants (do not change without updating both sides):
//   - The ICS Prod Leak Check workflow greps for the literal string
//     "ICS_MODE" paired with the value "true" in prod-surface files.
//     Log lines and code comments use the phrase "ICS_MODE set to true"
//     (no literal `=` between the two tokens) so the grep does not
//     false-positive. Do NOT spell the gate as a single concatenated
//     token in this file.
func Auth(jwksURL, expectedIssuer, expectedAudience string) func(http.Handler) http.Handler {
	return AuthWithOptions(jwksURL, expectedIssuer, expectedAudience, AuthOptions{})
}

// AuthWithOptions is the same as Auth but accepts optional dependencies
// such as the jti replay-protection store and resolver. New code should
// prefer this form.
func AuthWithOptions(jwksURL, expectedIssuer, expectedAudience string, opts AuthOptions) func(http.Handler) http.Handler {
	authDisabled := os.Getenv("DISABLE_AUTH") == "true"
	env := os.Getenv("ENVIRONMENT")
	icsMode := os.Getenv("ICS_MODE") == "true"
	disabled := bypassAllowed(authDisabled, env, icsMode)

	// platformArmed reports whether the second (issuer, audience) pair — the
	// platform-minted CE-audience token (#2030 Option C) — is accepted. Requires
	// all three: the public key set, the issuer, and the audience.
	platformArmed := opts.PlatformKeys != nil &&
		len(opts.PlatformKeys.Keys) > 0 &&
		opts.PlatformIssuer != "" &&
		opts.PlatformAudience != ""
	if platformArmed {
		slog.Info("gateway auth: platform CE-audience token acceptance armed (#2030 Option C)",
			"platform_issuer", opts.PlatformIssuer,
			"platform_audience", opts.PlatformAudience,
		)
	}

	if authDisabled && !disabled {
		slog.Error("CRITICAL: DISABLE_AUTH was set but the environment gates rejected the bypass",
			"environment", env,
			"ics_mode_set_to_true", icsMode,
		)
	}
	if disabled {
		// Emits "auth bypass active" with the environment name only. We
		// intentionally do NOT concatenate ICS_MODE + "=true" into a
		// single literal token that the ICS Prod Leak Check scans for
		// in prod-surface files.
		slog.Warn("auth bypass active — DISABLE_AUTH set and environment gate passed",
			"environment", env,
			"ics_mode_set_to_true", icsMode,
		)
	}

	cache := newJWKSCache(jwksURL)

	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// ── Token-first (#2045) ─────────────────────────────────────────
			// Cryptographic identity ALWAYS wins over the header-bypass shim. A
			// token whose (iss, aud) targets a RECOGNIZED pair — the Clerk pair
			// or the platform CE-audience pair (#2030/#2035) — is verified here;
			// a valid one propagates its claims (including the unforgeable
			// agent_id) and NEVER falls through to bypass, so the worker's signed
			// CE token drives agent/team-scoped RAG even under DISABLE_AUTH. A
			// recognized token that FAILS crypto/expiry/jti is REJECTED with 401,
			// never downgraded to the header-bypass (narrow-reject: no downgrade
			// attack on a token that claims to be genuine platform/Clerk).
			//
			// An Authorization value that does NOT target a recognized pair (no
			// token, non-Bearer scheme, malformed JWT, or unknown issuer/audience)
			// is treated as "no platform token" and handled by the bypass (dev) or
			// token-required (prod) branch below — so the browser (X-Tenant-Id /
			// X-Clearance headers, no verifiable Bearer) and any non-token dev
			// caller keep working unchanged.
			rawToken := bearerToken(r.Header.Get("Authorization"))
			if rawToken != "" {
				if tok, perr := jose.ParseSigned(rawToken, []jose.SignatureAlgorithm{jose.RS256, jose.ES256}); perr == nil {
					recClerk, recPlatform := classifyRecognizedToken(
						tok, expectedIssuer, expectedAudience,
						platformArmed, opts.PlatformIssuer, opts.PlatformAudience)
					if recClerk || recPlatform {
						claims, err := verifyRecognizedToken(
							r.Context(), cache, tok, recClerk, recPlatform,
							expectedIssuer, expectedAudience, opts)
						if err != nil {
							// Narrow-reject: the token targets a genuine platform/Clerk
							// (issuer, audience) but failed verification → forgery/tamper.
							slog.Warn("JWT verification failed for a recognized-issuer token — rejecting (no bypass downgrade)",
								"err", err, "path", r.URL.Path)
							writeJSONError(w, http.StatusUnauthorized, "invalid or expired token")
							return
						}
						if claims.Exp > 0 && time.Now().Unix() > claims.Exp {
							writeJSONError(w, http.StatusUnauthorized, "token expired")
							return
						}
						if !enforceJTI(w, r, opts, claims) {
							return
						}
						ctx := auth.WithVerified(context.WithValue(r.Context(), claimsContextKey{}, claims))
						next.ServeHTTP(w, r.WithContext(ctx))
						return
					}
				}
				// Parsed-but-unrecognized, or unparseable, token → treated as "no
				// platform token"; fall through to the bypass / prod branch.
			}

			// ── No recognized token ─────────────────────────────────────────
			// Dev/sandbox header-bypass shim (triple-gate, bug #861). Fires only
			// when NO recognized platform/Clerk token was presented, so a signed
			// worker CE token is never shadowed by the bypass (#2045).
			if disabled {
				// ICS shim path — synthesise a claims object from request
				// headers so downstream HTTP middleware (scope, scope GUCs,
				// RLS) has the right tenant-id + clearance to work with.
				//
				// #904 / #905 fix (QA #878 Run 10): the shim MUST plant
				// both the tenant scope AND a real clearance value. Before:
				//   - tenant came only from X-Org-Id with a fallback to
				//     "00000000-0000-0000-0000-000000000001" — so any
				//     caller sending X-Tenant-Id (QA's canonical header)
				//     silently got the same default tenant;
				//   - Clearance was hard-coded to 100, which the GUC
				//     clamp turned into app.clearance="1" (fine) but
				//     downstream `authctx.ClearanceLevel(ctx)` readers
				//     saw 100 — i.e. "L-infinity", trivially passing
				//     every L5 gate.
				// After: accept both canonical and legacy header names,
				// fail-closed when either is absent.
				tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
				if tenantID == "" {
					tenantID = strings.TrimSpace(r.Header.Get("X-Org-Id"))
				}
				if tenantID == "" {
					slog.Warn("ICS shim rejecting request: no X-Tenant-Id / X-Org-Id header", "path", r.URL.Path)
					writeJSONError(w, http.StatusForbidden, "missing X-Tenant-Id header (ICS shim requires explicit tenant)")
					return
				}

				rawClearance := r.Header.Get("X-Clearance")
				if rawClearance == "" {
					rawClearance = r.Header.Get("X-Clearance-Level")
				}
				clearance, ok := parseShimClearance(rawClearance)
				if !ok {
					slog.Warn("ICS shim rejecting request: invalid or missing X-Clearance header",
						"path", r.URL.Path, "raw", rawClearance)
					writeJSONError(w, http.StatusForbidden, "missing or invalid X-Clearance header (ICS shim requires explicit clearance)")
					return
				}

				claims := &ClerkClaims{
					Sub:   "dev-user",
					OrgID: tenantID,
					Exp:   time.Now().Add(1 * time.Hour).Unix(),
					Metadata: clerkMetadata{
						PillarID:  r.Header.Get("X-Pillar-Id"),
						TeamID:    r.Header.Get("X-Team-Id"),
						Clearance: clearance,
					},
				}
				ctx := auth.WithVerified(context.WithValue(r.Context(), claimsContextKey{}, claims))
				next.ServeHTTP(w, r.WithContext(ctx))
				return
			}

			// Prod (bypass never eligible) — a recognized, verifiable token is
			// required. Reaching here means the Authorization value did not target
			// a recognized (issuer, audience) pair.
			if rawToken == "" {
				writeJSONError(w, http.StatusUnauthorized, "missing authorization header")
				return
			}
			writeJSONError(w, http.StatusUnauthorized, "invalid or expired token")
		})
	}
}

func verifyAndParse(ctx context.Context, cache *jwksCache, tok *jose.JSONWebSignature, forceRefresh bool, expectedIssuer, expectedAudience string) (*ClerkClaims, error) {
	jwks, err := cache.get(ctx, forceRefresh)
	if err != nil {
		return nil, fmt.Errorf("fetch JWKS: %w", err)
	}
	return verifyWithKeySet(tok, jwks, expectedIssuer, expectedAudience)
}

// verifyWithKeySet is the shared verification core: it selects the signing key
// by `kid`, verifies the signature, unmarshals the claims, and enforces the
// expected issuer/audience. It is called for BOTH the Clerk key set (fetched via
// the jwksCache) and the static platform key set (#2030 Option C), so the two
// accepted (issuer, audience) pairs run identical checks with no drift.
func verifyWithKeySet(tok *jose.JSONWebSignature, jwks *jose.JSONWebKeySet, expectedIssuer, expectedAudience string) (*ClerkClaims, error) {
	if jwks == nil {
		return nil, fmt.Errorf("no JWKS")
	}

	headers := tok.Signatures
	if len(headers) == 0 {
		return nil, fmt.Errorf("no signatures in JWT")
	}

	kid := headers[0].Header.KeyID
	keys := jwks.Key(kid)
	if len(keys) == 0 {
		return nil, fmt.Errorf("key %q not found in JWKS", kid)
	}

	payload, err := tok.Verify(keys[0])
	if err != nil {
		return nil, fmt.Errorf("JWT signature verification: %w", err)
	}

	var claims ClerkClaims
	if err := json.Unmarshal(payload, &claims); err != nil {
		return nil, fmt.Errorf("unmarshal claims: %w", err)
	}

	// Validate issuer claim when configured.
	if expectedIssuer != "" && claims.Issuer != expectedIssuer {
		return nil, fmt.Errorf("invalid issuer: got %q, expected %q", claims.Issuer, expectedIssuer)
	}

	// Validate audience claim when configured.
	if expectedAudience != "" {
		found := false
		for _, a := range claims.Audience {
			if a == expectedAudience {
				found = true
				break
			}
		}
		if !found {
			return nil, fmt.Errorf("invalid audience: %v does not contain %q", claims.Audience, expectedAudience)
		}
	}

	return &claims, nil
}

// bearerToken extracts the raw token from an "Authorization: Bearer <t>" header.
// It returns "" for an absent header OR any non-Bearer scheme — the token-first
// ingress (#2045) treats such values as "no platform token", so a non-token dev
// caller (e.g. the browser's X-Tenant-Id/X-Clearance headers) is never rejected
// and stays bypass-eligible in dev.
func bearerToken(authHeader string) string {
	if authHeader == "" {
		return ""
	}
	t := strings.TrimPrefix(authHeader, "Bearer ")
	if t == authHeader {
		return ""
	}
	return strings.TrimSpace(t)
}

// classifyRecognizedToken inspects a parsed JWS's UNVERIFIED payload to decide
// whether it CLAIMS to be one of the two ingress-recognized (issuer, audience)
// pairs: the Clerk pair, or the platform CE-audience pair (#2030/#2035). The
// signature is deliberately NOT checked here — recognition only (a) selects
// which key set the token must then verify against, and (b) per the narrow-reject
// rule (#2045), decides whether a verification failure REJECTS (recognized) vs.
// falls through to the header-bypass (unrecognized). The predicate byte-matches
// the issuer/audience enforcement in verifyWithKeySet, so a token flagged
// "recognized" that then fails verification is a genuine forgery/tamper.
func classifyRecognizedToken(tok *jose.JSONWebSignature, clerkIssuer, clerkAudience string, platformArmed bool, platformIssuer, platformAudience string) (clerk, platform bool) {
	raw := tok.UnsafePayloadWithoutVerification()
	if len(raw) == 0 {
		return false, false
	}
	var c ClerkClaims
	if err := json.Unmarshal(raw, &c); err != nil {
		return false, false
	}
	clerk = clerkPairRecognizes(clerkIssuer, clerkAudience, c.Issuer, c.Audience)
	platform = platformArmed && platformIssuer != "" && c.Issuer == platformIssuer && audienceContains(c.Audience, platformAudience)
	return clerk, platform
}

// clerkPairRecognizes reports whether a token targets the Clerk (issuer,
// audience) pair given how that pair is configured. It mirrors the tiered
// enforcement verifyWithKeySet applies:
//   - issuer configured  → the token's iss must match it (and satisfy audience);
//   - only audience set   → the token's aud must contain it;
//   - NEITHER configured  → the pair is in "accept any JWKS-signed token" mode
//     (the pre-#2045 permissive contract), so any token is a Clerk candidate and
//     is verified against the served JWKS rather than shunted to the bypass.
func clerkPairRecognizes(expectedIssuer, expectedAudience, iss string, aud []string) bool {
	if expectedIssuer != "" {
		return iss == expectedIssuer && audienceContains(aud, expectedAudience)
	}
	if expectedAudience != "" {
		return audienceContains(aud, expectedAudience)
	}
	return true
}

// audienceContains reports whether aud satisfies the expected audience. An empty
// expected audience matches anything — mirroring verifyWithKeySet, which skips
// the audience check when it is unconfigured.
func audienceContains(aud []string, expected string) bool {
	if expected == "" {
		return true
	}
	for _, a := range aud {
		if a == expected {
			return true
		}
	}
	return false
}

// verifyRecognizedToken verifies a token that classifyRecognizedToken flagged as
// targeting the Clerk and/or platform pair, against the corresponding key set(s).
// It returns the parsed claims on the first successful verification, or the last
// error if every recognized pair fails (→ the caller REJECTS with 401 under the
// narrow-reject rule; it never downgrades to the header-bypass). The Clerk path
// retries once with a forced JWKS refresh to tolerate key rotation.
func verifyRecognizedToken(ctx context.Context, cache *jwksCache, tok *jose.JSONWebSignature, recClerk, recPlatform bool, expectedIssuer, expectedAudience string, opts AuthOptions) (*ClerkClaims, error) {
	var lastErr error
	if recClerk {
		claims, err := verifyAndParse(ctx, cache, tok, false, expectedIssuer, expectedAudience)
		if err != nil {
			// Retry with force-refresh for the key-rotation scenario.
			claims, err = verifyAndParse(ctx, cache, tok, true, expectedIssuer, expectedAudience)
		}
		if err == nil {
			return claims, nil
		}
		lastErr = err
	}
	if recPlatform {
		// #2030 Option C: verify the platform-minted CE-audience agent token
		// against the static platform key set. This ONE ingress chokepoint serves
		// both accepted (issuer, audience) pairs rather than a duplicated chain.
		claims, err := verifyWithKeySet(tok, opts.PlatformKeys, opts.PlatformIssuer, opts.PlatformAudience)
		if err == nil {
			return claims, nil
		}
		lastErr = err
	}
	if lastErr == nil {
		lastErr = fmt.Errorf("no recognized issuer/audience pair matched")
	}
	return nil, lastErr
}

// enforceJTI runs the LLD #385 replay-protection check on an already-verified
// token. It returns false — after writing a structured 401 — only when hard_deny
// mode rejects a missing/replayed jti; in every other case (log_warn, cache
// error fail-open, Clerk exempt, first-use) it returns true and the request
// proceeds. Extracted so the token-first path (#2045) reuses the identical check.
func enforceJTI(w http.ResponseWriter, r *http.Request, opts AuthOptions, claims *ClerkClaims) bool {
	result, mode := checkJTI(r.Context(), opts.JTIStore, opts.JTIModeResolver, claims, time.Now())
	switch result {
	case jtiResultMissing:
		slog.Warn("JWT missing jti claim",
			"iss", claims.Issuer,
			"sub", claims.Sub,
			"tenant", claims.OrgID,
			"mode", string(mode),
			"path", r.URL.Path,
		)
		if mode == JTIModeHardDeny {
			writeJSONErrorWithReason(w, http.StatusUnauthorized, "token missing jti claim", "jti_missing")
			return false
		}
	case jtiResultReplay:
		slog.Warn("JWT replay detected",
			"iss", claims.Issuer,
			"sub", claims.Sub,
			"tenant", claims.OrgID,
			"mode", string(mode),
			"path", r.URL.Path,
		)
		if mode == JTIModeHardDeny {
			writeJSONErrorWithReason(w, http.StatusUnauthorized, "token replay detected", "jti_replay")
			return false
		}
	case jtiResultCacheError:
		// Fail open — count already incremented. Log for visibility.
		slog.Warn("jti cache error, failing open",
			"iss", claims.Issuer,
			"sub", claims.Sub,
			"tenant", claims.OrgID,
			"path", r.URL.Path,
		)
	case jtiResultExempt:
		// Issuer is on the jti-exempt allowlist (Clerk session tokens). Replay
		// protection is handled at the session layer by the IdP; skip by design.
	case jtiResultOK:
		// First observation (or feature disabled) — nothing to do.
	}
	return true
}

// parseShimClearance maps a raw X-Clearance / X-Clearance-Level header
// value (case-insensitive) to a numeric clearance in [1,5]. Mirrors the
// Connect-RPC bypass interceptor's parseClearance — intentionally
// duplicated so the gateway package does not import the authbypass
// package (which is Connect-specific). Returns ok=false for empty or
// unrecognised input so the caller can fail-closed with a 403.
func parseShimClearance(raw string) (int32, bool) {
	v := strings.TrimSpace(strings.ToUpper(raw))
	switch v {
	case "L1", "1":
		return 1, true
	case "L2", "2":
		return 2, true
	case "L3", "3":
		return 3, true
	case "L4", "4":
		return 4, true
	case "L5", "5":
		return 5, true
	}
	return 0, false
}

func writeJSONError(w http.ResponseWriter, status int, msg string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(map[string]string{
		"code":    http.StatusText(status),
		"message": msg,
	})
}

// writeJSONErrorWithReason writes a structured error body with a machine-
// readable reason code, used by the jti replay-protection 401 responses
// so dashboards and SDKs can distinguish jti_missing vs jti_replay from a
// generic signature failure.
func writeJSONErrorWithReason(w http.ResponseWriter, status int, msg, reason string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(map[string]string{
		"code":    http.StatusText(status),
		"message": msg,
		"reason":  reason,
	})
}

// writeJSONErrorWithReqID writes a JSON error response that includes the
// request ID for client-side correlation. Use this from middleware that runs
// after the RequestID middleware.
func writeJSONErrorWithReqID(w http.ResponseWriter, r *http.Request, status int, msg string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	resp := map[string]string{
		"code":    http.StatusText(status),
		"message": msg,
	}
	if reqID := RequestIDFromContext(r.Context()); reqID != "" {
		resp["request_id"] = reqID
	}
	_ = json.NewEncoder(w).Encode(resp)
}
