// Redis derived cache boundary follows PJ2026-01060505, PJ2026-0104010803,
// PJ2026-010403 and implementation version draft-2026-06-22-p1-workbench-redis-derived-cache.
package workbenchruntime

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"os"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/redis/go-redis/v9"
)

const cacheKeySchemaVersion = "wb-derived-v1"

var cacheOperationDurationBucketsSeconds = []float64{0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1}
var cachePayloadBucketsBytes = []float64{512, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304}

var errCacheDisabled = errors.New("workbench redis cache disabled")

type cacheConfig struct {
	Enabled                        bool
	RedisURL                       string
	ConnectTimeout                 time.Duration
	OperationTimeout               time.Duration
	MaxKeyBytes                    int
	MaxPayloadBytes                int
	SessionsSummaryTTL             time.Duration
	TerminalTurnTTL                time.Duration
	TerminalTracePageTTL           time.Duration
	RunningPageTTL                 time.Duration
	UnavailableLivenessFailure     bool
	UnavailableReadMode            string
	UnavailableFallbackStateSource bool
}

type cacheKeyParts struct {
	Class              string
	ActorID            string
	SessionID          string
	TraceID            string
	Cursor             string
	ProjectionRevision string
	ProjectionSeq      int64
	Authority          string
}

type derivedCache struct {
	config      cacheConfig
	client      *redis.Client
	configured  bool
	initErr     error
	metrics     cacheMetrics
	createdTime time.Time
}

type cacheMetrics struct {
	mu                 sync.Mutex
	Hits               uint64
	Misses             uint64
	Sets               uint64
	Deletes            uint64
	Errors             uint64
	LastErrorKind      string
	LastErrorOp        string
	LastErrorAt        time.Time
	Requests           map[string]cacheCounterSeries
	Stale              map[string]cacheCounterSeries
	DBQueryAvoided     map[string]cacheCounterSeries
	OperationDurations map[string]*cacheHistogramSeries
	PayloadBytes       map[string]*cacheHistogramSeries
}

type cacheMetricLabels struct {
	Class     string
	Operation string
	Status    string
}

type cacheCounterSeries struct {
	Labels cacheMetricLabels
	Value  uint64
}

type cacheHistogramSeries struct {
	Labels  cacheMetricLabels
	Buckets []uint64
	Sum     float64
	Count   uint64
}

type cacheError struct {
	Op   string
	Kind string
	Err  error
}

func (e cacheError) Error() string {
	if e.Err == nil {
		return e.Kind
	}
	return e.Kind + ": " + e.Err.Error()
}

func (e cacheError) Unwrap() error { return e.Err }

func newDerivedCache(config cacheConfig) *derivedCache {
	cache := &derivedCache{config: config, configured: strings.TrimSpace(config.RedisURL) != "", createdTime: time.Now().UTC()}
	if !config.Enabled {
		return cache
	}
	if strings.TrimSpace(config.RedisURL) == "" {
		cache.initErr = cacheError{Op: "init", Kind: "cache_config_missing_url", Err: errors.New("redis url is empty")}
		return cache
	}
	options, err := redis.ParseURL(config.RedisURL)
	if err != nil {
		cache.initErr = cacheError{Op: "init", Kind: "cache_config_invalid_url", Err: err}
		return cache
	}
	if config.ConnectTimeout > 0 {
		options.DialTimeout = config.ConnectTimeout
	}
	if config.OperationTimeout > 0 {
		options.ReadTimeout = config.OperationTimeout
		options.WriteTimeout = config.OperationTimeout
	}
	cache.client = redis.NewClient(options)
	return cache
}

