package stats

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

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

const dataSourceSelectPattern = "SELECT id, protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind, trust_priority, enabled, first_seen_at, latest_seen_at, remark, updated_at FROM vehicle_data_source"

func TestDataSourceRepositoryQueriesWithOperationalFilters(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dataSourceSelectPattern).
		WithArgs("JT808", "115.231.168.135", "G7S", "PLATFORM", 1, 20, 10).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind",
			"trust_priority", "enabled", "first_seen_at", "latest_seen_at", "remark", "updated_at",
		}).AddRow(
			3, "JT808", "115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM",
			10, 1,
			time.Date(2026, 7, 8, 10, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
			time.Date(2026, 7, 12, 1, 16, 4, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
			"trusted source",
			time.Date(2026, 7, 12, 1, 16, 5, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
		))

	enabled := true
	rows, err := NewDataSourceRepository(db).Query(context.Background(), DataSourceQuery{
		Protocol:   "jt808",
		SourceIP:   "115.231.168.135",
		SourceCode: "G7S",
		SourceKind: "platform",
		Enabled:    &enabled,
		Limit:      20,
		Offset:     10,
	})
	if err != nil {
		t.Fatalf("Query() error = %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("row count = %d", len(rows))
	}
	row := rows[0]
	if row.ID != 3 || row.Protocol != "JT808" || row.PlatformName != "G7 平台" || row.SourceCode != "G7S" || row.SourceKind != "PLATFORM" || !row.Enabled {
		t.Fatalf("unexpected source row: %#v", row)
	}
	if row.LatestSeenAt != "2026-07-12 01:16:04" {
		t.Fatalf("latest seen = %q", row.LatestSeenAt)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestDataSourceRepositoryFiltersMissingSourceCode(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery(dataSourceSelectPattern).
		WithArgs("JT808", 50, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind",
			"trust_priority", "enabled", "first_seen_at", "latest_seen_at", "remark", "updated_at",
		}))

	missing := true
	_, err = NewDataSourceRepository(db).Query(context.Background(), DataSourceQuery{
		Protocol:          "JT808",
		SourceCodeMissing: &missing,
	})
	if err != nil {
		t.Fatalf("Query() error = %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestDataSourceRepositoryDiagnosesJT808SourceMapping(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery("FROM vehicle_data_source ds").
		WithArgs("JT808", "117.132.194.31", 1, 10, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
			"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
			"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
			"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
		}).AddRow(
			242190, "JT808", "117.132.194.31", "117.132.194.31:20471", nil, nil,
			"UNKNOWN", "2026-07-11 23:00:00", "2026-07-12 01:36:26", 9386, 120,
			5, 4, 2,
			3, 1, 0.75, 0, nil, 2, "g7s", "G7s", "g7s,xinda", "G7s,信达", "13307795425,14400000000",
		))

	missing := true
	rows, err := NewDataSourceRepository(db).QueryDiagnostics(context.Background(), DataSourceDiagnosticsQuery{
		Protocol:          "jt808",
		SourceIP:          "117.132.194.31",
		SourceCodeMissing: &missing,
		Limit:             10,
	})
	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 != "ambiguous_source_code" || row.IdentifierMatchedPhones != 3 || len(row.MatchedSourceCodes) != 2 || len(row.SamplePhones) != 2 {
		t.Fatalf("unexpected diagnostic row: %#v", row)
	}
	if row.SuggestedSourceKind != "UNKNOWN" || row.SuggestionConfidence != "HIGH" {
		t.Fatalf("unexpected kind suggestion: %#v", row)
	}
	sqlText, _ := buildDataSourceDiagnosticsSQL(DataSourceDiagnosticsQuery{Protocol: "JT808", Limit: 10})
	if !strings.Contains(sqlText, "ON ds.protocol = 'JT808'") || !strings.Contains(sqlText, "AND r.source_ip = ds.source_ip") {
		t.Fatalf("diagnostics should join by indexed source_ip:\n%s", sqlText)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestDataSourceDiagnosticsMappingIssueFilterUsesHaving(t *testing.T) {
	missing := false
	query := normalizeDataSourceDiagnosticsQuery(DataSourceDiagnosticsQuery{
		Protocol:          "JT808",
		SourceCodeMissing: &missing,
		MappingIssueOnly:  true,
		Limit:             10,
	})
	sqlText, args := buildDataSourceDiagnosticsSQL(query)
	if !strings.Contains(sqlText, "HAVING") || !strings.Contains(sqlText, "identifier_match_ratio < 0.8") || !strings.Contains(sqlText, "configured_source_code_platform_name") {
		t.Fatalf("mapping issue query should include aggregate HAVING:\n%s", sqlText)
	}
	if strings.Contains(sqlText, "ds.platform_name IS NOT NULL") {
		t.Fatalf("mapping issue HAVING should use aggregate aliases, not table-qualified platform_name:\n%s", sqlText)
	}
	if len(args) != 4 {
		t.Fatalf("mapping issue query args = %#v, want protocol/enabled/limit/offset", args)
	}
	countSQL, _ := buildDataSourceDiagnosticsCountSQL(query)
	if !strings.Contains(countSQL, "FROM (") || !strings.Contains(countSQL, "HAVING") {
		t.Fatalf("mapping issue count should wrap grouped diagnostics:\n%s", countSQL)
	}
	if strings.Contains(countSQL, "ds.platform_name IS NOT NULL") {
		t.Fatalf("mapping issue count HAVING should use aggregate aliases, not table-qualified platform_name:\n%s", countSQL)
	}
}

func TestDiagnoseDataSourceHighlightsConfiguredMappingIssues(t *testing.T) {
	lowCoverage := DataSourceDiagnosticRow{
		Protocol:                          "JT808",
		PlatformName:                      "东方北斗",
		SourceCode:                        "dongfang_beidou",
		RegistrationRows:                  388,
		PhoneCount:                        388,
		IdentifierMatchedPhones:           8,
		UnmappedPhoneCount:                380,
		IdentifierMatchRatio:              0.0206,
		ConfiguredSourceCodeMatchedPhones: 8,
		ConfiguredSourceCodePlatformName:  "东方北斗",
		MatchedSourceCodeCount:            1,
		MatchedSourceCodes:                []string{"dongfang_beidou"},
	}
	lowCoverage.ConfiguredSourceCodeConflict = configuredSourceCodeConflict(lowCoverage)
	lowCoverage.SourcePlatformNameMismatch = sourcePlatformNameMismatch(lowCoverage)
	reason, _ := diagnoseDataSource(lowCoverage)
	if reason != "low_identifier_coverage" {
		t.Fatalf("reason = %q, want low_identifier_coverage", reason)
	}

	lowCoverageMismatch := lowCoverage
	lowCoverageMismatch.PlatformName = "G7易流"
	lowCoverageMismatch.SourcePlatformNameMismatch = sourcePlatformNameMismatch(lowCoverageMismatch)
	reason, _ = diagnoseDataSource(lowCoverageMismatch)
	if reason != "low_identifier_coverage" {
		t.Fatalf("reason = %q, want low_identifier_coverage when coverage is weak even if platform name differs", reason)
	}

	nameMismatch := DataSourceDiagnosticRow{
		Protocol:                         "JT808",
		PlatformName:                     "G7易流",
		SourceCode:                       "dongfang_beidou",
		RegistrationRows:                 388,
		PhoneCount:                       388,
		IdentifierMatchedPhones:          8,
		ConfiguredSourceCodePlatformName: "东方北斗",
		MatchedSourceCodeCount:           1,
		MatchedSourceCodes:               []string{"dongfang_beidou"},
	}
	nameMismatch.SourcePlatformNameMismatch = sourcePlatformNameMismatch(nameMismatch)
	reason, _ = diagnoseDataSource(nameMismatch)
	if reason != "source_platform_name_mismatch" {
		t.Fatalf("reason = %q, want source_platform_name_mismatch", reason)
	}

	conflict := DataSourceDiagnosticRow{
		Protocol:                          "JT808",
		SourceCode:                        "g7s",
		RegistrationRows:                  20,
		PhoneCount:                        20,
		IdentifierMatchedPhones:           20,
		IdentifierMatchRatio:              1,
		ConfiguredSourceCodeMatchedPhones: 0,
		MatchedSourceCodeCount:            1,
		MatchedSourceCodes:                []string{"dongfang_beidou"},
	}
	conflict.ConfiguredSourceCodeConflict = configuredSourceCodeConflict(conflict)
	reason, _ = diagnoseDataSource(conflict)
	if reason != "source_code_conflict" {
		t.Fatalf("reason = %q, want source_code_conflict", reason)
	}
}

func TestDataSourceHandlerReturnsSourcePage(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_data_source").
		WithArgs("GB32960", 1).
		WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(3))
	mock.ExpectQuery(dataSourceSelectPattern).
		WithArgs("GB32960", 1, 50, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind",
			"trust_priority", "enabled", "first_seen_at", "latest_seen_at", "remark", "updated_at",
		}).AddRow(
			7, "GB32960", "8.134.95.166", "8.134.95.166:56432", "现代 HTWO", "HYUNDAI", "PLATFORM",
			5, 1, "2026-07-11 18:27:10", "2026-07-12 01:15:52", "", "2026-07-12 01:15:52",
		))

	handler := NewDataSourceHandler(NewDataSourceRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources?protocol=gb32960&enabled=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":3`, `"source_ip":"8.134.95.166"`, `"platform_name":"现代 HTWO"`, `"source_code":"HYUNDAI"`, `"source_kind":"PLATFORM"`} {
		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 TestDataSourceHandlerReturnsDiagnosticsPage(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_data_source ds").
		WithArgs("JT808", 1).
		WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
	mock.ExpectQuery("FROM vehicle_data_source ds").
		WithArgs("JT808", 1, 50, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
			"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
			"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
			"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
		}).AddRow(
			242190, "JT808", "117.132.194.31", "117.132.194.31:20471", nil, nil,
			"UNKNOWN", "2026-07-12 01:00:00", "2026-07-12 01:36:26", 2186, 1800,
			5, 4, 2,
			0, 4, 0.0, 0, nil, 0, nil, nil, nil, nil, "13307795425",
		))

	handler := NewDataSourceHandler(NewDataSourceRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/diagnostics?protocol=jt808&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":1`, `"reason":"no_identifier_match"`, `"sample_phones":["13307795425"]`} {
		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 TestDataSourceHandlerReturnsJT808IdentityGaps(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 jt808_registration r").
		WithArgs("117.132.194.31", "guangan_beidou", 3600).
		WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
	mock.ExpectQuery("FROM jt808_registration r").
		WithArgs("117.132.194.31", "guangan_beidou", 3600, 20, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"phone", "device_id", "plate", "vin", "source_ip", "source_endpoint",
			"source_code", "platform_name", "source_kind",
			"first_registered_at", "latest_registered_at", "latest_authenticated_at", "latest_seen_at", "latest_seen_age_seconds",
		}).AddRow(
			"13307795425", "", "沪A63305F", "unknown", "117.132.194.31", "117.132.194.31:20471",
			"guangan_beidou", "广安北斗", "PLATFORM",
			nil, nil, nil, "2026-07-12 22:24:53", 32,
		))

	handler := NewDataSourceHandler(NewDataSourceRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/jt808-identity-gaps?sourceIP=117.132.194.31&sourceCode=guangan_beidou&recentSeconds=3600&includeTotal=true&limit=20", 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`,
		`"phone":"13307795425"`,
		`"reason":"missing_phone_and_plate_binding"`,
		`"raw_frame_query_path":"/api/history/raw-frames?`,
		`phone=13307795425`,
		`dateFrom=2026-07-12+00%3A00%3A00`,
		`"data_source_query_path":"/api/stats/data-sources?`,
		`sourceIP=117.132.194.31`,
	} {
		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 TestDataSourceHandlerReturnsJT808MappingGaps(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 jt808_registration r").
		WithArgs("115.159.85.149", "dongfang_beidou", 3600).
		WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
	mock.ExpectQuery("FROM jt808_registration r").
		WithArgs("115.159.85.149", "dongfang_beidou", 3600, 20, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"phone", "device_id", "plate", "vin", "source_ip", "source_endpoint",
			"source_code", "platform_name", "source_kind", "identifier_vin", "identifier_plate",
			"matched_source_codes", "matched_platform_names", "latest_seen_at", "latest_seen_age_seconds",
		}).AddRow(
			"64646848246", "", "粤AG18312", "LKLG7C4E8NA774778", "115.159.85.149", "115.159.85.149:16885",
			"dongfang_beidou", "G7易流", "PLATFORM", nil, nil,
			"g7s", "G7s", "2026-07-12 23:38:22", 32,
		))

	handler := NewDataSourceHandler(NewDataSourceRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/jt808-mapping-gaps?sourceIP=115.159.85.149&sourceCode=dongfang_beidou&recentSeconds=3600&includeTotal=true&limit=20", 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`,
		`"phone":"64646848246"`,
		`"suggested_source_code":"dongfang_beidou"`,
		`"suggested_identifier_type":"JT808_PHONE"`,
		`"suggested_vin":"LKLG7C4E8NA774778"`,
		`"reason":"missing_source_phone_identifier"`,
		`"matched_source_codes":["g7s"]`,
		`"raw_frame_query_path":"/api/history/raw-frames?`,
		`phone=64646848246`,
		`"data_source_query_path":"/api/stats/data-sources?`,
		`sourceIP=115.159.85.149`,
		`"vehicle_identifier_example":"protocol=JT808, source_code=dongfang_beidou, identifier_type=JT808_PHONE, identifier_value=64646848246, vin=LKLG7C4E8NA774778, plate=粤AG18312, oem=G7易流"`,
	} {
		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 TestJT808MappingGapSQLUsesConfiguredSourceIdentifier(t *testing.T) {
	sqlText, args := buildJT808MappingGapSQL(normalizeJT808MappingGapQuery(JT808MappingGapQuery{
		SourceIP:      "115.159.85.149",
		SourceCode:    "dongfang_beidou",
		RecentSeconds: 3600,
		Limit:         20,
	}))
	for _, want := range []string{
		"JOIN vehicle_data_source ds",
		"source_vi.source_code = ds.source_code",
		"source_vi.identifier_type = 'JT808_PHONE'",
		"source_vi.identifier_value = r.phone",
		"source_vi.identifier_value IS NULL OR source_vi.vin IS NULL",
	} {
		if !strings.Contains(sqlText, want) {
			t.Fatalf("mapping gap SQL missing %q:\n%s", want, sqlText)
		}
	}
	if len(args) != 5 {
		t.Fatalf("args = %#v, want sourceIP/sourceCode/recent/limit/offset", args)
	}
}

func TestDataSourceDiagnosticsCanQueryRetiredSources(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery("FROM vehicle_data_source ds").
		WithArgs("JT808", 0, 50, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
			"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
			"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
			"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
		}))

	handler := NewDataSourceHandler(NewDataSourceRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/diagnostics?protocol=jt808&enabled=false", nil)
	response := httptest.NewRecorder()

	handler.ServeHTTP(response, request)

	if response.Code != http.StatusOK {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
	sqlText, args := buildDataSourceDiagnosticsSQL(normalizeDataSourceDiagnosticsQuery(DataSourceDiagnosticsQuery{Protocol: "JT808"}))
	if !strings.Contains(sqlText, "ds.enabled = ?") {
		t.Fatalf("diagnostics should default to enabled sources:\n%s", sqlText)
	}
	if len(args) < 2 || args[1] != 1 {
		t.Fatalf("diagnostics enabled default args = %#v, want enabled=1 before limit", args)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestDataSourceHandlerReturnsGenericDiagnosticsForNonJT808(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery("FROM vehicle_data_source ds").
		WithArgs("GB32960", 1, 50, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
			"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
			"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
			"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
		}).AddRow(
			7, "GB32960", "8.134.95.166", "8.134.95.166:32960", "现代 HTWO", nil,
			"UNKNOWN", "2026-07-12 01:00:00", "2026-07-12 01:36:26", 2186, 1800,
			0, 0, 0,
			0, 0, 0.0, 0, nil, 0, nil, nil, nil, nil, nil,
		))

	handler := NewDataSourceHandler(NewDataSourceRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/diagnostics?protocol=GB32960", nil)
	response := httptest.NewRecorder()

	handler.ServeHTTP(response, request)

	if response.Code != http.StatusOK {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
	body := response.Body.String()
	for _, want := range []string{`"protocol":"GB32960"`, `"reason":"source_code_missing"`, `"suggested_source_kind":"PLATFORM"`} {
		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 TestDataSourceHandlerReturnsKindSuggestionsPage(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_data_source ds").
		WithArgs("JT808", "UNKNOWN", 1).
		WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
	mock.ExpectQuery("FROM vehicle_data_source ds").
		WithArgs("JT808", "UNKNOWN", 1, 50, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
			"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
			"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
			"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
		}).AddRow(
			242190, "JT808", "117.132.194.31", "117.132.194.31:20471", "广安北斗", "guangan_beidou",
			"UNKNOWN", "2026-07-12 00:00:00", "2026-07-12 00:05:00", 300, 7200,
			0, 0, 0,
			0, 0, 0.0, 0, nil, 0, nil, nil, nil, nil, nil,
		))

	handler := NewDataSourceHandler(NewDataSourceRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/kind-suggestions?protocol=jt808&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":1`, `"suggested_source_kind":"UNKNOWN"`, `"suggestion_confidence":"MEDIUM"`, `"suggestion_reason":"manual_source_without_registration_evidence"`} {
		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 TestDataSourceHandlerReturnsKindSuggestionsForNonJT808(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery("FROM vehicle_data_source ds").
		WithArgs("GB32960", "UNKNOWN", 1, 50, 0).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
			"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
			"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
			"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
		}).AddRow(
			7, "GB32960", "8.134.95.166", "8.134.95.166:32960", "现代 HTWO", "HYUNDAI",
			"UNKNOWN", "2026-07-12 01:00:00", "2026-07-12 01:36:26", 2186, 1800,
			0, 0, 0,
			0, 0, 0.0, 0, nil, 0, nil, nil, nil, nil, nil,
		))

	handler := NewDataSourceHandler(NewDataSourceRepository(db))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/kind-suggestions?protocol=GB32960", 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{`"reason":"source_configured"`, `"suggested_source_kind":"PLATFORM"`, `"suggestion_reason":"non_jt808_configured_source"`} {
		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 TestSuggestSourceKindMarksStaleUnclassifiedSourceAsDirect(t *testing.T) {
	kind, confidence, reason := suggestSourceKind(DataSourceDiagnosticRow{
		SourceKind:           "UNKNOWN",
		Reason:               "no_registration",
		LatestSeenAgeSeconds: 25 * 3600,
	})
	if kind != "DIRECT" || confidence != "LOW" || reason != "stale_unclassified_source_without_registration" {
		t.Fatalf("unexpected suggestion: kind=%s confidence=%s reason=%s", kind, confidence, reason)
	}
}

func TestDataSourceHandlerPatchesOnlyManualFields(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New() error = %v", err)
	}
	defer db.Close()
	mock.ExpectQuery("SELECT 1 FROM vehicle_data_source WHERE id = \\? LIMIT 1").
		WithArgs(int64(3)).
		WillReturnRows(sqlmock.NewRows([]string{"one"}).AddRow(1))
	mock.ExpectExec("UPDATE vehicle_data_source SET platform_name = \\?, source_code = \\?, source_kind = \\?, trust_priority = \\?, enabled = \\?, remark = \\?, updated_at = CURRENT_TIMESTAMP WHERE id = \\?").
		WithArgs("G7 平台", "G7S", "PLATFORM", 10, 0, "可信 808 来源", int64(3)).
		WillReturnResult(sqlmock.NewResult(0, 0))

	handler := NewDataSourceHandler(NewDataSourceRepository(db))
	body := strings.NewReader(`{"platform_name":"G7 平台","source_code":"G7S","source_kind":"platform","trust_priority":10,"enabled":false,"remark":"可信 808 来源","latest_seen_at":"2099-01-01 00:00:00"}`)
	request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/3", body)
	response := httptest.NewRecorder()

	handler.ServeHTTP(response, request)

	if response.Code != http.StatusOK {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
	if !strings.Contains(response.Body.String(), `"updated":true`) {
		t.Fatalf("patch response should confirm update: %s", response.Body.String())
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestDataSourceHandlerRejectsConflictingSourceCodeFilters(t *testing.T) {
	handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{}))
	request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources?sourceCode=g7s&sourceCodeMissing=true", nil)
	response := httptest.NewRecorder()

	handler.ServeHTTP(response, request)

	if response.Code != http.StatusBadRequest {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
	if !strings.Contains(response.Body.String(), "sourceCode") {
		t.Fatalf("response should mention sourceCode: %s", response.Body.String())
	}
}

func TestDataSourceHandlerRejectsInvalidSourceCode(t *testing.T) {
	handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{}))
	request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/1", strings.NewReader(`{"source_code":"G7 中文"}`))
	response := httptest.NewRecorder()

	handler.ServeHTTP(response, request)

	if response.Code != http.StatusBadRequest {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
	if !strings.Contains(response.Body.String(), "source_code") {
		t.Fatalf("response should mention source_code: %s", response.Body.String())
	}
}

func TestDataSourceHandlerRejectsInvalidSourceKind(t *testing.T) {
	handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{}))
	request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/1", strings.NewReader(`{"source_kind":"temporary"}`))
	response := httptest.NewRecorder()

	handler.ServeHTTP(response, request)

	if response.Code != http.StatusBadRequest {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
	if !strings.Contains(response.Body.String(), "source_kind") {
		t.Fatalf("response should mention source_kind: %s", response.Body.String())
	}
}

func TestDataSourceHandlerRejectsEmptyPatch(t *testing.T) {
	handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{}))
	request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/1", strings.NewReader(`{}`))
	response := httptest.NewRecorder()

	handler.ServeHTTP(response, request)

	if response.Code != http.StatusBadRequest {
		t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
	}
	if !strings.Contains(response.Body.String(), "no mutable fields") {
		t.Fatalf("response should mention no mutable fields: %s", response.Body.String())
	}
}
