package identity

import (
	"context"
	"database/sql"
	"errors"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/DATA-DOG/go-sqlmock"

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

func TestCandidateKeysExcludeDeviceIDForBindingLookup(t *testing.T) {
	keys := CandidateKeys(envelope.FrameEnvelope{
		Phone:          "013307795425",
		DeviceID:       "D1",
		Plate:          "豫A12345",
		VehicleKeyHint: "D1",
	})
	got := []string{}
	for _, key := range keys {
		got = append(got, key.Column+"="+key.Value)
	}
	want := []string{"phone=13307795425", "plate=豫A12345", "vin=D1"}
	if len(got) != len(want) {
		t.Fatalf("candidate count = %d got=%#v", len(got), got)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("candidate[%d] = %q, want %q", i, got[i], want[i])
		}
	}
}

func TestCandidateKeysNormalizesPhone(t *testing.T) {
	keys := CandidateKeys(envelope.FrameEnvelope{
		Phone: "013079963379",
	})
	got := []string{}
	for _, key := range keys {
		got = append(got, key.Column+"="+key.Value)
	}
	want := []string{"phone=13079963379"}
	if len(got) != len(want) {
		t.Fatalf("candidate count = %d got=%#v", len(got), got)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("candidate[%d] = %q, want %q", i, got[i], want[i])
		}
	}
}