func cacheConfigFromEnv() cacheConfig {
	return cacheConfig{
		Enabled:                        envBool("HWLAB_WORKBENCH_CACHE_REDIS_ENABLED", false),
		RedisURL:                       first(os.Getenv("HWLAB_WORKBENCH_CACHE_REDIS_URL")),
		ConnectTimeout:                 time.Duration(envInt("HWLAB_WORKBENCH_CACHE_REDIS_CONNECT_TIMEOUT_MS", 200)) * time.Millisecond,
		OperationTimeout:               time.Duration(envInt("HWLAB_WORKBENCH_CACHE_REDIS_OPERATION_TIMEOUT_MS", 100)) * time.Millisecond,
		MaxKeyBytes:                    envInt("HWLAB_WORKBENCH_CACHE_REDIS_MAX_KEY_BYTES", 512),
		MaxPayloadBytes:                envInt("HWLAB_WORKBENCH_CACHE_REDIS_MAX_PAYLOAD_BYTES", 262144),
		SessionsSummaryTTL:             time.Duration(envInt("HWLAB_WORKBENCH_CACHE_REDIS_TTL_SESSIONS_SUMMARY_MS", 30000)) * time.Millisecond,
		TerminalTurnTTL:                time.Duration(envInt("HWLAB_WORKBENCH_CACHE_REDIS_TTL_TERMINAL_TURN_MS", 300000)) * time.Millisecond,
		TerminalTracePageTTL:           time.Duration(envInt("HWLAB_WORKBENCH_CACHE_REDIS_TTL_TERMINAL_TRACE_PAGE_MS", 300000)) * time.Millisecond,
		RunningPageTTL:                 time.Duration(envInt("HWLAB_WORKBENCH_CACHE_REDIS_TTL_RUNNING_PAGE_MS", 5000)) * time.Millisecond,
		UnavailableLivenessFailure:     envBool("HWLAB_WORKBENCH_CACHE_REDIS_UNAVAILABLE_LIVENESS_FAILURE", false),
		UnavailableReadMode:            first(os.Getenv("HWLAB_WORKBENCH_CACHE_REDIS_UNAVAILABLE_READ_MODE"), "db-or-degraded"),
		UnavailableFallbackStateSource: envBool("HWLAB_WORKBENCH_CACHE_REDIS_UNAVAILABLE_FALLBACK_STATE_SOURCE", false),
	}
}

func (c *derivedCache) Close() error {
	if c == nil || c.client == nil {
		return nil
	}
	return c.client.Close()
}

func (c *derivedCache) BuildKey(parts cacheKeyParts) (string, error) {
	if c == nil {
		return "", errCacheDisabled
	}
	return buildCacheKey(c.config.MaxKeyBytes, parts)
}

func (c *derivedCache) Get(ctx context.Context, key string, target any) (bool, error) {
	startedAt := time.Now()
	class := cacheKeyClass(key)
	if !c.usable() {
		return false, nil
	}
	if err := validateCacheKey(c.config.MaxKeyBytes, key); err != nil {
		wrapped := cacheError{Op: "get", Kind: classifyCacheError(err), Err: err}
		c.metrics.recordError(wrapped)
		c.metrics.recordCacheObservation(class, "get", "error", time.Since(startedAt), 0)
		return false, wrapped
	}
	var payload []byte
	err := c.withTimeout(ctx, func(opCtx context.Context) error {
		var err error
		payload, err = c.client.Get(opCtx, key).Bytes()
		return err
	})
	if errors.Is(err, redis.Nil) {
		c.metrics.recordMiss()
		c.metrics.recordCacheObservation(class, "get", "miss", time.Since(startedAt), 0)
		return false, nil
	}
	if err != nil {
		wrapped := cacheError{Op: "get", Kind: classifyCacheError(err), Err: err}
		c.metrics.recordError(wrapped)
		c.metrics.recordCacheObservation(class, "get", "unavailable", time.Since(startedAt), 0)
		return false, wrapped
	}
	if len(payload) > c.config.MaxPayloadBytes {
		wrapped := cacheError{Op: "get", Kind: "cache_payload_too_large", Err: fmt.Errorf("payload bytes %d exceeds %d", len(payload), c.config.MaxPayloadBytes)}
		c.metrics.recordError(wrapped)
		c.metrics.recordCacheObservation(class, "get", "error", time.Since(startedAt), len(payload))
		return false, wrapped
	}
	if target != nil {
		if err := json.Unmarshal(payload, target); err != nil {
			wrapped := cacheError{Op: "get", Kind: "cache_payload_decode_failed", Err: err}
			c.metrics.recordError(wrapped)
			c.metrics.recordCacheObservation(class, "get", "error", time.Since(startedAt), len(payload))
			return false, wrapped
		}
	}
	c.metrics.recordHit()
	c.metrics.recordCacheObservation(class, "get", "hit", time.Since(startedAt), len(payload))
	return true, nil
}

