// Package authbypass provides a Connect-RPC interceptor that injects a
// synthetic authenticated identity — for BOTH unary and streaming-handler
// RPCs — when BOTH the DISABLE_AUTH and ICS_MODE environment variables are
// set to "true".
//
// # Purpose
//
// The Interactive Configuration Sandbox (ICS — PRD #759, HLD #763 §2.1,
// LLD-1 #765 §3.2) needs to exercise every gRPC/Connect surface without a
// real Clerk login loop. The shim reads two request headers (X-Tenant-ID
// and X-Clearance-Level) and synthesises the same authctx.TenantContext the
// real Clerk path produces, so downstream RBAC / audit / scope middleware
// behave identically.
//
// Double-gate (runtime-guarded, not build-tagged)
//
// BOTH environment variables MUST be set to the literal string "true":
//
//   - DISABLE_AUTH=true
//   - ICS_MODE=true
//
// Any other state — one set and the other not, either set to anything
// other than "true", or both unset — must leave the production auth path
// unchanged. If exactly one of the two is set, MustCheckStartupGate()
// refuses to start the process. This is the fail-closed posture mandated
// by HLD #763 §2.1 and LLD-1 #765 §3.2.
//
// Architect preference (confirmed 2026-04-21): runtime-guarded instead of
// a build tag so we do NOT carry a parallel binary variant into production
// images. The bypass code is present in every binary but can only activate
// when the founder-authored ICS compose stack sets both envs.
//
// # Defaults when headers absent
//
// When the shim is active and a request arrives:
//
//   - X-Tenant-ID header present → that UUID is used.
//   - X-Tenant-ID absent → defaultTenantID is used
//     ("dev-tenant-00000000-0000-0000-0000-000000000000" — see the
//     DefaultTenantID constant).
//   - X-Clearance-Level header present and parseable (L1..L5 or 1..5) →
//     that level is used.
//   - X-Clearance-Level absent or unparseable → L5 is used.
//
// This is the documented ICS ergonomic: developers poking at the sandbox
// with curl get a working L5 identity out of the box. The
// {real-production} path is protected by MustCheckStartupGate — DISABLE_AUTH
// on its own cannot activate the shim.
package authbypass

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"strings"
	"sync"

	"connectrpc.com/connect"

	"github.com/upsquad-ai/upsquad-core/internal/auth"
	authctx "github.com/upsquad-ai/upsquad-core/internal/auth/context"
	"github.com/upsquad-ai/upsquad-core/internal/context/scope"
	authpb "github.com/upsquad-ai/upsquad-core/pkg/contextpb/upsquad/auth/v1"
)

// Environment variable names. Exported so tests and CI lint rules can
// reference the canonical names.
const (
	EnvDisableAuth = "DISABLE_AUTH"
	EnvICSMode     = "ICS_MODE"
	envTrue        = "true"
)

// Header names read from the incoming request when the shim is active.
// Case-insensitive per the net/http canonicalisation rules — these
// constants are the canonical forms.
//
// Two aliases are accepted for the clearance header:
//   - `X-Clearance` — canonical form documented in QA tooling + smoke-b.sh
//     (QA #878 Run 10, which surfaced #904 / #905).
//   - `X-Clearance-Level` — original shim header, preserved so existing
//     callers (seed scripts, dev curl examples) keep working.
//
// The shim reads the canonical name first and only falls back to the
// legacy alias when the canonical header is absent.
const (
	HeaderTenantID             = "X-Tenant-Id"
	HeaderClearance            = "X-Clearance"
	HeaderClearanceLevelLegacy = "X-Clearance-Level"
	// HeaderMemberID is the optional act-as (impersonation) header. When the
	// shim is active AND this header carries a members.id UUID, the shim
	// resolves that member from the DB (org-scoped to X-Tenant-Id, not
	// soft-deleted) and builds the synthetic identity from the RESOLVED
	// member — its id and its AUTHORITATIVE clearance + clearance_label,
	// which OVERRIDE the X-Clearance header. This powers the dev viewer-
	// switcher: "load any user's account with their real privileges"
	// (founder ask 2026-06-24, #1582).
	//
	// Hard-gated: only honoured when isActive() is true. The real Clerk /
	// production auth path NEVER reads this header — BypassInterceptor short-
	// circuits to a pass-through before injectSyntheticIdentity runs.
	HeaderMemberID = "X-Member-Id"
	// Deprecated: alias for HeaderClearance. Preserved so existing test
	// fixtures keep compiling without a churn diff; new code should
	// reference HeaderClearance.
	HeaderClearanceLevel = HeaderClearance
)

