package main

import (
	"context"
	"encoding/json"
	"errors"
	"os"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/segmentio/kafka-go"

	"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
	"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)

func TestProcessRealtimeMessageUsesUncancelledContextForUpdateAndCommit(t *testing.T) {
	parent, cancel := context.WithCancel(context.Background())
	cancel()

	env := envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		MessageID:    "0x0200",
		Phone:        "13307795425",
		VIN:          "VIN001",
		EventTimeMS:  1782918600000,
		ReceivedAtMS: 1782918600000,
		ParseStatus:  envelope.ParseOK,
		ParsedFields: map[string]any{"jt808.location.speed_kmh": "10"},
	}
	payload, err := json.Marshal(env)
	if err != nil {
		t.Fatal(err)
	}
	updater := &contextCheckingRealtimeUpdater{}
	committer := &contextCheckingMessageCommitter{}

	processRealtimeMessage(parent, discardRealtimeLogger{}, nil, updater, committer, kafka.Message{Value: payload})

	if updater.ctxErr != nil {
		t.Fatalf("updater saw cancelled context: %v", updater.ctxErr)
	}
	if committer.ctxErr != nil {
		t.Fatalf("committer saw cancelled context: %v", committer.ctxErr)
	}
	if updater.count != 1 || committer.count != 1 {
		t.Fatalf("updates=%d commits=%d", updater.count, committer.count)
	}
}

func TestProcessRealtimeMessageRecordsMetrics(t *testing.T) {
	env := envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		MessageID:    "0x0200",
		Phone:        "13307795425",
		VIN:          "VIN001",
		EventTimeMS:  1782918600000,
		ReceivedAtMS: 1782918600000,
		ParseStatus:  envelope.ParseOK,
		ParsedFields: map[string]any{"jt808.location.speed_kmh": "10"},
	}
	payload, err := json.Marshal(env)
	if err != nil {
		t.Fatal(err)
	}
	registry := metrics.NewRegistry()

	processRealtimeMessage(
		context.Background(),
		discardRealtimeLogger{},
		registry,
		&contextCheckingRealtimeUpdater{},
		&contextCheckingMessageCommitter{},
		kafka.Message{Topic: "vehicle.raw.go.jt808.v1", Partition: 2, Offset: 10, HighWaterMark: 16, Value: payload},
	)

	text := registry.Render()
	for _, want := range []string{
		`vehicle_realtime_kafka_messages_total{status="received",topic="vehicle.raw.go.jt808.v1"} 1`,
		`vehicle_realtime_last_message_unix_seconds{status="received",topic="vehicle.raw.go.jt808.v1"} `,
		`vehicle_realtime_updates_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`,
		`vehicle_realtime_last_update_unix_seconds{status="ok",topic="vehicle.raw.go.jt808.v1"} `,
		`vehicle_realtime_kafka_commits_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`,
		`vehicle_realtime_last_commit_unix_seconds{status="ok",topic="vehicle.raw.go.jt808.v1"} `,
		`vehicle_realtime_kafka_lag{partition="2",topic="vehicle.raw.go.jt808.v1"} 5`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("metrics missing %s:\n%s", want, text)
		}
	}
}

func TestProcessRealtimeMessageCommitsInvalidJSONAndRecordsMetrics(t *testing.T) {
	registry := metrics.NewRegistry()
	updater := &contextCheckingRealtimeUpdater{}
	committer := &contextCheckingMessageCommitter{}

	processRealtimeMessage(
		context.Background(),
		discardRealtimeLogger{},
		registry,
		updater,
		committer,
		kafka.Message{Topic: "vehicle.raw.go.jt808.v1", Partition: 2, Offset: 10, HighWaterMark: 12, Value: []byte(`{bad json`)},
	)

	if updater.count != 0 {
		t.Fatalf("updates = %d, want 0 for invalid JSON", updater.count)
	}
	if committer.count != 1 {
		t.Fatalf("commits = %d, want 1", committer.count)
	}
	text := registry.Render()
	for _, want := range []string{
		`vehicle_realtime_kafka_messages_total{status="received",topic="vehicle.raw.go.jt808.v1"} 1`,
		`vehicle_realtime_kafka_messages_total{status="invalid_json",topic="vehicle.raw.go.jt808.v1"} 1`,
		`vehicle_realtime_kafka_commits_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`,
		`vehicle_realtime_last_commit_unix_seconds{status="ok",topic="vehicle.raw.go.jt808.v1"} `,
		`vehicle_realtime_kafka_lag{partition="2",topic="vehicle.raw.go.jt808.v1"} 1`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("invalid JSON metric missing %s:\n%s", want, text)
		}
	}
}

