package main

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

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

func TestParseEndpoints(t *testing.T) {
	endpoints := parseEndpoints("gateway=http://127.0.0.1:20211/metrics, fast=http://127.0.0.1:20215/metrics")

	if len(endpoints) != 2 {
		t.Fatalf("len = %d, want 2: %#v", len(endpoints), endpoints)
	}
	if endpoints[0].Name != "gateway" || endpoints[0].URL != "http://127.0.0.1:20211/metrics" {
		t.Fatalf("endpoint[0] = %#v", endpoints[0])
	}
	if endpoints[1].Name != "fast" || endpoints[1].URL != "http://127.0.0.1:20215/metrics" {
		t.Fatalf("endpoint[1] = %#v", endpoints[1])
	}
}

func TestParseEndpointsUsesDefaultMetricsURLWhenOnlyBaseURLIsProvided(t *testing.T) {
	endpoints := parseEndpoints("gateway=http://127.0.0.1:20211")

	if len(endpoints) != 1 {
		t.Fatalf("len = %d, want 1", len(endpoints))
	}
	if !strings.HasSuffix(endpoints[0].URL, "/metrics") {
		t.Fatalf("url = %q, want /metrics suffix", endpoints[0].URL)
	}
}

func TestDefaultEndpointsSplitRealtimeAPIAndWriter(t *testing.T) {
	endpoints := parseEndpoints(defaultEndpoints)
	byName := map[string]string{}
	for _, endpoint := range endpoints {
		byName[endpoint.Name] = endpoint.URL
	}
	if got, want := byName["realtime"], "http://127.0.0.1:20216/metrics"; got != want {
		t.Fatalf("realtime endpoint = %q, want %q", got, want)
	}
	if got, want := byName["realtime-api"], "http://127.0.0.1:20200/metrics"; got != want {
		t.Fatalf("realtime-api endpoint = %q, want %q", got, want)
	}
	if got, want := byName["identity"], "http://127.0.0.1:20217/metrics"; got != want {
		t.Fatalf("identity endpoint = %q, want %q", got, want)
	}
	if got, want := byName["fields-projector"], "http://127.0.0.1:20218/metrics"; got != want {
		t.Fatalf("fields-projector endpoint = %q, want %q", got, want)
	}
	services := strings.Join(parseRequiredServices(defaultRequiredServices), ",")
	for _, want := range []string{"realtime", "identity", "realtime-api", "fields-projector"} {
		if !strings.Contains(services, want) {
			t.Fatalf("required services %q missing %q", services, want)
		}
	}
}

func TestParseRequiredConsumerTopics(t *testing.T) {
	parsed := parseRequiredConsumerTopics("history=a|b|a; stat = c | d ; ignored")

	if got := strings.Join(parsed["history"], ","); got != "a,b" {
		t.Fatalf("history topics = %q, want a,b", got)
	}
	if got := strings.Join(parsed["stat"], ","); got != "c,d" {
		t.Fatalf("stat topics = %q, want c,d", got)
	}
	if _, exists := parsed["ignored"]; exists {
		t.Fatalf("unexpected ignored service in %#v", parsed)
	}
}

func TestParseRequiredConsumerTopicsReturnsNilForEmptyValue(t *testing.T) {
	if parsed := parseRequiredConsumerTopics(" "); parsed != nil {
		t.Fatalf("parsed = %#v, want nil", parsed)
	}
}

func TestParseRequiredServicesDeduplicatesAndPreservesOrder(t *testing.T) {
	services := parseRequiredServices("gateway, history, gateway, stat")

	if got := strings.Join(services, ","); got != "gateway,history,stat" {
		t.Fatalf("services = %q, want gateway,history,stat", got)
	}
}

func TestParseRequiredServicesReturnsNilForEmptyValue(t *testing.T) {
	if services := parseRequiredServices(" "); services != nil {
		t.Fatalf("services = %#v, want nil", services)
	}
}

func TestRequiredServiceFindingsReportsMissingEndpoint(t *testing.T) {
	findings := requiredServiceFindings(
		[]endpoint{{Name: "gateway", URL: "http://127.0.0.1:20211/metrics"}},
		map[string]string{"gateway": "vehicle_service_info 1"},
		[]string{"gateway", "history"},
	)

	if len(findings) != 1 || findings[0] != "required service endpoint missing: history" {
		t.Fatalf("findings = %#v", findings)
	}
}

func TestRequiredServiceFindingsReportsConfiguredButUnscrapedService(t *testing.T) {
	findings := requiredServiceFindings(
		[]endpoint{
			{Name: "gateway", URL: "http://127.0.0.1:20211/metrics"},
			{Name: "history", URL: "http://127.0.0.1:20212/metrics"},
		},
		map[string]string{"gateway": "vehicle_service_info 1"},
		[]string{"gateway", "history"},
	)

	if len(findings) != 1 || findings[0] != "required service metrics missing: history" {
		t.Fatalf("findings = %#v", findings)
	}
}

func TestRequiredServiceFindingsCanBeDisabled(t *testing.T) {
	findings := requiredServiceFindings(nil, nil, nil)

	if len(findings) != 0 {
		t.Fatalf("findings = %#v, want none", findings)
	}
}

func TestFormatRequiredConsumerTopicsIsStable(t *testing.T) {
	formatted := formatRequiredConsumerTopics(map[string][]string{
		"stat":    {"vehicle.fields.go.jt808.v1", "vehicle.fields.go.gb32960.v1"},
		"history": {"vehicle.raw.go.jt808.v1"},
	})

	want := "history=vehicle.raw.go.jt808.v1;stat=vehicle.fields.go.gb32960.v1|vehicle.fields.go.jt808.v1"
	if formatted != want {
		t.Fatalf("formatted = %q, want %q", formatted, want)
	}
}

func TestDailyMileageDiagnosticsRequestURLUsesDefaultPathAndDate(t *testing.T) {
	requestURL, err := dailyMileageDiagnosticsRequestURL("http://127.0.0.1:20200", "2026-07-12")
	if err != nil {
		t.Fatalf("request URL error: %v", err)
	}
	if !strings.Contains(requestURL, defaultDailyMileageDiagnosticsPath) {
		t.Fatalf("url = %q, want default diagnostics path", requestURL)
	}
	if !strings.Contains(requestURL, "date=2026-07-12") {
		t.Fatalf("url = %q, want date query", requestURL)
	}
}

func TestDailyMileageFieldStatusRequestURLDerivesFromReasonsPath(t *testing.T) {
	requestURL, err := dailyMileageFieldStatusRequestURL("http://127.0.0.1:20200/api/stats/daily-metrics/diagnostics/reasons?severity=source_data", "2026-07-12")
	if err != nil {
		t.Fatalf("request URL error: %v", err)
	}
	for _, want := range []string{
		defaultDailyMileageFieldStatusPath,
		"date=2026-07-12",
		"severity=source_data",
	} {
		if !strings.Contains(requestURL, want) {
			t.Fatalf("url = %q, want %q", requestURL, want)
		}
	}
	if strings.Contains(requestURL, "/reasons") {
		t.Fatalf("url = %q should not keep /reasons", requestURL)
	}
}