func TestMySQLResolverFillsVINFromPhone(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000001")

	resolver := NewMySQLResolver(db, "vehicle_identity_binding")
	env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol: envelope.ProtocolJT808,
		Phone:    "013307795425",
		Parsed:   map[string]any{},
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	if env.VIN != "LNBVIN00000000001" {
		t.Fatalf("vin = %q", env.VIN)
	}
	identity, ok := env.Parsed["identity"].(map[string]any)
	if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
		t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverUsesDataSourceCodeForJT808IdentifierLookup(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	expectSourceCodeHit(mock, "115.231.168.135", "xinda")
	expectVehicleIdentifierHitForSource(mock, "xinda", "JT808_PHONE", "13307795425", "LNBVIN00000000001")

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		SourceCodeLookup: true,
		LookupCacheTTL:   time.Hour,
	})
	env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		Phone:          "013307795425",
		SourceEndpoint: "115.231.168.135:43625",
		Parsed:         map[string]any{},
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	if env.VIN != "LNBVIN00000000001" {
		t.Fatalf("vin = %q", env.VIN)
	}
	if env.SourceCode != "xinda" || env.PlatformName != "G7s" || env.SourceKind != "PLATFORM" {
		t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
	}
	identity, ok := env.Parsed["identity"].(map[string]any)
	if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.xinda" {
		t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
	}
	if identity["source_code"] != "xinda" || identity["platform_name"] != "G7s" || identity["source_kind"] != "PLATFORM" {
		t.Fatalf("identity source metadata = %#v", identity)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverVehicleIdentifierSourceOverridesDataSourceCode(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	mock.ExpectQuery("SELECT source_code").
		WithArgs("JT808", "115.159.85.149").
		WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow("dongfang_beidou", "G7易流", "PLATFORM"))
	expectVehicleIdentifierMissForSource(mock, "dongfang_beidou", "JT808_PHONE", "64341232682")
	expectVehicleIdentifierHitWithSource(mock, "JT808_PHONE", "64341232682", "LB9A32A23R0LS1045", "g7s", "G7s")

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		SourceCodeLookup: true,
		LookupCacheTTL:   time.Hour,
	})
	env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		Phone:          "064341232682",
		SourceEndpoint: "115.159.85.149:42823",
		Parsed:         map[string]any{},
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	if env.VIN != "LB9A32A23R0LS1045" {
		t.Fatalf("vin = %q", env.VIN)
	}
	if env.SourceCode != "g7s" || env.PlatformName != "G7s" || env.SourceKind != "PLATFORM" {
		t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
	}
	identity, ok := env.Parsed["identity"].(map[string]any)
	if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.g7s" {
		t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
	}
	if identity["source_code"] != "g7s" || identity["platform_name"] != "G7s" || identity["source_kind"] != "PLATFORM" {
		t.Fatalf("identity source metadata = %#v", identity)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverUsesStaleIdentifierCacheWhenLookupFails(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000001")

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		LookupCacheTTL: time.Nanosecond,
		StaleLookupTTL: time.Hour,
	})
	first, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol: envelope.ProtocolJT808,
		Phone:    "013307795425",
		Parsed:   map[string]any{},
	})
	if err != nil {
		t.Fatalf("first Resolve() error = %v", err)
	}
	if first.VIN != "LNBVIN00000000001" {
		t.Fatalf("first vin = %q", first.VIN)
	}

	time.Sleep(time.Millisecond)
	mock.ExpectQuery("SELECT DISTINCT vin").
		WithArgs("JT808", "JT808_PHONE", "13307795425").
		WillReturnError(errors.New("mysql temporarily unavailable"))
	second, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol: envelope.ProtocolJT808,
		Phone:    "013307795425",
		Parsed:   map[string]any{},
	})
	if err != nil {
		t.Fatalf("second Resolve() error = %v", err)
	}
	if second.VIN != "LNBVIN00000000001" {
		t.Fatalf("second vin = %q, want stale cached vin", second.VIN)
	}
	identity, ok := second.Parsed["identity"].(map[string]any)
	if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
		t.Fatalf("identity metadata = %#v", second.Parsed["identity"])
	}
	if identity["cache_status"] != "stale" {
		t.Fatalf("identity cache_status = %#v, want stale", identity["cache_status"])
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestNormalizeEndpointIPUsesSharedSourceEndpointKey(t *testing.T) {
	tests := map[string]string{
		"115.231.168.135:43625":          "115.231.168.135",
		" 115.159.85.149:28316 ":         "115.159.85.149",
		"mqtt://yutong/ytforward/shln/3": "mqtt",
		"MQTT://YUTONG/topic":            "mqtt",
	}
	for input, want := range tests {
		if got := normalizeEndpointIP(input); got != want {
			t.Fatalf("normalizeEndpointIP(%q) = %q, want %q", input, got, want)
		}
	}
}

func TestMySQLResolverKeepsDirectSourceKindWithoutSourceCode(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	mock.ExpectQuery("SELECT source_code").
		WithArgs("JT808", "39.144.3.22").
		WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow("", "", "DIRECT"))
	expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307765812", "LA9GG64L7PBAF4001")

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		SourceCodeLookup: true,
		LookupCacheTTL:   time.Hour,
	})
	env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		Phone:          "013307765812",
		SourceEndpoint: "39.144.3.22:60177",
		Parsed:         map[string]any{},
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	if env.VIN != "LA9GG64L7PBAF4001" {
		t.Fatalf("vin = %q", env.VIN)
	}
	if env.SourceCode != "" || env.SourceKind != "DIRECT" {
		t.Fatalf("source metadata = code:%q kind:%q", env.SourceCode, env.SourceKind)
	}
	identity, ok := env.Parsed["identity"].(map[string]any)
	if !ok || identity["source_kind"] != "DIRECT" {
		t.Fatalf("identity source metadata = %#v", env.Parsed["identity"])
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverFallsBackToGlobalIdentifierWhenSourceCodeMisses(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	expectSourceCodeHit(mock, "115.231.168.135", "xinda")
	expectVehicleIdentifierMissForSource(mock, "xinda", "JT808_PHONE", "13307795425")
	expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000002")

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		SourceCodeLookup: true,
		LookupCacheTTL:   time.Hour,
	})
	env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		Phone:          "013307795425",
		SourceEndpoint: "115.231.168.135:43625",
		Parsed:         map[string]any{},
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	if env.VIN != "LNBVIN00000000002" {
		t.Fatalf("vin = %q", env.VIN)
	}
	identity, ok := env.Parsed["identity"].(map[string]any)
	if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
		t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverUsesSingleGlobalIdentifierSourceCodeForJT808SourceMetadata(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	expectSourceCodeMiss(mock, "117.132.196.119")
	expectVehicleIdentifierHitWithSource(mock, "JT808_PHONE", "41456413943", "LNXNEGRR0SR321372", "xinda", "信达")

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		SourceCodeLookup: true,
		LookupCacheTTL:   time.Hour,
	})
	env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		Phone:          "41456413943",
		SourceEndpoint: "117.132.196.119:3275",
		Parsed:         map[string]any{},
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	if env.VIN != "LNXNEGRR0SR321372" {
		t.Fatalf("vin = %q", env.VIN)
	}
	if env.SourceCode != "xinda" || env.PlatformName != "信达" || env.SourceKind != "PLATFORM" {
		t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
	}
	identity, ok := env.Parsed["identity"].(map[string]any)
	if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.xinda" {
		t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
	}
	if identity["source_code"] != "xinda" || identity["platform_name"] != "信达" || identity["source_kind"] != "PLATFORM" {
		t.Fatalf("identity source metadata = %#v", identity)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverLooksUpVINByUniqueKeyWithoutSort(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
	mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\? AND vin IS NOT NULL AND vin <> ''$").
		WithArgs("13307795425").
		WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNBVIN00000000001"))

	resolver := NewMySQLResolver(db, "vehicle_identity_binding")
	env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol: envelope.ProtocolJT808,
		Phone:    "013307795425",
		Parsed:   map[string]any{},
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	if env.VIN != "LNBVIN00000000001" {
		t.Fatalf("vin = %q", env.VIN)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverDoesNotLookupBindingByDeviceID(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
	mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
		WithArgs("13307795425").
		WillReturnError(sql.ErrNoRows)

	resolver := NewMySQLResolver(db, "vehicle_identity_binding")
	env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol: envelope.ProtocolJT808,
		Phone:    "013307795425",
		DeviceID: "D1",
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	if env.VIN != "" {
		t.Fatalf("vin = %q", env.VIN)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverTracksJT808RegistrationWithoutWritingBinding(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13079963379")
	mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
		WithArgs("13079963379").
		WillReturnError(sql.ErrNoRows)
	expectVehicleIdentifierMiss(mock, "PLATE", "TEST123")
	mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
		WithArgs("TEST123").
		WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LKLG7C4E3NA774736"))
	mock.ExpectExec("INSERT INTO jt808_registration").
		WillReturnResult(sqlmock.NewResult(1, 1))

	resolver := NewMySQLResolver(db, "vehicle_identity_binding")
	env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		MessageID:      "0x0100",
		Phone:          "013079963379",
		DeviceID:       "DEV0001",
		Plate:          "TEST123",
		SourceEndpoint: "115.231.168.135:43625",
		Parsed: map[string]any{
			"registration": map[string]any{
				"province":     uint16(16),
				"city":         uint16(32),
				"manufacturer": "YUTNG",
				"device_type":  "ZK6105CHEVNPG4",
				"device_id":    "DEV0001",
				"plate_color":  uint8(2),
				"plate":        "TEST123",
			},
		},
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	if env.VIN != "LKLG7C4E3NA774736" {
		t.Fatalf("vin = %q", env.VIN)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverTracksFirstJT808LocationThenThrottles(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
	mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
		WithArgs("13307795425").
		WillReturnError(sql.ErrNoRows)
	mock.ExpectQuery("SELECT vin, device_id, plate FROM jt808_registration WHERE phone = \\?").
		WithArgs("13307795425").
		WillReturnError(sql.ErrNoRows)
	mock.ExpectExec("INSERT INTO jt808_registration").
		WillReturnResult(sqlmock.NewResult(1, 1))

	resolver := NewMySQLResolver(db, "vehicle_identity_binding")
	for i := 0; i < 2; i++ {
		_, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
			Protocol:       envelope.ProtocolJT808,
			MessageID:      "0x0200",
			Phone:          "013307795425",
			SourceEndpoint: "115.231.168.135:43625",
			Parsed:         map[string]any{},
		})
		if err != nil {
			t.Fatalf("Resolve() error = %v", err)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverUsesJT808RegistrationPlateForLocationVIN(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	expectVehicleIdentifierMiss(mock, "JT808_PHONE", "40692934322")
	mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
		WithArgs("40692934322").
		WillReturnError(sql.ErrNoRows)
	mock.ExpectQuery("SELECT vin, device_id, plate FROM jt808_registration WHERE phone = \\?").
		WithArgs("40692934322").
		WillReturnRows(sqlmock.NewRows([]string{"vin", "device_id", "plate"}).AddRow("unknown", "18285", "粤AG18285"))
	expectVehicleIdentifierMiss(mock, "PLATE", "粤AG18285")
	mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
		WithArgs("粤AG18285").
		WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNXNEGRR7SR318212"))
	mock.ExpectExec("INSERT INTO jt808_registration").
		WillReturnResult(sqlmock.NewResult(1, 1))

	resolver := NewMySQLResolver(db, "vehicle_identity_binding")
	env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		MessageID:      "0x0200",
		Phone:          "40692934322",
		SourceEndpoint: "115.231.168.135:47822",
		Parsed:         map[string]any{},
		Fields: map[string]any{
			envelope.FieldLatitude:  30.1,
			envelope.FieldLongitude: 120.1,
		},
	})
	if err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	if env.VIN != "LNXNEGRR7SR318212" {
		t.Fatalf("vin = %q", env.VIN)
	}
	if env.DeviceID != "18285" || env.Plate != "粤AG18285" {
		t.Fatalf("registration identity not copied: device=%q plate=%q", env.DeviceID, env.Plate)
	}
	identity, ok := env.Parsed["identity"].(map[string]any)
	if !ok || identity["source"] != "jt808_registration.plate" {
		t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverRefreshesRegistrationCacheAfterRegisterFrame(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	expectVehicleIdentifierMiss(mock, "JT808_PHONE", "40692934322")
	mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
		WithArgs("40692934322").
		WillReturnError(sql.ErrNoRows)
	mock.ExpectQuery("SELECT vin, device_id, plate FROM jt808_registration WHERE phone = \\?").
		WithArgs("40692934322").
		WillReturnError(sql.ErrNoRows)
	mock.ExpectExec("INSERT INTO jt808_registration").
		WillReturnResult(sqlmock.NewResult(1, 1))
	expectVehicleIdentifierMiss(mock, "PLATE", "粤AG18285")
	mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
		WithArgs("粤AG18285").
		WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNXNEGRR7SR318212"))
	mock.ExpectExec("INSERT INTO jt808_registration").
		WillReturnResult(sqlmock.NewResult(1, 1))

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		LocationTouchInterval: time.Hour,
		LookupCacheTTL:        time.Hour,
	})
	firstLocation, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		MessageID:      "0x0200",
		Phone:          "040692934322",
		SourceEndpoint: "115.231.168.135:47822",
		Parsed:         map[string]any{},
	})
	if err != nil {
		t.Fatalf("first location Resolve() error = %v", err)
	}
	if firstLocation.VIN != "" {
		t.Fatalf("first location vin = %q, want unresolved", firstLocation.VIN)
	}

	registered, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		MessageID:      "0x0100",
		Phone:          "040692934322",
		DeviceID:       "18285",
		Plate:          "粤AG18285",
		SourceEndpoint: "115.231.168.135:47822",
		Parsed: map[string]any{
			"registration": map[string]any{
				"device_id": "18285",
				"plate":     "粤AG18285",
			},
		},
	})
	if err != nil {
		t.Fatalf("registration Resolve() error = %v", err)
	}
	if registered.VIN != "LNXNEGRR7SR318212" {
		t.Fatalf("registered vin = %q", registered.VIN)
	}

	secondLocation, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		MessageID:      "0x0200",
		Phone:          "040692934322",
		SourceEndpoint: "115.231.168.135:47822",
		Parsed:         map[string]any{},
	})
	if err != nil {
		t.Fatalf("second location Resolve() error = %v", err)
	}
	if secondLocation.VIN != "LNXNEGRR7SR318212" {
		t.Fatalf("second location vin = %q, want cache-refreshed registration vin", secondLocation.VIN)
	}
	if secondLocation.DeviceID != "18285" || secondLocation.Plate != "粤AG18285" {
		t.Fatalf("second location identity not copied: device=%q plate=%q", secondLocation.DeviceID, secondLocation.Plate)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverDelegatesRegistrationPersistenceButKeepsLocalSession(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatal(err)
	}
	defer db.Close()
	var results []RegistrationWriteResult
	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		DisableRegistrationWrites: true,
		OnRegistrationWriteResult: func(result RegistrationWriteResult) {
			results = append(results, result)
		},
	})
	env := envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		MessageID:      JT808RegisterMessageID,
		Phone:          "013307795425",
		VIN:            "LTESTVIN000000001",
		DeviceID:       "DEV-1",
		Plate:          "粤A00001",
		SourceEndpoint: "115.231.168.135:43625",
		ParseStatus:    envelope.ParseOK,
	}

	resolved, err := resolver.Resolve(context.Background(), env)
	if err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	if resolved.VIN != env.VIN {
		t.Fatalf("resolved vin = %q, want %q", resolved.VIN, env.VIN)
	}
	entry, ok := resolver.registrationCache["13307795425"]
	if !ok || entry.vin != env.VIN || entry.plate != env.Plate || entry.deviceID != env.DeviceID {
		t.Fatalf("local session = %+v exists=%v", entry, ok)
	}
	if len(results) != 1 || results[0].Mode != "delegated" || results[0].Status != "ok" {
		t.Fatalf("registration write results = %#v", results)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unexpected mysql access: %v", err)
	}
}

