package main

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/signal"
	"sort"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"github.com/segmentio/kafka-go"

	"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
	"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
	"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
	"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
	"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
	"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
	"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
)

func main() {
	logger := observability.NewLogger("vehicle-stat-writer")
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	cfg := loadConfig()
	if err := cfg.Validate(); err != nil {
		logger.Error("invalid stat writer config", "error", err)
		os.Exit(1)
	}
	db, err := sql.Open("mysql", cfg.MySQLDSN)
	if err != nil {
		logger.Error("mysql open failed", "error", err)
		os.Exit(1)
	}
	defer db.Close()
	if err := db.PingContext(ctx); err != nil {
		logger.Error("mysql ping failed", "error", err)
		os.Exit(1)
	}
	registry := metrics.NewRegistry()
	metrics.RegisterKafkaConsumerInfo(registry, "vehicle-stat-writer", cfg.KafkaGroup, cfg.KafkaTopics)
	registry.SetGauge("vehicle_stat_project_interval_seconds", nil, cfg.ProjectInterval.Seconds())
	registry.SetGauge("vehicle_stat_source_touch_interval_seconds", nil, cfg.SourceTouchInterval.Seconds())
	registry.SetGauge("vehicle_stat_cache_retention_seconds", nil, cfg.CacheRetention.Seconds())
	registry.SetGauge("vehicle_stat_cache_cleanup_interval_seconds", nil, cfg.CacheCleanupInterval.Seconds())
	registry.SetGauge("vehicle_stat_baseline_miss_ttl_seconds", nil, cfg.BaselineMissTTL.Seconds())
	registry.SetGauge("vehicle_stat_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers))
	health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-stat-writer", []health.Check{
		{Name: "mysql", Check: db.PingContext},
	}, registry))

	writer := stats.NewWriter(db, cfg.Location)
	writer.SetProjectionInterval(cfg.ProjectInterval)
	writer.SetSourceTouchInterval(cfg.SourceTouchInterval)
	writer.SetCacheRetention(cfg.CacheRetention)
	writer.SetCacheCleanupInterval(cfg.CacheCleanupInterval)
	writer.SetBaselineMissTTL(cfg.BaselineMissTTL)
	writer.SetMaxCacheEntries(cfg.CacheMaxEntries)
	if cfg.EnsureSchema {
		if err := writer.EnsureSchema(ctx); err != nil {
			logger.Error("mysql schema bootstrap failed", "error", err)
			os.Exit(1)
		}
	}
	if cfg.NormalizePlatformSourcesOnStart {
		statDate := time.Now().In(cfg.Location).Format("2006-01-02")
		normalizeCtx, cancel := context.WithTimeout(ctx, cfg.NormalizePlatformSourcesTimeout)
		normalized, err := stats.NormalizePlatformSourceMileageForDate(normalizeCtx, db, statDate, envelope.ProtocolJT808)
		cancel()
		if err != nil {
			logger.Warn("platform source startup normalization failed", "stat_date", statDate, "protocol", envelope.ProtocolJT808, "normalized", normalized, "error", err)
		} else if normalized > 0 {
			logger.Info("platform source startup normalization finished", "stat_date", statDate, "protocol", envelope.ProtocolJT808, "normalized", normalized)
		}
	}
	var appender statAppender = retryStatAppender{
		delegate: writer,
		attempts: cfg.RetryAttempts,
		delay:    cfg.RetryDelay,
		registry: registry,
	}

	logger.Info("stat writer started", "group", cfg.KafkaGroup, "topics", strings.Join(cfg.KafkaTopics, ","), "workers", cfg.Workers, "project_interval_seconds", cfg.ProjectInterval.Seconds(), "source_touch_interval_seconds", cfg.SourceTouchInterval.Seconds(), "cache_retention_seconds", cfg.CacheRetention.Seconds(), "cache_cleanup_interval_seconds", cfg.CacheCleanupInterval.Seconds(), "baseline_miss_ttl_seconds", cfg.BaselineMissTTL.Seconds(), "cache_max_entries", cfg.CacheMaxEntries, "batch_size", cfg.BatchSize, "batch_wait_ms", cfg.BatchWait, "retry_attempts", cfg.RetryAttempts, "retry_delay_ms", cfg.RetryDelay.Milliseconds())
	var workers sync.WaitGroup
	for workerID := 1; workerID <= cfg.Workers; workerID++ {
		workers.Add(1)
		go func(id int) {
			defer workers.Done()
			runStatConsumer(ctx, logger, registry, appender, cfg, id)
		}(workerID)
	}
	workers.Wait()
}

