package main

import (
	"context"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/DATA-DOG/go-sqlmock"
	"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
	"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
)

func TestChooseTrustedSourceKeepsContinuingSourceAndRejectsNewJump(t *testing.T) {
	previous := []dailySourceLast{{
		VIN:            "LA9GG64L7PBAF4001",
		SourceKey:      normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:42630"),
		Phone:          "13307765812",
		SourceEndpoint: "115.231.168.135:42630",
		TotalKM:        4100.8,
	}}
	current := []dailySourceLast{
		{
			VIN:            "LA9GG64L7PBAF4001",
			SourceKey:      normalizedSourceKey("JT808", "13307765812", "", "115.159.85.149:28316"),
			Phone:          "13307765812",
			SourceEndpoint: "115.159.85.149:28316",
			TotalKM:        42447.2,
		},
		{
			VIN:            "LA9GG64L7PBAF4001",
			SourceKey:      normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:20215"),
			Phone:          "13307765812",
			SourceEndpoint: "115.231.168.135:20215",
			TotalKM:        4123.9,
		},
	}

	chosen, ok := chooseTrustedSource(current, previous)
	if !ok {
		t.Fatal("chooseTrustedSource() did not choose a source")
	}
	if chosen.current.SourceKey != normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:20215") {
		t.Fatalf("chosen source = %q", chosen.current.SourceKey)
	}
	if delta := chosen.current.TotalKM - chosen.previous.TotalKM; delta < 23 || delta > 24 {
		t.Fatalf("delta = %v, want about 23.1", delta)
	}
}

func TestChooseTrustedSourceAcceptsSmallNegativeMileageJitter(t *testing.T) {
	previous := []dailySourceLast{{
		VIN:            "LB9A32A28R0LS1574",
		SourceKey:      normalizedSourceKey("JT808", "64115156034", "", "115.159.85.149:53330"),
		Phone:          "64115156034",
		SourceEndpoint: "115.159.85.149:53330",
		TotalKM:        15355.4,
	}}
	current := []dailySourceLast{{
		VIN:            "LB9A32A28R0LS1574",
		SourceKey:      normalizedSourceKey("JT808", "64115156034", "", "115.159.85.149:53338"),
		Phone:          "64115156034",
		SourceEndpoint: "115.159.85.149:53338",
		TotalKM:        15355.3,
	}}

	chosen, ok := chooseTrustedSource(current, previous)
	if !ok {
		t.Fatal("chooseTrustedSource() should accept tiny negative mileage jitter")
	}
	if chosen.current.SourceKey != current[0].SourceKey {
		t.Fatalf("chosen source = %q", chosen.current.SourceKey)
	}
}

func TestChooseTrustedSourceAcceptsPlausibleMultiDayFallbackDelta(t *testing.T) {
	loc := time.FixedZone("Asia/Shanghai", 8*3600)
	sourceKey := normalizedSourceKey("GB32960", "", "", "8.134.95.166:37720")
	previous := []dailySourceLast{{
		VIN:            "LNXNEGRR1SR319498",
		SourceKey:      sourceKey,
		SourceEndpoint: "8.134.95.166:37720",
		TS:             time.Date(2026, 7, 4, 11, 7, 58, 0, loc),
		TotalKM:        8832.1,
	}}
	current := []dailySourceLast{{
		VIN:            "LNXNEGRR1SR319498",
		SourceKey:      sourceKey,
		SourceEndpoint: "8.134.95.166:37720",
		TS:             time.Date(2026, 7, 12, 2, 52, 47, 0, loc),
		TotalKM:        16665.6,
	}}

	chosen, ok := chooseTrustedSource(current, previous)
	if !ok {
		t.Fatal("chooseTrustedSource() should accept delta within the historical baseline window")
	}
	if delta := chosen.current.TotalKM - chosen.previous.TotalKM; delta < 7833.4 || delta > 7833.6 {
		t.Fatalf("delta = %v, want historical gap delta", delta)
	}
}

func TestDailySourceLastBuildsCandidateKeysBySourceIP(t *testing.T) {
	sourceA := dailySourceLast{
		VIN:            "LA9GG64L7PBAF4001",
		SourceKey:      normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:20215"),
		Phone:          "13307765812",
		SourceEndpoint: "115.231.168.135:20215",
		TotalKM:        4123.9,
	}
	sourceB := dailySourceLast{
		VIN:            "LA9GG64L7PBAF4001",
		SourceKey:      normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:42630"),
		Phone:          "13307765812",
		SourceEndpoint: "115.231.168.135:42630",
		TotalKM:        4100.8,
	}
	if sourceA.SourceKey != sourceB.SourceKey {
		t.Fatalf("same source IP should produce same source key: %q vs %q", sourceA.SourceKey, sourceB.SourceKey)
	}
}

