// Package audit — request-capture middleware (design §14.4 / §14.6 / §14.11).
//
// Records inbound REST traffic into `_System_RequestCapture` so the replay
// toolchain (scripts/audit-replay/, C15) can reconstruct traffic against a
// refactored backend and assert `_System_AuditLog` field-level equivalence
// (§14.5).
//
// Invariants enforced here:
//   - Default OFF. Gated by NEXUSCRM_REQUEST_CAPTURE=on (design §14.11).
//     Without explicit opt-in the middleware is a no-op — zero rows, zero
//     perf impact.
//   - Body is buffered up to DefaultBodyCapBytes (1 MiB, §14.2). Overflow
//     sets BodyTruncated=true; BodySize records the raw pre-scrub size for
//     monitoring.
//   - Bodies on JSON-like content types are scrubbed via ScrubJSONPayload
//     (§14.3). Non-JSON bodies are stored verbatim within the cap — this
//     middleware's redaction is narrow by design; §14.3 content-type-aware
//     scrub (form-url / XML / binary default-deny) is Phase 2B work.
//   - Headers always scrubbed via ScrubHeaders (§14.3 header blacklist).
//   - Response body is NEVER captured by default, even with capture enabled
//     (§14.4). Per-route opt-in via EnableResponseCapture(path); the blanket
//     kill-switch is NEXUSCRM_REQUEST_CAPTURE_RESPONSE=on (§14.11 env).
//   - DB write failures MUST NOT block the request (§14.6 failure policy):
//     the row is written asynchronously post-handler; any error is WARN-
//     logged and swallowed.
//   - UserId / SessionId / Entrypoint are read best-effort from ctx. If the
//     capture middleware runs before auth for a given route, those fields
//     are empty strings — acceptable per §14.6 ("after auth" is the design
//     intent; public / unauthenticated routes legitimately have no user).
package audit

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"github.com/rs/zerolog/log"
)

// RequestCaptureStore, RequestCaptureRow, NewSQLRequestCaptureStore, and
// nullIfEmpty live in request_capture_store.go (kept separate so this file
// stays focused on middleware logic).

// DefaultBodyCapBytes is the §14.2 "~1MB" body buffering cap. Bodies larger
// than this are truncated at the cap and BodyTruncated is set to true.
const DefaultBodyCapBytes = 1 * 1024 * 1024

// Env gate names per design §14.11.
const (
	EnvRequestCapture         = "NEXUSCRM_REQUEST_CAPTURE"
	EnvRequestCaptureResponse = "NEXUSCRM_REQUEST_CAPTURE_RESPONSE"
	// EnvRequestCaptureStrict, when truthy, rejects capture for requests
	// whose Content-Type is not in the scrub-coverage allowlist (currently
	// json + x-www-form-urlencoded). Default off: preserves backward compat
	// (XML / text/plain / multipart still write with pass-through body).
	EnvRequestCaptureStrict = "NEXUSCRM_REQUEST_CAPTURE_STRICT"
)

// ctxRequestIDKey is the gin-context key written by middleware.RequestID().
// Mirrored here so audit package does not need to import the interfaces/
// middleware package (would create a cycle).
const ctxRequestIDKey = "request_id"

// ctxUserKey is the gin-context key written by middleware.RequireAuth().
// Same rationale as ctxRequestIDKey — avoid the interfaces/middleware cycle.
const ctxUserKey = "user"

// ctxSessionIDKey mirrors constants.ContextKeySessionID. The capture
// middleware reads the validated JWT session identifier (jti) here; it
// MUST NEVER read `"token"` — that key used to hold the raw bearer token,
// which would make `_System_RequestCapture.SessionId` a replayable
// credential on DB dump.
const ctxSessionIDKey = "session_id"

// headerReplayRequestID identifies traffic originating from cmd/replay.
// When present, the capture middleware MUST skip writing a row — replay
// traffic is self-identified and does not need re-capture. Mirrored here
// (matches middleware.HeaderXReplayRequestID) to avoid an import cycle
// between audit and interfaces/middleware.
//
// Why the skip matters: without it, single-DB replay re-captures its own
// POSTs into `_System_RequestCapture` with the suffixed RequestId. The next
// cmd/replay invocation over the same time window would then replay those
// rows again (with a second-level suffix), causing exponential growth of
// the capture table and meaningless diff summaries on repeat runs.
const headerReplayRequestID = "X-Replay-RequestId"