func TestProcessRealtimeMessageSkipsExplicitNonRawEventKind(t *testing.T) {
	env := envelope.FrameEnvelope{
		Protocol:  envelope.ProtocolJT808,
		EventKind: envelope.EventKindFields,
		MessageID: "0x0200",
		VIN:       "VIN001",
	}
	payload, err := json.Marshal(env)
	if err != nil {
		t.Fatal(err)
	}
	registry := metrics.NewRegistry()
	updater := &contextCheckingRealtimeUpdater{}
	committer := &contextCheckingMessageCommitter{}

	processRealtimeMessage(
		context.Background(),
		discardRealtimeLogger{},
		registry,
		updater,
		committer,
		kafka.Message{Topic: "vehicle.raw.go.jt808.v1", Partition: 2, Offset: 10, HighWaterMark: 11, Value: payload},
	)

	if updater.count != 0 {
		t.Fatalf("updates = %d, want 0 for mismatched event kind", updater.count)
	}
	if committer.count != 1 || committer.messageCount != 1 {
		t.Fatalf("commits=%d messages=%d, want 1/1", committer.count, committer.messageCount)
	}
	text := registry.Render()
	for _, want := range []string{
		`vehicle_realtime_kafka_messages_total{status="event_kind_mismatch",topic="vehicle.raw.go.jt808.v1"} 1`,
		`vehicle_realtime_kafka_commits_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("mismatch metric missing %s:\n%s", want, text)
		}
	}
}

func TestProcessRealtimeMessageRecordsInvalidJSONCommitError(t *testing.T) {
	registry := metrics.NewRegistry()
	committer := &contextCheckingMessageCommitter{err: errors.New("commit failed")}
	logger := &recordingRealtimeLogger{}

	processRealtimeMessage(
		context.Background(),
		logger,
		registry,
		&contextCheckingRealtimeUpdater{},
		committer,
		kafka.Message{Topic: "vehicle.raw.go.gb32960.v1", Partition: 1, Offset: 8, HighWaterMark: 9, Value: []byte(`{bad json`)},
	)

	if committer.count != 1 {
		t.Fatalf("commits = %d, want 1", committer.count)
	}
	if !logger.containsError("kafka commit invalid envelope failed") {
		t.Fatalf("commit error was not logged: %#v", logger.errors)
	}
	text := registry.Render()
	if !strings.Contains(text, `vehicle_realtime_kafka_commits_total{status="error",topic="vehicle.raw.go.gb32960.v1"} 1`) {
		t.Fatalf("commit error metric missing:\n%s", text)
	}
}

func TestProcessRealtimeMessageSkipsNonRealtimeFrameBeforeUpdater(t *testing.T) {
	env := envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolGB32960,
		MessageID:    "0x01",
		VIN:          "VIN001",
		EventTimeMS:  1782918600000,
		ReceivedAtMS: 1782918600000,
		ParseStatus:  envelope.ParseOK,
		Parsed:       map[string]any{"platform_login": map[string]any{"username": "Hyundai"}},
	}
	payload, err := json.Marshal(env)
	if err != nil {
		t.Fatal(err)
	}
	registry := metrics.NewRegistry()
	updater := &contextCheckingRealtimeUpdater{}
	committer := &contextCheckingMessageCommitter{}

	processRealtimeMessage(
		context.Background(),
		discardRealtimeLogger{},
		registry,
		updater,
		committer,
		kafka.Message{Topic: "vehicle.raw.go.gb32960.v1", Partition: 1, Offset: 10, HighWaterMark: 11, Value: payload},
	)

	if updater.count != 0 {
		t.Fatalf("updates = %d, want 0 for non-realtime frame", updater.count)
	}
	if committer.messageCount != 1 {
		t.Fatalf("committed messages = %d, want 1", committer.messageCount)
	}
	text := registry.Render()
	for _, want := range []string{
		`vehicle_realtime_updates_total{status="skipped_non_realtime",topic="vehicle.raw.go.gb32960.v1"} 1`,
		`vehicle_realtime_kafka_commits_total{status="ok",topic="vehicle.raw.go.gb32960.v1"} 1`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("non-realtime skip metric missing %s:\n%s", want, text)
		}
	}
}

func TestProcessRealtimeMessageSkipsMissingVINBeforeUpdater(t *testing.T) {
	env := envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		MessageID:    "0x0200",
		Phone:        "13307795425",
		EventTimeMS:  1782918600000,
		ReceivedAtMS: 1782918600000,
		ParseStatus:  envelope.ParseOK,
		ParsedFields: map[string]any{"jt808.location.speed_kmh": 30},
	}
	payload, err := json.Marshal(env)
	if err != nil {
		t.Fatal(err)
	}
	registry := metrics.NewRegistry()
	updater := &contextCheckingRealtimeUpdater{}

	processRealtimeMessage(
		context.Background(),
		discardRealtimeLogger{},
		registry,
		updater,
		&contextCheckingMessageCommitter{},
		kafka.Message{Topic: "vehicle.raw.go.jt808.v1", Partition: 1, Offset: 10, HighWaterMark: 11, Value: payload},
	)

	if updater.count != 0 {
		t.Fatalf("updates = %d, want 0 for missing VIN", updater.count)
	}
	text := registry.Render()
	if !strings.Contains(text, `vehicle_realtime_updates_total{status="skipped_missing_vin",topic="vehicle.raw.go.jt808.v1"} 1`) {
		t.Fatalf("missing VIN skip metric missing:\n%s", text)
	}
}

func TestProcessRealtimeMessageSkipsMissingParsedFieldsBeforeUpdater(t *testing.T) {
	env := envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		MessageID:    "0x0200",
		VIN:          "VIN001",
		EventTimeMS:  1782918600000,
		ReceivedAtMS: 1782918600000,
		ParseStatus:  envelope.ParseOK,
		Parsed:       map[string]any{"location": map[string]any{"speed_kmh": 30}},
	}
	payload, err := json.Marshal(env)
	if err != nil {
		t.Fatal(err)
	}
	registry := metrics.NewRegistry()
	updater := &contextCheckingRealtimeUpdater{}

	processRealtimeMessage(
		context.Background(),
		discardRealtimeLogger{},
		registry,
		updater,
		&contextCheckingMessageCommitter{},
		kafka.Message{Topic: "vehicle.raw.go.jt808.v1", Partition: 1, Offset: 10, HighWaterMark: 11, Value: payload},
	)

	if updater.count != 0 {
		t.Fatalf("updates = %d, want 0 for missing parsed fields", updater.count)
	}
	text := registry.Render()
	if !strings.Contains(text, `vehicle_realtime_updates_total{status="skipped_missing_fields",topic="vehicle.raw.go.jt808.v1"} 1`) {
		t.Fatalf("missing parsed fields skip metric missing:\n%s", text)
	}
}

func TestCollectRealtimeBatchUsesBatchLimitAndWait(t *testing.T) {
	first := kafka.Message{Offset: 10}
	fetcher := &sliceRealtimeFetcher{messages: []kafka.Message{{Offset: 11}, {Offset: 12}, {Offset: 13}}}

	batch := collectRealtimeBatch(context.Background(), fetcher, first, 3, time.Second)

	if len(batch) != 3 {
		t.Fatalf("batch len = %d, want 3", len(batch))
	}
	if batch[0].Offset != 10 || batch[1].Offset != 11 || batch[2].Offset != 12 {
		t.Fatalf("unexpected offsets: %#v", []int64{batch[0].Offset, batch[1].Offset, batch[2].Offset})
	}
}

func TestProcessRealtimeBatchUsesBatchUpdaterAndCommitsAll(t *testing.T) {
	messages := []kafka.Message{
		realtimeTestMessage(t, 10, envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, MessageID: "0x02", VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1000, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 10}}),
		realtimeTestMessage(t, 11, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN002", EventTimeMS: 1100, ReceivedAtMS: 1100, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": 20}}),
	}
	registry := metrics.NewRegistry()
	updater := &recordingBatchRealtimeUpdater{}
	committer := &contextCheckingMessageCommitter{}

	processRealtimeBatch(context.Background(), discardRealtimeLogger{}, registry, updater, committer, messages)

	if updater.batchCount != 1 {
		t.Fatalf("batch updates = %d, want 1", updater.batchCount)
	}
	if updater.updateCount != 0 {
		t.Fatalf("fallback updates = %d, want 0", updater.updateCount)
	}
	if len(updater.batchEnvs) != 2 {
		t.Fatalf("batch envs = %d, want 2", len(updater.batchEnvs))
	}
	if committer.count != 1 || committer.messageCount != 2 {
		t.Fatalf("commits=%d committed_messages=%d, want 1/2", committer.count, committer.messageCount)
	}
	text := registry.Render()
	for _, want := range []string{
		`vehicle_realtime_batch_flush_total{status="ok"} 1`,
		`vehicle_realtime_batch_valid_messages_total{status="ok"} 2`,
		`vehicle_realtime_updates_total{status="ok",topic="vehicle.raw.go.gb32960.v1"} 1`,
		`vehicle_realtime_updates_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("batch metric missing %s:\n%s", want, text)
		}
	}
}

