package stats

import (
	"context"
	"database/sql"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

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

const dailyMetricSelectPattern = "SELECT m.vin, m.stat_date, m.protocol, m.source_id"
const dailyMetricSourceSelectPattern = "SELECT s.vin, s.stat_date, s.protocol, s.source_key, s.source_ip"
const dailyMetricSourceQualitySummarySelectPattern = "SELECT s.stat_date, s.protocol, s.quality_status, s.quality_reason"
const dailyMetricSourceSelectionSummarySelectPattern = "SELECT stat_date, protocol, selection_status, selection_reason"
const dailyMetricDiagnosisSelectPattern = "SELECT rt.vin, \\? AS stat_date, rt.protocol"
const dailyMetricDiagnosisSummarySelectPattern = "SELECT \\? AS stat_date,\\s+rt.protocol"
const dailyMetricDiagnosisReasonSummarySelectPattern = "SELECT stat_date, protocol, diagnosis, reason, COUNT\\(\\*\\) AS count"
const dailyMetricDiagnosisFieldStatusSummarySelectPattern = "SELECT stat_date, protocol, diagnosis, reason, realtime_mileage_field_status, COUNT\\(\\*\\) AS count"

func TestMetricRepositoryQueriesDailyMetricsWithFilters(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricSelectPattern).
		WithArgs("LKLG7C4E3NA774736", "JT808", "2026-07-01", "2026-07-01", 20, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "stat_date", "protocol", "source_id",
			"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
			"latest_total_mileage_km", "updated_at",
		}).AddRow(
			"LKLG7C4E3NA774736", time.Date(2026, 7, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), "JT808", 3,
			"115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM", 12.3,
			12357.9, time.Date(2026, 7, 1, 23, 9, 36, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
		))

	repository := NewMetricRepository(db)
	rows, err := repository.Query(context.Background(), MetricQuery{
		VIN:      "LKLG7C4E3NA774736",
		Protocol: "JT808",
		DateFrom: "2026-07-01",
		DateTo:   "2026-07-01",
		Limit:    20,
	})
	if err != nil {
		t.Fatalf("Query() error = %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("row count = %d", len(rows))
	}
	if rows[0].DailyMileageKM != 12.3 {
		t.Fatalf("unexpected row: %#v", rows[0])
	}
	if rows[0].SourceID == nil || *rows[0].SourceID != 3 {
		t.Fatalf("source id = %#v", rows[0].SourceID)
	}
	if rows[0].SourceIP != "115.231.168.135" || rows[0].PlatformName != "G7 平台" || rows[0].SourceCode != "G7S" || rows[0].SourceKind != "PLATFORM" {
		t.Fatalf("unexpected source evidence: %#v", rows[0])
	}
	if rows[0].StatDate != "2026-07-01" || rows[0].UpdatedAt != "2026-07-01 23:09:36" {
		t.Fatalf("unexpected time formatting: %#v", rows[0])
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricQueryReturnsSelectedSourceID(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricSelectPattern).
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "stat_date", "protocol", "source_id",
			"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
			"latest_total_mileage_km", "updated_at",
		}).AddRow(
			"LA9GG64L7PBAF4001", "2026-07-08", "JT808", 2,
			"117.132.194.31", "117.132.194.31:20471", "广安北斗", "guangan_beidou", "PLATFORM", 23.1,
			4123.9, "2026-07-08 13:30:57",
		))

	repo := NewMetricRepository(db)
	got, err := repo.Query(context.Background(), MetricQuery{
		VIN:      "LA9GG64L7PBAF4001",
		Protocol: "JT808",
		Limit:    1,
	})
	if err != nil {
		t.Fatalf("Query() error = %v", err)
	}
	if got[0].SourceID == nil || *got[0].SourceID != 2 {
		t.Fatalf("source id = %#v", got[0].SourceID)
	}
	if got[0].SourceKind != "PLATFORM" || got[0].PlatformName != "广安北斗" {
		t.Fatalf("source evidence = %#v", got[0])
	}
}