// Defaults applied when the corresponding header is absent or unparseable
// and the shim is active. See package doc.
//
// #904 / #905 (QA #878 Run 10) demonstrated that defaulting to L5 when the
// X-Clearance header was absent silently gave every caller platform-admin
// authority: smoke-b.sh sent `X-Clearance: 2` but the shim read only
// `X-Clearance-Level`, missed the header, and fell through to L5 —
// which made the service-layer L5 gate on UpdateJTIFeatureFlagConfig
// indistinguishable from an authorised call.
//
// After the fix, the shim fails closed when the clearance header is
// missing or unparseable. DefaultClearanceLevel is retained for a narrow
// bypass-only callsite (missing tenant header also triggers the fail-
// closed path) and exported for tests and tooling that still need the
// old documented default.
const (
	DefaultTenantID       = "dev-tenant-00000000-0000-0000-0000-000000000000"
	DefaultClearanceLevel = int32(5)
	DefaultClearanceLabel = "Executive" // mirrors migration 062 mapping for L5
)

// ErrStartupMisconfigured is returned by CheckStartupGate when exactly one
// of DISABLE_AUTH / ICS_MODE is set to "true". MustCheckStartupGate wraps
// this in a log.Fatal; library callers can use CheckStartupGate to get the
// error back.
var ErrStartupMisconfigured = errors.New(
	"authbypass: DISABLE_AUTH and ICS_MODE must both be 'true' to activate the shim; " +
		"setting exactly one is a misconfiguration",
)

// ErrActAsMemberNotFound is returned by an ActAsResolver when the
// X-Member-Id header names a member that does not exist in the requested
// org, belongs to another org, or has been soft-deleted. The shim maps it
// to permission_denied — impersonation against a bad target FAILS CLOSED
// rather than silently falling back to the X-Clearance identity, so the
// bug is visible at the boundary (#1582).
var ErrActAsMemberNotFound = errors.New(
	"authbypass: act-as member not found in org (non-existent, cross-org, or deleted)",
)

// ActAsMember is the minimal projection an ActAsResolver returns for a
// resolved impersonation target. Clearance + label are AUTHORITATIVE —
// they come from the members row and override the X-Clearance header.
type ActAsMember struct {
	MemberID       string
	ClearanceLevel int32
	ClearanceLabel string
}

// ActAsResolver resolves an X-Member-Id (act-as) header to the target
// member's authoritative identity, scoped to the supplied org. It is only
// ever invoked when the bypass shim is active (dev-only), so it carries no
// production code path. Implementations MUST scope the lookup to orgID and
// exclude soft-deleted rows, and MUST return ErrActAsMemberNotFound (not a
// zero-value success) when no matching live member exists.
type ActAsResolver interface {
	ResolveActAs(ctx context.Context, orgID, memberID string) (ActAsMember, error)
}

// config holds the optional dependencies wired into BypassInterceptor via
// functional options. The zero value (nil actAs) preserves the original
// header-only behaviour for callers that pass no options.
type config struct {
	actAs ActAsResolver
}

// Option configures BypassInterceptor.
type Option func(*config)