func TestMySQLResolverRetriesRegistrationUpsertTransientFailure(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	mock.ExpectExec("INSERT INTO jt808_registration").
		WillReturnError(errors.New("driver: bad connection: read tcp: connection reset by peer"))
	mock.ExpectExec("INSERT INTO jt808_registration").
		WillReturnResult(sqlmock.NewResult(1, 1))

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		LocationTouchInterval:       time.Hour,
		RegistrationWriteAttempts:   2,
		RegistrationWriteRetryDelay: -1,
		LookupCacheTTL:              time.Hour,
	})
	env := envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		MessageID:      "0x0200",
		Phone:          "040692934322",
		VIN:            "LNXNEGRR7SR318212",
		SourceEndpoint: "115.231.168.135:47822",
		Parsed:         map[string]any{},
	}
	if _, err := resolver.Resolve(context.Background(), env); err != nil {
		t.Fatalf("Resolve() error = %v, want internal retry to recover", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverAsyncRegistrationWritesDrainOnClose(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	mock.ExpectExec("INSERT INTO jt808_registration").
		WillReturnResult(sqlmock.NewResult(1, 1))
	results := make(chan RegistrationWriteResult, 2)

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		AsyncRegistrationWrites:    true,
		RegistrationWriteQueueSize: 8,
		RegistrationWriteWorkers:   1,
		RegistrationWriteTimeout:   time.Second,
		OnRegistrationWriteResult: func(result RegistrationWriteResult) {
			results <- result
		},
		LocationTouchInterval: time.Hour,
		LookupCacheTTL:        time.Hour,
	})
	env := envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		MessageID:      "0x0200",
		Phone:          "040692934322",
		VIN:            "LNXNEGRR7SR318212",
		SourceEndpoint: "115.231.168.135:47822",
		Parsed:         map[string]any{},
	}
	if _, err := resolver.Resolve(context.Background(), env); err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	if err := resolver.Close(); err != nil {
		t.Fatalf("Close() error = %v", err)
	}
	close(results)
	got := map[string]int{}
	for result := range results {
		got[result.Mode+":"+result.Status]++
	}
	if got["async_enqueue:ok"] != 1 || got["async_background:ok"] != 1 {
		t.Fatalf("registration write results = %#v, want enqueue/background ok", got)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverAsyncRegistrationWriteFailureMarksLocationRetry(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	mock.ExpectExec("INSERT INTO jt808_registration").
		WillReturnError(errors.New("driver: bad connection"))
	errs := make(chan error, 1)
	results := make(chan RegistrationWriteResult, 2)

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		AsyncRegistrationWrites:    true,
		RegistrationWriteQueueSize: 8,
		RegistrationWriteWorkers:   1,
		RegistrationWriteTimeout:   time.Second,
		OnRegistrationWriteResult: func(result RegistrationWriteResult) {
			results <- result
		},
		OnRegistrationWriteError: func(err error) {
			errs <- err
		},
		LocationTouchInterval:      time.Hour,
		LocationTouchRetryInterval: time.Hour,
		RegistrationWriteAttempts:  1,
		LookupCacheTTL:             time.Hour,
	})
	env := envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		MessageID:      "0x0200",
		Phone:          "040692934322",
		VIN:            "LNXNEGRR7SR318212",
		SourceEndpoint: "115.231.168.135:47822",
		Parsed:         map[string]any{},
	}
	if _, err := resolver.Resolve(context.Background(), env); err != nil {
		t.Fatalf("Resolve() error = %v, async write failure should be reported out-of-band", err)
	}
	if err := resolver.Close(); err != nil {
		t.Fatalf("Close() error = %v", err)
	}
	select {
	case err := <-errs:
		if err == nil {
			t.Fatal("async error callback received nil")
		}
	default:
		t.Fatal("async write failure was not reported")
	}
	stats := resolver.CacheStats()
	if stats.LocationTouchFailureEntries != 1 {
		t.Fatalf("location touch failure cache entries = %d, want 1", stats.LocationTouchFailureEntries)
	}
	close(results)
	got := map[string]int{}
	for result := range results {
		got[result.Mode+":"+result.Status]++
	}
	if got["async_enqueue:ok"] != 1 || got["async_background:error"] != 1 {
		t.Fatalf("registration write results = %#v, want enqueue ok/background error", got)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverBacksOffLocationTouchAfterExhaustedUpsert(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	mock.ExpectExec("INSERT INTO jt808_registration").
		WillReturnError(errors.New("driver: bad connection"))

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		LocationTouchInterval:      time.Hour,
		LocationTouchRetryInterval: time.Hour,
		RegistrationWriteAttempts:  1,
		LookupCacheTTL:             time.Hour,
	})
	env := envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		MessageID:      "0x0200",
		Phone:          "040692934322",
		VIN:            "LNXNEGRR7SR318212",
		SourceEndpoint: "115.231.168.135:47822",
		Parsed:         map[string]any{},
	}
	if _, err := resolver.Resolve(context.Background(), env); err == nil {
		t.Fatal("first Resolve() error = nil, want exhausted transient registration upsert failure")
	}
	stats := resolver.CacheStats()
	if stats.LocationTouchFailureEntries != 1 {
		t.Fatalf("location touch failure cache entries = %d, want 1", stats.LocationTouchFailureEntries)
	}
	if _, err := resolver.Resolve(context.Background(), env); err != nil {
		t.Fatalf("second Resolve() error = %v, want short backoff to skip immediate retry", err)
	}
	stats = resolver.CacheStats()
	if stats.LocationTouchFailureEntries != 1 {
		t.Fatalf("location touch failure cache entries after backoff = %d, want 1", stats.LocationTouchFailureEntries)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestIsTransientMySQLIdentityWriteError(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 !isTransientMySQLIdentityWriteError(err) {
			t.Fatalf("isTransientMySQLIdentityWriteError(%q) = false, want true", err.Error())
		}
	}
	for _, err := range []error{
		context.Canceled,
		context.DeadlineExceeded,
		errors.New("duplicate key conflict"),
		nil,
	} {
		if isTransientMySQLIdentityWriteError(err) {
			t.Fatalf("isTransientMySQLIdentityWriteError(%v) = true, want false", err)
		}
	}
}