func (c *derivedCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
	startedAt := time.Now()
	class := cacheKeyClass(key)
	if !c.usable() {
		return nil
	}
	if err := validateCacheKey(c.config.MaxKeyBytes, key); err != nil {
		wrapped := cacheError{Op: "set", Kind: classifyCacheError(err), Err: err}
		c.metrics.recordError(wrapped)
		c.metrics.recordCacheObservation(class, "set", "error", time.Since(startedAt), 0)
		return wrapped
	}
	payload, err := cachePayloadBytes(value, c.config.MaxPayloadBytes)
	if err != nil {
		wrapped := cacheError{Op: "set", Kind: classifyCacheError(err), Err: err}
		c.metrics.recordError(wrapped)
		c.metrics.recordCacheObservation(class, "set", "error", time.Since(startedAt), 0)
		return wrapped
	}
	err = c.withTimeout(ctx, func(opCtx context.Context) error {
		return c.client.Set(opCtx, key, payload, ttl).Err()
	})
	if err != nil {
		wrapped := cacheError{Op: "set", Kind: classifyCacheError(err), Err: err}
		c.metrics.recordError(wrapped)
		c.metrics.recordCacheObservation(class, "set", "unavailable", time.Since(startedAt), len(payload))
		return wrapped
	}
	c.metrics.recordSet()
	c.metrics.recordCacheObservation(class, "set", "stored", time.Since(startedAt), len(payload))
	return nil
}

func (c *derivedCache) RecordStale(class string) {
	if c == nil {
		return
	}
	c.metrics.recordStale(class)
}

func (c *derivedCache) RecordDBQueryAvoided(class string) {
	if c == nil {
		return
	}
	c.metrics.recordDBQueryAvoided(class)
}

func (c *derivedCache) PrometheusText(service string) string {
	if c == nil {
		var metrics cacheMetrics
		return metrics.prometheusText(service)
	}
	return c.metrics.prometheusText(service)
}

func (c *derivedCache) Delete(ctx context.Context, keys ...string) error {
	if !c.usable() || len(keys) == 0 {
		return nil
	}
	for _, key := range keys {
		if err := validateCacheKey(c.config.MaxKeyBytes, key); err != nil {
			wrapped := cacheError{Op: "delete", Kind: classifyCacheError(err), Err: err}
			c.metrics.recordError(wrapped)
			return wrapped
		}
	}
	err := c.withTimeout(ctx, func(opCtx context.Context) error {
		return c.client.Del(opCtx, keys...).Err()
	})
	if err != nil {
		wrapped := cacheError{Op: "delete", Kind: classifyCacheError(err), Err: err}
		c.metrics.recordError(wrapped)
		return wrapped
	}
	c.metrics.recordDelete(uint64(len(keys)))
	return nil
}

func (c *derivedCache) TTL(ctx context.Context, key string) (time.Duration, error) {
	if !c.usable() {
		return 0, nil
	}
	if err := validateCacheKey(c.config.MaxKeyBytes, key); err != nil {
		wrapped := cacheError{Op: "ttl", Kind: classifyCacheError(err), Err: err}
		c.metrics.recordError(wrapped)
		return 0, wrapped
	}
	var ttl time.Duration
	err := c.withTimeout(ctx, func(opCtx context.Context) error {
		var err error
		ttl, err = c.client.TTL(opCtx, key).Result()
		return err
	})
	if err != nil {
		wrapped := cacheError{Op: "ttl", Kind: classifyCacheError(err), Err: err}
		c.metrics.recordError(wrapped)
		return 0, wrapped
	}
	return ttl, nil
}

