package realtime

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/alicebob/miniredis/v2"
	"github.com/redis/go-redis/v9"

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

func TestRepositoryUpdatesMergedAndProtocolSnapshots(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		Fields: map[string]any{
			envelope.FieldLongitude:      121.1,
			envelope.FieldTotalMileageKM: 10.5,
		},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolGB32960,
		VIN:          "VIN001",
		EventTimeMS:  900,
		ReceivedAtMS: 1200,
		Fields: map[string]any{
			envelope.FieldLongitude: 120.0,
			envelope.FieldLatitude:  30.5,
		},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	merged, err := repo.GetMerged(ctx, "VIN001")
	if err != nil {
		t.Fatalf("GetMerged() error = %v", err)
	}
	if merged.Fields[envelope.FieldLongitude] != 121.1 {
		t.Fatalf("older longitude overwrote newer value: %#v", merged.Fields)
	}
	if merged.Fields[envelope.FieldLatitude] != 30.5 {
		t.Fatalf("new latitude missing: %#v", merged.Fields)
	}
	if got := protocolNames(merged.Protocols); strings.Join(got, ",") != "GB32960,JT808" {
		t.Fatalf("merged protocols = %#v", got)
	}
	protocol, err := repo.GetProtocol(ctx, "VIN001", envelope.ProtocolJT808)
	if err != nil {
		t.Fatalf("GetProtocol() error = %v", err)
	}
	if protocol.Fields[envelope.FieldTotalMileageKM] != 10.5 {
		t.Fatalf("protocol snapshot = %#v", protocol)
	}
}

func TestRepositoryStoresFullParsedOnlyInRealtimeRawAndMergesGB32960Units(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolGB32960,
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		Parsed: map[string]any{
			"header": map[string]any{"command": "0x02"},
			"data_units": []any{
				map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 88.0}},
			},
		},
		Fields: map[string]any{envelope.FieldSOCPercent: 88.0},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolGB32960,
		VIN:          "VIN001",
		EventTimeMS:  1100,
		ReceivedAtMS: 1200,
		Parsed: map[string]any{
			"data_units": []any{
				map[string]any{"type": "0x05", "name": "position", "value": map[string]any{"longitude": 121.1, "latitude": 30.2}},
			},
		},
		Fields: map[string]any{envelope.FieldLongitude: 121.1, envelope.FieldLatitude: 30.2},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	protocol, err := repo.GetProtocol(ctx, "VIN001", envelope.ProtocolGB32960)
	if err != nil {
		t.Fatalf("GetProtocol() error = %v", err)
	}
	realtimeRaw, err := repo.GetRealtimeRaw(ctx, "VIN001", envelope.ProtocolGB32960)
	if err != nil {
		t.Fatalf("GetRealtimeRaw() error = %v", err)
	}
	protocolJSON, err := json.Marshal(protocol)
	if err != nil {
		t.Fatal(err)
	}
	if strings.Contains(string(protocolJSON), `"parsed"`) {
		t.Fatalf("protocol snapshot should not duplicate full parsed data: %s", string(protocolJSON))
	}
	if len(realtimeRaw["data_units"].([]any)) != 2 {
		t.Fatalf("realtime-raw did not keep merged parsed fields: %#v", realtimeRaw)
	}
	merged, err := repo.GetMerged(ctx, "VIN001")
	if err != nil {
		t.Fatalf("GetMerged() error = %v", err)
	}
	mergedJSON, err := json.Marshal(merged)
	if err != nil {
		t.Fatal(err)
	}
	if strings.Contains(string(mergedJSON), "protocol_data") {
		t.Fatalf("merged snapshot should not duplicate full protocol parsed data: %s", string(mergedJSON))
	}
}

func TestRepositoryNormalizesFarFutureEventTime(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()
	received := time.Date(2026, 7, 12, 9, 30, 0, 0, time.UTC).UnixMilli()
	futureEvent := time.Date(2026, 7, 13, 9, 30, 0, 0, time.UTC).UnixMilli()

	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		MessageID:    "0x0200",
		VIN:          "VIN001",
		EventTimeMS:  futureEvent,
		ReceivedAtMS: received,
		Parsed:       map[string]any{"location": map[string]any{"longitude": 121.1}},
		ParsedFields: map[string]any{
			"jt808.location.longitude":        121.1,
			"jt808.location.latitude":         30.1,
			"jt808.location.total_mileage_km": 10241.2,
		},
		Fields: map[string]any{
			envelope.FieldLongitude:      121.1,
			envelope.FieldLatitude:       30.1,
			envelope.FieldTotalMileageKM: 10241.2,
		},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	protocol, err := repo.GetProtocol(ctx, "VIN001", envelope.ProtocolJT808)
	if err != nil {
		t.Fatalf("GetProtocol() error = %v", err)
	}
	if protocol.EventTimeMS != received {
		t.Fatalf("protocol event time = %d, want received %d", protocol.EventTimeMS, received)
	}
	meta, err := repo.client.HGetAll(ctx, realtimeKVMetaKey(envelope.ProtocolJT808, "VIN001")).Result()
	if err != nil {
		t.Fatalf("HGetAll meta error = %v", err)
	}
	if meta["event_time_ms"] != strconv.FormatInt(received, 10) {
		t.Fatalf("rt-kv event_time_ms = %q, want received %d", meta["event_time_ms"], received)
	}
}