func runStatConsumer(ctx context.Context, logger interface {
	Error(string, ...any)
	Warn(string, ...any)
}, registry *metrics.Registry, appender statAppender, cfg config, workerID int) {
	reader := kafka.NewReader(kafka.ReaderConfig{
		Brokers:     cfg.KafkaBrokers,
		GroupID:     cfg.KafkaGroup,
		GroupTopics: cfg.KafkaTopics,
		MinBytes:    1,
		MaxBytes:    10e6,
	})
	defer reader.Close()

	workerLabels := metrics.Labels{"worker": strconv.Itoa(workerID)}
	registry.SetGauge("vehicle_stat_worker_active", workerLabels, 1)
	defer registry.SetGauge("vehicle_stat_worker_active", workerLabels, 0)

	for {
		message, err := reader.FetchMessage(ctx)
		if err != nil {
			if ctx.Err() != nil {
				return
			}
			logger.Error("kafka fetch failed", "error", err)
			continue
		}
		batch := collectStatBatch(ctx, reader, message, cfg.BatchSize, time.Duration(cfg.BatchWait)*time.Millisecond)
		processStatBatchReliablyForWorker(ctx, logger, registry, appender, reader, batch, cfg.RetryDelay, workerLabels)
	}
}

const kafkaMessageOperationTimeout = 30 * time.Second

type statAppender interface {
	Append(context.Context, envelope.FrameEnvelope) error
}

type statResultAppender interface {
	AppendWithResult(context.Context, envelope.FrameEnvelope) (stats.AppendResult, error)
}

type statCacheReporter interface {
	CacheStats() stats.CacheStats
}

type kafkaMessageCommitter interface {
	CommitMessages(context.Context, ...kafka.Message) error
}

type kafkaMessageFetcher interface {
	FetchMessage(context.Context) (kafka.Message, error)
}

type statBatchItem struct {
	message   kafka.Message
	processed bool
}

type statBatchOutcome struct {
	commitMessages []kafka.Message
	retryMessages  []kafka.Message
}

var statWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
var statWriteE2EDurationBucketsMS = []float64{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000}
var statWriteE2ERecent = metrics.NewRecentLatencyByKey(512)

func collectStatBatch(ctx context.Context, fetcher kafkaMessageFetcher, first kafka.Message, maxSize int, maxWait time.Duration) []kafka.Message {
	if maxSize <= 1 {
		return []kafka.Message{first}
	}
	if maxWait <= 0 {
		maxWait = 100 * time.Millisecond
	}
	batch := []kafka.Message{first}
	deadline := time.Now().Add(maxWait)
	for len(batch) < maxSize {
		remaining := time.Until(deadline)
		if remaining <= 0 {
			break
		}
		fetchCtx, cancel := context.WithTimeout(ctx, remaining)
		message, err := fetcher.FetchMessage(fetchCtx)
		cancel()
		if err != nil {
			break
		}
		batch = append(batch, message)
	}
	return batch
}