func TestProcessRealtimeBatchKeepsPendingGaugePerWorker(t *testing.T) {
	message := realtimeTestMessage(t, 10, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		MessageID:    "0x0200",
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1000,
		ParseStatus:  envelope.ParseOK,
		ParsedFields: map[string]any{"jt808.location.speed_kmh": 10},
	})
	registry := metrics.NewRegistry()

	processRealtimeBatchForWorker(
		context.Background(),
		discardRealtimeLogger{},
		registry,
		&recordingBatchRealtimeUpdater{},
		&contextCheckingMessageCommitter{},
		[]kafka.Message{message},
		metrics.Labels{"worker": "2"},
	)

	text := registry.Render()
	for _, want := range []string{
		`vehicle_realtime_batch_pending_messages{worker="2"} 0`,
		`vehicle_realtime_batch_pending_valid_messages{worker="2"} 0`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("worker pending gauge missing or not reset, want %s:\n%s", want, text)
		}
	}
}

func TestProcessRealtimeBatchSkipsExplicitNonRawEventKindAndUpdatesValidRows(t *testing.T) {
	mismatchedPayload, err := envelope.FrameEnvelope{
		Protocol:  envelope.ProtocolJT808,
		EventKind: envelope.EventKindFields,
		MessageID: "0x0200",
		VIN:       "VIN_BAD",
	}.MarshalJSONBytes()
	if err != nil {
		t.Fatal(err)
	}
	valid := realtimeTestMessage(t, 11, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		EventKind:    envelope.EventKindRaw,
		MessageID:    "0x0200",
		VIN:          "VIN001",
		EventTimeMS:  1100,
		ReceivedAtMS: 1100,
		ParseStatus:  envelope.ParseOK,
		ParsedFields: map[string]any{"jt808.location.speed_kmh": "10"},
	})
	messages := []kafka.Message{
		{Topic: "vehicle.raw.go.jt808.v1", Partition: 0, Offset: 10, HighWaterMark: 12, Value: mismatchedPayload},
		valid,
	}
	registry := metrics.NewRegistry()
	updater := &recordingBatchRealtimeUpdater{}
	committer := &contextCheckingMessageCommitter{}

	processRealtimeBatch(context.Background(), discardRealtimeLogger{}, registry, updater, committer, messages)

	if updater.batchCount != 1 || len(updater.batchEnvs) != 1 {
		t.Fatalf("batch updates=%d envs=%d, want only valid row", updater.batchCount, len(updater.batchEnvs))
	}
	if committer.count != 1 || committer.messageCount != 2 {
		t.Fatalf("commits=%d messages=%d, want 1/2", committer.count, committer.messageCount)
	}
	text := registry.Render()
	for _, want := range []string{
		`vehicle_realtime_kafka_messages_total{status="event_kind_mismatch",topic="vehicle.raw.go.jt808.v1"} 1`,
		`vehicle_realtime_updates_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`,
		`vehicle_realtime_kafka_commits_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 2`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("batch mismatch metric missing %s:\n%s", want, text)
		}
	}
}

func TestProcessRealtimeBatchFallsBackAndCommitsProcessedPrefix(t *testing.T) {
	messages := []kafka.Message{
		{Topic: "vehicle.raw.go.jt808.v1", Partition: 0, Offset: 10, Value: []byte(`{bad json`)},
		realtimeTestMessage(t, 11, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN001", EventTimeMS: 1100, ReceivedAtMS: 1100, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": 10}}),
		realtimeTestMessage(t, 12, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN002", EventTimeMS: 1200, ReceivedAtMS: 1200, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": 20}}),
	}
	registry := metrics.NewRegistry()
	updater := &recordingBatchRealtimeUpdater{batchErr: errTestRealtimeUpdate, failUpdateAt: 2}
	committer := &contextCheckingMessageCommitter{}
	logger := &recordingRealtimeLogger{}

	outcome := processRealtimeBatch(context.Background(), logger, registry, updater, committer, messages)

	if updater.batchCount != 1 {
		t.Fatalf("batch updates = %d, want 1", updater.batchCount)
	}
	if updater.updateCount != 2 {
		t.Fatalf("fallback updates = %d, want 2", updater.updateCount)
	}
	if committer.count != 1 {
		t.Fatalf("commit calls = %d, want 1 processed-prefix commit", committer.count)
	}
	if len(committer.messages) != 2 || committer.messages[0].Offset != 10 || committer.messages[1].Offset != 11 {
		t.Fatalf("committed offsets = %#v, want [10 11]", committedOffsets(committer.messages))
	}
	if got := committedOffsets(outcome.retryMessages); len(got) != 1 || got[0] != 12 {
		t.Fatalf("retry offsets = %#v, want [12]", got)
	}
	if !logger.containsError("realtime fallback update failed") {
		t.Fatalf("fallback error was not logged: %#v", logger.errors)
	}
	text := registry.Render()
	for _, want := range []string{
		`vehicle_realtime_batch_flush_total{status="error"} 1`,
		`vehicle_realtime_updates_total{status="error",topic="vehicle.raw.go.jt808.v1"} 1`,
		`vehicle_realtime_kafka_commits_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 2`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("fallback metric missing %s:\n%s", want, text)
		}
	}
}

func TestProcessRealtimeBatchReliablyRetriesFailedSuffix(t *testing.T) {
	messages := []kafka.Message{
		{Topic: "vehicle.raw.go.jt808.v1", Partition: 0, Offset: 10, Value: []byte(`{bad json`)},
		realtimeTestMessage(t, 11, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN001", EventTimeMS: 1100, ReceivedAtMS: 1100, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": "10"}}),
		realtimeTestMessage(t, 12, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN002", EventTimeMS: 1200, ReceivedAtMS: 1200, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": "20"}}),
	}
	registry := metrics.NewRegistry()
	updater := &recordingBatchRealtimeUpdater{batchErr: errTestRealtimeUpdate, failUpdateAt: 2}
	committer := &contextCheckingMessageCommitter{}

	processRealtimeBatchReliably(
		context.Background(),
		discardRealtimeLogger{},
		registry,
		updater,
		committer,
		messages,
		time.Nanosecond,
	)

	if updater.batchCount != 2 {
		t.Fatalf("batch attempts = %d, want initial batch and failed suffix retry", updater.batchCount)
	}
	if updater.updateCount != 3 {
		t.Fatalf("fallback updates = %d, want 3", updater.updateCount)
	}
	if got := committedOffsets(committer.messages); len(got) != 3 || got[0] != 10 || got[1] != 11 || got[2] != 12 {
		t.Fatalf("committed offsets = %#v, want [10 11 12]", got)
	}
	text := registry.Render()
	if !strings.Contains(text, `vehicle_realtime_batch_retries_total{reason="write_error"} 1`) {
		t.Fatalf("write retry metric missing:\n%s", text)
	}
}