func TestTimeoutResolverAppliesDeadlineToDelegate(t *testing.T) {
	delegate := &deadlineCheckingResolver{}
	resolver := TimeoutResolver{Delegate: delegate, Timeout: 50 * time.Millisecond}

	if _, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808}); err != nil {
		t.Fatalf("Resolve() error = %v", err)
	}
	if delegate.deadline.IsZero() {
		t.Fatal("delegate did not receive a deadline")
	}
	if remaining := time.Until(delegate.deadline); remaining <= 0 || remaining > time.Second {
		t.Fatalf("deadline remaining = %s", remaining)
	}
}

func TestMySQLResolverCachesIdentityMissesForHighFrequencyFrames(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
	mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
		WithArgs("13307795425").
		WillReturnError(sql.ErrNoRows)

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		LocationTouchInterval: time.Hour,
		LookupCacheTTL:        time.Hour,
	})
	for i := 0; i < 2; i++ {
		_, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
			Protocol: envelope.ProtocolJT808,
			Phone:    "013307795425",
			Parsed:   map[string]any{},
		})
		if err != nil {
			t.Fatalf("Resolve() error = %v", err)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverBoundsIdentityLookupCaches(t *testing.T) {
	db, _ := newMockDB(t)
	defer db.Close()

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		LookupCacheTTL:        time.Hour,
		CacheCleanupInterval:  time.Hour,
		MaxCacheEntries:       2,
		LocationTouchInterval: time.Hour,
	})
	now := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC)
	for i, key := range []string{"lookup-1", "lookup-2", "lookup-3"} {
		entryNow := now.Add(time.Duration(i) * time.Second)
		resolver.cacheLookup(key, lookupCacheEntry{
			vin:       key,
			expiresAt: entryNow.Add(time.Hour),
		}, entryNow)
		resolver.cacheRegistration("phone-"+key, registrationCacheEntry{
			vin:       key,
			expiresAt: entryNow.Add(time.Hour),
		}, entryNow)
		resolver.cacheSourceMetadata("source-"+key, sourceMetadata{SourceCode: key}, false, entryNow)
	}

	stats := resolver.CacheStats()
	if stats.LookupEntries != 2 || stats.RegistrationEntries != 2 || stats.SourceCodeEntries != 2 || stats.MaxEntries != 2 {
		t.Fatalf("cache stats = %+v, want all identity caches capped at 2", stats)
	}
	resolver.lookupMu.Lock()
	_, hasOldLookup := resolver.lookupCache["lookup-1"]
	_, hasOldRegistration := resolver.registrationCache["phone-lookup-1"]
	_, hasOldSource := resolver.sourceCodeCache["source-lookup-1"]
	resolver.lookupMu.Unlock()
	if hasOldLookup || hasOldRegistration || hasOldSource {
		t.Fatalf("oldest cache entries should be evicted")
	}
}