func processStatMessage(ctx context.Context, logger interface {
	Error(string, ...any)
	Warn(string, ...any)
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, message kafka.Message) {
	processStatBatch(ctx, logger, registry, appender, committer, []kafka.Message{message})
}

func processStatBatch(ctx context.Context, logger interface {
	Error(string, ...any)
	Warn(string, ...any)
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message) statBatchOutcome {
	return processStatBatchForWorker(ctx, logger, registry, appender, committer, messages, nil)
}

func processStatBatchForWorker(ctx context.Context, logger interface {
	Error(string, ...any)
	Warn(string, ...any)
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, workerLabels metrics.Labels) statBatchOutcome {
	if len(messages) == 0 {
		return statBatchOutcome{}
	}
	messageCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
	defer cancel()

	setStatBatchPendingForWorker(registry, workerLabels, len(messages))
	defer setStatBatchPendingForWorker(registry, workerLabels, 0)
	defer recordStatCacheMetrics(registry, appender)
	items := make([]statBatchItem, len(messages))
	for index, message := range messages {
		items[index].message = message
	}
	for itemIndex, message := range messages {
		addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, "received")
		addStatLagMetric(registry, message)
		var env envelope.FrameEnvelope
		if err := json.Unmarshal(message.Value, &env); err != nil {
			addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, "invalid_json")
			logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
			items[itemIndex].processed = true
			continue
		}
		if status, err := validateStatFieldsEnvelope(message.Topic, env); err != nil {
			addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, status)
			logger.Warn("skip mismatched stat fields envelope", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "protocol", env.Protocol, "event_id", env.StableEventID(), "event_kind", env.EventKind, "error", err)
			items[itemIndex].processed = true
			continue
		}
		started := time.Now()
		result, err := appendStatEnvelope(messageCtx, appender, env)
		recordStatWriteDuration(registry, message, statusFromError(err), time.Since(started))
		recordStatSampleMetrics(registry, message, env.Protocol, result)
		recordStatSourceMetrics(registry, message, env.Protocol, result)
		recordStatProjectionMetrics(registry, message, env.Protocol, result)
		if err != nil {
			addStatMetric(registry, "vehicle_stat_writes_total", message, "error")
			logger.Error("mysql append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
			committed, commitErr := commitStatProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items)
			outcome := statBatchOutcome{retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed)}
			if commitErr != nil {
				outcome.commitMessages = committed
			}
			return outcome
		}
		addStatMetric(registry, "vehicle_stat_writes_total", message, "ok")
		recordStatWriteE2EDuration(registry, message, env)
		items[itemIndex].processed = true
	}
	if err := committer.CommitMessages(messageCtx, messages...); err != nil {
		for _, message := range messages {
			addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "error")
		}
		first := messages[0]
		logger.Error("kafka commit failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(messages), "error", err)
		return statBatchOutcome{commitMessages: messages}
	}
	for _, message := range messages {
		addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "ok")
	}
	return statBatchOutcome{}
}

func processStatBatchReliably(ctx context.Context, logger interface {
	Error(string, ...any)
	Warn(string, ...any)
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) {
	processStatBatchReliablyForWorker(ctx, logger, registry, appender, committer, messages, retryDelay, nil)
}

func processStatBatchReliablyForWorker(ctx context.Context, logger interface {
	Error(string, ...any)
	Warn(string, ...any)
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration, workerLabels metrics.Labels) {
	defer registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, 0)
	pending := messages
	for len(pending) > 0 {
		outcome := processStatBatchForWorker(ctx, logger, registry, appender, committer, pending, workerLabels)
		if len(outcome.commitMessages) > 0 {
			registry.IncCounter("vehicle_stat_batch_retries_total", metrics.Labels{"reason": "commit_error"})
			if !retryStatCommit(ctx, logger, registry, committer, outcome.commitMessages, retryDelay) {
				return
			}
		}
		pending = outcome.retryMessages
		registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, float64(len(pending)))
		if len(pending) == 0 {
			return
		}
		registry.IncCounter("vehicle_stat_batch_retries_total", metrics.Labels{"reason": "write_error"})
		if !waitForStatRetry(ctx, retryDelay) {
			return
		}
	}
}

func retryStatCommit(ctx context.Context, logger interface {
	Error(string, ...any)
	Warn(string, ...any)
}, registry *metrics.Registry, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) bool {
	for len(messages) > 0 {
		operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
		err := committer.CommitMessages(operationCtx, messages...)
		cancel()
		if err == nil {
			for _, message := range messages {
				addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "ok")
			}
			return true
		}
		for _, message := range messages {
			addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "error")
		}
		first := messages[0]
		logger.Error("kafka commit retry failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(messages), "error", err)
		registry.IncCounter("vehicle_stat_batch_retries_total", metrics.Labels{"reason": "commit_error"})
		if !waitForStatRetry(ctx, retryDelay) {
			return false
		}
	}
	return true
}

func waitForStatRetry(ctx context.Context, retryDelay time.Duration) bool {
	if retryDelay <= 0 {
		retryDelay = 100 * time.Millisecond
	}
	timer := time.NewTimer(retryDelay)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return false
	case <-timer.C:
		return true
	}
}

func validateStatFieldsEnvelope(topic string, env envelope.FrameEnvelope) (string, error) {
	return topics.ValidateFieldsEnvelope(topic, env)
}