func TestCollectDailyMileageDiagnostics(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if got := r.URL.Query().Get("date"); got != "2026-07-12" {
			t.Fatalf("date = %q, want 2026-07-12", got)
		}
		w.Header().Set("Content-Type", "application/json")
		switch r.URL.Path {
		case defaultDailyMileageDiagnosticsPath:
			_, _ = w.Write([]byte(`{
				"items":[
					{"stat_date":"2026-07-12","protocol":"YUTONG_MQTT","diagnosis":"NO_SOURCE_SAMPLE","reason":"realtime_location_has_total_mileage_but_no_stat_sample","count":7},
					{"stat_date":"2026-07-12","protocol":"JT808","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_non_positive","count":2}
				],
				"vehicle_total":349,
				"actionable_issue_total":9
			}`))
		case defaultDailyMileageFieldStatusPath:
			_, _ = w.Write([]byte(`{
				"items":[
					{"stat_date":"2026-07-12","protocol":"JT808","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_non_positive","realtime_mileage_field_status":"standard_field_non_positive","count":2,"severity":"source_data","field_status_severity":"source_data","field_status_action":"源头总里程小于等于 0"}
				],
				"total":1,
				"vehicle_total":349,
				"actionable_issue_total":9,
				"pipeline_issue_total":7,
				"source_data_issue_total":2
			}`))
		default:
			t.Fatalf("unexpected path %q", r.URL.Path)
		}
	}))
	defer server.Close()

	diagnostics, findings := collectDailyMileageDiagnostics(context.Background(), server.URL, "2026-07-12", time.Second, 0)
	if len(findings) != 0 {
		t.Fatalf("findings = %#v, want none", findings)
	}
	if diagnostics == nil {
		t.Fatal("diagnostics = nil")
	}
	if diagnostics.VehicleTotal != 349 || diagnostics.ActionableIssueTotal != 9 {
		t.Fatalf("diagnostics = %#v", diagnostics)
	}
	if diagnostics.PipelineIssueTotal != 7 || diagnostics.SourceDataIssueTotal != 2 {
		t.Fatalf("issue split = pipeline:%d source:%d", diagnostics.PipelineIssueTotal, diagnostics.SourceDataIssueTotal)
	}
	if len(diagnostics.Items) != 2 || diagnostics.Items[0].Severity != "pipeline" || diagnostics.Items[1].Severity != "source_data" {
		t.Fatalf("items = %#v", diagnostics.Items)
	}
	if diagnostics.FieldStatus == nil || diagnostics.FieldStatus.SummaryTotal != 1 || diagnostics.FieldStatus.Items[0].RealtimeMileageFieldStatus != "standard_field_non_positive" {
		t.Fatalf("field status = %#v", diagnostics.FieldStatus)
	}
}

func TestCollectDailyMileageDiagnosticsIncludesIssueSamples(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		switch r.URL.Path {
		case defaultDailyMileageDiagnosticsPath:
			_, _ = w.Write([]byte(`{
				"items":[
					{"stat_date":"2026-07-12","protocol":"JT808","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_non_positive","count":2},
					{"stat_date":"2026-07-12","protocol":"YUTONG_MQTT","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_missing","count":1}
				],
				"vehicle_total":3,
				"actionable_issue_total":3
			}`))
		case "/api/stats/daily-metrics/diagnostics":
			if got := r.URL.Query().Get("date"); got != "2026-07-12" {
				t.Fatalf("date = %q, want 2026-07-12", got)
			}
			if got := r.URL.Query().Get("limit"); got == "" {
				t.Fatal("sample detail request missing limit")
			}
			switch r.URL.Query().Get("protocol") {
			case "JT808":
				_, _ = w.Write([]byte(`{"items":[{"vin":"LKLG7C4E1NA774752","stat_date":"2026-07-12","protocol":"JT808","plate":"沪A03086F","peer":"222.66.200.68:12962","realtime_total_mileage_km":0,"realtime_field_count":23,"realtime_sample_fields":["jt808.location.latitude","jt808.location.speed_kmh"],"realtime_mileage_field_status":"standard_field_non_positive","realtime_mileage_evidence":"标准里程字段为 0","raw_frame_query_path":"/api/history/raw-frames?protocol=JT808&vin=LKLG7C4E1NA774752","source_sample_query_path":"/api/stats/daily-metrics/sources?protocol=JT808&vin=LKLG7C4E1NA774752","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_non_positive"}]}`))
			case "YUTONG_MQTT":
				_, _ = w.Write([]byte(`{"items":[{"vin":"LMRKH9AC9R1004121","stat_date":"2026-07-12","protocol":"YUTONG_MQTT","peer":"mqtt://yutong/ytforward/shln/3","realtime_field_count":27,"realtime_sample_fields":["yutong_mqtt.data.latitude","yutong_mqtt.data.meter_speed"],"realtime_mileage_field_status":"no_candidate_mileage_field","realtime_mileage_evidence":"未发现 mileage 字段","raw_frame_query_path":"/api/history/raw-frames?protocol=YUTONG_MQTT&vin=LMRKH9AC9R1004121","source_sample_query_path":"/api/stats/daily-metrics/sources?protocol=YUTONG_MQTT&vin=LMRKH9AC9R1004121","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_missing"}]}`))
			default:
				t.Fatalf("unexpected protocol %q", r.URL.Query().Get("protocol"))
			}
		case defaultDailyMileageFieldStatusPath:
			_, _ = w.Write([]byte(`{
				"items":[
					{"stat_date":"2026-07-12","protocol":"JT808","diagnosis":"NO_TOTAL_MILEAGE","reason":"realtime_total_mileage_non_positive","realtime_mileage_field_status":"standard_field_non_positive","count":2,"field_status_severity":"source_data"}
				],
				"total":1,
				"vehicle_total":3,
				"actionable_issue_total":3,
				"pipeline_issue_total":0,
				"source_data_issue_total":3
			}`))
		default:
			t.Fatalf("unexpected path %q", r.URL.Path)
		}
	}))
	defer server.Close()

	diagnostics, findings := collectDailyMileageDiagnostics(context.Background(), server.URL, "2026-07-12", time.Second, 2)
	if len(findings) != 0 {
		t.Fatalf("findings = %#v, want none", findings)
	}
	if diagnostics == nil {
		t.Fatal("diagnostics = nil")
	}
	if len(diagnostics.Samples) != 2 {
		t.Fatalf("samples = %#v, want two", diagnostics.Samples)
	}
	if diagnostics.Samples[0].VIN != "LKLG7C4E1NA774752" || diagnostics.Samples[0].Severity != capacity.DailyMileageSeveritySourceData {
		t.Fatalf("first sample = %#v", diagnostics.Samples[0])
	}
	if !strings.Contains(diagnostics.Samples[0].RecommendedOperatorAction, "总里程小于等于 0") {
		t.Fatalf("first sample recommended action = %q", diagnostics.Samples[0].RecommendedOperatorAction)
	}
	if diagnostics.Samples[0].RealtimeFieldCount != 23 || diagnostics.Samples[0].RealtimeMileageFieldStatus != "standard_field_non_positive" {
		t.Fatalf("first sample field diagnostics lost: %#v", diagnostics.Samples[0])
	}
	if diagnostics.Samples[0].RawFrameQueryPath == "" || diagnostics.Samples[0].SourceSampleQueryPath == "" {
		t.Fatalf("first sample trace links lost: %#v", diagnostics.Samples[0])
	}
	if diagnostics.Samples[1].VIN != "LMRKH9AC9R1004121" || diagnostics.Samples[1].Severity != capacity.DailyMileageSeveritySourceData {
		t.Fatalf("second sample = %#v", diagnostics.Samples[1])
	}
	if !strings.Contains(diagnostics.Samples[1].RecommendedOperatorAction, "缺少总里程字段") {
		t.Fatalf("second sample recommended action = %q", diagnostics.Samples[1].RecommendedOperatorAction)
	}
	if len(diagnostics.Samples[1].RealtimeSampleFields) != 2 || diagnostics.Samples[1].RealtimeMileageEvidence == "" {
		t.Fatalf("second sample realtime evidence lost: %#v", diagnostics.Samples[1])
	}
	if diagnostics.Samples[1].RawFrameQueryPath == "" || diagnostics.Samples[1].SourceSampleQueryPath == "" {
		t.Fatalf("second sample trace links lost: %#v", diagnostics.Samples[1])
	}
}