func TestMetricRepositoryQueriesDailyMetricSourcesWithEvidence(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricSourceSelectPattern).
		WithArgs("LA9GG64L7PBAF4001", "JT808", "2026-07-08", "2026-07-08", "115.231.168.135", "OK", "historical_source_baseline", 1, 20, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "stat_date", "protocol", "source_key", "source_ip", "source_endpoint", "phone", "device_id",
			"platform_name", "source_id", "source_code", "source_kind", "enabled", "trust_priority",
			"first_total_mileage_km", "latest_total_mileage_km", "daily_mileage_km", "sample_count",
			"first_event_time", "latest_event_time", "quality_status", "quality_reason", "is_selected", "updated_at",
		}).AddRow(
			"LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307795425@115.231.168.135", "115.231.168.135",
			"115.231.168.135:41561", "13307795425", nil,
			"信达", 5, "xinda", "PLATFORM", 1, 10,
			4100.8, 4123.9, 23.1, 128,
			"2026-07-08 00:01:00", "2026-07-08 23:59:00", "OK", "historical_source_baseline", 1, "2026-07-08 23:59:10",
		))

	selected := true
	repository := NewMetricRepository(db)
	rows, err := repository.QuerySources(context.Background(), MetricSourceQuery{
		VIN:           "LA9GG64L7PBAF4001",
		Protocol:      "jt808",
		DateFrom:      "2026-07-08",
		DateTo:        "2026-07-08",
		SourceIP:      "115.231.168.135",
		QualityStatus: "ok",
		QualityReason: "Historical_Source_Baseline",
		Selected:      &selected,
		Limit:         20,
	})
	if err != nil {
		t.Fatalf("QuerySources() error = %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("row count = %d", len(rows))
	}
	row := rows[0]
	if row.PlatformName != "信达" || row.SourceCode != "xinda" || row.SourceKind != "PLATFORM" || !row.IsSelected {
		t.Fatalf("unexpected source row: %#v", row)
	}
	if row.SourceEnabled == nil || !*row.SourceEnabled {
		t.Fatalf("source enabled = %#v", row.SourceEnabled)
	}
	if row.TrustPriority == nil || *row.TrustPriority != 10 {
		t.Fatalf("trust priority = %#v", row.TrustPriority)
	}
	if row.FirstTotalMileageKM == nil || *row.FirstTotalMileageKM != 4100.8 {
		t.Fatalf("first total mileage = %#v", row.FirstTotalMileageKM)
	}
	if row.LatestEventTime != "2026-07-08 23:59:00" || row.QualityReason != "historical_source_baseline" {
		t.Fatalf("unexpected event/quality fields: %#v", row)
	}
	if row.SelectionStatus != "selected" || row.SelectionReason != "selected_current_projection" {
		t.Fatalf("selection evidence = %#v", row)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricSourceSelectionEvidence(t *testing.T) {
	disabled := false
	sourceID := int64(3)
	tests := []struct {
		name       string
		row        MetricSourceRow
		wantStatus string
		wantReason string
	}{
		{
			name: "selected",
			row: MetricSourceRow{
				IsSelected:    true,
				QualityStatus: QualityOK,
			},
			wantStatus: "selected",
			wantReason: "selected_current_projection",
		},
		{
			name: "invalid quality",
			row: MetricSourceRow{
				QualityStatus: QualityInvalidDelta,
				QualityReason: "outside_daily_range",
			},
			wantStatus: "excluded",
			wantReason: "quality_outside_daily_range",
		},
		{
			name: "disabled source",
			row: MetricSourceRow{
				QualityStatus: QualityOK,
				SourceEnabled: &disabled,
				SourceID:      &sourceID,
			},
			wantStatus: "excluded",
			wantReason: "source_disabled",
		},
		{
			name: "unmanaged source",
			row: MetricSourceRow{
				QualityStatus: QualityOK,
			},
			wantStatus: "not_selected",
			wantReason: "unmanaged_source_lower_priority",
		},
		{
			name: "direct source without source table row",
			row: MetricSourceRow{
				QualityStatus: QualityOK,
				SourceKind:    "DIRECT",
			},
			wantStatus: "not_selected",
			wantReason: "lower_trust_priority_or_sample_count",
		},
		{
			name: "unknown source kind",
			row: MetricSourceRow{
				QualityStatus: QualityOK,
				SourceID:      &sourceID,
				SourceKind:    "UNKNOWN",
			},
			wantStatus: "not_selected",
			wantReason: "unknown_source_kind_lower_priority",
		},
		{
			name: "lower priority",
			row: MetricSourceRow{
				QualityStatus: QualityOK,
				SourceID:      &sourceID,
				SourceKind:    "PLATFORM",
			},
			wantStatus: "not_selected",
			wantReason: "lower_trust_priority_or_sample_count",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			status, reason, action := metricSourceSelectionEvidence(tt.row)
			if status != tt.wantStatus || reason != tt.wantReason || action == "" {
				t.Fatalf("metricSourceSelectionEvidence() = %q, %q, %q", status, reason, action)
			}
		})
	}
}

func TestMetricRepositoryQueriesDailyMetricSourceQualitySummary(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricSourceQualitySummarySelectPattern).
		WithArgs("JT808", "2026-07-12", "2026-07-12", "INVALID_DELTA", "outside_daily_range", 10, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"stat_date", "protocol", "quality_status", "quality_reason",
			"source_count", "vehicle_count", "selected_count", "sample_count", "daily_mileage_km",
		}).AddRow(
			"2026-07-12", "JT808", "INVALID_DELTA", "outside_daily_range",
			int64(3), int64(3), int64(0), int64(128), 12435.2,
		))

	repository := NewMetricRepository(db)
	rows, err := repository.QuerySourceQualitySummary(context.Background(), MetricSourceQuery{
		Protocol:      "jt808",
		DateFrom:      "2026-07-12",
		DateTo:        "2026-07-12",
		QualityStatus: "invalid_delta",
		QualityReason: "Outside_Daily_Range",
		Limit:         10,
	})
	if err != nil {
		t.Fatalf("QuerySourceQualitySummary() error = %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("row count = %d", len(rows))
	}
	row := rows[0]
	if row.StatDate != "2026-07-12" || row.Protocol != "JT808" || row.QualityStatus != "INVALID_DELTA" || row.QualityReason != "outside_daily_range" {
		t.Fatalf("unexpected summary key: %#v", row)
	}
	if row.SourceCount != 3 || row.VehicleCount != 3 || row.SelectedCount != 0 || row.SampleCount != 128 || row.DailyMileageKM != 12435.2 {
		t.Fatalf("unexpected summary values: %#v", row)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricRepositoryQueriesDailyMetricSourceSelectionSummary(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricSourceSelectionSummarySelectPattern).
		WithArgs("JT808", "2026-07-12", "2026-07-12", 10, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"stat_date", "protocol", "selection_status", "selection_reason",
			"source_count", "vehicle_count", "selected_count", "sample_count", "daily_mileage_km",
		}).AddRow(
			"2026-07-12", "JT808", "not_selected", "lower_trust_priority_or_sample_count",
			int64(36), int64(31), int64(0), int64(3200), 812.5,
		))

	repository := NewMetricRepository(db)
	rows, err := repository.QuerySourceSelectionSummary(context.Background(), MetricSourceQuery{
		Protocol: "jt808",
		DateFrom: "2026-07-12",
		DateTo:   "2026-07-12",
		Limit:    10,
	})
	if err != nil {
		t.Fatalf("QuerySourceSelectionSummary() error = %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("row count = %d", len(rows))
	}
	row := rows[0]
	if row.StatDate != "2026-07-12" || row.Protocol != "JT808" || row.SelectionStatus != "not_selected" || row.SelectionReason != "lower_trust_priority_or_sample_count" {
		t.Fatalf("unexpected summary key: %#v", row)
	}
	if row.SourceCount != 36 || row.VehicleCount != 31 || row.SelectedCount != 0 || row.SampleCount != 3200 || row.DailyMileageKM != 812.5 {
		t.Fatalf("unexpected summary values: %#v", row)
	}
	if row.SelectionAction == "" || !strings.Contains(row.SelectionAction, "更高可信优先级") {
		t.Fatalf("selection action = %q", row.SelectionAction)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestBuildMetricSourceSelectionSummarySQLGroupsByComputedSelectionEvidence(t *testing.T) {
	sqlText, args := buildMetricSourceSelectionSummarySQL(normalizeMetricSourceQuery(MetricSourceQuery{
		Protocol: "jt808",
		DateFrom: "2026-07-12",
		DateTo:   "2026-07-12",
		Limit:    20,
	}))
	for _, want := range []string{
		"selection_status",
		"selection_reason",
		"selected_current_projection",
		"source_disabled",
		"source_key LIKE '%@PLATFORM:%'",
		"source_key LIKE '%@DIRECT'",
		"ds.id IS NULL AND COALESCE(NULLIF(TRIM(ds.source_kind), ''), CASE WHEN s.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END) <> 'DIRECT'",
		"unmanaged_source_lower_priority",
		"unknown_source_kind_lower_priority",
		"lower_trust_priority_or_sample_count",
		"GROUP BY stat_date, protocol, selection_status, selection_reason",
	} {
		if !strings.Contains(sqlText, want) {
			t.Fatalf("selection summary SQL missing %q:\n%s", want, sqlText)
		}
	}
	if len(args) != 5 || args[0] != "JT808" || args[1] != "2026-07-12" || args[2] != "2026-07-12" || args[3] != 20 || args[4] != 0 {
		t.Fatalf("args = %#v", args)
	}
}

func TestBuildMetricSourceSQLCanOrderByDailyMileageDesc(t *testing.T) {
	selected := false
	sqlText, args := buildMetricSourceSQL(normalizeMetricSourceQuery(MetricSourceQuery{
		Protocol: "jt808",
		DateFrom: "2026-07-12",
		DateTo:   "2026-07-12",
		Selected: &selected,
		OrderBy:  "daily_mileage_desc",
		Limit:    20,
	}))

	for _, want := range []string{
		"ORDER BY s.daily_mileage_km DESC",
		"s.sample_count DESC",
		"s.latest_event_time DESC",
	} {
		if !strings.Contains(sqlText, want) {
			t.Fatalf("sql missing %q:\n%s", want, sqlText)
		}
	}
	if strings.Contains(sqlText, "ORDER BY s.stat_date DESC, s.vin ASC") {
		t.Fatalf("sql should use mileage ordering:\n%s", sqlText)
	}
	if len(args) != 6 || args[0] != "JT808" || args[1] != "2026-07-12" || args[2] != "2026-07-12" || args[3] != 0 || args[4] != 20 || args[5] != 0 {
		t.Fatalf("args = %#v", args)
	}
}

func TestMetricRepositoryQueriesDailyMetricDiagnostics(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricDiagnosisSelectPattern).
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "stat_date", "protocol", "plate", "platform_name", "peer",
			"snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at",
			"realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km",
			"source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json",
		}).AddRow(
			"LA9HE60A1PBAF4008", "2026-07-12", "JT808", "粤A12345", "信达", "115.231.168.135:47849",
			"2026-07-12 02:23:27", "2026-07-12 02:23:27", "2026-07-12 02:23:29", "2026-07-12 02:23:29",
			25246.6, "2026-07-12 02:23:27", nil, nil,
			0, 0, 0, nil, "", `{"jt808.location.latitude":"30.1","jt808.location.total_mileage_km":"25246.6"}`,
		))

	repository := NewMetricRepository(db)
	rows, err := repository.QueryDiagnostics(context.Background(), MetricDiagnosisQuery{
		Protocol:  "jt808",
		Date:      "2026-07-12",
		Diagnosis: "NO_SOURCE_SAMPLE",
		Limit:     20,
	})
	if err != nil {
		t.Fatalf("QueryDiagnostics() error = %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("row count = %d", len(rows))
	}
	row := rows[0]
	if row.Diagnosis != "NO_SOURCE_SAMPLE" || row.Reason != "realtime_location_has_total_mileage_but_no_stat_sample" {
		t.Fatalf("unexpected diagnosis: %#v", row)
	}
	if row.Severity != "pipeline" {
		t.Fatalf("severity = %q, want pipeline", row.Severity)
	}
	if !strings.Contains(row.RecommendedOperatorAction, "字段流 topic") {
		t.Fatalf("recommended action = %q", row.RecommendedOperatorAction)
	}
	if row.RealtimeTotalMileageKM == nil || *row.RealtimeTotalMileageKM != 25246.6 {
		t.Fatalf("realtime total mileage = %#v", row.RealtimeTotalMileageKM)
	}
	if row.DailyMileageKM != nil {
		t.Fatalf("daily mileage should be nil: %#v", row.DailyMileageKM)
	}
	if row.PlatformName != "信达" || row.Peer != "115.231.168.135:47849" {
		t.Fatalf("unexpected realtime evidence: %#v", row)
	}
	if row.RealtimeFieldCount != 2 || len(row.RealtimeSampleFields) != 2 {
		t.Fatalf("realtime field evidence = count:%d fields:%#v", row.RealtimeFieldCount, row.RealtimeSampleFields)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricDiagnosisWhereSupportsReasonAndSeverityFilters(t *testing.T) {
	query := normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
		Date:     "2026-07-12",
		Reason:   "REALTIME_TOTAL_MILEAGE_MISSING",
		Severity: "SOURCE_DATA",
	})

	where, args := buildMetricDiagnosisWhere(query)

	joined := strings.Join(where, "\n")
	for _, want := range []string{
		"CASE",
		"l.total_mileage_km IS NULL",
		"source_data",
	} {
		if !strings.Contains(joined, want) {
			t.Fatalf("where missing %q:\n%s", want, joined)
		}
	}
	if len(args) < 4 {
		t.Fatalf("args too short: %#v", args)
	}
	var hasReason, hasSeverity bool
	for _, arg := range args {
		switch arg {
		case "realtime_total_mileage_missing":
			hasReason = true
		case "source_data":
			hasSeverity = true
		}
	}
	if !hasReason || !hasSeverity {
		t.Fatalf("args = %#v, want reason/severity filters", args)
	}
}