func TestProcessRealtimeBatchReliablyRetriesCommitWithoutReappending(t *testing.T) {
	messages := []kafka.Message{
		realtimeTestMessage(t, 10, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN001", EventTimeMS: 1000, ReceivedAtMS: 1000, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": "10"}}),
		realtimeTestMessage(t, 11, envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: "VIN002", EventTimeMS: 1100, ReceivedAtMS: 1100, ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{"jt808.location.speed_kmh": "20"}}),
	}
	registry := metrics.NewRegistry()
	updater := &recordingBatchRealtimeUpdater{}
	committer := &contextCheckingMessageCommitter{err: errors.New("commit failed"), failOnCount: 1}

	processRealtimeBatchReliably(
		context.Background(),
		discardRealtimeLogger{},
		registry,
		updater,
		committer,
		messages,
		time.Nanosecond,
	)

	if updater.batchCount != 1 {
		t.Fatalf("batch attempts = %d, want 1 without replay after commit failure", updater.batchCount)
	}
	if committer.count != 2 {
		t.Fatalf("commit attempts = %d, want initial failure and one retry", committer.count)
	}
	text := registry.Render()
	if !strings.Contains(text, `vehicle_realtime_batch_retries_total{reason="commit_error"} 1`) {
		t.Fatalf("commit retry metric missing:\n%s", text)
	}
}

func TestCompositeRealtimeUpdaterUpdatesBothStores(t *testing.T) {
	env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001"}
	redisUpdater := &contextCheckingRealtimeUpdater{}
	snapshotUpdater := &contextCheckingRealtimeUpdater{}
	updater := compositeRealtimeUpdater{primary: redisUpdater, secondary: snapshotUpdater}

	if err := updater.Update(context.Background(), env); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	if redisUpdater.count != 1 || snapshotUpdater.count != 1 {
		t.Fatalf("updates primary=%d secondary=%d", redisUpdater.count, snapshotUpdater.count)
	}
}

func TestComposeRealtimeUpdaterSkipsDisabledRedisProjector(t *testing.T) {
	env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"}
	mysqlUpdater := &contextCheckingRealtimeUpdater{}
	updater := composeRealtimeUpdater(nil, mysqlUpdater)

	if err := updater.Update(context.Background(), env); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	if mysqlUpdater.count != 1 {
		t.Fatalf("mysql updates = %d, want 1", mysqlUpdater.count)
	}
}

func TestComposeRealtimeUpdaterReturnsPrimaryWhenMySQLSnapshotDisabled(t *testing.T) {
	env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001"}
	redisUpdater := &contextCheckingRealtimeUpdater{}
	updater := composeRealtimeUpdater(redisUpdater, nil)

	if err := updater.Update(context.Background(), env); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	if redisUpdater.count != 1 {
		t.Fatalf("redis updates = %d, want 1", redisUpdater.count)
	}
}

func TestAsyncSecondaryRealtimeUpdaterDoesNotBlockPrimaryPath(t *testing.T) {
	env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001"}
	primary := &contextCheckingRealtimeUpdater{}
	secondary := &blockingRealtimeUpdater{started: make(chan struct{}), release: make(chan struct{})}
	registry := metrics.NewRegistry()
	updater := newAsyncSecondaryRealtimeUpdater(primary, secondary, 8, 1, registry, nil)
	defer updater.Close()

	start := time.Now()
	if err := updater.Update(context.Background(), env); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
		t.Fatalf("async updater blocked primary path for %v", elapsed)
	}
	if primary.count != 1 {
		t.Fatalf("primary updates = %d, want 1", primary.count)
	}
	select {
	case <-secondary.started:
	case <-time.After(time.Second):
		t.Fatal("secondary update was not started asynchronously")
	}
	close(secondary.release)
	text := registry.Render()
	for _, want := range []string{
		`vehicle_realtime_async_queue_capacity{store="mysql"} 8`,
		`vehicle_realtime_async_queue_total{protocol="GB32960",status="queued",store="mysql"} 1`,
		`vehicle_realtime_async_queue_depth{protocol="GB32960",store="mysql"}`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("async queue metric missing %s:\n%s", want, text)
		}
	}
}

func TestAsyncSecondaryQueueDropDoesNotFailPrimaryUpdate(t *testing.T) {
	env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"}
	primary := &contextCheckingRealtimeUpdater{}
	registry := metrics.NewRegistry()
	updater := &asyncSecondaryRealtimeUpdater{
		primary:   primary,
		secondary: &contextCheckingRealtimeUpdater{},
		queue:     make(chan envelope.FrameEnvelope, 1),
		registry:  registry,
	}
	updater.queue <- envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN000"}

	if err := updater.Update(context.Background(), env); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	if primary.count != 1 {
		t.Fatalf("primary updates = %d, want 1", primary.count)
	}
	text := registry.Render()
	for _, want := range []string{
		`vehicle_realtime_async_queue_total{protocol="JT808",status="dropped",store="mysql"} 1`,
		`vehicle_realtime_async_queue_depth{protocol="JT808",store="mysql"} 1`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("async queue drop metric missing %s:\n%s", want, text)
		}
	}
}

func TestAsyncSecondaryRealtimeUpdaterCloseDrainsQueuedUpdates(t *testing.T) {
	secondary := &contextCheckingRealtimeUpdater{}
	updater := newAsyncSecondaryRealtimeUpdater(nil, secondary, 8, 1, nil, nil)

	for _, vin := range []string{"VIN001", "VIN002"} {
		if err := updater.Update(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: vin}); err != nil {
			t.Fatalf("Update(%s) error = %v", vin, err)
		}
	}
	updater.Close()

	if secondary.count != 2 {
		t.Fatalf("secondary updates = %d, want 2 after Close drains queue", secondary.count)
	}
}

