// Package proxy provides a logging reverse proxy used for speaker traffic debugging.
package proxy

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httputil"
	"os"
	"strings"
)

// alwaysSensitiveHeaders are stripped from log output unconditionally — they
// carry credentials whose plaintext value should never appear in a log line
// regardless of how the LoggingProxy was constructed.
var alwaysSensitiveHeaders = []string{
	"Authorization",
	"Proxy-Authorization",
	"Cookie",
	"Set-Cookie",
	"X-Api-Key",
	"X-Bose-Token",
}

// sensitiveHeaders is kept for backwards compatibility with callers that
// reference it by name; it now mirrors alwaysSensitiveHeaders.
var sensitiveHeaders = alwaysSensitiveHeaders

// LoggingProxy wraps a ReverseProxy to provide instrumentation.
type LoggingProxy struct {
	Proxy         *httputil.ReverseProxy
	Redact        bool
	LogBody       bool
	RecordEnabled bool
	MaxBodySize   int64
	Recorder      *Recorder

	// UnsafeLogCredentialHeaders enables a local-debug mode that dumps the full
	// unredacted headers (including Authorization, Cookie, …) to os.Stderr.
	// The main log.Printf call always receives redacted headers: alwaysSensitiveHeaders
	// (Authorization, Cookie, …) are unconditionally replaced with "[REDACTED]", and
	// the broader Redact flag covers additional fields. Non-redacted values pass through
	// sanitizeLog to strip newlines.
	//
	// CodeQL flags the log.Printf call (go/clear-text-logging) because it cannot model
	// the custom redaction logic inside formatHeaders. The lgtm annotation on that line
	// documents the reviewed suppression.
	//
	// Never enable in production. Activate via LOG_PROXY_CREDENTIALS=true.
	UnsafeLogCredentialHeaders bool
}

// NewLoggingProxy creates a lightweight logger for HTTP requests/responses.
func NewLoggingProxy(_ string, redact bool) *LoggingProxy {
	// targetURL logic should be handled by the caller or we can parse it here
	return &LoggingProxy{
		Redact:                     redact,
		LogBody:                    os.Getenv("LOG_PROXY_BODY") == "true",
		UnsafeLogCredentialHeaders: os.Getenv("LOG_PROXY_CREDENTIALS") == "true",
		MaxBodySize:                1024 * 10, // 10KB default limit for logging
	}
}

// SetRecorder sets the recorder for the proxy.
func (lp *LoggingProxy) SetRecorder(r *Recorder) {
	lp.Recorder = r
}

// LogRequest prints an abbreviated request with optional header/body redaction.
func (lp *LoggingProxy) LogRequest(r *http.Request) {
	headers := formatHeaders(r.Header, lp.Redact)

	bodyStr := "[HIDDEN]"

	if lp.LogBody && shouldLogBody(r.Header.Get("Content-Type")) {
		if r.Body != nil {
			bodyBytes, _ := io.ReadAll(r.Body)

			r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
			if int64(len(bodyBytes)) > lp.MaxBodySize {
				bodyStr = string(bodyBytes[:lp.MaxBodySize]) + "... [TRUNCATED]"
			} else {
				bodyStr = string(bodyBytes)
			}
		} else {
			bodyStr = "[EMPTY]"
		}
	}

	// lgtm[go/clear-text-logging] — formatHeaders() unconditionally redacts sensitive headers
	// (Authorization, Cookie, …); sanitizeLog() strips newline characters from URL and body.
	// CodeQL cannot model the custom redaction logic inside formatHeaders.
	log.Printf("[PROXY_REQ] %s %s\n  Headers:\n%s\n  Body: %s", r.Method, sanitizeLog(r.URL.String()), headers, sanitizeLog(bodyStr))

	// Debug only: write unredacted headers directly to stderr so credential
	// values never reach the structured log stream (go/clear-text-logging).
	if lp.UnsafeLogCredentialHeaders {
		fmt.Fprintf(os.Stderr, "[PROXY_REQ CREDENTIAL DEBUG] %s %s\n  Full headers:\n%s\n",
			r.Method, r.URL.String(), formatHeadersDebug(r.Header))
	}
}