func TestAggregateFromDailySourceUsesOlderHistoricalBaseline(t *testing.T) {
	current := dailySourceLast{
		VIN:            "LMRKH9AC2R1004087",
		SourceKey:      normalizedSourceKey("YUTONG_MQTT", "", "LMRKH9AC2R1004087", "mqtt://yutong/ytforward/shln/3"),
		DeviceID:       "LMRKH9AC2R1004087",
		SourceEndpoint: "mqtt://yutong/ytforward/shln/3",
		FirstTS:        time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
		TS:             time.Date(2026, 7, 8, 17, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
		FirstTotalKM:   120778,
		TotalKM:        120788,
		RawSampleCount: 15,
	}
	previous := dailySourceLast{
		VIN:            "LMRKH9AC2R1004087",
		SourceKey:      current.SourceKey,
		DeviceID:       "LMRKH9AC2R1004087",
		SourceEndpoint: "mqtt://yutong/ytforward/shln/3",
		TS:             time.Date(2026, 7, 4, 23, 58, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
		TotalKM:        120672,
	}

	agg := aggregateFromDailySource("2026-07-08", envelope.ProtocolYutongMQTT, current, previous, true)

	if agg.FirstKM != 120672 || agg.LatestKM != 120788 {
		t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
	}
	if agg.FirstEventTime != previous.TS || agg.LatestEventTime != current.TS {
		t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
	}
	if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonHistorical {
		t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
	}
	if agg.Count != 15 {
		t.Fatalf("sample count = %d, want 15", agg.Count)
	}
}

func TestAggregateFromDailySourceRejectsHistoricalBaselineJump(t *testing.T) {
	loc := time.FixedZone("Asia/Shanghai", 8*3600)
	current := dailySourceLast{
		VIN:            "LNXNEGRR6SR319464",
		SourceKey:      normalizedSourceKey("GB32960", "", "", "8.134.95.166:49206"),
		SourceEndpoint: "8.134.95.166:49206",
		FirstTS:        time.Date(2026, 7, 12, 8, 5, 42, 0, loc),
		TS:             time.Date(2026, 7, 12, 10, 44, 56, 0, loc),
		FirstTotalKM:   28004.2,
		TotalKM:        40009.7,
		RawSampleCount: 1938,
	}
	previous := dailySourceLast{
		VIN:            current.VIN,
		SourceKey:      current.SourceKey,
		SourceEndpoint: current.SourceEndpoint,
		TS:             time.Date(2026, 7, 3, 19, 0, 39, 0, loc),
		TotalKM:        10009.7,
	}

	agg := aggregateFromDailySource("2026-07-12", envelope.ProtocolGB32960, current, previous, true)

	if agg.FirstKM != previous.TotalKM || agg.LatestKM != current.TotalKM {
		t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
	}
	if !agg.FirstEventTime.Equal(previous.TS) || !agg.LatestEventTime.Equal(current.TS) {
		t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
	}
	if agg.QualityStatus != stats.QualityInvalidDelta || agg.QualityReason != "outside_daily_range" {
		t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
	}
}

func TestAggregateFromDailySourceUsesCurrentDayFirstWhenHistoryIsEmpty(t *testing.T) {
	current := dailySourceLast{
		VIN:            "LMRKH9AC2R1004087",
		SourceKey:      normalizedSourceKey("YUTONG_MQTT", "", "LMRKH9AC2R1004087", "mqtt://yutong/ytforward/shln/3"),
		DeviceID:       "LMRKH9AC2R1004087",
		SourceEndpoint: "mqtt://yutong/ytforward/shln/3",
		FirstTS:        time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
		TS:             time.Date(2026, 7, 8, 17, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
		FirstTotalKM:   120778,
		TotalKM:        120788,
		RawSampleCount: 15,
	}

	agg := aggregateFromDailySource("2026-07-08", envelope.ProtocolYutongMQTT, current, dailySourceLast{}, false)

	if agg.FirstKM != 120778 || agg.LatestKM != 120788 {
		t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
	}
	if agg.FirstEventTime != current.FirstTS || agg.LatestEventTime != current.TS {
		t.Fatalf("event range = %v -> %v", agg.FirstEventTime, agg.LatestEventTime)
	}
	if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonCurrentDayFirst {
		t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
	}
}

func TestBuildLastDiffAggregatesCarriesNearestHistoryAcrossEmptyDays(t *testing.T) {
	tdDB, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer tdDB.Close()
	mock.MatchExpectationsInOrder(true)

	loc := time.FixedZone("Asia/Shanghai", 8*3600)
	vin := "LMRKH9AC2R1004087"
	endpoint := "mqtt://yutong/ytforward/shln/3"
	dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc)
	dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)
	currentRows := func() *sqlmock.Rows {
		return sqlmock.NewRows([]string{
			"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
		})
	}
	previousRows := func() *sqlmock.Rows {
		return sqlmock.NewRows([]string{
			"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
		})
	}

	mock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
		WillReturnRows(previousRows())
	mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
		WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, int64(4)))
	mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-10 00:00:00'.*protocol = 'YUTONG_MQTT'").
		WillReturnRows(currentRows())
	mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-11 00:00:00'.*protocol = 'YUTONG_MQTT'").
		WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, int64(6)))

	aggregates, err := buildLastDiffAggregates(context.Background(), nil, tdDB, config{
		TDengineDatabase: "lingniu_vehicle_ts",
		DateFrom:         "2026-07-09",
		DateTo:           "2026-07-11",
		Protocols:        []envelope.Protocol{envelope.ProtocolYutongMQTT},
		Location:         loc,
	})
	if err != nil {
		t.Fatalf("buildLastDiffAggregates() error = %v", err)
	}
	sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", vin, "mqtt", "PLATFORM", "yutong")
	agg := aggregates[vin+"|2026-07-11|YUTONG_MQTT|"+sourceKey]
	if agg == nil {
		t.Fatalf("missing day-three aggregate; keys=%v", aggregateKeys(aggregates))
	}
	if agg.FirstKM != 100 || agg.LatestKM != 120 {
		t.Fatalf("km range = %v -> %v, want 100 -> 120", agg.FirstKM, agg.LatestKM)
	}
	if !agg.FirstEventTime.Equal(dayOneTS) || agg.QualityReason != stats.QualityReasonHistorical {
		t.Fatalf("baseline = %v reason=%q, want day-one historical baseline", agg.FirstEventTime, agg.QualityReason)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func aggregateKeys(aggregates map[string]*metricAgg) []string {
	keys := make([]string, 0, len(aggregates))
	for key := range aggregates {
		keys = append(keys, key)
	}
	return keys
}

func TestQueryRealtimeLocationLastRowsBuildsYutongSourceFromPeer(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()

	loc := time.FixedZone("Asia/Shanghai", 8*3600)
	eventTime := time.Date(2026, 7, 12, 5, 54, 50, 0, loc)
	mock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*LEFT JOIN vehicle_realtime_snapshot s").
		WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12").
		WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
			AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", eventTime, 11578.0))

	rows, err := queryRealtimeLocationLastRows(context.Background(), db, config{Location: loc}, envelope.ProtocolYutongMQTT, "2026-07-12")
	if err != nil {
		t.Fatalf("queryRealtimeLocationLastRows() error = %v", err)
	}
	sourceRows := rows["LMRKH9AC0R1004086"]
	if len(sourceRows) != 1 {
		t.Fatalf("rows = %d, want 1", len(sourceRows))
	}
	row := sourceRows[0]
	wantSourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong")
	if row.SourceKey != wantSourceKey {
		t.Fatalf("source key = %q", row.SourceKey)
	}
	if row.SourceCode != "yutong" || row.PlatformName != "宇通" || row.SourceKind != "PLATFORM" {
		t.Fatalf("source metadata = code:%q platform:%q kind:%q", row.SourceCode, row.PlatformName, row.SourceKind)
	}
	if row.TotalKM != 11578 || row.DeviceID != "LMRKH9AC0R1004086" || row.RawSampleCount != 1 {
		t.Fatalf("unexpected realtime fallback row: %#v", row)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing.T) {
	mysqlDB, mysqlMock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("mysql sqlmock.New() error = %v", err)
	}
	defer mysqlDB.Close()
	tdDB, tdMock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("td sqlmock.New() error = %v", err)
	}
	defer tdDB.Close()

	loc := time.FixedZone("Asia/Shanghai", 8*3600)
	currentTS := time.Date(2026, 7, 12, 5, 54, 50, 0, loc)
	previousTS := time.Date(2026, 7, 11, 23, 58, 0, 0, loc)
	mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
		WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12").
		WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
			AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", currentTS, 11578.0))
	tdMock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-12 00:00:00'.*protocol = 'YUTONG_MQTT'.*TOTAL_MILEAGE").
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
		}).AddRow(
			"LMRKH9AC0R1004086",
			"",
			"LMRKH9AC0R1004086",
			"mqtt://yutong/ytforward/shln/4",
			previousTS,
			`{"data":{"TOTAL_MILEAGE":11500000}}`,
			int64(12),
		))

	aggregates := map[string]*metricAgg{}
	added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
		TDengineDatabase: "lingniu_vehicle_ts",
		DateFrom:         "2026-07-12",
		DateTo:           "2026-07-12",
		Protocols:        []envelope.Protocol{envelope.ProtocolYutongMQTT},
		Location:         loc,
	}, aggregates)
	if err != nil {
		t.Fatalf("addRealtimeLocationFallbackAggregates() error = %v", err)
	}
	if added != 1 || len(aggregates) != 1 {
		t.Fatalf("added=%d aggregates=%d", added, len(aggregates))
	}
	for _, agg := range aggregates {
		if agg.FirstKM != 11500 || agg.LatestKM != 11578 {
			t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
		}
		if agg.SourceKey != stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong") {
			t.Fatalf("source key = %q", agg.SourceKey)
		}
		if agg.SourceCode != "yutong" || agg.PlatformName != "宇通" || agg.SourceKind != "PLATFORM" {
			t.Fatalf("source metadata = code:%q platform:%q kind:%q", agg.SourceCode, agg.PlatformName, agg.SourceKind)
		}
		if agg.QualityReason != "realtime_location_fallback_historical_baseline" {
			t.Fatalf("quality reason = %q", agg.QualityReason)
		}
	}
	if err := mysqlMock.ExpectationsWereMet(); err != nil {
		t.Fatalf("mysql sql expectations: %v", err)
	}
	if err := tdMock.ExpectationsWereMet(); err != nil {
		t.Fatalf("td sql expectations: %v", err)
	}
}