func TestAsyncSecondaryRealtimeUpdaterDoesNotQueueAfterClose(t *testing.T) {
	secondary := &contextCheckingRealtimeUpdater{}
	registry := metrics.NewRegistry()
	updater := newAsyncSecondaryRealtimeUpdater(nil, secondary, 8, 1, registry, nil)
	updater.Close()

	if err := updater.Update(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	if secondary.count != 0 {
		t.Fatalf("secondary updates = %d, want 0 after Close", secondary.count)
	}
	text := registry.Render()
	if !strings.Contains(text, `vehicle_realtime_async_queue_total{protocol="JT808",status="closed",store="mysql"} 1`) {
		t.Fatalf("closed queue metric missing:\n%s", text)
	}
}

func TestStoreMetricUpdaterRecordsStoreUpdateResults(t *testing.T) {
	registry := metrics.NewRegistry()
	env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"}
	updater := storeMetricUpdater{
		store:    "redis",
		delegate: &contextCheckingRealtimeUpdater{},
		registry: registry,
	}

	if err := updater.Update(context.Background(), env); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	text := registry.Render()
	for _, want := range []string{
		`vehicle_realtime_store_updates_total{protocol="JT808",status="ok",store="redis"} 1`,
		`vehicle_realtime_store_update_duration_ms{protocol="JT808",status="ok",store="redis"}`,
		`vehicle_realtime_store_update_duration_ms_histogram_bucket{le="+Inf",protocol="JT808",status="ok",store="redis"} 1`,
		`vehicle_realtime_store_update_duration_ms_histogram_count{protocol="JT808",status="ok",store="redis"} 1`,
		`vehicle_realtime_store_update_duration_ms_histogram_sum{protocol="JT808",status="ok",store="redis"}`,
		`vehicle_realtime_last_store_update_unix_seconds{protocol="JT808",status="ok",store="redis"} `,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("store update metric missing %s:\n%s", want, text)
		}
	}
}

func TestRetryRealtimeUpdaterRetriesTransientMySQLDeadlock(t *testing.T) {
	delegate := &retryOnceRealtimeUpdater{err: errors.New("Error 1213 (40001): Deadlock found when trying to get lock")}
	updater := retryRealtimeUpdater{delegate: delegate, attempts: 3}

	if err := updater.Update(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001"}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	if delegate.count != 2 {
		t.Fatalf("delegate updates = %d, want 2", delegate.count)
	}
}

func TestRetryRealtimeUpdaterRetriesTransientMySQLConnectionFailure(t *testing.T) {
	delegate := &retryOnceRealtimeUpdater{err: errors.New("driver: bad connection: write tcp: broken pipe")}
	updater := retryRealtimeUpdater{delegate: delegate, attempts: 3}

	if err := updater.Update(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	if delegate.count != 2 {
		t.Fatalf("delegate updates = %d, want 2", delegate.count)
	}
}

func TestIsTransientMySQLWriteError(t *testing.T) {
	for _, err := range []error{
		errors.New("dial tcp 127.0.0.1:3306: connection refused"),
		errors.New("read tcp: connection reset by peer"),
		errors.New("write tcp: broken pipe"),
		errors.New("driver: bad connection"),
		errors.New("invalid connection"),
		errors.New("i/o timeout"),
		errors.New("EOF"),
		errors.New("server is down"),
		errors.New("network is unreachable"),
	} {
		if !isTransientMySQLWriteError(err) {
			t.Fatalf("isTransientMySQLWriteError(%q) = false, want true", err.Error())
		}
	}
	for _, err := range []error{
		context.Canceled,
		context.DeadlineExceeded,
		errors.New("duplicate key conflict"),
		nil,
	} {
		if isTransientMySQLWriteError(err) {
			t.Fatalf("isTransientMySQLWriteError(%v) = true, want false", err)
		}
	}
}

func TestThrottledRealtimeUpdaterLimitsMySQLSnapshotFrequencyByVehicle(t *testing.T) {
	delegate := &contextCheckingRealtimeUpdater{}
	registry := metrics.NewRegistry()
	updater := newThrottledRealtimeUpdater(delegate, 10*time.Second, "mysql", registry)

	base := envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		MessageID:    "0x0200",
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		ParseStatus:  envelope.ParseOK,
		Parsed:       map[string]any{"location": map[string]any{"longitude": 121.1, "latitude": 30.2}},
	}
	for _, env := range []envelope.FrameEnvelope{
		base,
		func() envelope.FrameEnvelope {
			env := base
			env.EventTimeMS = 5000
			env.ReceivedAtMS = 5100
			return env
		}(),
		func() envelope.FrameEnvelope {
			env := base
			env.EventTimeMS = 11000
			env.ReceivedAtMS = 11100
			return env
		}(),
	} {
		if err := updater.Update(context.Background(), env); err != nil {
			t.Fatalf("Update() error = %v", err)
		}
	}
	if delegate.count != 2 {
		t.Fatalf("delegate updates = %d, want 2", delegate.count)
	}
	text := registry.Render()
	for _, want := range []string{
		`vehicle_realtime_throttle_total{protocol="JT808",status="passed",store="mysql"} 2`,
		`vehicle_realtime_throttle_total{protocol="JT808",status="skipped_interval",store="mysql"} 1`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("throttle metric missing %s:\n%s", want, text)
		}
	}
}

func TestThrottledRealtimeUpdaterCleansExpiredCacheEntries(t *testing.T) {
	delegate := &contextCheckingRealtimeUpdater{}
	registry := metrics.NewRegistry()
	updater := newThrottledRealtimeUpdaterWithCache(delegate, 10*time.Second, "mysql", registry, 48*time.Hour, 0).(*throttledRealtimeUpdater)
	loc := time.FixedZone("Asia/Shanghai", 8*3600)
	base := envelope.FrameEnvelope{
		Protocol:    envelope.ProtocolJT808,
		MessageID:   "0x0200",
		ParseStatus: envelope.ParseOK,
		Parsed:      map[string]any{"location": map[string]any{"longitude": 121.1, "latitude": 30.2}},
	}
	old := base
	old.VIN = "VIN-OLD"
	old.EventTimeMS = time.Date(2026, 7, 1, 12, 0, 0, 0, loc).UnixMilli()
	old.ReceivedAtMS = old.EventTimeMS
	current := base
	current.VIN = "VIN-CURRENT"
	current.EventTimeMS = time.Date(2026, 7, 4, 12, 0, 0, 0, loc).UnixMilli()
	current.ReceivedAtMS = current.EventTimeMS

	if err := updater.Update(context.Background(), old); err != nil {
		t.Fatalf("old Update() error = %v", err)
	}
	if err := updater.Update(context.Background(), current); err != nil {
		t.Fatalf("current Update() error = %v", err)
	}
	if delegate.count != 2 {
		t.Fatalf("delegate updates = %d, want 2", delegate.count)
	}
	if len(updater.lastMS) != 1 {
		t.Fatalf("throttle cache entries = %d, want 1", len(updater.lastMS))
	}
	if _, ok := updater.lastMS[string(envelope.ProtocolJT808)+"\x00VIN-CURRENT"]; !ok {
		t.Fatalf("current VIN missing from throttle cache")
	}
	text := registry.Render()
	for _, want := range []string{
		`vehicle_realtime_throttle_cache_entries{store="mysql"} 1`,
		`vehicle_realtime_throttle_cache_last_cleanup_deleted{store="mysql"} 1`,
		`vehicle_realtime_throttle_cache_last_cleanup_unix_seconds{store="mysql"} 1783137600`,
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("throttle cache metric missing %s:\n%s", want, text)
		}
	}
}