func TestRepositoryMergesNestedGB32960MotorSlicesBySerialNo(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolGB32960,
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		Parsed: map[string]any{
			"data_units": []any{
				map[string]any{
					"type": "0x02",
					"name": "drive_motor",
					"value": map[string]any{
						"motors": []any{
							map[string]any{"serial_no": 1, "speed_rpm": 5000},
							map[string]any{"serial_no": 2, "speed_rpm": 5100},
						},
					},
				},
			},
		},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolGB32960,
		VIN:          "VIN001",
		EventTimeMS:  1100,
		ReceivedAtMS: 1200,
		Parsed: map[string]any{
			"data_units": []any{
				map[string]any{
					"type": "0x02",
					"name": "drive_motor",
					"value": map[string]any{
						"motors": []any{
							map[string]any{"serial_no": 1, "speed_rpm": 5406},
							map[string]any{"serial_no": 2, "speed_rpm": 5377},
						},
					},
				},
			},
		},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	realtimeRaw, err := repo.GetRealtimeRaw(ctx, "VIN001", envelope.ProtocolGB32960)
	if err != nil {
		t.Fatalf("GetRealtimeRaw() error = %v", err)
	}
	units := realtimeRaw["data_units"].([]any)
	driveMotor := units[0].(map[string]any)
	value := driveMotor["value"].(map[string]any)
	motors := value["motors"].([]any)
	if len(motors) != 2 {
		t.Fatalf("motors len = %d, want 2: %#v", len(motors), motors)
	}
	first := motors[0].(map[string]any)
	if first["speed_rpm"] != float64(5406) {
		t.Fatalf("first motor was not updated: %#v", motors)
	}
}

func TestRepositoryCollapsesGB32960VendorStackFragmentsIntoSingleRealtimeSummary(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	first := map[string]any{
		"engine_work_state":           1,
		"stack_water_outlet_temp_c":   65,
		"hydrogen_inlet_pressure_kpa": 130,
		"cell_count":                  432,
		"avg_cell_voltage_v":          0.78,
		"frame_cell_start":            201,
		"frame_cell_count":            200,
		"frame_max_cell_voltage_v":    1.0,
		"frame_min_cell_voltage_v":    0.78,
	}
	second := map[string]any{
		"engine_work_state":           1,
		"stack_water_outlet_temp_c":   63,
		"hydrogen_inlet_pressure_kpa": 130,
		"cell_count":                  432,
		"avg_cell_voltage_v":          0.80,
		"frame_cell_start":            401,
		"frame_cell_count":            32,
		"frame_max_cell_voltage_v":    1.0,
		"frame_min_cell_voltage_v":    1.0,
	}
	for i, summary := range []map[string]any{first, second} {
		if err := repo.Update(ctx, envelope.FrameEnvelope{
			Protocol:     envelope.ProtocolGB32960,
			VIN:          "VIN001",
			EventTimeMS:  int64(1000 + i),
			ReceivedAtMS: int64(1100 + i),
			Parsed: map[string]any{
				"data_units": []any{
					map[string]any{
						"type": "0x30",
						"name": "gd_fc_stack",
						"value": map[string]any{
							"stack_count": 1,
							"summaries":   []any{summary},
						},
					},
				},
			},
		}); err != nil {
			t.Fatalf("Update() error = %v", err)
		}
	}

	realtimeRaw, err := repo.GetRealtimeRaw(ctx, "VIN001", envelope.ProtocolGB32960)
	if err != nil {
		t.Fatalf("GetRealtimeRaw() error = %v", err)
	}
	unit := realtimeRaw["data_units"].([]any)[0].(map[string]any)
	value := unit["value"].(map[string]any)
	summaries := value["summaries"].([]any)
	if len(summaries) != 1 {
		t.Fatalf("stack summaries should be a current stack state, not appended fragments: %#v", summaries)
	}
	summary := summaries[0].(map[string]any)
	for _, fragmentOnly := range []string{"frame_cell_start", "frame_cell_count", "frame_max_cell_voltage_v", "frame_min_cell_voltage_v"} {
		if _, ok := summary[fragmentOnly]; ok {
			t.Fatalf("stack summary should not expose fragment-only field %s: %#v", fragmentOnly, summary)
		}
	}
	if summary["stack_water_outlet_temp_c"] != float64(63) {
		t.Fatalf("summary should keep latest stack-level values: %#v", summary)
	}
}

func TestRepositoryReplacesGB32960VendorAuxiliarySubsystemsWithLatestState(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	for i, voltage := range []float64{372, 348} {
		if err := repo.Update(ctx, envelope.FrameEnvelope{
			Protocol:     envelope.ProtocolGB32960,
			VIN:          "VIN001",
			EventTimeMS:  int64(1000 + i),
			ReceivedAtMS: int64(1100 + i),
			Parsed: map[string]any{
				"data_units": []any{
					map[string]any{
						"type": "0x31",
						"name": "gd_fc_auxiliary",
						"value": map[string]any{
							"subsystem_count": 1,
							"subsystems": []any{map[string]any{
								"air_compressor_motor_voltage_v": voltage,
								"water_pump_voltage_v":           voltage,
							}},
						},
					},
				},
			},
		}); err != nil {
			t.Fatalf("Update() error = %v", err)
		}
	}

	realtimeRaw, err := repo.GetRealtimeRaw(ctx, "VIN001", envelope.ProtocolGB32960)
	if err != nil {
		t.Fatalf("GetRealtimeRaw() error = %v", err)
	}
	unit := realtimeRaw["data_units"].([]any)[0].(map[string]any)
	value := unit["value"].(map[string]any)
	subsystems := value["subsystems"].([]any)
	if len(subsystems) != 1 {
		t.Fatalf("auxiliary subsystems should keep current state, not append historical frames: %#v", subsystems)
	}
	subsystem := subsystems[0].(map[string]any)
	if subsystem["air_compressor_motor_voltage_v"] != float64(348) {
		t.Fatalf("auxiliary subsystem should keep latest values: %#v", subsystem)
	}
}