// WithActAsResolver enables the X-Member-Id act-as path. When set and the
// shim is active, a request carrying X-Member-Id is resolved through r and
// the synthetic identity is built from the resolved member. Without this
// option, X-Member-Id present on an active-shim request is rejected
// (fail-closed) — a service that cannot resolve members must not silently
// ignore an impersonation request.
func WithActAsResolver(r ActAsResolver) Option {
	return func(c *config) { c.actAs = r }
}

// CheckStartupGate returns nil when the env state is coherent:
//   - both DISABLE_AUTH and ICS_MODE set to "true" (shim will activate), or
//   - neither set to "true" (production auth path untouched).
//
// When exactly one is set to "true", returns ErrStartupMisconfigured.
// Call this once per process at startup before serving traffic.
func CheckStartupGate() error {
	disable := os.Getenv(EnvDisableAuth) == envTrue
	ics := os.Getenv(EnvICSMode) == envTrue
	if disable != ics {
		return fmt.Errorf("%w: DISABLE_AUTH=%q, ICS_MODE=%q",
			ErrStartupMisconfigured,
			os.Getenv(EnvDisableAuth),
			os.Getenv(EnvICSMode),
		)
	}
	return nil
}

// MustCheckStartupGate calls CheckStartupGate and, on error, emits a
// structured fatal log and exits the process with status 1. Intended for
// cmd/*/main.go wiring — callers that want to handle the error themselves
// should use CheckStartupGate.
func MustCheckStartupGate() {
	if err := CheckStartupGate(); err != nil {
		// log.Fatal style without pulling in log; slog + os.Exit keeps
		// the JSON-structured log pipeline intact for audit.
		slog.Error("fatal: authbypass startup gate refused to start",
			"err", err.Error(),
			"remediation", "set BOTH DISABLE_AUTH=true and ICS_MODE=true, or set NEITHER",
		)
		os.Exit(1)
	}
}

// warnOnce guards the one-line WARN banner that fires the first time the
// shim handles a request (or is constructed). Keeps logs quiet in tests
// that spin up many interceptors.
var warnOnce sync.Once

func emitStartupWarnBanner() {
	warnOnce.Do(func() {
		slog.Warn(
			"DISABLE_AUTH=true + ICS_MODE=true active — dev shim injecting " +
				"synthetic auth from X-Tenant-ID + X-Clearance-Level headers. " +
				"DO NOT USE IN PROD.",
		)
	})
}

// isActive reports whether both gate envs are set to "true". Read per
// request so tests using t.Setenv flip cleanly without re-constructing
// the interceptor.
func isActive() bool {
	return os.Getenv(EnvDisableAuth) == envTrue && os.Getenv(EnvICSMode) == envTrue
}