func TestThrottledRealtimeUpdaterFallsBackToReceivedTimeForFarFutureEvent(t *testing.T) {
	delegate := &contextCheckingRealtimeUpdater{}
	updater := newThrottledRealtimeUpdater(delegate, 10*time.Second, "mysql", nil)
	received := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC).UnixMilli()
	future := time.Date(2026, 7, 13, 10, 0, 0, 0, time.UTC).UnixMilli()
	base := envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		MessageID:    "0x0200",
		VIN:          "VIN001",
		EventTimeMS:  future,
		ReceivedAtMS: received,
		ParseStatus:  envelope.ParseOK,
		Parsed:       map[string]any{"location": map[string]any{"longitude": 121.1, "latitude": 30.2}},
	}
	for _, env := range []envelope.FrameEnvelope{
		base,
		func() envelope.FrameEnvelope {
			env := base
			env.EventTimeMS = received + 5_000
			env.ReceivedAtMS = received + 5_000
			return env
		}(),
		func() envelope.FrameEnvelope {
			env := base
			env.EventTimeMS = received + 11_000
			env.ReceivedAtMS = received + 11_000
			return env
		}(),
	} {
		if err := updater.Update(context.Background(), env); err != nil {
			t.Fatalf("Update() error = %v", err)
		}
	}
	if delegate.count != 2 {
		t.Fatalf("delegate updates = %d, want 2", delegate.count)
	}
}

func TestThrottledRealtimeUpdaterSkipsNonRealtimeFramesBeforeDelegate(t *testing.T) {
	delegate := &contextCheckingRealtimeUpdater{}
	registry := metrics.NewRegistry()
	updater := newThrottledRealtimeUpdater(delegate, 10*time.Second, "mysql", registry)

	if err := updater.Update(context.Background(), envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolGB32960,
		MessageID:    "0x01",
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		ParseStatus:  envelope.ParseOK,
		Parsed:       map[string]any{"platform_login": map[string]any{"username": "Hyundai"}},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	if delegate.count != 0 {
		t.Fatalf("delegate updates = %d, want 0", delegate.count)
	}
	text := registry.Render()
	if !strings.Contains(text, `vehicle_realtime_throttle_total{protocol="GB32960",status="skipped_non_realtime",store="mysql"} 1`) {
		t.Fatalf("non-realtime throttle metric missing:\n%s", text)
	}
}

func TestAsyncSecondaryRealtimeUpdaterLogsSecondaryErrors(t *testing.T) {
	env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", EventID: "event-1"}
	logger := &recordingRealtimeLogger{}
	updater := newAsyncSecondaryRealtimeUpdater(nil, failingRealtimeUpdater{}, 8, 1, nil, logger)
	defer updater.Close()

	if err := updater.Update(context.Background(), env); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		if logger.containsError("async realtime secondary update failed") {
			return
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("secondary error was not logged: %#v", logger.errors)
}

func TestKafkaTopicsFromEnvDefaultsToGoRawTopics(t *testing.T) {
	got := strings.Join(kafkaTopicsFromEnv(), ",")
	want := "vehicle.raw.go.gb32960.v1,vehicle.raw.go.jt808.v1,vehicle.raw.go.yutong-mqtt.v1"
	if got != want {
		t.Fatalf("topics = %q, want %q", got, want)
	}
}

func TestValidateRealtimeConsumerConfigRejectsFieldsTopics(t *testing.T) {
	err := validateRealtimeConsumerConfig([]string{"vehicle.fields.go.jt808.v1"}, 1)

	if err == nil || !strings.Contains(err.Error(), "raw topics only") {
		t.Fatalf("validateRealtimeConsumerConfig() error = %v, want raw topic rejection", err)
	}
}

func TestValidateRealtimeConsumerConfigRequiresPositiveWorkers(t *testing.T) {
	err := validateRealtimeConsumerConfig([]string{"vehicle.raw.go.jt808.v1"}, 0)

	if err == nil || !strings.Contains(err.Error(), "REALTIME_WORKERS") {
		t.Fatalf("validateRealtimeConsumerConfig() error = %v, want REALTIME_WORKERS hint", err)
	}
}

func TestThrottledRealtimeUpdaterIsSafeAcrossPartitionWorkers(t *testing.T) {
	delegate := &concurrentRealtimeUpdater{}
	updater := newThrottledRealtimeUpdater(delegate, time.Minute, "mysql", metrics.NewRegistry())
	env := envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		MessageID:    "0x0200",
		VIN:          "LNBVIN00000000001",
		EventTimeMS:  1783947000000,
		ReceivedAtMS: 1783947000000,
		ParseStatus:  envelope.ParseOK,
		ParsedFields: map[string]any{"jt808.location.speed_kmh": 10},
	}

	const workers = 32
	var wait sync.WaitGroup
	errorsCh := make(chan error, workers)
	for index := 0; index < workers; index++ {
		wait.Add(1)
		go func() {
			defer wait.Done()
			if err := updater.Update(context.Background(), env); err != nil {
				errorsCh <- err
			}
		}()
	}
	wait.Wait()
	close(errorsCh)
	for err := range errorsCh {
		t.Fatal(err)
	}
	if got := delegate.Count(); got != 1 {
		t.Fatalf("delegate updates = %d, want one throttled write for the same VIN", got)
	}
}

func TestRealtimeReaderConfigStartsAtLatestOffset(t *testing.T) {
	cfg := realtimeReaderConfig([]string{"127.0.0.1:9092"}, []string{"vehicle.raw.go.jt808.v1"})

	if cfg.StartOffset != kafka.LastOffset {
		t.Fatalf("StartOffset = %d, want kafka.LastOffset %d", cfg.StartOffset, kafka.LastOffset)
	}
	if got := strings.Join(cfg.GroupTopics, ","); got != "vehicle.raw.go.jt808.v1" {
		t.Fatalf("GroupTopics = %q", got)
	}
}

func TestRealtimeRoleControlsQueryAndWriterResponsibilities(t *testing.T) {
	tests := []struct {
		raw        string
		wantRole   string
		wantAPI    bool
		wantWriter bool
	}{
		{raw: "", wantRole: "combined", wantAPI: true, wantWriter: true},
		{raw: "combined", wantRole: "combined", wantAPI: true, wantWriter: true},
		{raw: "api", wantRole: "api", wantAPI: true, wantWriter: false},
		{raw: "query", wantRole: "api", wantAPI: true, wantWriter: false},
		{raw: "writer", wantRole: "writer", wantAPI: false, wantWriter: true},
		{raw: "projector", wantRole: "writer", wantAPI: false, wantWriter: true},
		{raw: "surprise", wantRole: "combined", wantAPI: true, wantWriter: true},
	}

	for _, tt := range tests {
		t.Run(tt.raw, func(t *testing.T) {
			if got := normalizeRealtimeRole(tt.raw); got != tt.wantRole {
				t.Fatalf("normalizeRealtimeRole(%q) = %q, want %q", tt.raw, got, tt.wantRole)
			}
			if got := realtimeRoleAllowsAPI(tt.raw); got != tt.wantAPI {
				t.Fatalf("realtimeRoleAllowsAPI(%q) = %v, want %v", tt.raw, got, tt.wantAPI)
			}
			if got := realtimeRoleAllowsWriter(tt.raw); got != tt.wantWriter {
				t.Fatalf("realtimeRoleAllowsWriter(%q) = %v, want %v", tt.raw, got, tt.wantWriter)
			}
		})
	}
}

func TestRealtimeRoleFromEnvDefaultsToCombined(t *testing.T) {
	t.Setenv("REALTIME_ROLE", "")
	if got := realtimeRoleFromEnv(); got != "combined" {
		t.Fatalf("role = %q, want combined", got)
	}
}

func TestRealtimeRoleDependencyMatrix(t *testing.T) {
	tests := []struct {
		name            string
		role            string
		redisProjector  bool
		wantRedis       bool
		wantServiceName string
	}{
		{name: "api queries redis", role: "api", redisProjector: false, wantRedis: true, wantServiceName: "vehicle-realtime-api"},
		{name: "mysql writer skips redis", role: "writer", redisProjector: false, wantRedis: false, wantServiceName: "vehicle-realtime-writer"},
		{name: "redis writer uses redis", role: "writer", redisProjector: true, wantRedis: true, wantServiceName: "vehicle-realtime-writer"},
		{name: "combined queries redis", role: "combined", redisProjector: false, wantRedis: true, wantServiceName: "vehicle-realtime-api"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			apiEnabled := realtimeRoleAllowsAPI(tt.role)
			writerEnabled := realtimeRoleAllowsWriter(tt.role)
			if got := realtimeRoleNeedsRedis(apiEnabled, writerEnabled, tt.redisProjector); got != tt.wantRedis {
				t.Fatalf("realtimeRoleNeedsRedis() = %v, want %v", got, tt.wantRedis)
			}
			if got := realtimeServiceName(tt.role); got != tt.wantServiceName {
				t.Fatalf("realtimeServiceName(%q) = %q, want %q", tt.role, got, tt.wantServiceName)
			}
		})
	}
}