func TestRepositoryWritesRealtimeKVHashes(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolGB32960,
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		Parsed: map[string]any{
			"data_units": []any{
				map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 88.0}},
				map[string]any{"type": "0x30", "name": "gd_fc_stack", "value": map[string]any{
					"stack_count": 1,
					"summaries":   []any{map[string]any{"stack_water_outlet_temp_c": 63}},
				}},
			},
		},
		ParsedFields: map[string]any{
			"gb32960.vehicle.type":                          "0x01",
			"gb32960.vehicle.soc_percent":                   88.0,
			"gb32960.gd_fc_stack.type":                      "0x30",
			"gb32960.gd_fc_stack.stack_count":               1,
			"gb32960.gd_fc_stack.stack_water_outlet_temp_c": 63,
		},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	dataUnitsKV, err := repo.client.HGetAll(ctx, "vehicle:rt-kv:GB32960:VIN001:values").Result()
	if err != nil {
		t.Fatalf("values kv HGetAll error = %v", err)
	}
	if dataUnitsKV["gb32960.vehicle.soc_percent"] != "88" {
		t.Fatalf("data_units kv = %#v", dataUnitsKV)
	}
	if dataUnitsKV["gb32960.gd_fc_stack.stack_water_outlet_temp_c"] != "63" {
		t.Fatalf("data_units stack kv = %#v", dataUnitsKV)
	}
	typeKV, err := repo.client.HGetAll(ctx, "vehicle:rt-kv:GB32960:VIN001:types").Result()
	if err != nil {
		t.Fatalf("types kv HGetAll error = %v", err)
	}
	if typeKV["gb32960.vehicle.soc_percent"] != "number" || typeKV["gb32960.gd_fc_stack.type"] != "string" {
		t.Fatalf("types kv = %#v", typeKV)
	}
	timeKV, err := repo.client.HGetAll(ctx, realtimeKVTimesKey(envelope.ProtocolGB32960, "VIN001")).Result()
	if err != nil {
		t.Fatalf("times kv HGetAll error = %v", err)
	}
	if timeKV["gb32960.vehicle.soc_percent"] != "1000" || timeKV["gb32960.gd_fc_stack.stack_water_outlet_temp_c"] != "1000" {
		t.Fatalf("times kv = %#v", timeKV)
	}
	metaKV, err := repo.client.HGetAll(ctx, "vehicle:rt-kv:GB32960:VIN001:meta").Result()
	if err != nil {
		t.Fatalf("meta kv HGetAll error = %v", err)
	}
	if metaKV["event_id"] == "" || metaKV["event_time_ms"] != "1000" || metaKV["protocol"] != "GB32960" {
		t.Fatalf("meta kv = %#v", metaKV)
	}
	if repo.client.Exists(ctx, "vehicle:rt-kv:GB32960:VIN001:data_units").Val() != 0 {
		t.Fatal("old domain kv key should not be written")
	}
	if ttl := repo.client.TTL(ctx, "vehicle:rt-kv:GB32960:VIN001:values").Val(); ttl != -1 {
		t.Fatalf("kv ttl should not expire, got %v", ttl)
	}
}