// responseCaptureRegistry is the process-global set of route paths that have
// opted into response-body capture. Mutation goes through EnableResponseCapture
// which holds the write lock; reads on the hot request path use RLock.
//
// Using a path-prefix match (not a gin route pattern) is intentional: simplest
// mechanism per the loop-prompt, and covers the common case of "enable capture
// for the /services/data/vXX/sobjects/<Obj>/ family" without dragging gin's
// route-template matching into the middleware.
var responseCaptureRegistry = struct {
	mu     sync.RWMutex
	routes map[string]struct{}
}{routes: map[string]struct{}{}}

// EnableResponseCapture opts a route path (exact-match) into response-body
// capture. Thread-safe. Intended to be called at startup from the handler-
// registration site for routes whose replay equivalence needs to assert on
// the response payload as well as the DB-side AuditLog diff.
//
// Callers should prefer exact paths with route variables stripped (e.g.
// "/services/data/v63.0/sobjects/Lead") since path-prefix is compared
// verbatim. Leaving this as a tiny, explicit surface is deliberate — §14.4
// warns that blanket response capture doubles storage and re-introduces PII
// risk, so "turn it on route-by-route" is the only sanctioned path.
func EnableResponseCapture(path string) {
	responseCaptureRegistry.mu.Lock()
	defer responseCaptureRegistry.mu.Unlock()
	responseCaptureRegistry.routes[path] = struct{}{}
}

// ResetResponseCaptureForTest clears the registry. Test-only helper; the
// middleware uses a process-global set which would otherwise leak between
// tests. Kept in the production file (not _test.go) so tests in other
// packages can import it too.
func ResetResponseCaptureForTest() {
	responseCaptureRegistry.mu.Lock()
	defer responseCaptureRegistry.mu.Unlock()
	responseCaptureRegistry.routes = map[string]struct{}{}
}

func isResponseCaptureEnabledForPath(path string) bool {
	responseCaptureRegistry.mu.RLock()
	defer responseCaptureRegistry.mu.RUnlock()
	_, ok := responseCaptureRegistry.routes[path]
	return ok
}

// RequestCaptureConfig carries the middleware's injectable knobs.
// Zero-value is "disabled" (store nil → no writes regardless of env).
type RequestCaptureConfig struct {
	Store        RequestCaptureStore
	BodyCapBytes int // 0 → DefaultBodyCapBytes
	// EnabledFn returns whether capture should run for THIS request. Defaults
	// to reading NEXUSCRM_REQUEST_CAPTURE env. Injectable for tests.
	EnabledFn func() bool
	// ResponseCaptureFn returns whether response-body capture should run for
	// the given path. Defaults to the process-global registry AND the env
	// kill-switch NEXUSCRM_REQUEST_CAPTURE_RESPONSE=on. Injectable for tests.
	ResponseCaptureFn func(path string) bool
	// StrictFn returns whether strict mode is active. In strict mode,
	// content types not covered by the scrubber (XML, multipart, text/plain)
	// cause the capture row to be DROPPED (not written) — prevents PII
	// leakage. Defaults to reading NEXUSCRM_REQUEST_CAPTURE_STRICT env.
	StrictFn func() bool
	// Now is the clock (injectable for deterministic tests).
	Now func() time.Time
}

// IsCaptureEnabledFromEnv returns true iff NEXUSCRM_REQUEST_CAPTURE is a
// truthy opt-in value ("on"/"true"/"1"/"yes" — case-insensitive). Anything
// else, including unset, returns false (§14.11 default-off).
func IsCaptureEnabledFromEnv() bool {
	return isTruthy(os.Getenv(EnvRequestCapture))
}