func TestMySQLResolverBoundsLocationTouchCache(t *testing.T) {
	db, _ := newMockDB(t)
	defer db.Close()

	resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
		LookupCacheTTL:        time.Hour,
		CacheCleanupInterval:  time.Hour,
		MaxCacheEntries:       2,
		LocationTouchInterval: time.Hour,
	})
	now := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC)
	resolver.touchMu.Lock()
	resolver.locationTouches["old-expired"] = now.Add(-2 * time.Hour)
	resolver.locationTouches["phone-1"] = now.Add(-2 * time.Minute)
	resolver.locationTouches["phone-2"] = now.Add(-time.Minute)
	resolver.locationTouches["phone-3"] = now
	resolver.locationTouchFailures["old-failure"] = now.Add(-time.Minute)
	resolver.locationTouchFailures["phone-2"] = now.Add(time.Minute)
	resolver.locationTouchFailures["phone-3"] = now.Add(2 * time.Minute)
	resolver.locationTouchFailures["phone-4"] = now.Add(3 * time.Minute)
	resolver.cleanupLocationTouchesLocked(now, false)
	resolver.touchMu.Unlock()

	stats := resolver.CacheStats()
	if stats.LocationTouchEntries != 2 || stats.LocationTouchFailureEntries != 2 || stats.MaxEntries != 2 {
		t.Fatalf("cache stats = %+v, want location touch caches capped at 2", stats)
	}
	resolver.touchMu.Lock()
	_, hasExpired := resolver.locationTouches["old-expired"]
	_, hasOldest := resolver.locationTouches["phone-1"]
	_, hasExpiredFailure := resolver.locationTouchFailures["old-failure"]
	_, hasOldestFailure := resolver.locationTouchFailures["phone-2"]
	resolver.touchMu.Unlock()
	if hasExpired || hasOldest || hasExpiredFailure || hasOldestFailure {
		t.Fatalf("expired and oldest location touch entries should be evicted")
	}
}