func TestRepositoryFastUpdateOnlyWritesPermanentKVAndMinuteOnline(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.FastUpdate(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolGB32960,
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		Parsed: map[string]any{
			"data_units": []any{
				map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 88.0}},
			},
		},
		ParsedFields: map[string]any{
			"gb32960.vehicle.type":        "0x01",
			"gb32960.vehicle.soc_percent": 88.0,
		},
	}); err != nil {
		t.Fatalf("FastUpdate() error = %v", err)
	}

	dataUnitsKV, err := repo.client.HGetAll(ctx, "vehicle:rt-kv:GB32960:VIN001:values").Result()
	if err != nil {
		t.Fatalf("values kv HGetAll error = %v", err)
	}
	if dataUnitsKV["gb32960.vehicle.soc_percent"] != "88" {
		t.Fatalf("data_units kv = %#v", dataUnitsKV)
	}
	typeKV, err := repo.client.HGetAll(ctx, "vehicle:rt-kv:GB32960:VIN001:types").Result()
	if err != nil {
		t.Fatalf("types kv HGetAll error = %v", err)
	}
	if typeKV["gb32960.vehicle.soc_percent"] != "number" {
		t.Fatalf("types kv = %#v", typeKV)
	}
	timeKV, err := repo.client.HGetAll(ctx, realtimeKVTimesKey(envelope.ProtocolGB32960, "VIN001")).Result()
	if err != nil {
		t.Fatalf("times kv HGetAll error = %v", err)
	}
	if timeKV["gb32960.vehicle.soc_percent"] != "1000" {
		t.Fatalf("times kv = %#v", timeKV)
	}
	if ttl := repo.client.TTL(ctx, "vehicle:rt-kv:GB32960:VIN001:values").Val(); ttl != -1 {
		t.Fatalf("kv ttl should not expire, got %v", ttl)
	}
	if ttl := repo.client.TTL(ctx, realtimeKVTimesKey(envelope.ProtocolGB32960, "VIN001")).Val(); ttl != -1 {
		t.Fatalf("kv times ttl should not expire, got %v", ttl)
	}
	if ttl := repo.client.TTL(ctx, "vehicle:online:GB32960:VIN001").Val(); ttl <= 0 || ttl > time.Minute {
		t.Fatalf("online ttl = %v, want within 1 minute", ttl)
	}
	state, err := repo.client.HGetAll(ctx, "vehicle:online-state:GB32960:VIN001").Result()
	if err != nil {
		t.Fatalf("online state HGetAll error = %v", err)
	}
	if state["online"] != "true" || state["last_seen_ms"] != "1100" || state["offline_after_ms"] != "61100" {
		t.Fatalf("online state = %#v", state)
	}
	if repo.client.ZScore(ctx, "vehicle:last_seen", "GB32960:VIN001").Err() != nil {
		t.Fatal("last_seen should index protocol+vin")
	}
	if repo.client.SIsMember(ctx, "vehicle:rt-index:GB32960", "VIN001").Val() != true {
		t.Fatal("rt-index should contain vin by protocol")
	}
	if repo.client.Exists(ctx, "vehicle:latest:VIN001").Val() != 0 {
		t.Fatal("fast update should not write merged snapshot")
	}
}

func TestRepositoryFastUpdateDoesNotLetOlderFramesOverwriteNewerFields(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	firstResult, err := repo.FastUpdateWithResult(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		MessageID:    "0x0200",
		VIN:          "VIN001",
		EventTimeMS:  2000,
		ReceivedAtMS: 2100,
		ParsedFields: map[string]any{
			"jt808.location.speed_kmh": 50,
		},
	})
	if err != nil {
		t.Fatalf("newer FastUpdate() error = %v", err)
	}
	if firstResult.FieldsSeen != 1 || firstResult.FieldsWritten != 1 || firstResult.FieldsSkippedStale != 0 {
		t.Fatalf("newer result = %#v, want 1/1/0", firstResult)
	}
	if firstResult.EnvelopesSeen != 1 || firstResult.EnvelopesUpdated != 1 {
		t.Fatalf("newer envelope result = %#v, want seen=1 updated=1", firstResult)
	}
	secondResult, err := repo.FastUpdateWithResult(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		MessageID:    "0x0200",
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 2200,
		ParsedFields: map[string]any{
			"jt808.location.speed_kmh": 10,
			"jt808.location.latitude":  30.5,
		},
	})
	if err != nil {
		t.Fatalf("older FastUpdate() error = %v", err)
	}
	if secondResult.FieldsSeen != 2 || secondResult.FieldsWritten != 1 || secondResult.FieldsSkippedStale != 1 {
		t.Fatalf("older result = %#v, want 2/1/1", secondResult)
	}
	if secondResult.EnvelopesSeen != 1 || secondResult.EnvelopesUpdated != 1 {
		t.Fatalf("older envelope result = %#v, want seen=1 updated=1", secondResult)
	}

	values, err := repo.client.HGetAll(ctx, realtimeKVValuesKey(envelope.ProtocolJT808, "VIN001")).Result()
	if err != nil {
		t.Fatalf("values HGetAll error = %v", err)
	}
	if values["jt808.location.speed_kmh"] != "50" {
		t.Fatalf("older frame overwrote speed: %#v", values)
	}
	if values["jt808.location.latitude"] != "30.5" {
		t.Fatalf("older frame should still add previously unseen latitude: %#v", values)
	}
	times, err := repo.client.HGetAll(ctx, realtimeKVTimesKey(envelope.ProtocolJT808, "VIN001")).Result()
	if err != nil {
		t.Fatalf("times HGetAll error = %v", err)
	}
	if times["jt808.location.speed_kmh"] != "2000" || times["jt808.location.latitude"] != "1000" {
		t.Fatalf("field times = %#v", times)
	}
	meta, err := repo.client.HGetAll(ctx, realtimeKVMetaKey(envelope.ProtocolJT808, "VIN001")).Result()
	if err != nil {
		t.Fatalf("meta HGetAll error = %v", err)
	}
	if meta["event_time_ms"] != "2000" {
		t.Fatalf("meta should keep latest frame time: %#v", meta)
	}
}