func TestRealtimeAPIDoesNotExposeDuplicateMileagePointRoute(t *testing.T) {
	source, err := os.ReadFile("main.go")
	if err != nil {
		t.Fatalf("read main.go: %v", err)
	}
	if strings.Contains(string(source), "/api/history/mileage-points") {
		t.Fatalf("realtime api should expose mileage through /api/history/locations only")
	}
}

func TestRealtimeAPICachesBindingPlateResolver(t *testing.T) {
	source, err := os.ReadFile("main.go")
	if err != nil {
		t.Fatalf("read main.go: %v", err)
	}
	for _, want := range []string{"NewCachedPlateResolverWithMaxEntries", "PLATE_CACHE_TTL_SECONDS", "PLATE_CACHE_MAX_ENTRIES", "recordPlateCacheStats", "vehicle_realtime_plate_cache_entries"} {
		if !strings.Contains(string(source), want) {
			t.Fatalf("realtime api should configure cached plate resolver, missing %s", want)
		}
	}
}

func TestRealtimeAPIExposesPostRawFrameQueryRoute(t *testing.T) {
	source, err := os.ReadFile("main.go")
	if err != nil {
		t.Fatalf("read main.go: %v", err)
	}
	if !strings.Contains(string(source), `"/api/history/raw-frames/query"`) {
		t.Fatalf("realtime api should expose POST raw frame query route")
	}
}

func TestRealtimeAPIExposesOperationalRealtimeRoutes(t *testing.T) {
	source, err := os.ReadFile("main.go")
	if err != nil {
		t.Fatalf("read main.go: %v", err)
	}
	for _, want := range []string{`"/api/realtime/online"`, `"/api/debug/pipeline"`} {
		if !strings.Contains(string(source), want) {
			t.Fatalf("realtime api should expose operational route %s", want)
		}
	}
}

func TestRealtimeAPIExposesDataSourceManagementRoutes(t *testing.T) {
	source, err := os.ReadFile("main.go")
	if err != nil {
		t.Fatalf("read main.go: %v", err)
	}
	for _, want := range []string{`"/api/stats/data-sources"`, `"/api/stats/data-sources/"`, "NewDataSourceHandler"} {
		if !strings.Contains(string(source), want) {
			t.Fatalf("realtime api should expose data source management route %s", want)
		}
	}
}

func TestRealtimeAPIExposesDailyMetricSourceRoute(t *testing.T) {
	source, err := os.ReadFile("main.go")
	if err != nil {
		t.Fatalf("read main.go: %v", err)
	}
	for _, want := range []string{`"/api/stats/daily-metrics"`, `"/api/stats/daily-metrics/sources"`, `"/api/stats/daily-metrics/sources/quality"`, `"/api/stats/daily-metrics/sources/selection"`, `"/api/stats/daily-metrics/diagnostics"`, `"/api/stats/daily-metrics/diagnostics/summary"`, `"/api/stats/daily-metrics/diagnostics/reasons"`, `"/api/stats/daily-metrics/diagnostics/field-status"`, "NewMetricHandler"} {
		if !strings.Contains(string(source), want) {
			t.Fatalf("realtime api should expose metric route %s", want)
		}
	}
}