func TestDailyMileageDiagnosticsSampleRequestURLDerivesDetailPath(t *testing.T) {
	requestURL, err := dailyMileageDiagnosticsSampleRequestURL(
		"http://127.0.0.1:20200/api/stats/daily-metrics/diagnostics/reasons?date=2026-07-11",
		"2026-07-12",
		capacity.DailyMileageDiagnosisReasonItem{Protocol: "jt808", Diagnosis: "no_total_mileage"},
		3,
	)
	if err != nil {
		t.Fatalf("sample request URL error: %v", err)
	}
	for _, want := range []string{
		"/api/stats/daily-metrics/diagnostics?",
		"date=2026-07-12",
		"protocol=JT808",
		"diagnosis=NO_TOTAL_MILEAGE",
		"limit=3",
		"offset=0",
	} {
		if !strings.Contains(requestURL, want) {
			t.Fatalf("url = %q, want %q", requestURL, want)
		}
	}
}

func TestDailyMileageDiagnosticFindingsCanGateOnlyPipelineIssues(t *testing.T) {
	diagnostics := &capacity.DailyMileageDiagnostics{
		ActionableIssueTotal: 9,
		PipelineIssueTotal:   7,
		SourceDataIssueTotal: 2,
	}

	findings := dailyMileageDiagnosticFindings(diagnostics, -1, 0, -1)

	if len(findings) != 1 {
		t.Fatalf("findings = %#v, want one pipeline finding", findings)
	}
	if !strings.Contains(findings[0], "daily mileage pipeline issues 7 exceeds 0") {
		t.Fatalf("finding = %q, want pipeline threshold", findings[0])
	}
	if strings.Contains(findings[0], "actionable issues") {
		t.Fatalf("pipeline-only gate should not use actionable threshold: %q", findings[0])
	}
}

func TestDailyMileageDiagnosticFindingsReportsOnlyWhenPipelineIsCleanAndSourceDataExists(t *testing.T) {
	diagnostics := &capacity.DailyMileageDiagnostics{
		ActionableIssueTotal: 3,
		PipelineIssueTotal:   0,
		SourceDataIssueTotal: 3,
	}

	findings := dailyMileageDiagnosticFindings(diagnostics, -1, 0, -1)

	if len(findings) != 0 {
		t.Fatalf("findings = %#v, want none because source-data issues should not block pipeline gate", findings)
	}
}

func TestSummarizeDailyMileageFieldStatusPrefersDiagnosisSeverity(t *testing.T) {
	actionable, pipeline, sourceData := summarizeDailyMileageFieldStatus([]capacity.DailyMileageFieldStatusSummaryItem{
		{
			Diagnosis:                  "NO_TOTAL_MILEAGE",
			RealtimeMileageFieldStatus: "candidate_field_unmapped",
			FieldStatusSeverity:        capacity.DailyMileageSeverityPipeline,
			Severity:                   capacity.DailyMileageSeveritySourceData,
			Count:                      4,
		},
		{
			Diagnosis:                  "NO_TOTAL_MILEAGE",
			RealtimeMileageFieldStatus: "no_candidate_mileage_field",
			FieldStatusSeverity:        capacity.DailyMileageSeveritySourceData,
			Count:                      3,
		},
		{
			Diagnosis:                  "OK",
			RealtimeMileageFieldStatus: "daily_metric_exists",
			FieldStatusSeverity:        capacity.DailyMileageSeverityOK,
			Count:                      10,
		},
	})

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

func TestCollectDailyMileageDiagnosticsReportsHTTPError(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "mysql unavailable", http.StatusInternalServerError)
	}))
	defer server.Close()

	diagnostics, findings := collectDailyMileageDiagnostics(context.Background(), server.URL, "", time.Second, 0)
	if diagnostics != nil {
		t.Fatalf("diagnostics = %#v, want nil", diagnostics)
	}
	if len(findings) != 1 || !strings.Contains(findings[0], "status 500") {
		t.Fatalf("findings = %#v, want status 500", findings)
	}
}

func TestDailyMileageSourceQualityRequestURLUsesDefaultPathAndDateRange(t *testing.T) {
	requestURL, err := dailyMileageSourceQualityRequestURL("http://127.0.0.1:20200", "2026-07-12", 3)
	if err != nil {
		t.Fatalf("request URL error: %v", err)
	}
	for _, want := range []string{
		defaultDailyMileageSourceQualityPath,
		"dateFrom=2026-07-12",
		"dateTo=2026-07-12",
		"includeTotal=true",
		"limit=3",
		"offset=0",
	} {
		if !strings.Contains(requestURL, want) {
			t.Fatalf("url = %q, want %q", requestURL, want)
		}
	}
}