func (c *derivedCache) Summary() map[string]any {
	if c == nil {
		return map[string]any{"enabled": false, "configured": false, "role": "derived-read-cache", "valuesRedacted": true}
	}
	result := map[string]any{
		"enabled":                        c.config.Enabled,
		"configured":                     c.config.RedisURL != "",
		"role":                           "derived-read-cache",
		"adapter":                        "redis",
		"schemaVersion":                  cacheKeySchemaVersion,
		"maxKeyBytes":                    c.config.MaxKeyBytes,
		"maxPayloadBytes":                c.config.MaxPayloadBytes,
		"unavailableReadMode":            c.config.UnavailableReadMode,
		"unavailableFallbackStateSource": c.config.UnavailableFallbackStateSource,
		"secretMaterialStored":           false,
		"valuesRedacted":                 true,
		"metrics":                        c.metrics.snapshot(),
	}
	if c.initErr != nil {
		result["degraded"] = true
		result["errorKind"] = classifyCacheError(c.initErr)
	}
	return result
}

func (c *derivedCache) Diagnostic(ctx context.Context) map[string]any {
	result := c.Summary()
	if c == nil || !c.config.Enabled {
		result["available"] = false
		return result
	}
	if c.initErr != nil || c.client == nil {
		result["available"] = false
		result["degraded"] = true
		return result
	}
	err := c.withTimeout(ctx, func(opCtx context.Context) error {
		return c.client.Ping(opCtx).Err()
	})
	if err != nil {
		wrapped := cacheError{Op: "ping", Kind: classifyCacheError(err), Err: err}
		c.metrics.recordError(wrapped)
		result["available"] = false
		result["degraded"] = true
		result["errorKind"] = wrapped.Kind
		result["metrics"] = c.metrics.snapshot()
		return result
	}
	result["available"] = true
	result["degraded"] = false
	return result
}

func (c *derivedCache) usable() bool {
	return c != nil && c.config.Enabled && c.client != nil && c.initErr == nil
}

func (c *derivedCache) withTimeout(ctx context.Context, fn func(context.Context) error) error {
	if ctx == nil {
		ctx = context.Background()
	}
	if c == nil || c.config.OperationTimeout <= 0 {
		return fn(ctx)
	}
	opCtx, cancel := context.WithTimeout(ctx, c.config.OperationTimeout)
	defer cancel()
	return fn(opCtx)
}

func buildCacheKey(maxBytes int, parts cacheKeyParts) (string, error) {
	if strings.TrimSpace(parts.Class) == "" {
		return "", cacheError{Op: "key", Kind: "cache_key_invalid", Err: errors.New("class is required")}
	}
	if strings.TrimSpace(parts.ActorID) == "" {
		return "", cacheError{Op: "key", Kind: "cache_key_invalid", Err: errors.New("actor id is required")}
	}
	if strings.TrimSpace(parts.Authority) == "" && strings.TrimSpace(parts.ProjectionRevision) == "" && parts.ProjectionSeq <= 0 {
		return "", cacheError{Op: "key", Kind: "cache_key_invalid", Err: errors.New("projection authority is required")}
	}
	components := []string{
		"hwlab",
		"workbench",
		cacheKeySchemaVersion,
		"class=" + safeCacheToken(parts.Class),
		"actor=" + cacheHash(parts.ActorID),
		"session=" + optionalCacheHash(parts.SessionID),
		"trace=" + optionalCacheHash(parts.TraceID),
		"cursor=" + optionalCacheHash(parts.Cursor),
	}
	if parts.ProjectionSeq > 0 {
		components = append(components, "seq="+strconv.FormatInt(parts.ProjectionSeq, 10))
	}
	if strings.TrimSpace(parts.ProjectionRevision) != "" {
		components = append(components, "rev="+cacheHash(parts.ProjectionRevision))
	}
	if strings.TrimSpace(parts.Authority) != "" {
		components = append(components, "auth="+cacheHash(parts.Authority))
	}
	key := strings.Join(components, ":")
	if maxBytes <= 0 {
		maxBytes = 512
	}
	if len([]byte(key)) <= maxBytes {
		return key, nil
	}
	compact := strings.Join(components[:4], ":") + ":hash=" + cacheHash(key)
	if len([]byte(compact)) > maxBytes {
		return "", cacheError{Op: "key", Kind: "cache_key_too_large", Err: fmt.Errorf("key bytes exceed %d", maxBytes)}
	}
	return compact, nil
}