func TestRepositoryFastUpdateDoesNotLetOlderReceivedFrameOverwriteOnlineState(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.FastUpdate(ctx, envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		MessageID:      "0x0200",
		VIN:            "VIN001",
		SourceEndpoint: "newer.example:808",
		EventTimeMS:    2000,
		ReceivedAtMS:   5000,
		ParsedFields: map[string]any{
			"jt808.location.speed_kmh": 50,
		},
	}); err != nil {
		t.Fatalf("newer FastUpdate() error = %v", err)
	}
	if err := repo.FastUpdate(ctx, envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		MessageID:      "0x0200",
		VIN:            "VIN001",
		SourceEndpoint: "older.example:808",
		EventTimeMS:    1000,
		ReceivedAtMS:   4000,
		ParsedFields: map[string]any{
			"jt808.location.latitude": 30.5,
		},
	}); err != nil {
		t.Fatalf("older FastUpdate() error = %v", err)
	}

	state, err := repo.client.HGetAll(ctx, "vehicle:online-state:JT808:VIN001").Result()
	if err != nil {
		t.Fatalf("online state HGetAll error = %v", err)
	}
	if state["last_seen_ms"] != "5000" || state["offline_after_ms"] != "65000" || state["source_endpoint"] != "newer.example:808" {
		t.Fatalf("older received frame should not overwrite online state: %#v", state)
	}
	score, err := repo.client.ZScore(ctx, "vehicle:last_seen", "JT808:VIN001").Result()
	if err != nil {
		t.Fatalf("last_seen ZScore error = %v", err)
	}
	if score != 5000 {
		t.Fatalf("last_seen score = %v, want 5000", score)
	}
	status, err := repo.IsOnline(ctx, "VIN001")
	if err != nil {
		t.Fatalf("IsOnline() error = %v", err)
	}
	if status.LastSeenMS != 5000 || status.SourceEndpoint != "newer.example:808" {
		t.Fatalf("online status should keep newer received frame: %#v", status)
	}
}

func TestRepositoryFastUpdateBatchWritesMultipleRealtimeProjections(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	result, err := repo.FastUpdateBatchWithResult(ctx, []envelope.FrameEnvelope{
		{
			Protocol:     envelope.ProtocolGB32960,
			VIN:          "VIN001",
			EventTimeMS:  1000,
			ReceivedAtMS: 1100,
			Parsed: map[string]any{
				"data_units": []any{map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 88.0}}},
			},
			ParsedFields: map[string]any{
				"gb32960.vehicle.type":        "0x01",
				"gb32960.vehicle.soc_percent": 88.0,
			},
		},
		{
			Protocol:     envelope.ProtocolJT808,
			VIN:          "VIN002",
			EventTimeMS:  2000,
			ReceivedAtMS: 2200,
			MessageID:    "0x0200",
			Parsed:       map[string]any{"location": map[string]any{"longitude": 121.1, "latitude": 30.2}},
			ParsedFields: map[string]any{"jt808.location.longitude": 121.1, "jt808.location.latitude": 30.2},
			Fields:       map[string]any{envelope.FieldLongitude: 121.1, envelope.FieldLatitude: 30.2},
		},
	})
	if err != nil {
		t.Fatalf("FastUpdateBatch() error = %v", err)
	}
	if result.FieldsSeen != 4 || result.FieldsWritten != 4 || result.FieldsSkippedStale != 0 ||
		result.EnvelopesSeen != 2 || result.EnvelopesUpdated != 2 {
		t.Fatalf("FastUpdateBatchWithResult() result = %#v, want fields 4/4/0 and envelopes 2/2", result)
	}

	gbValues, err := repo.client.HGetAll(ctx, "vehicle:rt-kv:GB32960:VIN001:values").Result()
	if err != nil {
		t.Fatalf("gb values HGetAll error = %v", err)
	}
	if gbValues["gb32960.vehicle.soc_percent"] != "88" {
		t.Fatalf("gb values = %#v", gbValues)
	}
	jtValues, err := repo.client.HGetAll(ctx, "vehicle:rt-kv:JT808:VIN002:values").Result()
	if err != nil {
		t.Fatalf("jt values HGetAll error = %v", err)
	}
	if jtValues["jt808.location.longitude"] != "121.1" || jtValues["jt808.location.latitude"] != "30.2" {
		t.Fatalf("jt values = %#v", jtValues)
	}
	if repo.client.ZScore(ctx, "vehicle:last_seen", "GB32960:VIN001").Err() != nil {
		t.Fatal("last_seen should contain GB32960 VIN001")
	}
	if repo.client.ZScore(ctx, "vehicle:last_seen", "JT808:VIN002").Err() != nil {
		t.Fatal("last_seen should contain JT808 VIN002")
	}
}

func TestRepositoryFastUpdateReportsSkippedEnvelopesWithoutWritingRedis(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	result, err := repo.FastUpdateBatchWithResult(ctx, []envelope.FrameEnvelope{
		{
			Protocol:     envelope.ProtocolGB32960,
			MessageID:    "0x01",
			VIN:          "VIN001",
			EventTimeMS:  1000,
			ReceivedAtMS: 1000,
			Parsed:       map[string]any{"platform_login": map[string]any{"username": "Hyundai"}},
		},
		{
			Protocol:     envelope.ProtocolJT808,
			MessageID:    "0x0200",
			EventTimeMS:  2000,
			ReceivedAtMS: 2000,
			ParsedFields: map[string]any{"jt808.location.speed_kmh": 30},
		},
		{
			Protocol:     envelope.ProtocolJT808,
			MessageID:    "0x0200",
			VIN:          "VIN002",
			EventTimeMS:  3000,
			ReceivedAtMS: 3000,
			Parsed:       map[string]any{"location": map[string]any{"speed_kmh": 30}},
		},
	})
	if err != nil {
		t.Fatalf("FastUpdateBatchWithResult() error = %v", err)
	}
	if result.EnvelopesSeen != 3 || result.EnvelopesUpdated != 0 ||
		result.EnvelopesSkippedNonRealtime != 1 || result.EnvelopesSkippedMissingVIN != 1 ||
		result.EnvelopesSkippedMissingFields != 1 {
		t.Fatalf("result = %#v, want non-realtime, missing vin and missing parsed fields skips", result)
	}
	if result.FieldsSeen != 0 || result.FieldsWritten != 0 || result.FieldsSkippedStale != 0 {
		t.Fatalf("field result = %#v, want no field writes", result)
	}
	if repo.client.Exists(ctx, "vehicle:rt-kv:GB32960:VIN001:values").Val() != 0 {
		t.Fatal("non-realtime frame should not write realtime kv")
	}
	if repo.client.Exists(ctx, "vehicle:online:JT808:VIN002").Val() != 0 {
		t.Fatal("missing parsed fields frame should not mark vehicle online")
	}
}