func TestCollectDailyMileageSourceQuality(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != defaultDailyMileageSourceQualityPath {
			t.Fatalf("path = %q, want %q", r.URL.Path, defaultDailyMileageSourceQualityPath)
		}
		if got := r.URL.Query().Get("dateFrom"); got != "2026-07-12" {
			t.Fatalf("dateFrom = %q, want 2026-07-12", got)
		}
		if got := r.URL.Query().Get("dateTo"); got != "2026-07-12" {
			t.Fatalf("dateTo = %q, want 2026-07-12", got)
		}
		if got := r.URL.Query().Get("includeTotal"); got != "true" {
			t.Fatalf("includeTotal = %q, want true", got)
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{
			"items":[
				{"stat_date":"2026-07-12","protocol":"JT808","quality_status":"OK","quality_reason":"historical_source_baseline","source_count":10,"vehicle_count":10,"selected_count":10,"sample_count":200,"daily_mileage_km":800.5},
				{"stat_date":"2026-07-12","protocol":"GB32960","quality_status":"OK","quality_reason":"current_day_fallback_after_invalid_baseline","source_count":4,"vehicle_count":4,"selected_count":4,"sample_count":40,"daily_mileage_km":12.3},
				{"stat_date":"2026-07-12","protocol":"JT808","quality_status":"INVALID_DELTA","quality_reason":"outside_daily_range","source_count":3,"vehicle_count":3,"selected_count":0,"sample_count":12,"daily_mileage_km":4000},
				{"stat_date":"2026-07-12","protocol":"YUTONG_MQTT","quality_status":"NO_PREVIOUS_BASELINE","quality_reason":"current_day_first_sample","source_count":2,"vehicle_count":2,"selected_count":0,"sample_count":2,"daily_mileage_km":0},
				{"stat_date":"2026-07-12","protocol":"YUTONG_MQTT","quality_status":"OK","quality_reason":"realtime_location_fallback_historical_baseline","source_count":5,"vehicle_count":5,"selected_count":5,"sample_count":5,"daily_mileage_km":0}
			],
			"total":7,
			"limit":5,
			"offset":0
		}`))
	}))
	defer server.Close()

	diagnostics, findings := collectDailyMileageSourceQuality(context.Background(), server.URL, "2026-07-12", time.Second, 5)
	if len(findings) != 0 {
		t.Fatalf("findings = %#v, want none", findings)
	}
	if diagnostics == nil {
		t.Fatal("diagnostics = nil")
	}
	if diagnostics.SummaryTotal != 7 || !diagnostics.ItemsTruncated {
		t.Fatalf("summary total/truncated = %d/%v, want 7/true", diagnostics.SummaryTotal, diagnostics.ItemsTruncated)
	}
	if diagnostics.SourceCount != 24 || diagnostics.OKSourceCount != 19 || diagnostics.InvalidDeltaSourceCount != 3 || diagnostics.NoBaselineSourceCount != 2 || diagnostics.FallbackAfterInvalidBaselineSourceCount != 4 || diagnostics.RealtimeLocationFallbackSourceCount != 5 {
		t.Fatalf("source quality totals = %#v", diagnostics)
	}
	if diagnostics.VehicleCount != 24 || diagnostics.SampleCount != 259 {
		t.Fatalf("vehicle/sample totals = %d/%d", diagnostics.VehicleCount, diagnostics.SampleCount)
	}
	if len(diagnostics.Items) != 5 {
		t.Fatalf("items = %#v, want five", diagnostics.Items)
	}
}

func TestDailyMileageSourceQualityFindingsCanGateInvalidDelta(t *testing.T) {
	diagnostics := &capacity.DailyMileageSourceQualityDiagnostics{
		InvalidDeltaSourceCount: 3,
		NoBaselineSourceCount:   2,
	}

	findings := dailyMileageSourceQualityFindings(diagnostics, 0, -1, -1, -1)

	if len(findings) != 1 || !strings.Contains(findings[0], "daily mileage source INVALID_DELTA count 3 exceeds 0") {
		t.Fatalf("findings = %#v", findings)
	}
}

func TestDailyMileageSourceQualityFindingsCanGateNoBaseline(t *testing.T) {
	diagnostics := &capacity.DailyMileageSourceQualityDiagnostics{
		InvalidDeltaSourceCount: 3,
		NoBaselineSourceCount:   2,
	}

	findings := dailyMileageSourceQualityFindings(diagnostics, -1, 0, -1, -1)

	if len(findings) != 1 || !strings.Contains(findings[0], "daily mileage source NO_PREVIOUS_BASELINE count 2 exceeds 0") {
		t.Fatalf("findings = %#v", findings)
	}
}

func TestDailyMileageSourceQualityFindingsCanGateFallbackAfterInvalidBaseline(t *testing.T) {
	diagnostics := &capacity.DailyMileageSourceQualityDiagnostics{
		FallbackAfterInvalidBaselineSourceCount: 4,
	}

	findings := dailyMileageSourceQualityFindings(diagnostics, -1, -1, 0, -1)

	if len(findings) != 1 || !strings.Contains(findings[0], "daily mileage source current-day fallback after invalid baseline count 4 exceeds 0") {
		t.Fatalf("findings = %#v", findings)
	}
}

func TestDailyMileageSourceQualityFindingsCanGateRealtimeLocationFallback(t *testing.T) {
	diagnostics := &capacity.DailyMileageSourceQualityDiagnostics{
		RealtimeLocationFallbackSourceCount: 5,
	}

	findings := dailyMileageSourceQualityFindings(diagnostics, -1, -1, -1, 0)

	if len(findings) != 1 || !strings.Contains(findings[0], "daily mileage source realtime-location fallback count 5 exceeds 0") {
		t.Fatalf("findings = %#v", findings)
	}
}

func TestDailyMileageSourceSelectionRequestURLUsesDefaultPathAndDateRange(t *testing.T) {
	requestURL, err := dailyMileageSourceSelectionRequestURL("http://127.0.0.1:20200", "2026-07-12", 3)
	if err != nil {
		t.Fatalf("request URL error: %v", err)
	}
	for _, want := range []string{
		defaultDailyMileageSourceSelectionPath,
		"dateFrom=2026-07-12",
		"dateTo=2026-07-12",
		"includeTotal=true",
		"limit=3",
		"offset=0",
	} {
		if !strings.Contains(requestURL, want) {
			t.Fatalf("url = %q, want %q", requestURL, want)
		}
	}
}

func TestDailyMileageSourceSelectionSampleRequestURLUsesSourceDetailPath(t *testing.T) {
	requestURL, err := dailyMileageSourceSelectionSampleRequestURL("http://127.0.0.1:20200/api/stats/daily-metrics/sources/selection", "2026-07-12", 3)
	if err != nil {
		t.Fatalf("request URL error: %v", err)
	}
	for _, want := range []string{
		defaultDailyMileageSourcesPath,
		"dateFrom=2026-07-12",
		"dateTo=2026-07-12",
		"includeTotal=false",
		"selected=false",
		"orderBy=dailyMileageDesc",
		"limit=3",
		"offset=0",
	} {
		if !strings.Contains(requestURL, want) {
			t.Fatalf("url = %q, want %q", requestURL, want)
		}
	}
	if strings.Contains(requestURL, "/selection?") {
		t.Fatalf("url = %q, should use source detail path", requestURL)
	}
}

func TestCollectDailyMileageSourceSelection(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if got := r.URL.Query().Get("dateFrom"); got != "2026-07-12" {
			t.Fatalf("dateFrom = %q, want 2026-07-12", got)
		}
		if got := r.URL.Query().Get("dateTo"); got != "2026-07-12" {
			t.Fatalf("dateTo = %q, want 2026-07-12", got)
		}
		w.Header().Set("Content-Type", "application/json")
		switch r.URL.Path {
		case defaultDailyMileageSourceSelectionPath:
			if got := r.URL.Query().Get("includeTotal"); got != "true" {
				t.Fatalf("includeTotal = %q, want true", got)
			}
			_, _ = w.Write([]byte(`{
				"items":[
					{"stat_date":"2026-07-12","protocol":"JT808","selection_status":"selected","selection_reason":"selected_current_projection","source_count":10,"vehicle_count":10,"selected_count":10,"sample_count":200,"daily_mileage_km":800.5},
					{"stat_date":"2026-07-12","protocol":"JT808","selection_status":"not_selected","selection_reason":"unmanaged_source_lower_priority","source_count":3,"vehicle_count":3,"selected_count":0,"sample_count":12,"daily_mileage_km":40},
					{"stat_date":"2026-07-12","protocol":"JT808","selection_status":"not_selected","selection_reason":"unknown_source_kind_lower_priority","source_count":2,"vehicle_count":2,"selected_count":0,"sample_count":8,"daily_mileage_km":20},
					{"stat_date":"2026-07-12","protocol":"JT808","selection_status":"excluded","selection_reason":"invalid_delta","source_count":1,"vehicle_count":1,"selected_count":0,"sample_count":2,"daily_mileage_km":0}
				],
				"total":5,
				"limit":4,
				"offset":0
			}`))
		case defaultDailyMileageSourcesPath:
			selected := r.URL.Query().Get("selected")
			if got := r.URL.Query().Get("includeTotal"); got != "false" {
				t.Fatalf("includeTotal = %q, want false", got)
			}
			if selected == "true" {
				if got := r.URL.Query().Get("vin"); got == "" {
					t.Fatalf("selected source query should include vin")
				}
				if got := r.URL.Query().Get("protocol"); got != "JT808" {
					t.Fatalf("selected source protocol = %q, want JT808", got)
				}
				_, _ = w.Write([]byte(`{
					"items":[
						{"vin":"` + r.URL.Query().Get("vin") + `","stat_date":"2026-07-12","protocol":"JT808","source_key":"JT808|source|selected","source_ip":"10.0.0.9","source_endpoint":"10.0.0.9:43625","phone":"13300000009","platform_name":"可信平台","source_code":"trusted","source_kind":"PLATFORM","daily_mileage_km":12,"sample_count":20,"quality_status":"OK","selection_status":"selected","selection_reason":"selected_current_projection","selection_action":"selected","updated_at":"2026-07-12T10:01:00+08:00"}
					],
					"total":1,
					"limit":1,
					"offset":0
				}`))
				return
			}
			if selected != "false" {
				t.Fatalf("selected = %q, want false", selected)
			}
			if got := r.URL.Query().Get("orderBy"); got != "dailyMileageDesc" {
				t.Fatalf("orderBy = %q, want dailyMileageDesc", got)
			}
			_, _ = w.Write([]byte(`{
				"items":[
					{"vin":"VIN-UNKNOWN","stat_date":"2026-07-12","protocol":"JT808","source_key":"JT808|source|unknown","source_ip":"10.0.0.3","source_endpoint":"10.0.0.3:43625","phone":"13300000003","platform_name":"未知类型平台","source_code":"unknown","source_kind":"UNKNOWN","daily_mileage_km":30,"sample_count":4,"quality_status":"OK","selection_status":"not_selected","selection_reason":"unknown_source_kind_lower_priority","selection_action":"set_source_kind","updated_at":"2026-07-12T10:00:00+08:00"},
					{"vin":"VIN-UNMANAGED","stat_date":"2026-07-12","protocol":"JT808","source_key":"JT808|source|unmanaged","source_ip":"10.0.0.2","source_endpoint":"10.0.0.2:43625","phone":"13300000002","platform_name":"未维护平台","source_kind":"PLATFORM","daily_mileage_km":20,"sample_count":3,"quality_status":"OK","selection_status":"not_selected","selection_reason":"unmanaged_source_lower_priority","selection_action":"create_vehicle_data_source","updated_at":"2026-07-12T10:00:00+08:00"},
					{"vin":"VIN-LOW","stat_date":"2026-07-12","protocol":"JT808","source_key":"JT808|source|low","source_ip":"10.0.0.1","source_endpoint":"10.0.0.1:43625","phone":"13300000001","platform_name":"低优先级平台","source_code":"G7s","source_kind":"PLATFORM","daily_mileage_km":10,"sample_count":2,"quality_status":"OK","selection_status":"not_selected","selection_reason":"lower_trust_priority_or_sample_count","selection_action":"none","updated_at":"2026-07-12T10:00:00+08:00"}
				],
				"total":3,
				"limit":40,
				"offset":0
			}`))
		default:
			t.Fatalf("path = %q, want source selection or source detail path", r.URL.Path)
		}
	}))
	defer server.Close()

	diagnostics, findings := collectDailyMileageSourceSelection(context.Background(), server.URL, "2026-07-12", time.Second, 4, 2)
	if len(findings) != 0 {
		t.Fatalf("findings = %#v, want none", findings)
	}
	if diagnostics == nil {
		t.Fatal("diagnostics = nil")
	}
	if diagnostics.SummaryTotal != 5 || !diagnostics.ItemsTruncated {
		t.Fatalf("summary total/truncated = %d/%v, want 5/true", diagnostics.SummaryTotal, diagnostics.ItemsTruncated)
	}
	if diagnostics.SourceCount != 16 || diagnostics.SelectedSourceCount != 10 || diagnostics.NotSelectedSourceCount != 5 || diagnostics.ExcludedSourceCount != 1 {
		t.Fatalf("selection totals = %#v", diagnostics)
	}
	if diagnostics.NotSelectedMileageKM != 60 {
		t.Fatalf("not selected mileage = %v, want 60", diagnostics.NotSelectedMileageKM)
	}
	if diagnostics.UnmanagedSourceCount != 3 || diagnostics.UnknownKindSourceCount != 2 {
		t.Fatalf("source management totals unmanaged=%d unknown=%d", diagnostics.UnmanagedSourceCount, diagnostics.UnknownKindSourceCount)
	}
	if diagnostics.VehicleCount != 16 || diagnostics.SampleCount != 222 {
		t.Fatalf("vehicle/sample totals = %d/%d", diagnostics.VehicleCount, diagnostics.SampleCount)
	}
	if len(diagnostics.Protocols) != 1 {
		t.Fatalf("protocols = %#v, want one JT808 summary", diagnostics.Protocols)
	}
	if got := diagnostics.Protocols[0]; got.Protocol != "JT808" || got.SourceCount != 16 || got.SelectedSourceCount != 10 || got.NotSelectedSourceCount != 5 || got.NotSelectedMileageKM != 60 || got.ExcludedSourceCount != 1 || got.UnmanagedSourceCount != 3 || got.UnknownKindSourceCount != 2 {
		t.Fatalf("JT808 protocol summary = %#v", got)
	}
	if len(diagnostics.Items) != 4 {
		t.Fatalf("items = %#v, want four", diagnostics.Items)
	}
	if len(diagnostics.Samples) != 2 {
		t.Fatalf("samples = %#v, want two source management samples", diagnostics.Samples)
	}
	if diagnostics.Samples[0].VIN != "VIN-UNKNOWN" || diagnostics.Samples[0].SelectionAction != "set_source_kind" {
		t.Fatalf("first sample = %#v", diagnostics.Samples[0])
	}
	if diagnostics.Samples[0].SelectedSource == nil || diagnostics.Samples[0].SelectedSource.PlatformName != "可信平台" || diagnostics.Samples[0].SelectedSource.DailyMileageKM != 12 {
		t.Fatalf("first selected source = %#v", diagnostics.Samples[0].SelectedSource)
	}
	if !strings.Contains(diagnostics.Samples[0].SelectedSourceQueryPath, "selected=true") || !strings.Contains(diagnostics.Samples[0].SelectedSourceQueryPath, "vin=VIN-UNKNOWN") {
		t.Fatalf("first selected source query path = %q", diagnostics.Samples[0].SelectedSourceQueryPath)
	}
	if diagnostics.Samples[1].VIN != "VIN-UNMANAGED" || diagnostics.Samples[1].SelectionAction != "create_vehicle_data_source" {
		t.Fatalf("second sample = %#v", diagnostics.Samples[1])
	}
	if diagnostics.Samples[1].SelectedSource == nil || diagnostics.Samples[1].SelectedSource.SourceIP != "10.0.0.9" {
		t.Fatalf("second selected source = %#v", diagnostics.Samples[1].SelectedSource)
	}
}

func TestDailyMileageSourceSelectionFindingsCanGateSelectedSourceFloor(t *testing.T) {
	diagnostics := &capacity.DailyMileageSourceSelectionDiagnostics{SelectedSourceCount: 0}

	findings := dailyMileageSourceSelectionFindings(diagnostics, 1, nil, -1, -1, -1, -1)

	if len(findings) != 1 || !strings.Contains(findings[0], "selected source count 0 is below 1") {
		t.Fatalf("findings = %#v", findings)
	}
}

func TestDailyMileageSourceSelectionFindingsCanGateSelectedSourceByProtocol(t *testing.T) {
	diagnostics := &capacity.DailyMileageSourceSelectionDiagnostics{
		Protocols: []capacity.DailyMileageSourceSelectionProtocol{
			{Protocol: "GB32960", SelectedSourceCount: 12},
			{Protocol: "JT808", SelectedSourceCount: 0},
		},
	}

	findings := dailyMileageSourceSelectionFindings(diagnostics, -1, []string{"GB32960", "JT808", "YUTONG_MQTT"}, 1, -1, -1, -1)

	if len(findings) != 2 {
		t.Fatalf("findings = %#v, want JT808 and YUTONG_MQTT", findings)
	}
	if !strings.Contains(findings[0], "protocol JT808 is 0 below 1") {
		t.Fatalf("first finding = %q", findings[0])
	}
	if !strings.Contains(findings[1], "protocol YUTONG_MQTT is 0 below 1") {
		t.Fatalf("second finding = %q", findings[1])
	}
}

func TestDailyMileageSourceSelectionFindingsCanGateUnmanagedAndUnknownKind(t *testing.T) {
	diagnostics := &capacity.DailyMileageSourceSelectionDiagnostics{
		UnmanagedSourceCount:   3,
		UnknownKindSourceCount: 2,
	}

	findings := dailyMileageSourceSelectionFindings(diagnostics, -1, nil, -1, 0, 0, -1)

	if len(findings) != 2 {
		t.Fatalf("findings = %#v, want two", findings)
	}
	if !strings.Contains(findings[0], "unmanaged source count 3 exceeds 0") {
		t.Fatalf("first finding = %q", findings[0])
	}
	if !strings.Contains(findings[1], "UNKNOWN source_kind count 2 exceeds 0") {
		t.Fatalf("second finding = %q", findings[1])
	}
}

func TestDailyMileageSourceSelectionFindingsCanGateNotSelectedMileage(t *testing.T) {
	diagnostics := &capacity.DailyMileageSourceSelectionDiagnostics{
		NotSelectedMileageKM: 16736.6,
	}

	findings := dailyMileageSourceSelectionFindings(diagnostics, -1, nil, -1, -1, -1, 1000)

	if len(findings) != 1 || !strings.Contains(findings[0], "not-selected source mileage 16736.6km exceeds 1000.0km") {
		t.Fatalf("findings = %#v", findings)
	}
}

func TestDailyMileageSourceSelectionSampleIncludesHighMileageNotSelectedSource(t *testing.T) {
	sample := capacity.DailyMileageSourceSelectionSample{
		SelectionStatus: "not_selected",
		SelectionReason: "lower_trust_priority_or_sample_count",
		DailyMileageKM:  1234.5,
	}

	if !isProblematicDailyMileageSourceSelectionSample(sample) {
		t.Fatalf("sample should be collected for not-selected mileage evidence")
	}
}

func TestDataSourceDiagnosticsRequestURLUsesDefaultPathAndTotal(t *testing.T) {
	requestURL, err := dataSourceDiagnosticsRequestURL("http://127.0.0.1:20200", 3)
	if err != nil {
		t.Fatalf("request URL error: %v", err)
	}
	for _, want := range []string{
		defaultDataSourceDiagnosticsPath,
		"sourceCodeMissing=true",
		"includeTotal=true",
		"limit=3",
		"offset=0",
	} {
		if !strings.Contains(requestURL, want) {
			t.Fatalf("url = %q, want %q", requestURL, want)
		}
	}
}

func TestDataSourceMappingIssuesRequestURLUsesDiagnosticsPath(t *testing.T) {
	requestURL, err := dataSourceMappingIssuesRequestURL("http://127.0.0.1:20200", 3)
	if err != nil {
		t.Fatalf("request URL error: %v", err)
	}
	for _, want := range []string{
		defaultDataSourceDiagnosticsPath,
		"sourceCodeMissing=false",
		"mappingIssueOnly=true",
		"includeTotal=true",
		"limit=3",
		"offset=0",
	} {
		if !strings.Contains(requestURL, want) {
			t.Fatalf("url = %q, want %q", requestURL, want)
		}
	}
}

func TestCollectDataSourceDiagnostics(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != defaultDataSourceDiagnosticsPath {
			t.Fatalf("path = %q, want %q", r.URL.Path, defaultDataSourceDiagnosticsPath)
		}
		if got := r.URL.Query().Get("includeTotal"); got != "true" {
			t.Fatalf("includeTotal = %q, want true", got)
		}
		if got := r.URL.Query().Get("limit"); got != "3" {
			t.Fatalf("limit = %q, want 3", got)
		}
		if got := r.URL.Query().Get("mappingIssueOnly"); got == "true" {
			if missing := r.URL.Query().Get("sourceCodeMissing"); missing != "false" {
				t.Fatalf("mapping issue sourceCodeMissing = %q, want false", missing)
			}
			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(`{
				"items":[
					{"id":7,"protocol":"JT808","source_ip":"115.159.85.149","platform_name":"G7易流","source_code":"dongfang_beidou","source_kind":"PLATFORM","latest_seen_age_seconds":60,"registration_rows":388,"phone_count":388,"identifier_matched_phones":8,"unmapped_phone_count":380,"identifier_match_ratio":0.0206,"configured_source_code_matched_phones":8,"matched_source_code_count":1,"reason":"low_identifier_coverage","recommended_operator_action":"补充该来源手机号映射"}
				],
				"total":1,
				"limit":3,
				"offset":0
			}`))
			return
		}
		if got := r.URL.Query().Get("sourceCodeMissing"); got != "true" {
			t.Fatalf("sourceCodeMissing = %q, want true", got)
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{
			"items":[
				{"id":253218,"protocol":"JT808","source_ip":"117.132.196.43","latest_source_endpoint":"117.132.196.43:38256","source_kind":"UNKNOWN","latest_seen_age_seconds":120,"registration_rows":1,"phone_count":1,"candidate_source_code":"guangan_beidou","candidate_platform_name":"广安北斗","reason":"candidate_available","recommended_operator_action":"确认来源平台","suggested_source_kind":"PLATFORM","suggestion_confidence":"HIGH","suggestion_reason":"single_source_code_with_many_phones_or_long_activity"},
				{"id":253219,"protocol":"JT808","source_ip":"117.132.196.44","source_kind":"UNKNOWN","latest_seen_age_seconds":600,"registration_rows":1,"phone_count":1,"candidate_source_code":"guangan_beidou","candidate_platform_name":"广安北斗","reason":"candidate_available","recommended_operator_action":"确认来源平台","suggested_source_kind":"PLATFORM","suggestion_confidence":"HIGH","suggestion_reason":"single_source_code_with_many_phones_or_long_activity"},
				{"id":253220,"protocol":"JT808","source_ip":"117.132.196.45","source_kind":"UNKNOWN","latest_seen_age_seconds":20,"reason":"no_registration","recommended_operator_action":"等待注册证据","suggested_source_kind":"UNKNOWN","suggestion_confidence":"LOW","suggestion_reason":"insufficient_evidence"}
			],
			"total":400,
			"limit":3,
			"offset":0
		}`))
	}))
	defer server.Close()

	diagnostics, findings := collectDataSourceDiagnostics(context.Background(), server.URL, time.Second, 1, 3, 300)
	if len(findings) != 0 {
		t.Fatalf("findings = %#v, want none", findings)
	}
	if diagnostics == nil {
		t.Fatal("diagnostics = nil")
	}
	if diagnostics.MissingSourceCodeTotal != 400 {
		t.Fatalf("missing source code total = %d, want 400", diagnostics.MissingSourceCodeTotal)
	}
	if diagnostics.ActionableCandidateTotal != 2 {
		t.Fatalf("actionable candidates = %d, want 2", diagnostics.ActionableCandidateTotal)
	}
	if diagnostics.RecentMissingSourceCodeTotal != 2 || diagnostics.RecentAgeThresholdSeconds != 300 {
		t.Fatalf("recent summary = %d threshold=%d, want 2/300", diagnostics.RecentMissingSourceCodeTotal, diagnostics.RecentAgeThresholdSeconds)
	}
	if diagnostics.ReasonCounts["candidate_available"] != 2 || diagnostics.ReasonCounts["no_registration"] != 1 {
		t.Fatalf("reason counts = %#v", diagnostics.ReasonCounts)
	}
	if !diagnostics.ReasonCountsTruncated {
		t.Fatalf("reason counts should be marked truncated when total exceeds fetched rows")
	}
	if len(diagnostics.Samples) != 1 || diagnostics.Samples[0].CandidateSourceCode != "guangan_beidou" {
		t.Fatalf("samples = %#v", diagnostics.Samples)
	}
	if diagnostics.MappingIssueTotal != 1 || diagnostics.RecentMappingIssueTotal != 1 {
		t.Fatalf("mapping issue summary = total %d recent %d", diagnostics.MappingIssueTotal, diagnostics.RecentMappingIssueTotal)
	}
	if diagnostics.MappingIssueReasonCounts["low_identifier_coverage"] != 1 {
		t.Fatalf("mapping reason counts = %#v", diagnostics.MappingIssueReasonCounts)
	}
	if len(diagnostics.MappingIssueSamples) != 1 || diagnostics.MappingIssueSamples[0].UnmappedPhoneCount != 380 {
		t.Fatalf("mapping samples = %#v", diagnostics.MappingIssueSamples)
	}
}