func TestMetricRepositoryQueriesDailyMetricDiagnosisSummary(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricDiagnosisSummarySelectPattern).
		WillReturnRows(sqlmock.NewRows([]string{
			"stat_date", "protocol", "active_count", "ok_count", "missing_daily_count", "no_source_sample_count", "no_total_mileage_count",
		}).AddRow(
			"2026-07-12", "JT808", int64(244), int64(244), int64(0), int64(0), int64(0),
		))

	repository := NewMetricRepository(db)
	rows, err := repository.QueryDiagnosisSummary(context.Background(), MetricDiagnosisQuery{
		Protocol: "jt808",
		Date:     "2026-07-12",
	})
	if err != nil {
		t.Fatalf("QueryDiagnosisSummary() error = %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("row count = %d", len(rows))
	}
	row := rows[0]
	if row.StatDate != "2026-07-12" || row.Protocol != "JT808" || row.ActiveCount != 244 || row.OKCount != 244 || row.ActionableIssueCount != 0 {
		t.Fatalf("unexpected summary row: %#v", row)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricRepositoryQueriesDailyMetricDiagnosisReasonSummary(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricDiagnosisReasonSummarySelectPattern).
		WillReturnRows(sqlmock.NewRows([]string{
			"stat_date", "protocol", "diagnosis", "reason", "count",
		}).AddRow(
			"2026-07-12", "YUTONG_MQTT", "NO_TOTAL_MILEAGE", "realtime_total_mileage_not_reported_on_stat_date", int64(7),
		))

	repository := NewMetricRepository(db)
	rows, err := repository.QueryDiagnosisReasonSummary(context.Background(), MetricDiagnosisQuery{
		Protocol:  "yutong_mqtt",
		Date:      "2026-07-12",
		Diagnosis: "no_total_mileage",
	})
	if err != nil {
		t.Fatalf("QueryDiagnosisReasonSummary() error = %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("row count = %d", len(rows))
	}
	row := rows[0]
	if row.StatDate != "2026-07-12" || row.Protocol != "YUTONG_MQTT" || row.Diagnosis != "NO_TOTAL_MILEAGE" || row.Reason != "realtime_total_mileage_not_reported_on_stat_date" || row.Count != 7 || row.Severity != metricDiagnosisSeveritySourceData {
		t.Fatalf("unexpected reason summary row: %#v", row)
	}
	if !strings.Contains(row.RecommendedOperatorAction, "不是统计日期内上报") {
		t.Fatalf("recommended action = %q", row.RecommendedOperatorAction)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricRepositoryQueriesDailyMetricDiagnosisFieldStatusSummary(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricDiagnosisFieldStatusSummarySelectPattern).
		WillReturnRows(sqlmock.NewRows([]string{
			"stat_date", "protocol", "diagnosis", "reason", "realtime_mileage_field_status", "count",
		}).AddRow(
			"2026-07-12", "YUTONG_MQTT", "NO_TOTAL_MILEAGE", "realtime_total_mileage_missing", "no_candidate_mileage_field", int64(9),
		))

	repository := NewMetricRepository(db)
	rows, err := repository.QueryDiagnosisFieldStatusSummary(context.Background(), MetricDiagnosisQuery{
		Protocol:  "yutong_mqtt",
		Date:      "2026-07-12",
		Diagnosis: "no_total_mileage",
		Reason:    "realtime_total_mileage_missing",
	})
	if err != nil {
		t.Fatalf("QueryDiagnosisFieldStatusSummary() error = %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("row count = %d", len(rows))
	}
	row := rows[0]
	if row.StatDate != "2026-07-12" || row.Protocol != "YUTONG_MQTT" || row.Diagnosis != "NO_TOTAL_MILEAGE" || row.RealtimeMileageFieldStatus != "no_candidate_mileage_field" || row.Count != 9 {
		t.Fatalf("unexpected field status summary row: %#v", row)
	}
	if row.Severity != metricDiagnosisSeveritySourceData {
		t.Fatalf("severity = %q", row.Severity)
	}
	if row.FieldStatusSeverity != metricDiagnosisSeveritySourceData {
		t.Fatalf("field status severity = %q", row.FieldStatusSeverity)
	}
	if !strings.Contains(row.FieldStatusAction, "源平台") {
		t.Fatalf("field status action = %q", row.FieldStatusAction)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricDiagnosisFieldStatusSeverityDistinguishesFreshProjectionEvidence(t *testing.T) {
	tests := []struct {
		status string
		want   string
	}{
		{status: "daily_metric_exists", want: metricDiagnosisSeverityOK},
		{status: "candidate_field_unmapped", want: metricDiagnosisSeverityPipeline},
		{status: "mapped_protocol_field_without_fresh_evidence", want: metricDiagnosisSeveritySourceData},
		{status: "standard_field_not_consumed", want: metricDiagnosisSeverityPipeline},
		{status: "standard_field_time_missing", want: metricDiagnosisSeverityPipeline},
		{status: "no_realtime_fields", want: metricDiagnosisSeverityPipeline},
		{status: "standard_field_non_positive", want: metricDiagnosisSeveritySourceData},
		{status: "standard_field_stale", want: metricDiagnosisSeveritySourceData},
		{status: "no_candidate_mileage_field", want: metricDiagnosisSeveritySourceData},
	}
	for _, tt := range tests {
		t.Run(tt.status, func(t *testing.T) {
			if got := metricDiagnosisMileageFieldStatusSeverity(tt.status); got != tt.want {
				t.Fatalf("metricDiagnosisMileageFieldStatusSeverity(%q) = %q, want %q", tt.status, got, tt.want)
			}
		})
	}
}

func TestMetricDiagnosisFieldStatusSummaryTotalsPreferDiagnosisSeverity(t *testing.T) {
	_, actionable, pipeline, sourceData := metricDiagnosisFieldStatusSummaryIssueTotals([]MetricDiagnosisFieldStatusSummaryRow{
		{
			Diagnosis:                  "MISSING_DAILY",
			Reason:                     "source_samples_all_invalid",
			Severity:                   metricDiagnosisSeveritySourceData,
			RealtimeMileageFieldStatus: "source_sample_exists",
			FieldStatusSeverity:        metricDiagnosisSeverityPipeline,
			Count:                      2,
		},
	})

	if actionable != 2 || pipeline != 0 || sourceData != 2 {
		t.Fatalf("totals actionable=%d pipeline=%d source=%d", actionable, pipeline, sourceData)
	}
}

func TestMetricDiagnosisIssueSeverity(t *testing.T) {
	tests := []struct {
		name      string
		diagnosis string
		reason    string
		want      string
	}{
		{name: "ok", diagnosis: "OK", want: metricDiagnosisSeverityOK},
		{name: "missing daily", diagnosis: "MISSING_DAILY", want: metricDiagnosisSeverityPipeline},
		{name: "missing daily all invalid samples", diagnosis: "MISSING_DAILY", reason: "source_samples_all_invalid", want: metricDiagnosisSeveritySourceData},
		{name: "missing daily all excluded samples", diagnosis: "MISSING_DAILY", reason: "source_samples_all_excluded", want: metricDiagnosisSeveritySourceData},
		{name: "no source sample", diagnosis: "NO_SOURCE_SAMPLE", want: metricDiagnosisSeverityPipeline},
		{name: "time missing", diagnosis: "NO_TOTAL_MILEAGE", reason: "realtime_total_mileage_time_missing", want: metricDiagnosisSeverityPipeline},
		{name: "source no mileage", diagnosis: "NO_TOTAL_MILEAGE", reason: "realtime_total_mileage_non_positive", want: metricDiagnosisSeveritySourceData},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := metricDiagnosisIssueSeverity(tt.diagnosis, tt.reason); got != tt.want {
				t.Fatalf("metricDiagnosisIssueSeverity() = %q, want %q", got, tt.want)
			}
		})
	}
}

func TestBuildActiveRealtimeMetricSQLUsesBusinessRealtimeTimestamp(t *testing.T) {
	sqlText, args := buildActiveRealtimeMetricSQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
		Protocol: "jt808",
		Date:     "2026-07-12",
	}))
	for _, want := range []string{
		"vehicle_realtime_snapshot",
		"vehicle_realtime_location",
		"event_time >= ?",
		"received_at >= ?",
		"protocol = ?",
	} {
		if !strings.Contains(sqlText, want) {
			t.Fatalf("active realtime SQL missing %q:\n%s", want, sqlText)
		}
	}
	if strings.Contains(sqlText, "updated_at >= ?") {
		t.Fatalf("active realtime SQL should not use row mutation time as vehicle activity evidence:\n%s", sqlText)
	}
	if strings.Contains(sqlText, "COALESCE(event_time") {
		t.Fatalf("active realtime SQL should not let stale event_time hide received_at:\n%s", sqlText)
	}
	if strings.Count(sqlText, "protocol = ?") != 2 {
		t.Fatalf("protocol filter should be pushed into both realtime tables:\n%s", sqlText)
	}
	if len(args) != 10 {
		t.Fatalf("args = %d, want 10: %#v", len(args), args)
	}
}

func TestBuildMetricDiagnosisSQLRequiresPositiveRealtimeMileage(t *testing.T) {
	summarySQL, _ := buildMetricDiagnosisSummarySQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
		Protocol: "jt808",
		Date:     "2026-07-12",
	}))
	detailSQL, _ := buildMetricDiagnosisSQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
		Protocol:  "jt808",
		Date:      "2026-07-12",
		Diagnosis: "NO_SOURCE_SAMPLE",
	}))
	noTotalSQL, _ := buildMetricDiagnosisSQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
		Protocol:  "jt808",
		Date:      "2026-07-12",
		Diagnosis: "NO_TOTAL_MILEAGE",
	}))
	for _, want := range []string{
		"l.total_mileage_km > 0",
		"l.total_mileage_km IS NULL OR l.total_mileage_km <= 0",
	} {
		combinedSQL := summarySQL + "\n" + detailSQL + "\n" + noTotalSQL
		if !strings.Contains(combinedSQL, want) {
			t.Fatalf("diagnosis SQL missing %q:\n%s", want, combinedSQL)
		}
	}
}