func commitStatProcessedPrefixAfterFailure(ctx context.Context, logger interface {
	Error(string, ...any)
	Warn(string, ...any)
}, registry *metrics.Registry, committer kafkaMessageCommitter, items []statBatchItem) ([]kafka.Message, error) {
	committable := processedPrefixMessagesByPartition(items)
	if len(committable) == 0 {
		return nil, nil
	}
	if err := committer.CommitMessages(ctx, committable...); err != nil {
		for _, message := range committable {
			addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "error")
		}
		first := committable[0]
		logger.Error("kafka processed-prefix commit failed after mysql append failure", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(committable), "error", err)
		return committable, err
	}
	for _, message := range committable {
		addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "ok")
	}
	return committable, nil
}

func processedPrefixMessagesByPartition(items []statBatchItem) []kafka.Message {
	type partitionKey struct {
		topic     string
		partition int
	}
	groups := map[partitionKey][]statBatchItem{}
	for _, item := range items {
		key := partitionKey{topic: item.message.Topic, partition: item.message.Partition}
		groups[key] = append(groups[key], item)
	}
	var keys []partitionKey
	for key := range groups {
		keys = append(keys, key)
	}
	sort.Slice(keys, func(i, j int) bool {
		if keys[i].topic != keys[j].topic {
			return keys[i].topic < keys[j].topic
		}
		return keys[i].partition < keys[j].partition
	})
	var out []kafka.Message
	for _, key := range keys {
		group := groups[key]
		sort.Slice(group, func(i, j int) bool {
			return group[i].message.Offset < group[j].message.Offset
		})
		var previousOffset int64
		for index, item := range group {
			if index > 0 && item.message.Offset != previousOffset+1 {
				break
			}
			if !item.processed {
				break
			}
			out = append(out, item.message)
			previousOffset = item.message.Offset
		}
	}
	return out
}

func appendStatEnvelope(ctx context.Context, appender statAppender, env envelope.FrameEnvelope) (stats.AppendResult, error) {
	if resultAppender, ok := appender.(statResultAppender); ok {
		return resultAppender.AppendWithResult(ctx, env)
	}
	return stats.AppendResult{}, appender.Append(ctx, env)
}

type retryStatAppender struct {
	delegate statAppender
	attempts int
	delay    time.Duration
	registry *metrics.Registry
}

func (a retryStatAppender) Append(ctx context.Context, env envelope.FrameEnvelope) error {
	_, err := a.AppendWithResult(ctx, env)
	return err
}

func (a retryStatAppender) AppendWithResult(ctx context.Context, env envelope.FrameEnvelope) (stats.AppendResult, error) {
	if a.delegate == nil {
		return stats.AppendResult{}, nil
	}
	attempts := a.attempts
	if attempts <= 0 {
		attempts = 1
	}
	var result stats.AppendResult
	var err error
	for attempt := 1; attempt <= attempts; attempt++ {
		result, err = appendStatEnvelope(ctx, a.delegate, env)
		if err == nil || !isTransientMySQLStatError(err) {
			return result, err
		}
		if attempt == attempts {
			a.recordRetry("single", "exhausted")
			return result, err
		}
		a.recordRetry("single", "retry")
		if a.delay <= 0 {
			continue
		}
		timer := time.NewTimer(a.delay)
		select {
		case <-ctx.Done():
			timer.Stop()
			return result, ctx.Err()
		case <-timer.C:
		}
	}
	return result, err
}

func (a retryStatAppender) recordRetry(operation string, status string) {
	if a.registry == nil {
		return
	}
	labels := metrics.Labels{"operation": operation, "status": status}
	a.registry.IncCounter("vehicle_stat_write_retries_total", labels)
	metrics.RecordLastActivity(a.registry, "vehicle_stat_last_write_retry_unix_seconds", labels)
}

func (a retryStatAppender) CacheStats() stats.CacheStats {
	if reporter, ok := a.delegate.(statCacheReporter); ok {
		return reporter.CacheStats()
	}
	return stats.CacheStats{}
}