func validateCacheKey(maxBytes int, key string) error {
	if strings.TrimSpace(key) == "" {
		return cacheError{Op: "key", Kind: "cache_key_invalid", Err: errors.New("cache key is empty")}
	}
	if maxBytes <= 0 {
		maxBytes = 512
	}
	if len([]byte(key)) > maxBytes {
		return cacheError{Op: "key", Kind: "cache_key_too_large", Err: fmt.Errorf("key bytes exceed %d", maxBytes)}
	}
	return nil
}

func cachePayloadBytes(value any, maxBytes int) ([]byte, error) {
	payload, err := json.Marshal(value)
	if err != nil {
		return nil, cacheError{Op: "payload", Kind: "cache_payload_encode_failed", Err: err}
	}
	var decoded any
	if err := json.Unmarshal(payload, &decoded); err != nil {
		return nil, cacheError{Op: "payload", Kind: "cache_payload_decode_failed", Err: err}
	}
	if key, ok := cachePayloadSensitiveKey(decoded); ok {
		return nil, cacheError{Op: "payload", Kind: "cache_payload_rejected", Err: fmt.Errorf("sensitive payload key %q is not cacheable", key)}
	}
	if maxBytes <= 0 {
		maxBytes = 262144
	}
	if len(payload) > maxBytes {
		return nil, cacheError{Op: "payload", Kind: "cache_payload_too_large", Err: fmt.Errorf("payload bytes %d exceeds %d", len(payload), maxBytes)}
	}
	return payload, nil
}

func cachePayloadSensitiveKey(value any) (string, bool) {
	switch item := value.(type) {
	case map[string]any:
		for key, child := range item {
			if cachePayloadKeyRejected(key) {
				return key, true
			}
			if nested, ok := cachePayloadSensitiveKey(child); ok {
				return nested, true
			}
		}
	case []any:
		for _, child := range item {
			if nested, ok := cachePayloadSensitiveKey(child); ok {
				return nested, true
			}
		}
	case []map[string]any:
		for _, child := range item {
			if nested, ok := cachePayloadSensitiveKey(child); ok {
				return nested, true
			}
		}
	}
	return "", false
}

func cachePayloadKeyRejected(key string) bool {
	normalized := normalizedCachePayloadKey(key)
	for _, fragment := range []string{"authorization", "cookie", "secret", "password", "token", "apikey", "databaseurl", "dbdsn", "dsn", "prompt", "providerpayload", "stdout", "stderr"} {
		if strings.Contains(normalized, fragment) {
			return true
		}
	}
	return false
}

func normalizedCachePayloadKey(key string) string {
	lower := strings.ToLower(strings.TrimSpace(key))
	var builder strings.Builder
	for _, ch := range lower {
		if ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' {
			builder.WriteRune(ch)
		}
	}
	return builder.String()
}