func TestRepositoryListsOnlineStatusesAndPipelineSummary(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	for _, env := range []envelope.FrameEnvelope{
		{
			Protocol:     envelope.ProtocolGB32960,
			VIN:          "VIN001",
			EventTimeMS:  1000,
			ReceivedAtMS: 1100,
			Parsed: map[string]any{
				"data_units": []any{map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 88.0}}},
			},
			ParsedFields: map[string]any{"gb32960.vehicle.soc_percent": 88.0},
		},
		{
			Protocol:     envelope.ProtocolJT808,
			VIN:          "VIN002",
			EventTimeMS:  2000,
			ReceivedAtMS: 2200,
			MessageID:    "0x0200",
			Parsed:       map[string]any{"location": map[string]any{"longitude": 121.1, "latitude": 30.2}},
			ParsedFields: map[string]any{"jt808.location.longitude": 121.1, "jt808.location.latitude": 30.2},
			Fields:       map[string]any{envelope.FieldLongitude: 121.1, envelope.FieldLatitude: 30.2},
		},
	} {
		if err := repo.FastUpdate(ctx, env); err != nil {
			t.Fatalf("FastUpdate() error = %v", err)
		}
	}

	items, total, err := repo.ListOnline(ctx, OnlineListQuery{Protocol: envelope.ProtocolJT808, Limit: 10})
	if err != nil {
		t.Fatalf("ListOnline() error = %v", err)
	}
	if total != 1 || len(items) != 1 || items[0].VIN != "VIN002" || !items[0].Online {
		t.Fatalf("jt808 online list total=%d items=%#v", total, items)
	}

	summary, err := repo.PipelineSummary(ctx)
	if err != nil {
		t.Fatalf("PipelineSummary() error = %v", err)
	}
	got := map[envelope.Protocol]ProtocolPipelineSummary{}
	for _, item := range summary.Protocols {
		got[item.Protocol] = item
	}
	if got[envelope.ProtocolGB32960].IndexedCount != 1 || got[envelope.ProtocolGB32960].OnlineCount != 1 || got[envelope.ProtocolGB32960].LatestSeenMS != 1100 {
		t.Fatalf("gb32960 summary = %#v", got[envelope.ProtocolGB32960])
	}
	if got[envelope.ProtocolJT808].IndexedCount != 1 || got[envelope.ProtocolJT808].OnlineCount != 1 || got[envelope.ProtocolJT808].LatestSeenMS != 2200 {
		t.Fatalf("jt808 summary = %#v", got[envelope.ProtocolJT808])
	}
}

func TestOnlineIndexHandlerReturnsPagedOnlineStatuses(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.FastUpdate(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolGB32960,
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		Parsed: map[string]any{
			"data_units": []any{map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 88.0}}},
		},
		ParsedFields: map[string]any{"gb32960.vehicle.soc_percent": 88.0},
	}); err != nil {
		t.Fatalf("FastUpdate() error = %v", err)
	}

	handler := NewOnlineIndexHandler(repo)
	request := httptest.NewRequest(http.MethodGet, "/api/realtime/online?protocol=gb32960&limit=5", nil)
	response := httptest.NewRecorder()
	handler.ServeHTTP(response, request)

	if response.Code != http.StatusOK {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
	body := response.Body.String()
	for _, want := range []string{`"protocol":"GB32960"`, `"vin":"VIN001"`, `"online":true`, `"total":1`} {
		if !strings.Contains(body, want) {
			t.Fatalf("response missing %s: %s", want, body)
		}
	}
}

func TestPipelineDebugHandlerReturnsProtocolSummary(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.FastUpdate(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolYutongMQTT,
		VIN:          "VIN003",
		EventTimeMS:  3000,
		ReceivedAtMS: 3300,
		Parsed:       map[string]any{"data": map[string]any{"TOTAL_MILEAGE": 1}},
		ParsedFields: map[string]any{"yutong_mqtt.data.total_mileage": 1},
		Fields:       map[string]any{envelope.FieldTotalMileageKM: 1},
	}); err != nil {
		t.Fatalf("FastUpdate() error = %v", err)
	}

	handler := NewPipelineDebugHandler(repo)
	request := httptest.NewRequest(http.MethodGet, "/api/debug/pipeline", nil)
	response := httptest.NewRecorder()
	handler.ServeHTTP(response, request)

	if response.Code != http.StatusOK {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
	body := response.Body.String()
	for _, want := range []string{`"protocol":"YUTONG_MQTT"`, `"indexed_count":1`, `"online_count":1`, `"latest_seen_ms":3300`} {
		if !strings.Contains(body, want) {
			t.Fatalf("response missing %s: %s", want, body)
		}
	}
}