func TestDataSourceKindSuggestionsRequestURLUsesDefaultPathAndUnknownKind(t *testing.T) {
	requestURL, err := dataSourceKindSuggestionsRequestURL("http://127.0.0.1:20200", "gb32960", 3)
	if err != nil {
		t.Fatalf("request URL error: %v", err)
	}
	for _, want := range []string{
		defaultDataSourceKindSuggestionsPath,
		"protocol=GB32960",
		"sourceKind=UNKNOWN",
		"includeTotal=true",
		"limit=3",
		"offset=0",
	} {
		if !strings.Contains(requestURL, want) {
			t.Fatalf("url = %q, want %q", requestURL, want)
		}
	}
}

func TestCollectDataSourceKindSuggestionsAggregatesPlatformCandidates(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != defaultDataSourceKindSuggestionsPath {
			t.Fatalf("path = %q, want %q", r.URL.Path, defaultDataSourceKindSuggestionsPath)
		}
		if got := r.URL.Query().Get("sourceKind"); got != "UNKNOWN" {
			t.Fatalf("sourceKind = %q, want UNKNOWN", got)
		}
		if got := r.URL.Query().Get("includeTotal"); got != "true" {
			t.Fatalf("includeTotal = %q, want true", got)
		}
		if got := r.URL.Query().Get("limit"); got != "3" {
			t.Fatalf("limit = %q, want 3", got)
		}
		w.Header().Set("Content-Type", "application/json")
		switch r.URL.Query().Get("protocol") {
		case "GB32960":
			_, _ = w.Write([]byte(`{
				"items":[
					{"id":7,"protocol":"GB32960","source_ip":"8.134.95.166","latest_source_endpoint":"8.134.95.166:32960","platform_name":"现代 HTWO","source_code":"Hyundai","source_kind":"UNKNOWN","latest_seen_age_seconds":90,"reason":"source_configured","suggested_source_kind":"PLATFORM","suggestion_confidence":"MEDIUM","suggestion_reason":"non_jt808_configured_source"}
				],
				"total":1,
				"limit":3,
				"offset":0
			}`))
		case "JT808":
			_, _ = w.Write([]byte(`{
				"items":[
					{"id":253218,"protocol":"JT808","source_ip":"117.132.196.43","source_kind":"UNKNOWN","latest_seen_age_seconds":600,"registration_rows":1,"phone_count":12,"candidate_source_code":"guangan_beidou","candidate_platform_name":"广安北斗","reason":"candidate_available","suggested_source_kind":"PLATFORM","suggestion_confidence":"HIGH","suggestion_reason":"single_source_code_with_many_phones_or_long_activity"},
					{"id":253219,"protocol":"JT808","source_ip":"117.132.196.44","source_kind":"UNKNOWN","latest_seen_age_seconds":60,"reason":"no_registration","suggested_source_kind":"UNKNOWN","suggestion_confidence":"LOW","suggestion_reason":"insufficient_evidence"}
				],
				"total":2,
				"limit":3,
				"offset":0
			}`))
		case "YUTONG_MQTT":
			_, _ = w.Write([]byte(`{"items":[],"total":0,"limit":3,"offset":0}`))
		default:
			t.Fatalf("unexpected protocol query %q", r.URL.Query().Get("protocol"))
		}
	}))
	defer server.Close()

	diagnostics, findings := collectDataSourceKindSuggestions(
		context.Background(),
		server.URL,
		time.Second,
		[]string{"GB32960", "JT808", "YUTONG_MQTT"},
		1,
		3,
		300,
	)
	if len(findings) != 0 {
		t.Fatalf("findings = %#v, want none", findings)
	}
	if diagnostics == nil {
		t.Fatal("diagnostics = nil")
	}
	if diagnostics.UnknownSourceKindTotal != 3 {
		t.Fatalf("unknown source kind total = %d, want 3", diagnostics.UnknownSourceKindTotal)
	}
	if diagnostics.RecentUnknownSourceKindTotal != 2 || diagnostics.RecentAgeThresholdSeconds != 300 {
		t.Fatalf("recent summary = %d threshold=%d, want 2/300", diagnostics.RecentUnknownSourceKindTotal, diagnostics.RecentAgeThresholdSeconds)
	}
	if diagnostics.PlatformKindCandidateTotal != 2 {
		t.Fatalf("platform kind candidates = %d, want 2", diagnostics.PlatformKindCandidateTotal)
	}
	if diagnostics.RecentPlatformKindCandidateTotal != 1 {
		t.Fatalf("recent platform kind candidates = %d, want 1", diagnostics.RecentPlatformKindCandidateTotal)
	}
	if diagnostics.ReasonCounts["non_jt808_configured_source"] != 1 || diagnostics.ReasonCounts["single_source_code_with_many_phones_or_long_activity"] != 1 {
		t.Fatalf("reason counts = %#v", diagnostics.ReasonCounts)
	}
	if len(diagnostics.Samples) != 1 || diagnostics.Samples[0].Protocol != "GB32960" {
		t.Fatalf("samples = %#v", diagnostics.Samples)
	}
}