func TestBuildMetricDiagnosisFieldStatusSummarySQLClassifiesMileageEvidence(t *testing.T) {
	sqlText, args := buildMetricDiagnosisFieldStatusSummarySQL(normalizeMetricDiagnosisQuery(MetricDiagnosisQuery{
		Protocol:  "yutong_mqtt",
		Date:      "2026-07-12",
		Diagnosis: "NO_TOTAL_MILEAGE",
		Reason:    "realtime_total_mileage_missing",
		Severity:  "source_data",
	}))
	for _, want := range []string{
		"realtime_mileage_field_status",
		"standard_field_non_positive",
		"standard_field_stale",
		"mapped_protocol_field_without_fresh_evidence",
		"candidate_field_unmapped",
		"no_candidate_mileage_field",
		"LOWER(s.parsed_json) LIKE '%mileage%'",
		"GROUP BY stat_date, protocol, diagnosis, reason, realtime_mileage_field_status",
	} {
		if !strings.Contains(sqlText, want) {
			t.Fatalf("field status summary SQL missing %q:\n%s", want, sqlText)
		}
	}
	for _, want := range []any{"NO_TOTAL_MILEAGE", "realtime_total_mileage_missing", "source_data"} {
		var found bool
		for _, arg := range args {
			if arg == want {
				found = true
				break
			}
		}
		if !found {
			t.Fatalf("args missing %v: %#v", want, args)
		}
	}
}