func TestAddRealtimeLocationFallbackReusesAggregateHistoryAcrossEmptyDays(t *testing.T) {
	mysqlDB, mysqlMock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("mysql sqlmock.New() error = %v", err)
	}
	defer mysqlDB.Close()
	tdDB, tdMock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("td sqlmock.New() error = %v", err)
	}
	defer tdDB.Close()

	loc := time.FixedZone("Asia/Shanghai", 8*3600)
	vin := "LMRKH9AC2R1004087"
	endpoint := "mqtt://yutong/ytforward/shln/3"
	sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", vin, "mqtt", "PLATFORM", "yutong")
	dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc)
	dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)

	tdMock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
		}))
	for _, date := range []string{"2026-07-09", "2026-07-10"} {
		mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
			WithArgs("YUTONG_MQTT", date, date).
			WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}))
	}
	mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
		WithArgs("YUTONG_MQTT", "2026-07-11", "2026-07-11").
		WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
			AddRow(vin, endpoint, dayThreeTS, 120.0))

	aggregates := map[string]*metricAgg{
		vin + "|2026-07-09|YUTONG_MQTT|" + sourceKey: {
			VIN:             vin,
			Date:            "2026-07-09",
			Protocol:        envelope.ProtocolYutongMQTT,
			LatestKM:        100,
			Count:           4,
			SourceKey:       sourceKey,
			DeviceID:        vin,
			SourceEndpoint:  endpoint,
			SourceCode:      "yutong",
			PlatformName:    "宇通",
			SourceKind:      "PLATFORM",
			LatestEventTime: dayOneTS,
		},
	}
	added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
		TDengineDatabase: "lingniu_vehicle_ts",
		DateFrom:         "2026-07-09",
		DateTo:           "2026-07-11",
		Protocols:        []envelope.Protocol{envelope.ProtocolYutongMQTT},
		Location:         loc,
	}, aggregates)
	if err != nil {
		t.Fatalf("addRealtimeLocationFallbackAggregates() error = %v", err)
	}
	if added != 1 || len(aggregates) != 2 {
		t.Fatalf("added=%d aggregates=%d, want 1 and 2", added, len(aggregates))
	}
	agg := aggregates[vin+"|2026-07-11|YUTONG_MQTT|"+sourceKey]
	if agg == nil {
		t.Fatal("missing realtime-location fallback aggregate")
	}
	if agg.FirstKM != 100 || agg.LatestKM != 120 || !agg.FirstEventTime.Equal(dayOneTS) {
		t.Fatalf("fallback range = %v@%v -> %v, want 100@day-one -> 120", agg.FirstKM, agg.FirstEventTime, agg.LatestKM)
	}
	if agg.QualityReason != "realtime_location_fallback_historical_baseline" {
		t.Fatalf("quality reason = %q", agg.QualityReason)
	}
	if err := mysqlMock.ExpectationsWereMet(); err != nil {
		t.Fatalf("mysql sql expectations: %v", err)
	}
	if err := tdMock.ExpectationsWereMet(); err != nil {
		t.Fatalf("td sql expectations: %v", err)
	}
}

func TestQueryDailyLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()

	loc := time.FixedZone("Asia/Shanghai", 8*3600)
	firstTS := time.Date(2026, 7, 8, 8, 0, 0, 0, loc)
	lastTS := time.Date(2026, 7, 8, 18, 0, 0, 0, loc)
	mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
		}).AddRow(
			"LMRKH9AC6R1004108",
			"",
			"LMRKH9AC6R1004108",
			"mqtt://yutong/ytforward/shln/3",
			firstTS,
			`{"yutong_mqtt.data.total_mileage":"65422000"}`,
			lastTS,
			`{"yutong_mqtt.data.total_mileage":"65423000"}`,
			int64(42),
		))

	rows, err := queryDailyLastSourceRows(context.Background(), db, config{
		TDengineDatabase: "lingniu_vehicle_ts",
		Location:         loc,
	}, envelope.ProtocolYutongMQTT, "2026-07-08")
	if err != nil {
		t.Fatalf("queryDailyLastSourceRows() error = %v", err)
	}
	sourceRows := rows["LMRKH9AC6R1004108"]
	if len(sourceRows) != 1 {
		t.Fatalf("rows = %d, want 1", len(sourceRows))
	}
	if sourceRows[0].FirstTotalKM != 65422 || sourceRows[0].TotalKM != 65423 {
		t.Fatalf("km range = %v -> %v", sourceRows[0].FirstTotalKM, sourceRows[0].TotalKM)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()

	loc := time.FixedZone("Asia/Shanghai", 8*3600)
	lastTS := time.Date(2026, 7, 7, 23, 58, 0, 0, loc)
	mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-08 00:00:00'.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
		}).AddRow(
			"LMRKH9AC6R1004108",
			"",
			"LMRKH9AC6R1004108",
			"mqtt://yutong/ytforward/shln/3",
			lastTS,
			`{"yutong_mqtt.data.total_mileage":"65377000"}`,
			int64(31),
		))

	rows, err := queryPreviousLastSourceRows(context.Background(), db, config{
		TDengineDatabase: "lingniu_vehicle_ts",
		Location:         loc,
	}, envelope.ProtocolYutongMQTT, "2026-07-08")
	if err != nil {
		t.Fatalf("queryPreviousLastSourceRows() error = %v", err)
	}
	sourceRows := rows["LMRKH9AC6R1004108"]
	if len(sourceRows) != 1 {
		t.Fatalf("rows = %d, want 1", len(sourceRows))
	}
	if sourceRows[0].TotalKM != 65377 {
		t.Fatalf("previous total = %v, want 65377", sourceRows[0].TotalKM)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestBackfillBeforePredicatesSearchesAllEarlierHistory(t *testing.T) {
	where := strings.Join(backfillBeforePredicates("2026-07-08"), " AND ")
	if !strings.Contains(where, "event_time < '2026-07-08 00:00:00'") {
		t.Fatalf("pre-window predicate missing exclusive upper bound: %s", where)
	}
	if strings.Contains(where, "event_time >=") || strings.Contains(where, "ts >=") {
		t.Fatalf("pre-window predicate must not stop at the previous day: %s", where)
	}
}

func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()

	loc := time.FixedZone("Asia/Shanghai", 8*3600)
	utcInstant := time.Date(2026, 7, 12, 15, 59, 59, 0, time.UTC)
	mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'").
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
		}).AddRow(
			"LMRKH9AC7R1004098",
			"",
			"LMRKH9AC7R1004098",
			"mqtt://yutong/ytforward/shln/4",
			utcInstant,
			`{"yutong_mqtt.data.total_mileage":"41249000"}`,
			int64(1),
		))

	rows, err := queryPreviousLastSourceRows(context.Background(), db, config{
		TDengineDatabase: "lingniu_vehicle_ts",
		Location:         loc,
	}, envelope.ProtocolYutongMQTT, "2026-07-13")
	if err != nil {
		t.Fatalf("queryPreviousLastSourceRows() error = %v", err)
	}
	sourceRows := rows["LMRKH9AC7R1004098"]
	if len(sourceRows) != 1 {
		t.Fatalf("rows = %d, want 1", len(sourceRows))
	}
	want := time.Date(2026, 7, 12, 23, 59, 59, 0, loc)
	if !sourceRows[0].TS.Equal(want) || sourceRows[0].TS.Location().String() != loc.String() {
		t.Fatalf("previous event time = %s (%s), want %s (%s)", sourceRows[0].TS, sourceRows[0].TS.Location(), want, want.Location())
	}
}