func classifyCacheError(err error) string {
	if err == nil {
		return ""
	}
	var cacheErr cacheError
	if errors.As(err, &cacheErr) && cacheErr.Kind != "" {
		return cacheErr.Kind
	}
	if errors.Is(err, redis.Nil) {
		return "cache_miss"
	}
	if errors.Is(err, context.Canceled) {
		return "cache_context_canceled"
	}
	if errors.Is(err, context.DeadlineExceeded) {
		return "cache_timeout"
	}
	var netErr net.Error
	if errors.As(err, &netErr) && netErr.Timeout() {
		return "cache_timeout"
	}
	message := strings.ToLower(err.Error())
	switch {
	case strings.Contains(message, "connection refused") || strings.Contains(message, "no such host") || strings.Contains(message, "network is unreachable") || strings.Contains(message, "i/o timeout"):
		return "cache_unavailable"
	case strings.Contains(message, "payload") && strings.Contains(message, "large"):
		return "cache_payload_too_large"
	case strings.Contains(message, "sensitive payload"):
		return "cache_payload_rejected"
	case strings.Contains(message, "key") && strings.Contains(message, "exceed"):
		return "cache_key_too_large"
	case strings.Contains(message, "key"):
		return "cache_key_invalid"
	default:
		return "cache_error"
	}
}

func safeCacheToken(value string) string {
	text := strings.ToLower(strings.TrimSpace(value))
	var builder strings.Builder
	for _, ch := range text {
		switch {
		case ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9':
			builder.WriteRune(ch)
		case ch == '-' || ch == '_' || ch == '.':
			builder.WriteRune(ch)
		default:
			builder.WriteRune('-')
		}
	}
	result := strings.Trim(builder.String(), "-._")
	if result == "" {
		return "unknown"
	}
	return result
}

func optionalCacheHash(value string) string {
	if strings.TrimSpace(value) == "" {
		return "none"
	}
	return cacheHash(value)
}

func cacheHash(value string) string {
	digest := sha256.Sum256([]byte(strings.TrimSpace(value)))
	return hex.EncodeToString(digest[:])[:24]
}

func cacheKeyClass(key string) string {
	for _, part := range strings.Split(key, ":") {
		if strings.HasPrefix(part, "class=") {
			return safeCacheToken(strings.TrimPrefix(part, "class="))
		}
	}
	return "unknown"
}

func (m *cacheMetrics) recordHit() {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.Hits++
}

func (m *cacheMetrics) recordMiss() {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.Misses++
}

func (m *cacheMetrics) recordSet() {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.Sets++
}

func (m *cacheMetrics) recordDelete(count uint64) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.Deletes += count
}

func (m *cacheMetrics) recordError(err error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.Errors++
	m.LastErrorKind = classifyCacheError(err)
	var cacheErr cacheError
	if errors.As(err, &cacheErr) {
		m.LastErrorOp = cacheErr.Op
	}
	m.LastErrorAt = time.Now().UTC()
}

func (m *cacheMetrics) recordCacheObservation(class string, operation string, status string, duration time.Duration, payloadBytes int) {
	m.mu.Lock()
	defer m.mu.Unlock()
	labels := cacheLabels(class, operation, status)
	m.incrementCounterLocked(&m.Requests, labels)
	m.recordHistogramLocked(&m.OperationDurations, labels, duration.Seconds(), cacheOperationDurationBucketsSeconds)
	if payloadBytes > 0 {
		m.recordHistogramLocked(&m.PayloadBytes, labels, float64(payloadBytes), cachePayloadBucketsBytes)
	}
}

func (m *cacheMetrics) recordStale(class string) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.incrementCounterLocked(&m.Stale, cacheLabels(class, "read", "stale"))
}

func (m *cacheMetrics) recordDBQueryAvoided(class string) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.incrementCounterLocked(&m.DBQueryAvoided, cacheLabels(class, "read", "hit"))
}

func (m *cacheMetrics) incrementCounterLocked(target *map[string]cacheCounterSeries, labels cacheMetricLabels) {
	if *target == nil {
		*target = map[string]cacheCounterSeries{}
	}
	key := cacheMetricKey(labels)
	entry := (*target)[key]
	if entry.Labels.Class == "" {
		entry.Labels = labels
	}
	entry.Value++
	(*target)[key] = entry
}