// BypassInterceptor returns a Connect interceptor that, when the shim is
// active, overwrites the request context with a synthetic authenticated
// identity derived from request headers. It covers BOTH unary and
// streaming-handler RPCs.
//
// When the shim is NOT active, the interceptor is a pure pass-through —
// downstream Clerk/real-auth interceptors run unchanged, on both arms.
//
// Intended ordering (per LLD-1 #765 §3.2):
//
//	connect.WithInterceptors(
//	    authbypass.BypassInterceptor(),   // no-op unless shim active
//	    clerkAuth.Interceptor(),          // short-circuits if ctx already verified
//	    rbac.Interceptor(),
//	    audit.Interceptor(),
//	)
//
// The Clerk interceptor MUST treat auth.IsVerified(ctx) == true as
// "already authenticated" and pass through. That behaviour is the
// pre-existing convention in internal/gateway/auth.go.
//
// Fail-closed posture (#904 / #905 fix): when the shim is active but the
// caller omits either X-Tenant-Id or X-Clearance (no fallback to the
// legacy X-Clearance-Level header either), the interceptor rejects the
// request with permission_denied. The ICS compose stack always sets both
// headers per .env.ics.example + seed scripts; defaulting-to-L5 in the
// shim was the root cause of #905 ("L2 caller can flip L5 fields") and
// defaulting-the-tenant-to-a-fixed-UUID was the root cause of #904
// ("ListMembers returns rows from the wrong tenant").
//
// Streaming parity (#2137): the streaming-handler arm reads the same
// headers off conn.RequestHeader() and runs the SAME
// injectSyntheticIdentity validation as the unary arm — including the
// member-exists-in-org act-as check and its fail-closed 403 on a
// bogus/cross-org/deleted member. Before #2137 BypassInterceptor was a
// connect.UnaryInterceptorFunc, whose streaming methods are no-ops, so a
// streaming RPC (e.g. Quad's server-streaming SendMessage) ignored
// X-Member-Id entirely and ran as the outer "Dev User". That was the
// #2119 live-acceptance limitation this fix removes.
func BypassInterceptor(opts ...Option) connect.Interceptor {
	cfg := &config{}
	for _, opt := range opts {
		opt(cfg)
	}

	// Emit the WARN once at construction time so operators see it in
	// startup logs regardless of whether traffic has arrived yet. Uses
	// sync.Once so repeated construction (tests, multi-server binaries)
	// does not spam logs.
	if isActive() {
		emitStartupWarnBanner()
	}

	return &bypassInterceptor{actAs: cfg.actAs}
}

// bypassInterceptor implements connect.Interceptor. Both the unary and
// streaming-handler arms funnel through the SAME injectSyntheticIdentity, so
// their validation semantics are identical by construction: a missing/invalid
// X-Tenant-Id or X-Clearance, an X-Member-Id with no resolver wired, or an
// act-as target that is non-existent / cross-org / soft-deleted all fail
// closed with permission_denied on either path. The streaming-CLIENT arm is a
// pass-through — the shim is a server-side identity injector and has no role
// on outbound client streams.
type bypassInterceptor struct {
	actAs ActAsResolver
}

// Compile-time assertion that the shim satisfies the full interceptor
// interface (not just the unary subset it implemented before #2137).
var _ connect.Interceptor = (*bypassInterceptor)(nil)

// WrapUnary injects the synthetic identity for unary RPCs.
func (b *bypassInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
	return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
		// HARD GATE: when the shim is inactive (the only state reachable in
		// production, since MustCheckStartupGate refuses to boot any other
		// combination), this is a pure pass-through. X-Member-Id and every
		// other shim header are never read on the real-auth path.
		if !isActive() {
			return next(ctx, req)
		}
		newCtx, err := injectSyntheticIdentity(ctx, req.Header(), b.actAs)
		if err != nil {
			return nil, err
		}
		return next(newCtx, req)
	}
}

// WrapStreamingClient is a pass-through. The bypass shim is a server-side
// identity injector; it never mutates outbound client calls.
func (b *bypassInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {
	return next
}

// WrapStreamingHandler injects the synthetic identity for streaming
// (server-, client-, and bidi-stream) handler RPCs (#2137). The synthetic
// context is established BEFORE the handler receives its first message, so
// retrieval-scope anchoring, RBAC role resolution, scope-member bridging and
// audit attribution observe the act-as member for the entire stream — exactly
// as they do on the unary path. Headers are read from conn.RequestHeader()
// and validated by the SAME injectSyntheticIdentity that WrapUnary uses.
func (b *bypassInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
	return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
		// HARD GATE — identical to WrapUnary. Pure pass-through in
		// production; the shim reads no header on the real-auth path.
		if !isActive() {
			return next(ctx, conn)
		}
		newCtx, err := injectSyntheticIdentity(ctx, conn.RequestHeader(), b.actAs)
		if err != nil {
			return err
		}
		return next(newCtx, conn)
	}
}