func TestRealtimeAPIDoesNotExposeMySQLKVRoute(t *testing.T) {
	source, err := os.ReadFile("main.go")
	if err != nil {
		t.Fatalf("read main.go: %v", err)
	}
	if strings.Contains(string(source), `"/api/realtime/kv"`) || strings.Contains(string(source), "NewKVQuery") {
		t.Fatal("realtime api should not expose MySQL realtime kv route")
	}
}

func TestRealtimeAPIRedisProjectorHasProductionGuardMetric(t *testing.T) {
	source, err := os.ReadFile("main.go")
	if err != nil {
		t.Fatalf("read main.go: %v", err)
	}
	for _, want := range []string{"REALTIME_ROLE", "vehicle_realtime_role_info", "vehicle_realtime_writer_enabled", "REALTIME_REDIS_PROJECTOR_ENABLED", "vehicle_realtime_redis_projector_enabled"} {
		if !strings.Contains(string(source), want) {
			t.Fatalf("realtime api should expose redis projector guard %s", want)
		}
	}
}

func TestRealtimeAPIClosesAsyncMySQLUpdaterBeforeStatsDB(t *testing.T) {
	sourceBytes, err := os.ReadFile("main.go")
	if err != nil {
		t.Fatalf("read main.go: %v", err)
	}
	source := string(sourceBytes)
	for _, want := range []string{"closeRealtimeUpdaters := func() {}", "closeRealtimeUpdaters = asyncUpdater.Close", "defer closeStats()", "defer closeRealtimeUpdaters()"} {
		if !strings.Contains(source, want) {
			t.Fatalf("realtime api should close async mysql updater on shutdown, missing %s", want)
		}
	}
	if strings.Index(source, "defer closeStats()") > strings.Index(source, "defer closeRealtimeUpdaters()") {
		t.Fatalf("closeRealtimeUpdaters should be deferred after closeStats so it runs first")
	}
}

type contextCheckingRealtimeUpdater struct {
	ctxErr error
	count  int
}

type concurrentRealtimeUpdater struct {
	mu    sync.Mutex
	count int
}

func (u *concurrentRealtimeUpdater) Update(context.Context, envelope.FrameEnvelope) error {
	u.mu.Lock()
	u.count++
	u.mu.Unlock()
	return nil
}

func (u *concurrentRealtimeUpdater) Count() int {
	u.mu.Lock()
	defer u.mu.Unlock()
	return u.count
}

func (u *contextCheckingRealtimeUpdater) Update(ctx context.Context, _ envelope.FrameEnvelope) error {
	u.ctxErr = ctx.Err()
	u.count++
	return u.ctxErr
}

type contextCheckingMessageCommitter struct {
	ctxErr       error
	count        int
	messageCount int
	messages     []kafka.Message
	err          error
	failOnCount  int
}

func (c *contextCheckingMessageCommitter) CommitMessages(ctx context.Context, messages ...kafka.Message) error {
	c.ctxErr = ctx.Err()
	c.count++
	c.messageCount += len(messages)
	c.messages = append(c.messages, messages...)
	if c.ctxErr != nil {
		return c.ctxErr
	}
	if c.err != nil && (c.failOnCount == 0 || c.count == c.failOnCount) {
		return c.err
	}
	return nil
}

type discardRealtimeLogger struct{}

func (discardRealtimeLogger) Info(string, ...any)  {}
func (discardRealtimeLogger) Error(string, ...any) {}
func (discardRealtimeLogger) Warn(string, ...any)  {}

type recordingRealtimeLogger struct {
	mu     sync.Mutex
	errors []string
}

func (l *recordingRealtimeLogger) Info(string, ...any) {}
func (l *recordingRealtimeLogger) Warn(string, ...any) {}
func (l *recordingRealtimeLogger) Error(message string, _ ...any) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.errors = append(l.errors, message)
}

func (l *recordingRealtimeLogger) containsError(message string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	for _, got := range l.errors {
		if got == message {
			return true
		}
	}
	return false
}

type blockingRealtimeUpdater struct {
	started chan struct{}
	release chan struct{}
	once    sync.Once
}

func (u *blockingRealtimeUpdater) Update(context.Context, envelope.FrameEnvelope) error {
	u.once.Do(func() { close(u.started) })
	<-u.release
	return nil
}

type failingRealtimeUpdater struct{}

func (failingRealtimeUpdater) Update(context.Context, envelope.FrameEnvelope) error {
	return errTestRealtimeUpdate
}

var errTestRealtimeUpdate = errors.New("test realtime update failed")

type retryOnceRealtimeUpdater struct {
	err   error
	count int
}

func (u *retryOnceRealtimeUpdater) Update(context.Context, envelope.FrameEnvelope) error {
	u.count++
	if u.count == 1 {
		return u.err
	}
	return nil
}

type sliceRealtimeFetcher struct {
	messages []kafka.Message
	index    int
}

func (f *sliceRealtimeFetcher) FetchMessage(ctx context.Context) (kafka.Message, error) {
	if f.index >= len(f.messages) {
		return kafka.Message{}, ctx.Err()
	}
	message := f.messages[f.index]
	f.index++
	return message, nil
}

type recordingBatchRealtimeUpdater struct {
	batchCount   int
	updateCount  int
	batchEnvs    []envelope.FrameEnvelope
	batchErr     error
	failUpdateAt int
}

func (u *recordingBatchRealtimeUpdater) UpdateBatch(_ context.Context, envs []envelope.FrameEnvelope) error {
	u.batchCount++
	u.batchEnvs = append(u.batchEnvs, envs...)
	return u.batchErr
}

func (u *recordingBatchRealtimeUpdater) Update(context.Context, envelope.FrameEnvelope) error {
	u.updateCount++
	if u.failUpdateAt > 0 && u.updateCount == u.failUpdateAt {
		return errTestRealtimeUpdate
	}
	return nil
}

func realtimeTestMessage(t *testing.T, offset int64, env envelope.FrameEnvelope) kafka.Message {
	t.Helper()
	payload, err := json.Marshal(env)
	if err != nil {
		t.Fatal(err)
	}
	return kafka.Message{
		Topic:     topicForTestProtocol(env.Protocol),
		Partition: 0,
		Offset:    offset,
		Value:     payload,
	}
}

func topicForTestProtocol(protocol envelope.Protocol) string {
	switch protocol {
	case envelope.ProtocolGB32960:
		return "vehicle.raw.go.gb32960.v1"
	case envelope.ProtocolYutongMQTT:
		return "vehicle.raw.go.yutong_mqtt.v1"
	default:
		return "vehicle.raw.go.jt808.v1"
	}
}

func committedOffsets(messages []kafka.Message) []int64 {
	offsets := make([]int64, 0, len(messages))
	for _, message := range messages {
		offsets = append(offsets, message.Offset)
	}
	return offsets
}