// IsStrictCaptureFromEnv reports whether NEXUSCRM_REQUEST_CAPTURE_STRICT is
// truthy. In strict mode, requests with content types outside the
// scrub-coverage allowlist are NOT written to _System_RequestCapture at all
// — prevents PII leakage pending Phase 2B XML/multipart scrub work.
func IsStrictCaptureFromEnv() bool {
	return isTruthy(os.Getenv(EnvRequestCaptureStrict))
}

// isResponseCaptureAllowedFromEnv returns the global kill-switch value.
// Per-route opt-in is still required on top of this — the env gate is a
// chainsaw "do not ever emit response bodies on this deployment", NOT an
// enable-everything shortcut. Default false.
func isResponseCaptureAllowedFromEnv() bool {
	return isTruthy(os.Getenv(EnvRequestCaptureResponse))
}

func isTruthy(v string) bool {
	switch strings.ToLower(strings.TrimSpace(v)) {
	case "on", "true", "1", "yes":
		return true
	}
	return false
}

// RequestCaptureMiddleware returns a gin middleware that writes a
// `_System_RequestCapture` row for each request, when enabled.
//
// Chain position: register AFTER middleware.RequestID() (so we can prefer
// the caller-supplied X-Request-ID over a freshly generated UUID) and
// AFTER AuditEntrypointMiddleware (so EntrypointFromContext returns the
// real entrypoint). The auth middleware stamps user/session — if it runs
// after this one for a given route (public endpoints), UserId/SessionId
// will be empty, which is acceptable per §14.6.
func RequestCaptureMiddleware(cfg RequestCaptureConfig) gin.HandlerFunc {
	// Freeze config defaults once at construction; hot path is lock-free.
	if cfg.BodyCapBytes <= 0 {
		cfg.BodyCapBytes = DefaultBodyCapBytes
	}
	if cfg.EnabledFn == nil {
		cfg.EnabledFn = IsCaptureEnabledFromEnv
	}
	if cfg.ResponseCaptureFn == nil {
		cfg.ResponseCaptureFn = func(path string) bool {
			return isResponseCaptureAllowedFromEnv() && isResponseCaptureEnabledForPath(path)
		}
	}
	if cfg.StrictFn == nil {
		cfg.StrictFn = IsStrictCaptureFromEnv
	}
	if cfg.Now == nil {
		cfg.Now = func() time.Time { return time.Now().UTC() }
	}

	return func(c *gin.Context) {
		// Short-circuit: capture disabled or no store → pass-through.
		if cfg.Store == nil || !cfg.EnabledFn() {
			c.Next()
			return
		}

		// Short-circuit: replay-originated traffic is self-identified and
		// must NOT be re-captured (Pass B HIGH, PR #1850). Re-capturing
		// would cause exponential growth of `_System_RequestCapture` on
		// repeat `cmd/replay` invocations.
		if replayRID := c.GetHeader(headerReplayRequestID); replayRID != "" {
			log.Warn().
				Str("replay_request_id", replayRID).
				Str("path", c.Request.URL.Path).
				Msg("request_capture: skipping replay traffic")
			c.Next()
			return
		}

		received := cfg.Now()
		reqID := resolveRequestID(c)

		// Buffer body up to cap; rewind for the handler.
		rawBody, truncated, rawSize, err := readBodyWithCap(c.Request.Body, cfg.BodyCapBytes)
		if err != nil {
			// Body read failed — log WARN and skip capture (we cannot give
			// the handler a partial body either, so reset to an empty reader
			// is already done inside readBodyWithCap on failure).
			log.Warn().Err(err).Str("request_id", reqID).Msg("request_capture: body read failed; skipping row")
			c.Next()
			return
		}
		c.Request.Body = io.NopCloser(bytes.NewReader(rawBody))

		// Strict mode: drop the capture row entirely for content types we
		// cannot scrub (XML, multipart, text/plain). The handler still runs
		// normally — this only affects whether a row is written.
		if cfg.StrictFn() && !isScrubbableContentType(c.Request.Header.Get("Content-Type")) {
			log.Debug().Str("request_id", reqID).
				Str("content_type", c.Request.Header.Get("Content-Type")).
				Msg("request_capture: strict mode dropped non-scrubbable content type")
			c.Next()
			return
		}

		// Prepare capture buffer for response body, if opted-in.
		capturePath := c.FullPath()
		if capturePath == "" {
			capturePath = c.Request.URL.Path
		}
		wantResponse := cfg.ResponseCaptureFn(capturePath)
		var respBuf *bytes.Buffer
		origWriter := c.Writer
		if wantResponse {
			respBuf = &bytes.Buffer{}
			c.Writer = &captureWriter{ResponseWriter: c.Writer, buf: respBuf, cap: cfg.BodyCapBytes}
		}

		c.Next()

		// Restore writer (avoid leaking capture wrapper to later middleware).
		if wantResponse {
			c.Writer = origWriter
		}

		latency := cfg.Now().Sub(received)
		row := buildRow(c, reqID, received, rawBody, rawSize, truncated, respBuf, latency)

		// Best-effort write — never blocks or fails the request.
		// Pass ctx for trace values only; writeCaptureRow detaches from cancellation via context.WithoutCancel.
		go writeCaptureRow(c.Request.Context(), cfg.Store, row)
	}
}