func TestRepositoryReplacesJT808LocationWhenUpdatingRealtimeRaw(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		MessageID:    "0x0200",
		Parsed: map[string]any{
			"header": map[string]any{
				"message_id":    "0x0200",
				"phone_raw":     "013307795425",
				"phone_bcd_hex": "013307795425",
			},
			"location": map[string]any{
				"additional": []any{
					map[string]any{"id": "0x01", "parsed": map[string]any{"raw_value_tenth_km": 102412}},
				},
				"total_mileage_km": 10241.2,
			},
		},
		Fields: map[string]any{envelope.FieldTotalMileageKM: 10241.2},
	}); err != nil {
		t.Fatalf("Update() initial error = %v", err)
	}
	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		VIN:          "VIN001",
		EventTimeMS:  2000,
		ReceivedAtMS: 2100,
		MessageID:    "0x0200",
		Parsed: map[string]any{
			"header": map[string]any{
				"message_id": "0x0200",
				"phone":      "13307795425",
			},
			"location": map[string]any{
				"total_mileage_km":        10241.3,
				"network_signal_strength": byte(31),
			},
		},
		Fields: map[string]any{envelope.FieldTotalMileageKM: 10241.3},
	}); err != nil {
		t.Fatalf("Update() replacement error = %v", err)
	}

	realtimeRaw, err := repo.GetRealtimeRaw(ctx, "VIN001", envelope.ProtocolJT808)
	if err != nil {
		t.Fatalf("GetRealtimeRaw() error = %v", err)
	}
	header := realtimeRaw["header"].(map[string]any)
	if _, ok := header["phone_raw"]; ok {
		t.Fatalf("header should be replaced, got %#v", header)
	}
	location := realtimeRaw["location"].(map[string]any)
	if _, ok := location["additional"]; ok {
		t.Fatalf("jt808 location should be replaced, got %#v", location)
	}
	if location["total_mileage_km"] != float64(10241.3) {
		t.Fatalf("mileage = %#v", location["total_mileage_km"])
	}
}

func TestRepositoryOnlineStatus(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	status, err := repo.IsOnline(ctx, "VIN001")
	if err != nil {
		t.Fatalf("IsOnline() error = %v", err)
	}
	if status.Online {
		t.Fatal("empty vin should be offline")
	}

	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		Fields:       map[string]any{envelope.FieldSpeedKMH: 12.3},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	status, err = repo.IsOnline(ctx, "VIN001")
	if err != nil {
		t.Fatalf("IsOnline() error = %v", err)
	}
	if !status.Online || status.LastSeenMS != 1100 {
		t.Fatalf("online status = %#v", status)
	}
}

func TestRepositoryUsesEnvelopeParsedFieldsForRealtimeKV(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		MessageID:    "0x0200",
		Parsed: map[string]any{
			"location": map[string]any{"speed_kmh": 99},
		},
		ParsedFields: map[string]any{
			"jt808.location.speed_kmh": "12.3",
		},
		Fields: map[string]any{envelope.FieldSpeedKMH: 12.3},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	values, err := repo.client.HGetAll(ctx, realtimeKVValuesKey(envelope.ProtocolJT808, "VIN001")).Result()
	if err != nil {
		t.Fatalf("HGetAll() error = %v", err)
	}
	if got, want := values["jt808.location.speed_kmh"], "12.3"; got != want {
		t.Fatalf("speed kv = %#v, want %q; all=%#v", got, want, values)
	}
}

func TestRepositoryDropsNonPositiveTotalMileageFromRealtimeKV(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		MessageID:    "0x0200",
		ParsedFields: map[string]any{
			"jt808.location.latitude":         "31.259555",
			"jt808.location.longitude":        "119.892413",
			"jt808.location.total_mileage_km": "0",
		},
		Fields: map[string]any{
			envelope.FieldLatitude:       31.259555,
			envelope.FieldLongitude:      119.892413,
			envelope.FieldTotalMileageKM: 0,
		},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	values, err := repo.client.HGetAll(ctx, realtimeKVValuesKey(envelope.ProtocolJT808, "VIN001")).Result()
	if err != nil {
		t.Fatalf("HGetAll() error = %v", err)
	}
	if _, exists := values["jt808.location.total_mileage_km"]; exists {
		t.Fatalf("realtime kv should drop non-positive mileage: %#v", values)
	}
	if values["jt808.location.latitude"] != "31.259555" {
		t.Fatalf("realtime kv should keep valid fields: %#v", values)
	}
}