func isTransientMySQLStatError(err error) bool {
	if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
		return false
	}
	text := strings.ToLower(strings.TrimSpace(err.Error()))
	return strings.Contains(text, "deadlock") ||
		strings.Contains(text, "error 1213") ||
		strings.Contains(text, "40001") ||
		strings.Contains(text, "lock wait timeout") ||
		strings.Contains(text, "error 1205") ||
		strings.Contains(text, "timeout") ||
		strings.Contains(text, "temporary") ||
		strings.Contains(text, "temporarily") ||
		strings.Contains(text, "connection refused") ||
		strings.Contains(text, "connection reset") ||
		strings.Contains(text, "connection closed") ||
		strings.Contains(text, "broken pipe") ||
		strings.Contains(text, "bad connection") ||
		strings.Contains(text, "invalid connection") ||
		strings.Contains(text, "i/o timeout") ||
		text == "eof" ||
		strings.Contains(text, "unexpected eof") ||
		strings.Contains(text, "server is down") ||
		strings.Contains(text, "network is unreachable") ||
		strings.Contains(text, "no route to host")
}

func addStatMetric(registry *metrics.Registry, name string, message kafka.Message, status string) {
	if registry == nil {
		return
	}
	labels := metrics.Labels{"topic": message.Topic, "status": status}
	registry.IncCounter(name, labels)
	switch name {
	case "vehicle_stat_kafka_messages_total":
		metrics.RecordLastActivity(registry, "vehicle_stat_last_message_unix_seconds", labels)
	case "vehicle_stat_writes_total":
		metrics.RecordLastActivity(registry, "vehicle_stat_last_write_unix_seconds", labels)
	case "vehicle_stat_kafka_commits_total":
		metrics.RecordLastActivity(registry, "vehicle_stat_last_commit_unix_seconds", labels)
	}
}

func recordStatSampleMetrics(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, result stats.AppendResult) {
	addStatSampleMetric(registry, message, protocol, "found", result.SamplesFound)
	addStatSampleMetric(registry, message, protocol, "written", result.SamplesWritten)
	addStatSampleMetric(registry, message, protocol, "skipped_missing_fields", result.SamplesSkippedMissingFields)
	addStatSampleMetric(registry, message, protocol, "skipped_missing_vin", result.SamplesSkippedMissingVIN)
	addStatSampleMetric(registry, message, protocol, "skipped_missing_mileage", result.SamplesSkippedMissingMileage)
	addStatSampleMetric(registry, message, protocol, "skipped_non_mileage_frame", result.SamplesSkippedNonMileageFrame)
	addStatSampleMetric(registry, message, protocol, "skipped_non_positive_mileage", result.SamplesSkippedNonPositiveMileage)
	addStatSampleMetric(registry, message, protocol, "skipped_missing_time", result.SamplesSkippedMissingTime)
	addStatSampleMetric(registry, message, protocol, "skipped_same_mileage", result.SamplesSkippedSameMileage)
	addStatSampleMetric(registry, message, protocol, "skipped_missing_source", result.SamplesSkippedMissingSource)
	addStatSampleMetric(registry, message, protocol, "event_time_future_adjusted", result.SamplesAdjustedFutureEventTime)
}

func addStatSampleMetric(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, status string, count int) {
	if registry == nil || count == 0 {
		return
	}
	registry.AddCounter("vehicle_stat_samples_total", metrics.Labels{
		"topic":    message.Topic,
		"protocol": statProtocolLabel(protocol),
		"status":   status,
	}, float64(count))
}

func recordStatSourceMetrics(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, result stats.AppendResult) {
	addStatSourceMetric(registry, message, protocol, "attempted", result.SourceTouchesAttempted)
	addStatSourceMetric(registry, message, protocol, "written", result.SourceTouchesWritten)
	addStatSourceMetric(registry, message, protocol, "skipped_throttled", result.SourceTouchesSkippedThrottled)
	addStatSourceMetric(registry, message, protocol, "skipped_missing_endpoint", result.SourceTouchesSkippedMissing)
	addStatSourceMetric(registry, message, protocol, "skipped_unmanaged", result.SourceTouchesSkippedUnmanaged)
}

func addStatSourceMetric(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, status string, count int) {
	if registry == nil || count == 0 {
		return
	}
	registry.AddCounter("vehicle_stat_sources_total", metrics.Labels{
		"topic":    message.Topic,
		"protocol": statProtocolLabel(protocol),
		"status":   status,
	}, float64(count))
}

func recordStatProjectionMetrics(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, result stats.AppendResult) {
	addStatProjectionMetric(registry, message, protocol, "attempted", result.ProjectionsAttempted)
	addStatProjectionMetric(registry, message, protocol, "written", result.ProjectionsWritten)
	addStatProjectionMetric(registry, message, protocol, "skipped_throttled", result.ProjectionsSkippedThrottled)
}