type deadlineCheckingResolver struct {
	mu       sync.Mutex
	deadline time.Time
}

func (r *deadlineCheckingResolver) Resolve(ctx context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
	deadline, _ := ctx.Deadline()
	r.mu.Lock()
	r.deadline = deadline
	r.mu.Unlock()
	return env, nil
}

func TestMySQLResolverEnsuresMinimalIdentitySchema(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identity_binding").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec("ALTER TABLE vehicle_identity_binding ADD COLUMN oem").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP INDEX uk_identity_device").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP COLUMN device_id").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle \\(").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identifier").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec("CREATE TABLE IF NOT EXISTS jt808_registration").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec("ALTER TABLE jt808_registration ADD COLUMN source_ip").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec("ALTER TABLE jt808_registration ADD KEY idx_jt808_registration_source_ip").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec("UPDATE jt808_registration").
		WillReturnResult(sqlmock.NewResult(0, 0))

	resolver := NewMySQLResolver(db, "vehicle_identity_binding")
	if err := resolver.EnsureSchema(context.Background()); err != nil {
		t.Fatalf("EnsureSchema() error = %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMySQLResolverIgnoresExistingOEMColumn(t *testing.T) {
	db, mock := newMockDB(t)
	defer db.Close()
	mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identity_binding").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec("ALTER TABLE vehicle_identity_binding ADD COLUMN oem").
		WillReturnError(errors.New("Error 1060 (42S21): Duplicate column name 'oem'"))
	mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP INDEX uk_identity_device").
		WillReturnError(errors.New("Error 1091 (42000): Can't DROP 'uk_identity_device'; check that column/key exists"))
	mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP COLUMN device_id").
		WillReturnError(errors.New("Error 1091 (42000): Can't DROP 'device_id'; check that column/key exists"))
	mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle \\(").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identifier").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec("CREATE TABLE IF NOT EXISTS jt808_registration").
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec("ALTER TABLE jt808_registration ADD COLUMN source_ip").
		WillReturnError(errors.New("Error 1060 (42S21): Duplicate column name 'source_ip'"))
	mock.ExpectExec("ALTER TABLE jt808_registration ADD KEY idx_jt808_registration_source_ip").
		WillReturnError(errors.New("Error 1061 (42000): Duplicate key name 'idx_jt808_registration_source_ip'"))
	mock.ExpectExec("UPDATE jt808_registration").
		WillReturnResult(sqlmock.NewResult(0, 0))

	resolver := NewMySQLResolver(db, "vehicle_identity_binding")
	if err := resolver.EnsureSchema(context.Background()); err != nil {
		t.Fatalf("EnsureSchema() error = %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestIdentitySchemaUsesBusinessKeysOnly(t *testing.T) {
	binding := identityBindingTableSQL("vehicle_identity_binding")
	registration := jt808RegistrationTableSQL
	for _, sqlText := range []string{binding, registration} {
		for _, column := range []string{"id BIGINT", "AUTO_INCREMENT", "created_at"} {
			if strings.Contains(sqlText, column) {
				t.Fatalf("identity schema should not contain %s:\n%s", column, sqlText)
			}
		}
	}
	if !strings.Contains(binding, "vin VARCHAR(32) PRIMARY KEY") {
		t.Fatalf("binding table should key by vin:\n%s", binding)
	}
	if strings.Contains(binding, "device_id") {
		t.Fatalf("binding table should not contain device_id:\n%s", binding)
	}
	if !strings.Contains(registration, "phone VARCHAR(32) PRIMARY KEY") {
		t.Fatalf("registration table should key by phone:\n%s", registration)
	}
	if !strings.Contains(registration, "source_ip VARCHAR(64)") || !strings.Contains(registration, "idx_jt808_registration_source_ip") {
		t.Fatalf("registration table should keep indexed source_ip:\n%s", registration)
	}
	if !strings.Contains(vehicleIdentifierTableSQL, "PRIMARY KEY (protocol, source_code, identifier_type, identifier_value)") {
		t.Fatalf("vehicle identifier should use protocol/source/type/value as key:\n%s", vehicleIdentifierTableSQL)
	}
	if strings.Contains(vehicleIdentifierTableSQL, "AUTO_INCREMENT") {
		t.Fatalf("vehicle identifier should not use surrogate auto increment id:\n%s", vehicleIdentifierTableSQL)
	}
}

func newMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) {
	t.Helper()
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	return db, mock
}

func expectVehicleIdentifierMiss(mock sqlmock.Sqlmock, identifierType string, value string) {
	mock.ExpectQuery("SELECT DISTINCT vin").
		WithArgs("JT808", identifierType, value).
		WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}))
}