func (m *cacheMetrics) recordHistogramLocked(target *map[string]*cacheHistogramSeries, labels cacheMetricLabels, value float64, buckets []float64) {
	if value < 0 {
		value = 0
	}
	if *target == nil {
		*target = map[string]*cacheHistogramSeries{}
	}
	key := cacheMetricKey(labels)
	entry := (*target)[key]
	if entry == nil {
		entry = &cacheHistogramSeries{Labels: labels, Buckets: make([]uint64, len(buckets))}
		(*target)[key] = entry
	}
	for index, bucket := range buckets {
		if value <= bucket {
			entry.Buckets[index]++
		}
	}
	entry.Sum += value
	entry.Count++
}

func (m *cacheMetrics) snapshot() map[string]any {
	m.mu.Lock()
	defer m.mu.Unlock()
	result := map[string]any{
		"hits":                    int64(m.Hits),
		"misses":                  int64(m.Misses),
		"sets":                    int64(m.Sets),
		"deletes":                 int64(m.Deletes),
		"errors":                  int64(m.Errors),
		"requestSeries":           len(m.Requests),
		"operationDurationSeries": len(m.OperationDurations),
		"payloadBytesSeries":      len(m.PayloadBytes),
		"staleSeries":             len(m.Stale),
		"dbQueryAvoidedSeries":    len(m.DBQueryAvoided),
		"valuesRedacted":          true,
	}
	if m.LastErrorKind != "" {
		result["lastErrorKind"] = m.LastErrorKind
		result["lastErrorOp"] = m.LastErrorOp
		result["lastErrorAt"] = m.LastErrorAt.Format(time.RFC3339Nano)
	}
	return result
}

func (m *cacheMetrics) prometheusText(service string) string {
	m.mu.Lock()
	defer m.mu.Unlock()
	if service == "" {
		service = "hwlab-workbench-runtime"
	}
	lines := []string{
		"# HELP workbench_cache_requests_total Workbench derived Redis cache requests by low-cardinality class/status.",
		"# TYPE workbench_cache_requests_total counter",
	}
	for _, entry := range m.Requests {
		lines = append(lines, prometheusCounterLine("workbench_cache_requests_total", service, entry.Labels, entry.Value))
	}
	lines = append(lines,
		"# HELP workbench_cache_hit_ratio Workbench derived Redis cache hit ratio by class and operation.",
		"# TYPE workbench_cache_hit_ratio gauge",
	)
	for _, line := range m.prometheusHitRatioLinesLocked(service) {
		lines = append(lines, line)
	}
	lines = append(lines,
		"# HELP workbench_cache_operation_duration_seconds Workbench derived Redis cache operation duration in seconds.",
		"# TYPE workbench_cache_operation_duration_seconds histogram",
	)
	lines = append(lines, prometheusHistogramLines("workbench_cache_operation_duration_seconds", service, m.OperationDurations, cacheOperationDurationBucketsSeconds)...)
	lines = append(lines,
		"# HELP workbench_cache_payload_bytes Workbench derived Redis cache payload size in bytes.",
		"# TYPE workbench_cache_payload_bytes histogram",
	)
	lines = append(lines, prometheusHistogramLines("workbench_cache_payload_bytes", service, m.PayloadBytes, cachePayloadBucketsBytes)...)
	lines = append(lines,
		"# HELP workbench_cache_stale_total Workbench derived Redis cache stale reads detected by payload freshness metadata.",
		"# TYPE workbench_cache_stale_total counter",
	)
	for _, entry := range m.Stale {
		lines = append(lines, prometheusCounterLine("workbench_cache_stale_total", service, entry.Labels, entry.Value))
	}
	lines = append(lines,
		"# HELP workbench_cache_unavailable_total Workbench derived Redis cache unavailable/error requests.",
		"# TYPE workbench_cache_unavailable_total counter",
	)
	for _, entry := range m.Requests {
		if entry.Labels.Status == "unavailable" || entry.Labels.Status == "error" {
			lines = append(lines, prometheusCounterLine("workbench_cache_unavailable_total", service, entry.Labels, entry.Value))
		}
	}
	lines = append(lines,
		"# HELP workbench_db_query_avoided_total Workbench database reads avoided by derived Redis cache hits.",
		"# TYPE workbench_db_query_avoided_total counter",
	)
	for _, entry := range m.DBQueryAvoided {
		lines = append(lines, prometheusCounterLine("workbench_db_query_avoided_total", service, entry.Labels, entry.Value))
	}
	lines = append(lines, "")
	return strings.Join(lines, "\n")
}