func addStatProjectionMetric(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, status string, count int) {
	if registry == nil || count == 0 {
		return
	}
	registry.AddCounter("vehicle_stat_projections_total", metrics.Labels{
		"topic":    message.Topic,
		"protocol": statProtocolLabel(protocol),
		"status":   status,
	}, float64(count))
}

func statProtocolLabel(protocol envelope.Protocol) string {
	protocolLabel := strings.TrimSpace(string(protocol))
	if protocolLabel == "" {
		return "UNKNOWN"
	}
	return protocolLabel
}

func recordStatCacheMetrics(registry *metrics.Registry, appender statAppender) {
	if registry == nil {
		return
	}
	reporter, ok := appender.(statCacheReporter)
	if !ok {
		return
	}
	stats := reporter.CacheStats()
	setStatCacheGauge(registry, "last_total_mileage", stats.LastTotalMileageEntries, stats.MaxEntries, stats.LastCleanupTotalMileage, stats.TotalMileageEvictions)
	setStatCacheGauge(registry, "source_seen", stats.LastSourceSeenEntries, stats.MaxEntries, stats.LastCleanupSourceSeen, stats.SourceSeenEvictions)
	setStatCacheGauge(registry, "projection", stats.LastProjectionEntries, stats.MaxEntries, stats.LastCleanupProjection, stats.ProjectionEvictions)
	setStatCacheGauge(registry, "baseline", stats.BaselineEntries, stats.MaxEntries, stats.LastCleanupBaseline, stats.BaselineEvictions)
	if !stats.LastCleanupAt.IsZero() {
		registry.SetGauge("vehicle_stat_cache_last_cleanup_unix_seconds", nil, float64(stats.LastCleanupAt.Unix()))
	}
}

func setStatCacheGauge(registry *metrics.Registry, cache string, entries int, maxEntries int, lastCleanupDeleted int, evictions int) {
	labels := metrics.Labels{"cache": cache}
	registry.SetGauge("vehicle_stat_cache_entries", labels, float64(entries))
	registry.SetGauge("vehicle_stat_cache_max_entries", labels, float64(maxEntries))
	registry.SetGauge("vehicle_stat_cache_last_cleanup_deleted", labels, float64(lastCleanupDeleted))
	registry.SetGauge("vehicle_stat_cache_evictions_total", labels, float64(evictions))
}

func addStatLagMetric(registry *metrics.Registry, message kafka.Message) {
	if registry == nil {
		return
	}
	registry.SetKafkaLag("vehicle_stat_kafka_lag", message.Topic, message.Partition, message.Offset, message.HighWaterMark)
}

func recordStatWriteDuration(registry *metrics.Registry, message kafka.Message, status string, elapsed time.Duration) {
	if registry == nil {
		return
	}
	registry.ObserveHistogram("vehicle_stat_write_duration_ms_histogram", metrics.Labels{
		"topic":  message.Topic,
		"status": status,
	}, statWriteDurationBucketsMS, float64(elapsed.Milliseconds()))
}

func recordStatWriteE2EDuration(registry *metrics.Registry, message kafka.Message, env envelope.FrameEnvelope) {
	if registry == nil || env.ReceivedAtMS <= 0 {
		return
	}
	elapsed := time.Since(time.UnixMilli(env.ReceivedAtMS)).Milliseconds()
	if elapsed < 0 {
		elapsed = 0
	}
	labels := metrics.Labels{"topic": message.Topic}
	registry.ObserveHistogram("vehicle_stat_write_e2e_duration_ms_histogram", labels, statWriteE2EDurationBucketsMS, float64(elapsed))
	p99, samples := statWriteE2ERecent.Observe(message.Topic, float64(elapsed))
	registry.SetGauge("vehicle_stat_write_e2e_recent_p99_ms", labels, p99)
	registry.SetGauge("vehicle_stat_write_e2e_recent_samples", labels, float64(samples))
	metrics.RecordLastActivity(registry, "vehicle_stat_last_write_e2e_unix_seconds", labels)
}

func setStatBatchPending(registry *metrics.Registry, messages int) {
	setStatBatchPendingForWorker(registry, nil, messages)
}

func setStatBatchPendingForWorker(registry *metrics.Registry, workerLabels metrics.Labels, messages int) {
	if registry == nil {
		return
	}
	registry.SetGauge("vehicle_stat_batch_pending_messages", workerLabels, float64(messages))
}