func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) {
	fields := fieldsForStats(envelope.ProtocolYutongMQTT, "LMRKH9AC6R1004108", `{
	  "data": {
	    "TOTAL_MILEAGE": 65423000,
	    "METER_SPEED": 12.3
	  },
	  "root": {
	    "device": "LMRKH9AC6R1004108"
	  }
	}`)

	if got := fields["yutong_mqtt.data.total_mileage"]; got == nil {
		t.Fatalf("fields missing raw yutong total mileage: %#v", fields)
	}

	env := envelope.FrameEnvelope{
		Protocol:     envelope.ProtocolYutongMQTT,
		VIN:          "LMRKH9AC6R1004108",
		EventTimeMS:  time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
		ReceivedAtMS: time.Date(2026, 7, 8, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).UnixMilli(),
		Fields:       fields,
	}
	samples, err := stats.SamplesFromEnvelope(env, time.FixedZone("Asia/Shanghai", 8*3600))
	if err != nil {
		t.Fatalf("SamplesFromEnvelope() error = %v", err)
	}
	if len(samples) != 1 {
		t.Fatalf("samples = %d, want 1", len(samples))
	}
	if samples[0].TotalMileageKM != 65423 {
		t.Fatalf("total mileage km = %v, want 65423", samples[0].TotalMileageKM)
	}
}