func TestJT808IdentityGapsRequestURLUsesDefaultPathAndRecentSeconds(t *testing.T) {
	requestURL, err := jt808IdentityGapsRequestURL("http://127.0.0.1:20200", 86400, 3)
	if err != nil {
		t.Fatalf("request URL error: %v", err)
	}
	for _, want := range []string{
		defaultJT808IdentityGapsPath,
		"includeTotal=true",
		"recentSeconds=86400",
		"limit=3",
		"offset=0",
	} {
		if !strings.Contains(requestURL, want) {
			t.Fatalf("url = %q, want %q", requestURL, want)
		}
	}
}

func TestCollectJT808IdentityGaps(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != defaultJT808IdentityGapsPath {
			t.Fatalf("path = %q, want %q", r.URL.Path, defaultJT808IdentityGapsPath)
		}
		if got := r.URL.Query().Get("includeTotal"); got != "true" {
			t.Fatalf("includeTotal = %q, want true", got)
		}
		if got := r.URL.Query().Get("recentSeconds"); got != "86400" {
			t.Fatalf("recentSeconds = %q, want 86400", got)
		}
		if got := r.URL.Query().Get("limit"); got != "2" {
			t.Fatalf("limit = %q, want 2", got)
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{
			"items":[
				{"phone":"41025503543","vin":"unknown","source_ip":"122.152.221.156","source_endpoint":"122.152.221.156:23185","source_code":"dongfang_beidou","platform_name":"广安车联","source_kind":"PLATFORM","latest_seen_at":"2026-07-12 14:01:02","latest_seen_age_seconds":30,"reason":"missing_phone_binding","recommended_operator_action":"将该 phone 维护到 vehicle_identifier","raw_frame_query_path":"/api/history/raw-frames?protocol=JT808&phone=41025503543","data_source_query_path":"/api/stats/data-sources?protocol=JT808&sourceIP=122.152.221.156"}
			],
			"total":1,
			"limit":2,
			"offset":0,
			"recentSeconds":86400
		}`))
	}))
	defer server.Close()

	diagnostics, findings := collectJT808IdentityGaps(context.Background(), server.URL, time.Second, 86400, 2)
	if len(findings) != 0 {
		t.Fatalf("findings = %#v, want none", findings)
	}
	if diagnostics == nil {
		t.Fatal("diagnostics = nil")
	}
	if diagnostics.Total != 1 || diagnostics.RecentSeconds != 86400 {
		t.Fatalf("diagnostics summary = total %d recent %d, want 1/86400", diagnostics.Total, diagnostics.RecentSeconds)
	}
	if len(diagnostics.Samples) != 1 || diagnostics.Samples[0].Phone != "41025503543" {
		t.Fatalf("samples = %#v", diagnostics.Samples)
	}
	if diagnostics.Samples[0].RawFrameQueryPath == "" || diagnostics.Samples[0].DataSourceQueryPath == "" {
		t.Fatalf("diagnostic links should be preserved: %#v", diagnostics.Samples[0])
	}
}

func TestDataSourceDiagnosticFindingsCanGateMissingSourceCode(t *testing.T) {
	diagnostics := &capacity.DataSourceDiagnostics{MissingSourceCodeTotal: 400}

	findings := dataSourceDiagnosticFindings(diagnostics, 100, -1, -1, -1, -1)

	if len(findings) != 1 || !strings.Contains(findings[0], "data source missing source_code 400 exceeds 100") {
		t.Fatalf("findings = %#v", findings)
	}
}

func TestDataSourceKindDiagnosticFindingsCanGateRecentPlatformCandidates(t *testing.T) {
	diagnostics := &capacity.DataSourceDiagnostics{
		PlatformKindCandidateTotal:       3,
		RecentPlatformKindCandidateTotal: 1,
		RecentAgeThresholdSeconds:        300,
	}

	findings := dataSourceKindDiagnosticFindings(diagnostics, -1, 0)

	if len(findings) != 1 || !strings.Contains(findings[0], "recent UNKNOWN source_kind platform candidates 1 exceeds 0") {
		t.Fatalf("findings = %#v", findings)
	}
	if !strings.Contains(findings[0], "recent_age_seconds=300") {
		t.Fatalf("finding should include recent age threshold: %q", findings[0])
	}
}

func TestDataSourceKindDiagnosticFindingsCanGatePlatformCandidates(t *testing.T) {
	diagnostics := &capacity.DataSourceDiagnostics{
		PlatformKindCandidateTotal: 3,
	}

	findings := dataSourceKindDiagnosticFindings(diagnostics, 0, -1)

	if len(findings) != 1 || !strings.Contains(findings[0], "UNKNOWN source_kind platform candidates 3 exceeds 0") {
		t.Fatalf("findings = %#v", findings)
	}
}

func TestJT808IdentityGapFindingsCanGate(t *testing.T) {
	diagnostics := &capacity.JT808IdentityGapDiagnostics{
		Total:         2,
		RecentSeconds: 86400,
	}

	findings := jt808IdentityGapFindings(diagnostics, 1)

	if len(findings) != 1 || !strings.Contains(findings[0], "JT808 unresolved identity gaps 2 exceeds 1") {
		t.Fatalf("findings = %#v", findings)
	}
	if !strings.Contains(findings[0], "recent_seconds=86400") {
		t.Fatalf("finding should include recent seconds: %q", findings[0])
	}
}

func TestDataSourceDiagnosticFindingsCanGateActionableCandidates(t *testing.T) {
	diagnostics := &capacity.DataSourceDiagnostics{
		MissingSourceCodeTotal:   400,
		ActionableCandidateTotal: 3,
	}

	findings := dataSourceDiagnosticFindings(diagnostics, -1, -1, 0, -1, -1)

	if len(findings) != 1 || !strings.Contains(findings[0], "data source actionable candidates 3 exceeds 0") {
		t.Fatalf("findings = %#v", findings)
	}
}

func TestDataSourceDiagnosticFindingsCanGateRecentMissingSourceCode(t *testing.T) {
	diagnostics := &capacity.DataSourceDiagnostics{
		MissingSourceCodeTotal:       400,
		RecentMissingSourceCodeTotal: 2,
		RecentAgeThresholdSeconds:    300,
	}

	findings := dataSourceDiagnosticFindings(diagnostics, -1, 0, -1, -1, -1)

	if len(findings) != 1 || !strings.Contains(findings[0], "data source recent missing source_code 2 exceeds 0") {
		t.Fatalf("findings = %#v", findings)
	}
	if !strings.Contains(findings[0], "recent_age_seconds=300") {
		t.Fatalf("finding should include recent age threshold: %q", findings[0])
	}
}

func TestDataSourceDiagnosticFindingsCanGateMappingIssues(t *testing.T) {
	diagnostics := &capacity.DataSourceDiagnostics{
		MappingIssueTotal:         3,
		RecentMappingIssueTotal:   2,
		RecentAgeThresholdSeconds: 300,
	}

	findings := dataSourceDiagnosticFindings(diagnostics, -1, -1, -1, 0, 1)

	if len(findings) != 2 {
		t.Fatalf("findings = %#v, want 2", findings)
	}
	if !strings.Contains(findings[0], "data source mapping issues 3 exceeds 0") {
		t.Fatalf("mapping issue finding missing: %#v", findings)
	}
	if !strings.Contains(findings[1], "data source recent mapping issues 2 exceeds 1") {
		t.Fatalf("recent mapping issue finding missing: %#v", findings)
	}
}