func TestMetricDiagnosisTreatsZeroMileageAsNoTotalMileage(t *testing.T) {
	diagnosis, reason := classifyMetricDiagnosis(false, 0, 0, 0,
		sql.NullFloat64{Float64: 0, Valid: true},
		"2026-07-12 02:51:04",
		"2026-07-12",
	)
	if diagnosis != "NO_TOTAL_MILEAGE" || reason != "realtime_total_mileage_non_positive" {
		t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason)
	}
}

func TestMetricDiagnosisTreatsAllInvalidSourceSamplesAsSourceData(t *testing.T) {
	diagnosis, reason := classifyMetricDiagnosis(false, 2, 0, 0,
		sql.NullFloat64{Float64: 47724.2, Valid: true},
		"2026-07-13 00:07:45",
		"2026-07-13",
	)
	if diagnosis != "MISSING_DAILY" || reason != "source_samples_all_invalid" {
		t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason)
	}
	if got := metricDiagnosisIssueSeverity(diagnosis, reason); got != metricDiagnosisSeveritySourceData {
		t.Fatalf("severity=%s, want source_data", got)
	}
	if action := metricDiagnosisRecommendedOperatorAction(diagnosis, reason); !strings.Contains(action, "质量规则排除") {
		t.Fatalf("action=%q", action)
	}
}

func TestMetricDiagnosisTreatsAllExcludedSourceSamplesAsSourceData(t *testing.T) {
	diagnosis, reason := classifyMetricDiagnosis(false, 1, 1, 0,
		sql.NullFloat64{Float64: 33574.6, Valid: true},
		"2026-07-13 00:11:56",
		"2026-07-13",
	)
	if diagnosis != "MISSING_DAILY" || reason != "source_samples_all_excluded" {
		t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason)
	}
	if got := metricDiagnosisIssueSeverity(diagnosis, reason); got != metricDiagnosisSeveritySourceData {
		t.Fatalf("severity=%s, want source_data", got)
	}
	if action := metricDiagnosisRecommendedOperatorAction(diagnosis, reason); !strings.Contains(action, "没有可参与选举的来源") {
		t.Fatalf("action=%q", action)
	}
}

func TestMetricDiagnosisExplainsStaleRealtimeMileage(t *testing.T) {
	diagnosis, reason := classifyMetricDiagnosis(false, 0, 0, 0,
		sql.NullFloat64{Float64: 85344, Valid: true},
		"2026-07-11 23:59:59",
		"2026-07-12",
	)
	if diagnosis != "NO_TOTAL_MILEAGE" || reason != "realtime_total_mileage_not_reported_on_stat_date" {
		t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason)
	}
}

func TestMetricDiagnosisExplainsMissingRealtimeMileageTime(t *testing.T) {
	diagnosis, reason := classifyMetricDiagnosis(false, 0, 0, 0,
		sql.NullFloat64{Float64: 85344, Valid: true},
		"",
		"2026-07-12",
	)
	if diagnosis != "NO_TOTAL_MILEAGE" || reason != "realtime_total_mileage_time_missing" {
		t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason)
	}
}

func TestMetricDiagnosisExplainsMissingRealtimeMileage(t *testing.T) {
	diagnosis, reason := classifyMetricDiagnosis(false, 0, 0, 0,
		sql.NullFloat64{},
		"",
		"2026-07-12",
	)
	if diagnosis != "NO_TOTAL_MILEAGE" || reason != "realtime_total_mileage_missing" {
		t.Fatalf("diagnosis=%s reason=%s", diagnosis, reason)
	}
}