// LogResponse prints an abbreviated response with optional header/body redaction.
func (lp *LoggingProxy) LogResponse(r *http.Response) {
	headers := formatHeaders(r.Header, lp.Redact)

	bodyStr := "[HIDDEN]"

	if lp.LogBody && shouldLogBody(r.Header.Get("Content-Type")) {
		if r.Body != nil {
			bodyBytes, _ := io.ReadAll(r.Body)

			r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
			if int64(len(bodyBytes)) > lp.MaxBodySize {
				bodyStr = string(bodyBytes[:lp.MaxBodySize]) + "... [TRUNCATED]"
			} else {
				bodyStr = string(bodyBytes)
			}
		} else {
			bodyStr = "[EMPTY]"
		}
	}

	log.Printf("[PROXY_RES] %d %s\n  Headers:\n%s\n  Body: %s", r.StatusCode, sanitizeLog(r.Request.URL.String()), headers, sanitizeLog(bodyStr))

	// Debug only: write unredacted headers directly to stderr.
	if lp.UnsafeLogCredentialHeaders {
		fmt.Fprintf(os.Stderr, "[PROXY_RES CREDENTIAL DEBUG] %d %s\n  Full headers:\n%s\n",
			r.StatusCode, r.Request.URL.String(), formatHeadersDebug(r.Header))
	}

	if lp.Recorder != nil && lp.RecordEnabled {
		_ = lp.Recorder.Record("upstream", r.Request, r)
	}
}

func formatHeaders(h http.Header, redact bool) string {
	var sb strings.Builder
	// In Go, http.Header is a map[string][]string.
	// Iterating over the map directly allows us to see the actual keys
	// stored in the map, which might not be canonical if set directly.
	for k, vv := range h {
		val := strings.Join(vv, ", ")
		// Credential-bearing headers are always redacted; the broader Redact
		// toggle covers additional sensitive fields. Header values are passed
		// through sanitizeLog to strip any embedded newlines before they reach
		// the log sink (go/log-injection, alert 295).
		switch {
		case isAlwaysSensitive(k):
			val = "[REDACTED]"
		case redact && isSensitive(k):
			val = "[REDACTED]"
		default:
			val = sanitizeLog(val)
		}

		fmt.Fprintf(&sb, "    %s: %s\n", k, val)
	}

	return strings.TrimSuffix(sb.String(), "\n")
}

// formatHeadersDebug formats headers without any redaction. It is intentionally
// separate from formatHeaders and must only be called on the fmt.Fprintf(os.Stderr, …)
// path — never on the log.Printf path — to keep credential values out of the
// structured log stream.
func formatHeadersDebug(h http.Header) string {
	var sb strings.Builder

	for k, vv := range h {
		fmt.Fprintf(&sb, "    %s: %s\n", k, strings.Join(vv, ", "))
	}

	return strings.TrimSuffix(sb.String(), "\n")
}

// isAlwaysSensitive returns true for credential-bearing headers that must
// never appear unredacted in logs regardless of caller configuration.
func isAlwaysSensitive(header string) bool {
	for _, h := range alwaysSensitiveHeaders {
		if strings.EqualFold(h, header) {
			return true
		}
	}

	return false
}

func isSensitive(header string) bool {
	for _, h := range sensitiveHeaders {
		if strings.EqualFold(h, header) {
			return true
		}
	}

	return false
}

func shouldLogBody(contentType string) bool {
	contentType = strings.ToLower(contentType)

	return strings.Contains(contentType, "xml") ||
		strings.Contains(contentType, "json") ||
		strings.Contains(contentType, "text") ||
		contentType == ""
}