func TestRepositoryDropsNonPositiveRawTotalMileageFromRealtimeKV(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolYutongMQTT,
		VIN:          "LMRKH9AC3R1004101",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		MessageID:    "MQTT",
		Parsed: map[string]any{
			"data": map[string]any{
				"LATITUDE":      30.590921,
				"LONGITUDE":     121.075044,
				"TOTAL_MILEAGE": 0,
			},
		},
		ParsedFields: map[string]any{
			"yutong_mqtt.data.latitude":      30.590921,
			"yutong_mqtt.data.longitude":     121.075044,
			"yutong_mqtt.data.total_mileage": 0,
		},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	values, err := repo.client.HGetAll(ctx, realtimeKVValuesKey(envelope.ProtocolYutongMQTT, "LMRKH9AC3R1004101")).Result()
	if err != nil {
		t.Fatalf("HGetAll() error = %v", err)
	}
	if _, exists := values["yutong_mqtt.data.total_mileage"]; exists {
		t.Fatalf("realtime kv should drop non-positive raw mileage: %#v", values)
	}
	if values["yutong_mqtt.data.latitude"] != "30.590921" {
		t.Fatalf("realtime kv should keep valid fields: %#v", values)
	}
}

func TestRepositorySkipsFramesWithoutVIN(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		Phone:        "13307811170",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		Fields: map[string]any{
			envelope.FieldSpeedKMH:       23.0,
			envelope.FieldTotalMileageKM: 10003.7,
		},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	if _, err := repo.GetMerged(ctx, "JT808:13307811170"); err == nil {
		t.Fatal("phone-only frame should not create a merged realtime snapshot")
	}
	if _, err := repo.GetRealtimeRaw(ctx, "JT808:13307811170", envelope.ProtocolJT808); err == nil {
		t.Fatal("phone-only frame should not create realtime-raw")
	}
	status, err := repo.IsOnline(ctx, "JT808:13307811170")
	if err != nil {
		t.Fatalf("IsOnline() error = %v", err)
	}
	if status.Online {
		t.Fatalf("phone-only frame should not mark realtime online: %#v", status)
	}
}

func TestRepositoryDoesNotOverwritePositiveMileageWithZero(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	ctx := context.Background()

	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		Fields: map[string]any{
			envelope.FieldTotalMileageKM: 12345.6,
		},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}
	if err := repo.Update(ctx, envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		VIN:          "VIN001",
		EventTimeMS:  2000,
		ReceivedAtMS: 2100,
		Fields: map[string]any{
			envelope.FieldSpeedKMH:       22.0,
			envelope.FieldTotalMileageKM: 0,
		},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	merged, err := repo.GetMerged(ctx, "VIN001")
	if err != nil {
		t.Fatalf("GetMerged() error = %v", err)
	}
	if merged.Fields[envelope.FieldTotalMileageKM] != 12345.6 {
		t.Fatalf("zero mileage overwrote positive value: %#v", merged.Fields)
	}
	if merged.Fields[envelope.FieldSpeedKMH] != 22.0 {
		t.Fatalf("new speed should still merge: %#v", merged.Fields)
	}
}

func TestHandlerReturnsMergedSnapshot(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	if err := repo.Update(context.Background(), envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		VIN:            "VIN001",
		SourceEndpoint: "115.231.168.135:43625",
		EventTimeMS:    1000,
		ReceivedAtMS:   1100,
		Fields:         map[string]any{envelope.FieldSpeedKMH: 12.3},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles/VIN001", nil)
	rec := httptest.NewRecorder()
	NewHandler(repo).ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
	}
	if !stringsContains(rec.Body.String(), `"vin":"VIN001"`) {
		t.Fatalf("unexpected body: %s", rec.Body.String())
	}
	if stringsContains(rec.Body.String(), "source_endpoint") {
		t.Fatalf("realtime snapshot should not expose source_endpoint: %s", rec.Body.String())
	}
}

func TestHandlerReturnsNotFoundForPhoneOnlyVehicleKey(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	if err := repo.Update(context.Background(), envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolJT808,
		Phone:        "13307811170",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		Fields:       map[string]any{envelope.FieldSpeedKMH: 12.3},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles/JT808:13307811170", nil)
	rec := httptest.NewRecorder()
	NewHandler(repo).ServeHTTP(rec, req)
	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
	}
}

func TestHandlerReturnsRealtimeRaw(t *testing.T) {
	repo, closeFn := newTestRepository(t)
	defer closeFn()
	if err := repo.Update(context.Background(), envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolGB32960,
		VIN:          "VIN001",
		EventTimeMS:  1000,
		ReceivedAtMS: 1100,
		Parsed: map[string]any{
			"data_units": []any{
				map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 90.0}},
			},
		},
	}); err != nil {
		t.Fatalf("Update() error = %v", err)
	}

	req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles/VIN001/realtime-raw/GB32960", nil)
	rec := httptest.NewRecorder()
	NewHandler(repo).ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
	}
	if !stringsContains(rec.Body.String(), `"soc_percent":90`) {
		t.Fatalf("unexpected body: %s", rec.Body.String())
	}
}

func newTestRepository(t *testing.T) (*Repository, func()) {
	t.Helper()
	server, err := miniredis.Run()
	if err != nil {
		t.Fatalf("miniredis.Run() error = %v", err)
	}
	client := redis.NewClient(&redis.Options{Addr: server.Addr()})
	return NewRepository(client, Config{OnlineTTL: time.Minute}), func() {
		_ = client.Close()
		server.Close()
	}
}

func stringsContains(value string, pattern string) bool {
	return strings.Contains(value, pattern)
}

func protocolNames(protocols []envelope.Protocol) []string {
	names := make([]string, 0, len(protocols))
	for _, protocol := range protocols {
		names = append(names, string(protocol))
	}
	return names
}