func (m *cacheMetrics) prometheusHitRatioLinesLocked(service string) []string {
	type totals struct{ hit, total uint64 }
	groups := map[string]totals{}
	labels := map[string]cacheMetricLabels{}
	for _, entry := range m.Requests {
		if entry.Labels.Operation != "get" {
			continue
		}
		key := entry.Labels.Class + "\x00" + entry.Labels.Operation
		group := groups[key]
		if entry.Labels.Status == "hit" {
			group.hit += entry.Value
		}
		group.total += entry.Value
		groups[key] = group
		labels[key] = cacheMetricLabels{Class: entry.Labels.Class, Operation: entry.Labels.Operation, Status: "all"}
	}
	lines := []string{}
	for key, group := range groups {
		ratio := 0.0
		if group.total > 0 {
			ratio = float64(group.hit) / float64(group.total)
		}
		lines = append(lines, prometheusGaugeLine("workbench_cache_hit_ratio", service, labels[key], ratio))
	}
	return lines
}

func prometheusHistogramLines(name string, service string, series map[string]*cacheHistogramSeries, buckets []float64) []string {
	lines := []string{}
	for _, entry := range series {
		for index, bucket := range buckets {
			lines = append(lines, fmt.Sprintf("%s_bucket{%s,le=\"%s\"} %d", name, prometheusLabels(service, entry.Labels), prometheusFloat(bucket), entry.Buckets[index]))
		}
		lines = append(lines, fmt.Sprintf("%s_bucket{%s,le=\"+Inf\"} %d", name, prometheusLabels(service, entry.Labels), entry.Count))
		lines = append(lines, fmt.Sprintf("%s_sum{%s} %.6f", name, prometheusLabels(service, entry.Labels), entry.Sum))
		lines = append(lines, fmt.Sprintf("%s_count{%s} %d", name, prometheusLabels(service, entry.Labels), entry.Count))
	}
	return lines
}

func prometheusCounterLine(name string, service string, labels cacheMetricLabels, value uint64) string {
	return fmt.Sprintf("%s{%s} %d", name, prometheusLabels(service, labels), value)
}

func prometheusGaugeLine(name string, service string, labels cacheMetricLabels, value float64) string {
	return fmt.Sprintf("%s{%s} %.6f", name, prometheusLabels(service, labels), value)
}

func prometheusLabels(service string, labels cacheMetricLabels) string {
	return fmt.Sprintf("service=\"%s\",cache_key_class=\"%s\",operation=\"%s\",cache_status=\"%s\"", prometheusEscape(service), prometheusEscape(labels.Class), prometheusEscape(labels.Operation), prometheusEscape(labels.Status))
}

func prometheusEscape(value string) string {
	return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(value, "\\", "\\\\"), "\n", "\\n"), "\"", "\\\"")
}

func prometheusFloat(value float64) string {
	return strconv.FormatFloat(value, 'f', -1, 64)
}

func cacheLabels(class string, operation string, status string) cacheMetricLabels {
	return cacheMetricLabels{Class: safeCacheToken(first(class, "unknown")), Operation: safeCacheToken(first(operation, "unknown")), Status: safeCacheToken(first(status, "unknown"))}
}

func cacheMetricKey(labels cacheMetricLabels) string {
	return labels.Class + "\x00" + labels.Operation + "\x00" + labels.Status
}

func envBool(name string, fallback bool) bool {
	value := strings.ToLower(strings.TrimSpace(os.Getenv(name)))
	if value == "" {
		return fallback
	}
	switch value {
	case "1", "true", "yes", "y", "on", "enabled":
		return true
	case "0", "false", "no", "n", "off", "disabled":
		return false
	default:
		return fallback
	}
}