func expectVehicleIdentifierHit(mock sqlmock.Sqlmock, identifierType string, value string, vin string) {
	expectVehicleIdentifierHitWithSource(mock, identifierType, value, vin, "", "")
}

func expectVehicleIdentifierHitWithSource(mock sqlmock.Sqlmock, identifierType string, value string, vin string, sourceCode string, platformName string) {
	mock.ExpectQuery("SELECT DISTINCT vin").
		WithArgs("JT808", identifierType, value).
		WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}).AddRow(vin, sourceCode, platformName))
}

func expectSourceCodeHit(mock sqlmock.Sqlmock, sourceIP string, sourceCode string) {
	mock.ExpectQuery("SELECT source_code").
		WithArgs("JT808", sourceIP).
		WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow(sourceCode, "G7s", "PLATFORM"))
}

func expectSourceCodeMiss(mock sqlmock.Sqlmock, sourceIP string) {
	mock.ExpectQuery("SELECT source_code").
		WithArgs("JT808", sourceIP).
		WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}))
}

func expectVehicleIdentifierMissForSource(mock sqlmock.Sqlmock, sourceCode string, identifierType string, value string) {
	mock.ExpectQuery("SELECT DISTINCT vin").
		WithArgs("JT808", identifierType, value, sourceCode).
		WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}))
}

func expectVehicleIdentifierHitForSource(mock sqlmock.Sqlmock, sourceCode string, identifierType string, value string, vin string) {
	mock.ExpectQuery("SELECT DISTINCT vin").
		WithArgs("JT808", identifierType, value, sourceCode).
		WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}).AddRow(vin, sourceCode, "G7s"))
}