// readBodyWithCap reads up to cap+1 bytes from r. Returns (body, truncated,
// rawSize, err). rawSize is the pre-scrub size observed; when the body
// exceeds cap we only know it's > cap (a true rawSize would require draining
// the full body, defeating the cap). Callers should treat rawSize > cap as
// "at least cap+1".
func readBodyWithCap(r io.ReadCloser, cap int) (body []byte, truncated bool, rawSize int, err error) {
	if r == nil {
		return nil, false, 0, nil
	}
	defer r.Close()
	// Read cap+1 bytes; if we got cap+1, the body was larger → truncate to cap.
	buf := make([]byte, cap+1)
	n, readErr := io.ReadFull(r, buf)
	if readErr != nil && readErr != io.EOF && readErr != io.ErrUnexpectedEOF {
		return nil, false, 0, readErr
	}
	if n > cap {
		return buf[:cap], true, n, nil
	}
	return buf[:n], false, n, nil
}

// resolveRequestID prefers an existing request_id on the gin context
// (stamped by middleware.RequestID() one step up the chain) over a freshly
// generated UUID. This keeps RequestCapture.RequestId equal to AuditLog's
// RequestId for the same request, which §14.9 requires for the one-to-many
// logical join.
func resolveRequestID(c *gin.Context) string {
	if v, ok := c.Get(ctxRequestIDKey); ok {
		if s, ok := v.(string); ok && s != "" {
			return s
		}
	}
	return uuid.New().String()
}

func buildRow(c *gin.Context, reqID string, received time.Time,
	rawBody []byte, rawSize int, truncated bool, respBuf *bytes.Buffer,
	latency time.Duration) RequestCaptureRow {

	headers := ScrubHeaders(c.Request.Header)
	headersJSON, _ := json.Marshal(headers)

	bodyStr := scrubBodyByContentType(c.Request.Header.Get("Content-Type"), rawBody)

	ep := EntrypointFromContext(c.Request.Context())
	if ep == "" {
		ep = EntrypointUnknown
	}

	userID, sessionID := extractUserSession(c)

	var clientIP sql.NullString
	if ip := c.ClientIP(); ip != "" {
		clientIP = sql.NullString{String: ip, Valid: true}
	}

	var respBody sql.NullString
	if respBuf != nil && respBuf.Len() > 0 {
		// Scrub as JSON when possible; fallback to raw bounded by cap.
		respBody = sql.NullString{String: scrubBodyByContentType(
			c.Writer.Header().Get("Content-Type"), respBuf.Bytes()), Valid: true}
	}

	latencyMs := int(latency / time.Millisecond)
	if latencyMs < 0 {
		latencyMs = 0
	}

	return RequestCaptureRow{
		RequestID:      reqID,
		ReceivedAt:     received,
		Method:         c.Request.Method,
		Path:           c.Request.URL.Path,
		Query:          ScrubQueryString(c.Request.URL.RawQuery),
		Headers:        string(headersJSON),
		Body:           bodyStr,
		BodySize:       rawSize,
		BodyTruncated:  truncated,
		ResponseStatus: c.Writer.Status(),
		ResponseBody:   respBody,
		UserID:         userID,
		SessionID:      sessionID,
		ClientIP:       clientIP,
		Entrypoint:     string(ep),
		LatencyMs:      latencyMs,
	}
}