func statusFromError(err error) string {
	if err != nil {
		return "error"
	}
	return "ok"
}

type config struct {
	KafkaBrokers                    []string
	KafkaTopics                     []string
	KafkaGroup                      string
	MySQLDSN                        string
	EnsureSchema                    bool
	Location                        *time.Location
	ProjectInterval                 time.Duration
	SourceTouchInterval             time.Duration
	CacheRetention                  time.Duration
	CacheCleanupInterval            time.Duration
	BaselineMissTTL                 time.Duration
	CacheMaxEntries                 int
	Workers                         int
	BatchSize                       int
	BatchWait                       int
	RetryAttempts                   int
	RetryDelay                      time.Duration
	NormalizePlatformSourcesOnStart bool
	NormalizePlatformSourcesTimeout time.Duration
}

func (c config) Validate() error {
	if len(c.KafkaTopics) == 0 {
		return fmt.Errorf("KAFKA_TOPICS must include fields topics")
	}
	for _, topic := range c.KafkaTopics {
		if !strings.HasPrefix(topic, "vehicle.fields.") {
			return fmt.Errorf("stat-writer consumes fields topics only, got %q", topic)
		}
	}
	if c.Workers <= 0 {
		return fmt.Errorf("STATS_WORKERS must be greater than zero")
	}
	return nil
}

func loadConfig() config {
	loc, err := time.LoadLocation(env("LOCAL_TZ", "Asia/Shanghai"))
	if err != nil {
		loc = time.FixedZone("Asia/Shanghai", 8*3600)
	}
	return config{
		KafkaBrokers:                    splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
		KafkaTopics:                     splitCSV(env("KAFKA_TOPICS", strings.Join([]string{topics.FieldsGB32960, topics.FieldsJT808, topics.FieldsYutongMQTT}, ","))),
		KafkaGroup:                      env("KAFKA_GROUP", "go-stat-writer"),
		MySQLDSN:                        env("MYSQL_DSN", ""),
		EnsureSchema:                    env("MYSQL_ENSURE_SCHEMA", "true") != "false",
		Location:                        loc,
		ProjectInterval:                 time.Duration(envInt("STATS_PROJECT_INTERVAL_SECONDS", 15)) * time.Second,
		SourceTouchInterval:             time.Duration(envInt("STATS_SOURCE_TOUCH_INTERVAL_SECONDS", 60)) * time.Second,
		CacheRetention:                  time.Duration(envInt("STATS_CACHE_RETENTION_HOURS", 72)) * time.Hour,
		CacheCleanupInterval:            time.Duration(envInt("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", 600)) * time.Second,
		BaselineMissTTL:                 time.Duration(envInt("STATS_BASELINE_MISS_TTL_SECONDS", 60)) * time.Second,
		CacheMaxEntries:                 envInt("STATS_CACHE_MAX_ENTRIES", 1000000),
		Workers:                         envInt("STATS_WORKERS", 3),
		BatchSize:                       envInt("STATS_BATCH_SIZE", 200),
		BatchWait:                       envInt("STATS_BATCH_WAIT_MS", 20),
		RetryAttempts:                   envInt("STATS_RETRY_ATTEMPTS", 3),
		RetryDelay:                      time.Duration(envInt("STATS_RETRY_DELAY_MS", 20)) * time.Millisecond,
		NormalizePlatformSourcesOnStart: env("STATS_NORMALIZE_PLATFORM_SOURCES_ON_START", "true") != "false",
		NormalizePlatformSourcesTimeout: time.Duration(envInt("STATS_NORMALIZE_PLATFORM_SOURCES_TIMEOUT_SECONDS", 30)) * time.Second,
	}
}

func env(key string, fallback string) string {
	value := strings.TrimSpace(os.Getenv(key))
	if value == "" {
		return fallback
	}
	return value
}

func envInt(key string, fallback int) int {
	value := strings.TrimSpace(os.Getenv(key))
	if value == "" {
		return fallback
	}
	parsed, err := strconv.Atoi(value)
	if err != nil {
		return fallback
	}
	return parsed
}

func splitCSV(value string) []string {
	var out []string
	for _, item := range strings.Split(value, ",") {
		item = strings.TrimSpace(item)
		if item != "" {
			out = append(out, item)
		}
	}
	return out
}