func TestClearBackfillTargetMileageClearsExactKey(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectExec(`DELETE FROM vehicle_daily_mileage_source WHERE vin = \? AND stat_date = \? AND protocol = \?`).
		WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectExec(`DELETE FROM vehicle_daily_mileage WHERE vin = \? AND stat_date = \? AND protocol = \?`).
		WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
		WillReturnResult(sqlmock.NewResult(0, 1))
	if err := clearBackfillTargetMileage(context.Background(), db, "LA9GG64L7PBAF4001", "2026-07-08", envelope.ProtocolJT808); err != nil {
		t.Fatalf("clearBackfillTargetMileage() error = %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.MatchExpectationsInOrder(true)

	projNow := time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
	aggregates := map[string]*metricAgg{
		"LA9GG64L7PBAF4001|2026-07-08|JT808|JT808:13307765812@115.231.168.135": {
			VIN:             "LA9GG64L7PBAF4001",
			Date:            "2026-07-08",
			Protocol:        envelope.ProtocolJT808,
			FirstKM:         4100,
			LatestKM:        4101,
			Count:           1,
			SourceKey:       "JT808:13307765812@115.231.168.135",
			Phone:           "13307765812",
			SourceEndpoint:  "115.231.168.135:20215",
			FirstEventTime:  projNow,
			LatestEventTime: projNow,
			QualityStatus:   stats.QualityInvalidDelta,
			QualityReason:   "outside_daily_range",
		},
	}

	mock.ExpectExec(`DELETE FROM vehicle_daily_mileage_source WHERE vin = \? AND stat_date = \? AND protocol = \?`).
		WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectExec(`DELETE FROM vehicle_daily_mileage WHERE vin = \? AND stat_date = \? AND protocol = \?`).
		WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectExec(`INSERT INTO vehicle_data_source`).
		WithArgs("JT808", "115.231.168.135", "115.231.168.135:20215", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
		WithArgs(
			"LA9GG64L7PBAF4001",
			"2026-07-08",
			"JT808",
			"JT808:13307765812@115.231.168.135",
			"115.231.168.135",
			"115.231.168.135:20215",
			"13307765812",
			"",
			"",
			float64(4100),
			float64(4101),
			float64(1),
			int64(1),
			projNow,
			projNow,
			stats.QualityInvalidDelta,
			"outside_daily_range",
		).
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectBegin()
	mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
		WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(2500)).
		WillReturnResult(sqlmock.NewResult(1, 0))
	mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`).
		WithArgs(
			"LA9GG64L7PBAF4001",
			"2026-07-08",
			"JT808",
			int64(2500),
			"LA9GG64L7PBAF4001",
			"2026-07-08",
			"JT808",
		).
		WillReturnResult(sqlmock.NewResult(0, 0))
	mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`).
		WithArgs(
			"LA9GG64L7PBAF4001",
			"2026-07-08",
			"JT808",
			"LA9GG64L7PBAF4001",
			"2026-07-08",
			"JT808",
		).
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectCommit()

	written, err := writeAggregates(context.Background(), db, aggregates, 500)
	if err != nil {
		t.Fatalf("writeAggregates() error = %v", err)
	}
	if written != 1 {
		t.Fatalf("written = %d, want 1", written)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestWriteAggregatesSkipsBlankSourceBeforeClearingTarget(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()

	written, err := writeAggregates(context.Background(), db, map[string]*metricAgg{
		"LA9GG64L7PBAF4001|2026-07-08|JT808|JT808:13307765812@": {
			VIN:             "LA9GG64L7PBAF4001",
			Date:            "2026-07-08",
			Protocol:        envelope.ProtocolJT808,
			FirstKM:         4100,
			LatestKM:        4101,
			Count:           1,
			SourceKey:       "JT808:13307765812@",
			Phone:           "13307765812",
			SourceEndpoint:  "",
			FirstEventTime:  time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
			LatestEventTime: time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
			QualityStatus:   stats.QualityNoPreviousBaseline,
			QualityReason:   "missing_previous_source",
		},
	}, 500)
	if err != nil {
		t.Fatalf("writeAggregates() error = %v", err)
	}
	if written != 0 {
		t.Fatalf("written = %d, want 0", written)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestAddSamplesUsesCurrentDayFirstSampleBaseline(t *testing.T) {
	loc := time.FixedZone("Asia/Shanghai", 8*3600)
	firstSamples, err := stats.SamplesFromEnvelope(envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		VIN:            "LA9GG64L7PBAF4001",
		Phone:          "13307765812",
		SourceEndpoint: "115.231.168.135:20215",
		EventTimeMS:    time.Date(2026, 7, 8, 12, 0, 0, 0, loc).UnixMilli(),
		Fields: map[string]any{
			"jt808.location.total_mileage_km": 4123.9,
		},
	}, loc)
	if err != nil {
		t.Fatalf("SamplesFromEnvelope() error = %v", err)
	}
	secondSamples, err := stats.SamplesFromEnvelope(envelope.FrameEnvelope{
		Protocol:       envelope.ProtocolJT808,
		VIN:            "LA9GG64L7PBAF4001",
		Phone:          "13307765812",
		SourceEndpoint: "115.231.168.135:20215",
		EventTimeMS:    time.Date(2026, 7, 8, 13, 0, 0, 0, loc).UnixMilli(),
		Fields: map[string]any{
			"jt808.location.total_mileage_km": 4129.9,
		},
	}, loc)
	if err != nil {
		t.Fatalf("SamplesFromEnvelope() error = %v", err)
	}
	aggregates := map[string]*metricAgg{}
	addSamples(aggregates, firstSamples)
	addSamples(aggregates, secondSamples)
	if len(aggregates) != 1 {
		t.Fatalf("aggregate count = %d, want 1", len(aggregates))
	}
	for _, agg := range aggregates {
		if agg.QualityStatus != stats.QualityOK {
			t.Fatalf("quality = %q, want %q", agg.QualityStatus, stats.QualityOK)
		}
		if agg.QualityReason != "current_day_first_sample" {
			t.Fatalf("quality reason = %q", agg.QualityReason)
		}
		if agg.FirstKM != 4123.9 || agg.LatestKM != 4129.9 {
			t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
		}
		if agg.FirstEventTime != time.Date(2026, 7, 8, 12, 0, 0, 0, loc) {
			t.Fatalf("first event time = %v", agg.FirstEventTime)
		}
	}
}

func TestLoadConfigDefaultsBackfillMethodToLastDiff(t *testing.T) {
	t.Setenv("BACKFILL_METHOD", "")
	t.Setenv("BACKFILL_DAYS_BACK", "")
	t.Setenv("BACKFILL_WINDOW_DAYS", "")
	t.Setenv("BACKFILL_DATE_FROM", "2026-07-08")
	t.Setenv("BACKFILL_DATE_TO", "2026-07-08")
	t.Setenv("BACKFILL_PROTOCOLS", "JT808")
	t.Setenv("LOCAL_TZ", "Asia/Shanghai")

	cfg, err := loadConfig()
	if err != nil {
		t.Fatalf("loadConfig() error = %v", err)
	}
	if cfg.Method != "last_diff" {
		t.Fatalf("method = %q, want last_diff", cfg.Method)
	}
	if cfg.EventTimeFullScan {
		t.Fatal("event-time full scan should be opt-in")
	}
}

func TestBackfillTimePredicatesUsePrimaryTimeForCoarseScanAndEventTimeForBusinessDay(t *testing.T) {
	where := strings.Join(backfillTimePredicates(config{}, "2026-07-13", "2026-07-14"), " AND ")
	for _, want := range []string{
		"ts >= '2026-07-12 00:00:00'",
		"ts < '2026-07-15 00:00:00'",
		"event_time >= '2026-07-13 00:00:00'",
		"event_time < '2026-07-14 00:00:00'",
	} {
		if !strings.Contains(where, want) {
			t.Fatalf("time predicates missing %q: %s", want, where)
		}
	}
}

func TestBackfillTimePredicatesAllowExplicitDeepEventTimeScan(t *testing.T) {
	where := strings.Join(backfillTimePredicates(config{EventTimeFullScan: true}, "2026-07-13", "2026-07-14"), " AND ")
	if strings.Contains(where, "ts >=") || strings.Contains(where, "ts <") {
		t.Fatalf("deep event-time scan must not apply storage-time bounds: %s", where)
	}
	for _, want := range []string{
		"event_time >= '2026-07-13 00:00:00'",
		"event_time < '2026-07-14 00:00:00'",
	} {
		if !strings.Contains(where, want) {
			t.Fatalf("deep event-time scan missing %q: %s", want, where)
		}
	}
}

func TestResolveBackfillDateRangeDefaultsToToday(t *testing.T) {
	t.Setenv("BACKFILL_DATE_FROM", "")
	t.Setenv("BACKFILL_DATE_TO", "")
	t.Setenv("BACKFILL_DAYS_BACK", "")
	t.Setenv("BACKFILL_WINDOW_DAYS", "")
	loc := time.FixedZone("Asia/Shanghai", 8*3600)

	dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc)
	if dateFrom != "2026-07-12" || dateTo != "2026-07-12" {
		t.Fatalf("date range = %s -> %s", dateFrom, dateTo)
	}
}

func TestResolveBackfillDateRangeUsesRelativeWindow(t *testing.T) {
	t.Setenv("BACKFILL_DATE_FROM", "")
	t.Setenv("BACKFILL_DATE_TO", "")
	t.Setenv("BACKFILL_DAYS_BACK", "1")
	t.Setenv("BACKFILL_WINDOW_DAYS", "3")
	loc := time.FixedZone("Asia/Shanghai", 8*3600)

	dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc)
	if dateFrom != "2026-07-09" || dateTo != "2026-07-11" {
		t.Fatalf("date range = %s -> %s", dateFrom, dateTo)
	}
}

func TestResolveBackfillDateRangePrefersExplicitDates(t *testing.T) {
	t.Setenv("BACKFILL_DATE_FROM", "2026-07-01")
	t.Setenv("BACKFILL_DATE_TO", "2026-07-03")
	t.Setenv("BACKFILL_DAYS_BACK", "1")
	t.Setenv("BACKFILL_WINDOW_DAYS", "3")
	loc := time.FixedZone("Asia/Shanghai", 8*3600)

	dateFrom, dateTo := resolveBackfillDateRange(time.Date(2026, 7, 12, 10, 0, 0, 0, loc), loc)
	if dateFrom != "2026-07-01" || dateTo != "2026-07-03" {
		t.Fatalf("date range = %s -> %s", dateFrom, dateTo)
	}
}

func TestBackfillEnvFilesPrefersExplicitList(t *testing.T) {
	got := backfillEnvFiles("/tmp/a.env,/tmp/b.env", "/tmp/legacy.env", []string{"/tmp/default.env"})
	if got != "/tmp/a.env,/tmp/b.env" {
		t.Fatalf("env files = %q", got)
	}
}

func TestBackfillEnvFilesUsesExistingDefaults(t *testing.T) {
	dir := t.TempDir()
	missing := filepath.Join(dir, "missing.env")
	base := filepath.Join(dir, "base.env")
	stat := filepath.Join(dir, "stat-writer.env")
	if err := os.WriteFile(base, []byte("A=1\n"), 0600); err != nil {
		t.Fatalf("write base env: %v", err)
	}
	if err := os.WriteFile(stat, []byte("B=2\n"), 0600); err != nil {
		t.Fatalf("write stat env: %v", err)
	}

	got := backfillEnvFiles("", "", []string{missing, base, stat})
	want := base + "," + stat
	if got != want {
		t.Fatalf("env files = %q, want %q", got, want)
	}
}