func TestMetricHandlerReturnsDailyMetrics(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_daily_mileage m").
		WithArgs("LB9A32A21R0LS1707", "GB32960", "2020-07-01", "2020-07-01").
		WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(42))
	mock.ExpectQuery(dailyMetricSelectPattern).
		WithArgs("LB9A32A21R0LS1707", "GB32960", "2020-07-01", "2020-07-01", 50, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "stat_date", "protocol", "source_id",
			"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
			"latest_total_mileage_km", "updated_at",
		}).AddRow(
			"LB9A32A21R0LS1707", "2020-07-01", "GB32960", 1,
			"8.134.95.166", "8.134.95.166:32960", "现代 HTWO", "HYUNDAI", "PLATFORM", 0.0,
			53490.9, "2026-07-01 22:28:25",
		))

	handler := NewMetricHandler(NewMetricRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?vin=LB9A32A21R0LS1707&protocol=GB32960&dateFrom=2020-07-01&dateTo=2020-07-01&includeTotal=true", 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{`"vin":"LB9A32A21R0LS1707"`, `"platform_name":"现代 HTWO"`, `"source_kind":"PLATFORM"`, `"daily_mileage_km":0`, `"total":42`} {
		if !strings.Contains(body, want) {
			t.Fatalf("response missing %s: %s", want, body)
		}
	}
	if strings.Contains(body, "created_at") {
		t.Fatalf("daily mileage response should not expose created_at: %s", body)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricHandlerReturnsDailyMetricSources(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_daily_mileage_source s").
		WithArgs("LA9GG64L7PBAF4001", "JT808", "2026-07-08", "2026-07-08", "historical_source_baseline", 1).
		WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(2))
	mock.ExpectQuery(dailyMetricSourceSelectPattern).
		WithArgs("LA9GG64L7PBAF4001", "JT808", "2026-07-08", "2026-07-08", "historical_source_baseline", 1, 50, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "stat_date", "protocol", "source_key", "source_ip", "source_endpoint", "phone", "device_id",
			"platform_name", "source_id", "source_code", "source_kind", "enabled", "trust_priority",
			"first_total_mileage_km", "latest_total_mileage_km", "daily_mileage_km", "sample_count",
			"first_event_time", "latest_event_time", "quality_status", "quality_reason", "is_selected", "updated_at",
		}).AddRow(
			"LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307795425@115.231.168.135", "115.231.168.135",
			"115.231.168.135:41561", "13307795425", nil,
			"信达", 5, "xinda", "PLATFORM", 1, 10,
			4100.8, 4123.9, 23.1, 128,
			"2026-07-08 00:01:00", "2026-07-08 23:59:00", "OK", "historical_source_baseline", 1, "2026-07-08 23:59:10",
		))

	handler := NewMetricHandler(NewMetricRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/sources?vin=LA9GG64L7PBAF4001&protocol=JT808&dateFrom=2026-07-08&dateTo=2026-07-08&qualityReason=Historical_Source_Baseline&selected=true&includeTotal=true", 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{`"total":2`, `"source_key":"JT808:13307795425@115.231.168.135"`, `"platform_name":"信达"`, `"source_kind":"PLATFORM"`, `"is_selected":true`, `"quality_status":"OK"`, `"selection_status":"selected"`, `"selection_reason":"selected_current_projection"`} {
		if !strings.Contains(body, want) {
			t.Fatalf("response missing %s: %s", want, body)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricHandlerReturnsDailyMetricSourceQualitySummary(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\(").
		WithArgs("JT808", "2026-07-12", "2026-07-12", "INVALID_DELTA", "outside_daily_range").
		WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
	mock.ExpectQuery(dailyMetricSourceQualitySummarySelectPattern).
		WithArgs("JT808", "2026-07-12", "2026-07-12", "INVALID_DELTA", "outside_daily_range", 5, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"stat_date", "protocol", "quality_status", "quality_reason",
			"source_count", "vehicle_count", "selected_count", "sample_count", "daily_mileage_km",
		}).AddRow(
			"2026-07-12", "JT808", "INVALID_DELTA", "outside_daily_range",
			int64(3), int64(3), int64(0), int64(128), 12435.2,
		))

	handler := NewMetricHandler(NewMetricRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/sources/quality?protocol=JT808&dateFrom=2026-07-12&dateTo=2026-07-12&qualityStatus=invalid_delta&qualityReason=Outside_Daily_Range&includeTotal=true&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{`"total":1`, `"protocol":"JT808"`, `"quality_status":"INVALID_DELTA"`, `"quality_reason":"outside_daily_range"`, `"source_count":3`, `"vehicle_count":3`, `"selected_count":0`, `"sample_count":128`} {
		if !strings.Contains(body, want) {
			t.Fatalf("response missing %s: %s", want, body)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricHandlerReturnsDailyMetricSourceSelectionSummary(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\(").
		WithArgs("JT808", "2026-07-12", "2026-07-12").
		WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(2))
	mock.ExpectQuery(dailyMetricSourceSelectionSummarySelectPattern).
		WithArgs("JT808", "2026-07-12", "2026-07-12", 10, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"stat_date", "protocol", "selection_status", "selection_reason",
			"source_count", "vehicle_count", "selected_count", "sample_count", "daily_mileage_km",
		}).AddRow(
			"2026-07-12", "JT808", "excluded", "quality_outside_daily_range",
			int64(3), int64(3), int64(0), int64(128), 12435.2,
		))

	handler := NewMetricHandler(NewMetricRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/sources/selection?protocol=JT808&dateFrom=2026-07-12&dateTo=2026-07-12&includeTotal=true&limit=10", 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{`"total":2`, `"protocol":"JT808"`, `"selection_status":"excluded"`, `"selection_reason":"quality_outside_daily_range"`, `"selection_action":"来源质量未通过`, `"source_count":3`, `"vehicle_count":3`, `"sample_count":128`} {
		if !strings.Contains(body, want) {
			t.Fatalf("response missing %s: %s", want, body)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricHandlerReturnsDailyMetricDiagnostics(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricDiagnosisSelectPattern).
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "stat_date", "protocol", "plate", "platform_name", "peer",
			"snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at",
			"realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km",
			"source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json",
		}).AddRow(
			"LMRKH9AC2R1004087", "2026-07-12", "YUTONG_MQTT", "粤A99999", "宇通 MQTT", "yutong",
			"2026-07-12 10:01:00", nil, "2026-07-12 10:01:01", nil,
			nil, nil, nil, nil,
			0, 0, 0, nil, "", `{"yutong_mqtt.data.latitude":"30.593478","yutong_mqtt.data.longitude":"121.073451","yutong_mqtt.data.meter_speed":"0"}`,
		))

	handler := NewMetricHandler(NewMetricRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics?protocol=YUTONG_MQTT&date=2026-07-12&diagnosis=NO_TOTAL_MILEAGE", 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{`"vin":"LMRKH9AC2R1004087"`, `"diagnosis":"NO_TOTAL_MILEAGE"`, `"reason":"realtime_total_mileage_missing"`, `"severity":"source_data"`, `"recommended_operator_action":"当日实时数据缺少总里程字段`, `"realtime_field_count":3`, `"realtime_mileage_field_status":"no_candidate_mileage_field"`, `"realtime_mileage_evidence":"实时快照已有字段`, `"raw_frame_query_path":"/api/history/raw-frames?`, `"source_sample_query_path":"/api/stats/daily-metrics/sources?`, `dateFrom=2026-07-12+00%3A00%3A00`, `protocol=YUTONG_MQTT`, `vin=LMRKH9AC2R1004087`, `"yutong_mqtt.data.latitude"`, `"yutong_mqtt.data.meter_speed"`, `"total":1`} {
		if !strings.Contains(body, want) {
			t.Fatalf("response missing %s: %s", want, body)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricDiagnosisTraceQueryPaths(t *testing.T) {
	row := MetricDiagnosisRow{
		VIN:      "LMRKH9AC2R1004087",
		StatDate: "2026-07-12",
		Protocol: "YUTONG_MQTT",
	}

	rawPath := metricDiagnosisRawFrameQueryPath(row)
	for _, want := range []string{
		"/api/history/raw-frames?",
		"dateFrom=2026-07-12+00%3A00%3A00",
		"dateTo=2026-07-13+00%3A00%3A00",
		"includeFields=true",
		"limit=20",
		"orderBy=eventTime",
		"protocol=YUTONG_MQTT",
		"vin=LMRKH9AC2R1004087",
	} {
		if !strings.Contains(rawPath, want) {
			t.Fatalf("raw path missing %q: %s", want, rawPath)
		}
	}

	sourcePath := metricDiagnosisSourceSampleQueryPath(row)
	for _, want := range []string{
		"/api/stats/daily-metrics/sources?",
		"dateFrom=2026-07-12",
		"dateTo=2026-07-12",
		"includeTotal=true",
		"limit=50",
		"protocol=YUTONG_MQTT",
		"vin=LMRKH9AC2R1004087",
	} {
		if !strings.Contains(sourcePath, want) {
			t.Fatalf("source path missing %q: %s", want, sourcePath)
		}
	}
}

func TestMetricRepositoryFlagsCandidateMileageFieldsWhenStandardMileageMissing(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricDiagnosisSelectPattern).
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "stat_date", "protocol", "plate", "platform_name", "peer",
			"snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at",
			"realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km",
			"source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json",
		}).AddRow(
			"VIN001", "2026-07-12", "YUTONG_MQTT", "", "宇通 MQTT", "mqtt://yutong/ytforward/shln/3",
			"2026-07-12 10:01:00", nil, "2026-07-12 10:01:01", nil,
			nil, nil, nil, nil,
			0, 0, 0, nil, "", `{"yutong_mqtt.data.latitude":"30.593478","yutong_mqtt.data.odometer":"65423000","yutong_mqtt.data.meter_speed":"12"}`,
		))

	repository := NewMetricRepository(db)
	rows, err := repository.QueryDiagnostics(context.Background(), MetricDiagnosisQuery{
		Protocol:  "YUTONG_MQTT",
		Date:      "2026-07-12",
		Diagnosis: "NO_TOTAL_MILEAGE",
		Limit:     20,
	})
	if err != nil {
		t.Fatalf("QueryDiagnostics() error = %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("row count = %d", len(rows))
	}
	row := rows[0]
	if row.RealtimeMileageFieldStatus != "candidate_field_unmapped" {
		t.Fatalf("mileage field status = %q, row=%#v", row.RealtimeMileageFieldStatus, row)
	}
	if len(row.RealtimeMileageCandidateFields) != 1 || row.RealtimeMileageCandidateFields[0] != "yutong_mqtt.data.odometer" {
		t.Fatalf("candidate fields = %#v", row.RealtimeMileageCandidateFields)
	}
	if !strings.Contains(row.RealtimeMileageEvidence, "协议字段映射") {
		t.Fatalf("evidence = %q", row.RealtimeMileageEvidence)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricRepositoryTreatsMergedMappedMileageFieldAsStaleEvidence(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricDiagnosisSelectPattern).
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "stat_date", "protocol", "plate", "platform_name", "peer",
			"snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at",
			"realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km",
			"source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json",
		}).AddRow(
			"VIN001", "2026-07-12", "YUTONG_MQTT", "", "宇通 MQTT", "mqtt://yutong/ytforward/shln/3",
			"2026-07-12 10:01:00", nil, "2026-07-12 10:01:01", nil,
			nil, nil, nil, nil,
			0, 0, 0, nil, "", `{"yutong_mqtt.data.latitude":"30.593478","yutong_mqtt.data.total_mileage":"65423000","yutong_mqtt.data.meter_speed":"12"}`,
		))

	repository := NewMetricRepository(db)
	rows, err := repository.QueryDiagnostics(context.Background(), MetricDiagnosisQuery{
		Protocol:  "YUTONG_MQTT",
		Date:      "2026-07-12",
		Diagnosis: "NO_TOTAL_MILEAGE",
		Limit:     20,
	})
	if err != nil {
		t.Fatalf("QueryDiagnostics() error = %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("row count = %d", len(rows))
	}
	row := rows[0]
	if row.RealtimeMileageFieldStatus != "mapped_protocol_field_without_fresh_evidence" {
		t.Fatalf("mileage field status = %q, row=%#v", row.RealtimeMileageFieldStatus, row)
	}
	if len(row.RealtimeMileageCandidateFields) != 1 || row.RealtimeMileageCandidateFields[0] != "yutong_mqtt.data.total_mileage" {
		t.Fatalf("candidate fields = %#v", row.RealtimeMileageCandidateFields)
	}
	if !strings.Contains(row.RealtimeMileageEvidence, "合并快照") {
		t.Fatalf("evidence = %q", row.RealtimeMileageEvidence)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricRepositoryReportsStaleStandardMileageBeforeCandidateFields(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricDiagnosisSelectPattern).
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "stat_date", "protocol", "plate", "platform_name", "peer",
			"snapshot_event_time", "location_event_time", "snapshot_updated_at", "location_updated_at",
			"realtime_total_mileage_km", "realtime_total_mileage_event_time", "daily_mileage_km", "daily_latest_total_mileage_km",
			"source_sample_count", "ok_source_count", "selectable_source_count", "latest_stat_event_time", "quality_statuses", "snapshot_parsed_json",
		}).AddRow(
			"VIN001", "2026-07-13", "YUTONG_MQTT", "", "宇通 MQTT", "mqtt://yutong/ytforward/shln/3",
			"2026-07-13 10:01:00", "2026-07-13 10:01:00", "2026-07-13 10:01:01", "2026-07-13 10:01:01",
			65423.0, "2026-07-12 23:59:59", nil, nil,
			0, 0, 0, nil, "", `{"yutong_mqtt.data.total_mileage":"65423000"}`,
		))

	repository := NewMetricRepository(db)
	rows, err := repository.QueryDiagnostics(context.Background(), MetricDiagnosisQuery{
		Protocol:  "YUTONG_MQTT",
		Date:      "2026-07-13",
		Diagnosis: "NO_TOTAL_MILEAGE",
		Limit:     20,
	})
	if err != nil {
		t.Fatalf("QueryDiagnostics() error = %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("row count = %d", len(rows))
	}
	row := rows[0]
	if row.Reason != "realtime_total_mileage_not_reported_on_stat_date" {
		t.Fatalf("reason = %q", row.Reason)
	}
	if row.RealtimeMileageFieldStatus != "standard_field_stale" {
		t.Fatalf("mileage field status = %q, row=%#v", row.RealtimeMileageFieldStatus, row)
	}
	if len(row.RealtimeMileageCandidateFields) != 0 {
		t.Fatalf("candidate fields should be hidden for standard stale field: %#v", row.RealtimeMileageCandidateFields)
	}
	if !strings.Contains(row.RealtimeMileageEvidence, "不在统计日") {
		t.Fatalf("evidence = %q", row.RealtimeMileageEvidence)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricHandlerReturnsDailyMetricDiagnosisSummary(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricDiagnosisSummarySelectPattern).
		WillReturnRows(sqlmock.NewRows([]string{
			"stat_date", "protocol", "active_count", "ok_count", "missing_daily_count", "no_source_sample_count", "no_total_mileage_count",
		}).AddRow(
			"2026-07-12", "YUTONG_MQTT", int64(13), int64(7), int64(0), int64(0), int64(6),
		))

	handler := NewMetricHandler(NewMetricRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics/summary?protocol=YUTONG_MQTT&date=2026-07-12", 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"`, `"active_count":13`, `"ok_count":7`, `"no_total_mileage_count":6`, `"active_total":13`, `"actionable_issue_total":6`} {
		if !strings.Contains(body, want) {
			t.Fatalf("response missing %s: %s", want, body)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricHandlerReturnsDailyMetricDiagnosisReasonSummary(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricDiagnosisReasonSummarySelectPattern).
		WillReturnRows(sqlmock.NewRows([]string{
			"stat_date", "protocol", "diagnosis", "reason", "count",
		}).AddRow(
			"2026-07-12", "JT808", "NO_TOTAL_MILEAGE", "realtime_total_mileage_non_positive", int64(2),
		))

	handler := NewMetricHandler(NewMetricRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics/reasons?protocol=JT808&date=2026-07-12&diagnosis=NO_TOTAL_MILEAGE", 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":"JT808"`, `"diagnosis":"NO_TOTAL_MILEAGE"`, `"reason":"realtime_total_mileage_non_positive"`, `"count":2`, `"severity":"source_data"`, `"recommended_operator_action":"终端上报总里程小于等于 0`, `"vehicle_total":2`, `"actionable_issue_total":2`, `"pipeline_issue_total":0`, `"source_data_issue_total":2`} {
		if !strings.Contains(body, want) {
			t.Fatalf("response missing %s: %s", want, body)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricHandlerReturnsDailyMetricDiagnosisFieldStatusSummary(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricDiagnosisFieldStatusSummarySelectPattern).
		WillReturnRows(sqlmock.NewRows([]string{
			"stat_date", "protocol", "diagnosis", "reason", "realtime_mileage_field_status", "count",
		}).AddRow(
			"2026-07-12", "JT808", "NO_TOTAL_MILEAGE", "realtime_total_mileage_non_positive", "standard_field_non_positive", int64(2),
		))

	handler := NewMetricHandler(NewMetricRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics/field-status?protocol=JT808&date=2026-07-12&diagnosis=NO_TOTAL_MILEAGE", 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":"JT808"`, `"diagnosis":"NO_TOTAL_MILEAGE"`, `"reason":"realtime_total_mileage_non_positive"`, `"realtime_mileage_field_status":"standard_field_non_positive"`, `"field_status_severity":"source_data"`, `"field_status_action":"源头总里程小于等于 0`, `"vehicle_total":2`, `"source_data_issue_total":2`} {
		if !strings.Contains(body, want) {
			t.Fatalf("response missing %s: %s", want, body)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricHandlerReturnsEmptyItemsArrayWhenNoRows(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricSelectPattern).
		WithArgs("YUTONG_MQTT", 50, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "stat_date", "protocol", "source_id",
			"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
			"latest_total_mileage_km", "updated_at",
		}))

	handler := NewMetricHandler(NewMetricRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?protocol=YUTONG_MQTT", 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()
	if !strings.Contains(body, `"items":[]`) || strings.Contains(body, `"items":null`) {
		t.Fatalf("expected empty items array, got: %s", body)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricHandlerSkipsTotalCountByDefault(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dailyMetricSelectPattern).
		WithArgs("JT808", 1, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"vin", "stat_date", "protocol", "source_id",
			"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
			"latest_total_mileage_km", "updated_at",
		}).AddRow(
			"LKLG7C4E3NA774736", "2026-07-02", "JT808", 3,
			"115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM", 12.3,
			8805.1, "2026-07-02 23:59:59",
		))

	handler := NewMetricHandler(NewMetricRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?protocol=JT808&limit=1", 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()
	if !strings.Contains(body, `"total":1`) {
		t.Fatalf("response should use page size as total when total count is not requested: %s", body)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMetricHandlerRejectsInvalidPagination(t *testing.T) {
	handler := NewMetricHandler(NewMetricRepository(&sql.DB{}))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?limit=2001", nil)
	response := httptest.NewRecorder()

	handler.ServeHTTP(response, request)

	if response.Code != http.StatusBadRequest {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
}

func TestMetricHandlerRejectsInvalidDiagnosis(t *testing.T) {
	handler := NewMetricHandler(NewMetricRepository(&sql.DB{}))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics?diagnosis=BAD", nil)
	response := httptest.NewRecorder()

	handler.ServeHTTP(response, request)

	if response.Code != http.StatusBadRequest {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
}

func TestMetricHandlerRejectsInvalidDiagnosisReason(t *testing.T) {
	handler := NewMetricHandler(NewMetricRepository(&sql.DB{}))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics?reason=unknown_reason", nil)
	response := httptest.NewRecorder()

	handler.ServeHTTP(response, request)

	if response.Code != http.StatusBadRequest {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
}

func TestMetricHandlerRejectsInvalidDiagnosisSeverity(t *testing.T) {
	handler := NewMetricHandler(NewMetricRepository(&sql.DB{}))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics/diagnostics?severity=critical", nil)
	response := httptest.NewRecorder()

	handler.ServeHTTP(response, request)

	if response.Code != http.StatusBadRequest {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
}