// injectSyntheticIdentity builds a *authpb.TenantContext from the request
// headers, stores it on ctx via authctx.WithTenantContext, and marks the
// context as auth-verified so the Clerk interceptor knows to pass through.
//
// Returns a permission_denied error when either header is missing or
// unparseable — fail-closed per the #904 / #905 fix. The ICS compose
// stack always sets both headers; this only fires for misconfigured
// callers and so is acceptable as a hard rejection.
func injectSyntheticIdentity(ctx context.Context, h headerGetter, actAs ActAsResolver) (context.Context, error) {
	tenantID := strings.TrimSpace(h.Get(HeaderTenantID))
	if tenantID == "" {
		return ctx, connect.NewError(
			connect.CodePermissionDenied,
			fmt.Errorf("authbypass: missing %s header — ICS shim requires explicit tenant", HeaderTenantID),
		)
	}

	rawClearance := h.Get(HeaderClearance)
	if rawClearance == "" {
		rawClearance = h.Get(HeaderClearanceLevelLegacy)
	}
	level, label, ok := parseClearance(rawClearance)
	if !ok {
		return ctx, connect.NewError(
			connect.CodePermissionDenied,
			fmt.Errorf("authbypass: missing or invalid %s header — ICS shim requires explicit clearance", HeaderClearance),
		)
	}

	tc := &authpb.TenantContext{
		TenantId:       tenantID,
		MemberId:       "",                     // unknown in shim — audit attribution via ClerkUserId
		ClerkUserId:    "ics-shim:" + tenantID, // prefixed to make audit grep trivial
		ClearanceLevel: level,
		ClearanceLabel: label,
		GlobalRoleIds:  nil,
	}

	// Optional act-as (impersonation) path. When X-Member-Id is present the
	// caller wants to load a SPECIFIC member's account — its real id,
	// clearance and label — so the whole request behaves as that member
	// (retrieval-scope anchoring, RBAC role resolution, edit-gating). The
	// resolved member's clearance is AUTHORITATIVE and overrides the
	// X-Clearance value above.
	if memberID := strings.TrimSpace(h.Get(HeaderMemberID)); memberID != "" {
		if actAs == nil {
			// Header asked for impersonation but this service has no resolver
			// wired. Fail closed rather than silently run as the X-Clearance
			// identity — a silent privilege mismatch is exactly the class of
			// bug #1582 wants visible.
			return ctx, connect.NewError(
				connect.CodePermissionDenied,
				fmt.Errorf("authbypass: %s present but act-as is not supported on this service", HeaderMemberID),
			)
		}
		resolved, err := actAs.ResolveActAs(ctx, tenantID, memberID)
		if err != nil {
			// Non-existent / cross-org / deleted member, or a lookup failure —
			// reject, never fall back.
			return ctx, connect.NewError(
				connect.CodePermissionDenied,
				fmt.Errorf("authbypass: cannot act as member %q in org %q: %w", memberID, tenantID, err),
			)
		}
		tc.MemberId = resolved.MemberID
		tc.ClearanceLevel = resolved.ClearanceLevel // authoritative — overrides X-Clearance
		tc.ClearanceLabel = resolved.ClearanceLabel
		tc.ClerkUserId = "ics-shim:actas:" + resolved.MemberID
	}

	ctx = authctx.WithTenantContext(ctx, tc)
	ctx = auth.WithVerified(ctx)

	// SECURITY-CRITICAL persona bridge (#1777).
	//
	// Manager-gated write RPCs (mcpserver decide/update/status, group
	// mutations, the L3 credential path) read the CALLER via
	// scope.MemberIDFromContext — NOT authctx.TenantContext.MemberId. In
	// dev-bypass the outer HTTP scope middleware plants that scope member id
	// from a lookup of the synthetic Sub="dev-user", so the X-Member-Id
	// persona resolved above never reached the authz check and the real
	// Manager 403'd on their own binding. Bridge the RESOLVED act-as member
	// into scope so persona-switching drives authz, matching what the SPA
	// sends.
	//
	// This trust of the transmitted X-Member-Id identity is gated STRICTLY
	// behind the dev-bypass shim: this function is ONLY reached from
	// BypassInterceptor when isActive() (DISABLE_AUTH=true AND ICS_MODE=true)
	// is true — the production auth path short-circuits to a pure pass-through
	// before injectSyntheticIdentity ever runs (see BypassInterceptor's HARD
	// GATE), and MustCheckStartupGate refuses to boot any partial combination.
	// Therefore a client-supplied X-Member-Id in a non-dev config is NEVER
	// read here and CANNOT override the Clerk-resolved caller. We only plant a
	// non-empty resolved id so we never clobber the outer scope member id with
	// an empty value on the non-act-as shim path.
	if tc.MemberId != "" {
		ctx = scope.WithMemberID(ctx, tc.MemberId)

		// #2364: re-plant the per-request `app.member_id` GUC to the acted-as
		// persona on the request transaction.
		//
		// WHY: the outer HTTP scope middleware (internal/gateway/scope.go) runs
		// BEFORE this interceptor — it opens the request tx, planted app.member_id
		// from the synthetic `Sub="dev-user"` (→ the base dev-user, e.g. d001),
		// and put the tx on ctx via scope.WithTx. This interceptor then resolves
		// the act-as persona and updates the Go-ctx member id above — but the tx
		// GUC still reads the base dev-user. Member-scoped RLS/visibility reads
		// the GUC, NOT the ctx (e.g. QWP-3 ListVisibleWorkflowRuns'
		// `current_setting('app.member_id')` visibility predicate), so without
		// this re-plant the persona is ignored and the Quad Workflows Panel is
		// empty for everyone.
		//
		// SAFETY: app.member_id is NOT a tenant-isolation boundary (that is
		// app.org_id, which is UNCHANGED here) — its only production consumer is
		// the QWP visibility filter; no RLS POLICY keys off it. The persona is
		// already validated same-org by the ActAsResolver (cross-org/bogus/
		// deleted → ErrActAsMemberNotFound → 403 above), so re-planting it cannot
		// cross an org boundary. This whole function is dev-shim-scoped
		// (isActive()); the prod Clerk path never reaches it.
		//
		// The re-plant runs on the SAME tx the RLS query rides (scope.WithTx),
		// mirroring the outer middleware's set_config with SET LOCAL semantics
		// (the `true` is_local flag). A nil tx (unit tests / non-HTTP paths that
		// bypass the scope middleware) is a graceful no-op — the ctx bridge above
		// still carries the persona for any tx-less consumer.
		if tx := scope.TxFromContext(ctx); tx != nil {
			if _, err := tx.Exec(ctx, "SELECT set_config('app.member_id', $1, true)", tc.MemberId); err != nil {
				return ctx, connect.NewError(
					connect.CodeInternal,
					fmt.Errorf("authbypass: re-plant app.member_id GUC for act-as member %q: %w", tc.MemberId, err),
				)
			}
		}
	}
	return ctx, nil
}

// headerGetter is the minimum surface of http.Header we rely on. Narrowed
// to a one-method interface so unit tests can pass a plain map.
type headerGetter interface {
	Get(string) string
}

// parseClearance maps a header value to (numeric level, human label, ok).
// Accepted forms (case-insensitive): "L1".."L5", "1".."5".
//
// Returns ok=false for empty or unrecognised input — the caller is
// expected to reject the request rather than silently default to L5
// (see #904 / #905 fix rationale in injectSyntheticIdentity).
func parseClearance(raw string) (int32, string, bool) {
	v := strings.TrimSpace(strings.ToUpper(raw))
	switch v {
	case "L1", "1":
		return 1, "Standard", true
	case "L2", "2":
		return 2, "Internal", true
	case "L3", "3":
		return 3, "Sensitive", true
	case "L4", "4":
		return 4, "Privileged", true
	case "L5", "5":
		return 5, "Executive", true
	default:
		return 0, "", false
	}
}