// extractUserSession reads the UserSession (if any) that auth middleware
// stamped. Returns (UserId, SessionId) as NullString — both may be unset on
// public / unauthenticated routes.
//
// The capture middleware cannot import the auth-feature package without
// forming a cycle (auth imports audit for emit), so this code type-asserts
// against the `ID string` field via a minimal interface, avoiding the
// dependency edge.
func extractUserSession(c *gin.Context) (sql.NullString, sql.NullString) {
	var userID, sessionID sql.NullString
	if v, ok := c.Get(ctxUserKey); ok {
		if id, ok := userSessionID(v); ok {
			userID = sql.NullString{String: id, Valid: true}
		}
	}
	// Read the JWT session identifier (jti) — NEVER the raw bearer token.
	// auth.RequireAuth stamps ctxSessionIDKey with claims.RegisteredClaims.ID.
	if v, ok := c.Get(ctxSessionIDKey); ok {
		if s, ok := v.(string); ok && s != "" {
			sessionID = sql.NullString{String: s, Valid: true}
		}
	}
	return userID, sessionID
}

// userSessionID extracts .ID from an auth.UserSession value without
// importing the package. Accepts either the struct or a pointer; returns
// ("", false) for any other shape.
func userSessionID(v any) (string, bool) {
	type hasID interface{ GetID() string }
	if h, ok := v.(hasID); ok {
		return h.GetID(), true
	}
	// Reflectionless shape check: auth.UserSession exposes a plain `ID`
	// field. Use a narrow struct-tag-free assertion shim:
	type idOnly struct{ ID string }
	switch t := v.(type) {
	case idOnly:
		return t.ID, t.ID != ""
	case *idOnly:
		if t != nil {
			return t.ID, t.ID != ""
		}
	}
	// Last resort: JSON round-trip. Cheap for the common case (UserSession
	// is ~8 fields) and sidesteps the import cycle. Failure → no id.
	b, err := json.Marshal(v)
	if err != nil {
		return "", false
	}
	var peek struct {
		ID string `json:"id"`
	}
	if err := json.Unmarshal(b, &peek); err != nil {
		return "", false
	}
	return peek.ID, peek.ID != ""
}

// writeCaptureRow runs the INSERT and WARN-logs on failure. Never returns an
// error — the caller (goroutine) does not care.
func writeCaptureRow(ctx context.Context, store RequestCaptureStore, row RequestCaptureRow) {
	// Detach from the request context's cancel so a client disconnect does
	// not abort the capture write. Preserve values (trace ids, etc).
	writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
	defer cancel()
	if err := store.InsertRequestCapture(writeCtx, row); err != nil {
		log.Warn().Err(err).
			Str("request_id", row.RequestID).
			Str("path", row.Path).
			Msg("request_capture: DB write failed; request already completed")
	}
}

// captureWriter is a gin.ResponseWriter that tees the body into buf up to
// cap bytes. Status + header passthrough are inherited from the embedded
// ResponseWriter.
type captureWriter struct {
	gin.ResponseWriter
	buf *bytes.Buffer
	cap int
}

func (w *captureWriter) Write(b []byte) (int, error) {
	// Tee up to remaining capacity.
	if remaining := w.cap - w.buf.Len(); remaining > 0 {
		if len(b) <= remaining {
			w.buf.Write(b)
		} else {
			w.buf.Write(b[:remaining])
		}
	}
	return w.ResponseWriter.Write(b)
}

func (w *captureWriter) WriteString(s string) (int, error) {
	return w.Write([]byte(s))
}

// Sentinel exported for tests that want to assert on "no response body
// column populated". Not used at runtime.
var _ = sql.ErrNoRows
var _ http.ResponseWriter = (*captureWriter)(nil)
