package main

import (
	"context"
	"database/sql/driver"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

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

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

func TestReportItemLimitExpandsWhenCSVOutputIsRequested(t *testing.T) {
	if got := reportItemLimit(50, "", ""); got != 50 {
		t.Fatalf("reportItemLimit without csv = %d, want 50", got)
	}
	if got := reportItemLimit(50, "unresolved.csv", ""); got != -1 {
		t.Fatalf("reportItemLimit with unresolved csv = %d, want -1", got)
	}
	if got := reportItemLimit(50, "", "conflicts.csv"); got != -1 {
		t.Fatalf("reportItemLimit with conflicts csv = %d, want -1", got)
	}
}

func TestUnsupportedMappingFilesError(t *testing.T) {
	err := unsupportedMappingFilesError(identity.MappingScanReport{})
	if err != nil {
		t.Fatalf("unsupportedMappingFilesError(empty) = %v", err)
	}
	err = unsupportedMappingFilesError(identity.MappingScanReport{
		UnsupportedFiles: 2,
		UnsupportedItems: []identity.MappingUnsupportedFileReport{
			{File: "G7s/legacy.xls", Ext: ".xls"},
			{File: "信达/legacy.xlsb", Ext: ".xlsb"},
		},
	})
	if err == nil {
		t.Fatal("unsupportedMappingFilesError() nil, want error")
	}
	text := err.Error()
	for _, want := range []string{"2 unsupported workbook", "G7s/legacy.xls", "信达/legacy.xlsb", "before -apply"} {
		if !strings.Contains(text, want) {
			t.Fatalf("error missing %q: %s", want, text)
		}
	}
}

func TestValidateMappingApplyInputAllowsDryRunButBlocksApply(t *testing.T) {
	scan := identity.MappingScanReport{
		UnsupportedFiles: 1,
		UnsupportedItems: []identity.MappingUnsupportedFileReport{
			{File: "G7s/legacy.xls", Ext: ".xls"},
		},
	}
	if err := validateMappingApplyInput(false, scan); err != nil {
		t.Fatalf("validateMappingApplyInput(dry-run) = %v", err)
	}
	if err := validateMappingApplyInput(true, scan); err == nil {
		t.Fatal("validateMappingApplyInput(apply) nil, want error")
	}
}

func TestWriteUnresolvedCSV(t *testing.T) {
	path := filepath.Join(t.TempDir(), "unresolved.csv")
	err := writeUnresolvedCSV(path, []identity.MappingRecord{
		{
			File:            "G7s/example.xlsx",
			Sheet:           "Sheet1",
			Row:             2,
			SourceCode:      "g7s",
			SourceName:      "G7s",
			Protocol:        "JT808",
			IdentifierType:  identity.IdentifierTypeJT808Phone,
			IdentifierValue: "13307795425",
			RawValue:        "013307795425",
			Plate:           "粤AG18312",
			OEM:             "G7s",
		},
	})
	if err != nil {
		t.Fatalf("writeUnresolvedCSV() error = %v", err)
	}
	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("ReadFile() error = %v", err)
	}
	text := string(data)
	for _, want := range []string{"identifier_type", "JT808_PHONE", "13307795425", "vin_not_found_in_legacy_binding"} {
		if !strings.Contains(text, want) {
			t.Fatalf("csv missing %q:\n%s", want, text)
		}
	}
}

func TestWriteConflictsCSV(t *testing.T) {
	path := filepath.Join(t.TempDir(), "conflicts.csv")
	err := writeConflictsCSV(path, []identity.MappingConflict{
		{
			Record: identity.MappingRecord{
				File:            "source.xlsx",
				Sheet:           "Sheet1",
				Row:             3,
				SourceCode:      "xinda",
				Protocol:        "JT808",
				IdentifierType:  identity.IdentifierTypePlate,
				IdentifierValue: "粤AG18312",
				Plate:           "粤AG18312",
			},
			ExistingVIN: "VIN001",
			NewVIN:      "VIN002",
			Reason:      "identifier already points to another vin",
		},
	})
	if err != nil {
		t.Fatalf("writeConflictsCSV() error = %v", err)
	}
	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("ReadFile() error = %v", err)
	}
	text := string(data)
	for _, want := range []string{"existing_vin", "VIN001", "VIN002", "identifier already points to another vin"} {
		if !strings.Contains(text, want) {
			t.Fatalf("csv missing %q:\n%s", want, text)
		}
	}
}

func TestSyncJT808DataSourcesFromIdentifiersPreservesManualPlatformNames(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\\(\\*\\)").
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(3))
	mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
	mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
	mock.ExpectExec("INSERT INTO vehicle_data_source").
		WillReturnResult(driver.RowsAffected(3))
	mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(5))
	mock.ExpectExec("UPDATE vehicle_data_source").
		WillReturnResult(driver.RowsAffected(5))
	mock.ExpectQuery("SELECT DISTINCT s.vin, s.stat_date, s.protocol").
		WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date", "protocol"}).
			AddRow("LNXNEGRRXSR319449", "2026-07-13", "JT808"))
	mock.ExpectBegin()
	mock.ExpectExec("INSERT INTO vehicle_daily_mileage").
		WillReturnResult(driver.RowsAffected(1))
	mock.ExpectExec("UPDATE vehicle_daily_mileage_source s").
		WillReturnResult(driver.RowsAffected(1))
	mock.ExpectExec("DELETE FROM vehicle_daily_mileage").
		WillReturnResult(driver.RowsAffected(0))
	mock.ExpectCommit()

	report, err := syncJT808DataSourcesFromIdentifiers(context.Background(), db, true)
	if err != nil {
		t.Fatalf("syncJT808DataSourcesFromIdentifiers() error = %v", err)
	}
	if !report.Apply || report.CandidateSources != 3 || report.SkippedSources != 1 || report.ConflictingSources != 2 || report.Synced != 3 || report.PlatformKindCandidates != 5 || report.PlatformKindClassified != 5 || report.ReprojectDailyMileageTargets != 1 || report.ReprojectedDailyMileageTargets != 1 {
		t.Fatalf("report = %#v", report)
	}
	for _, want := range []string{
		"COUNT(DISTINCT",
		"vehicle_identifier vi",
		"TRIM(r.source_ip) AS source_ip",
		"GROUP BY TRIM(r.source_ip)",
		"inferred.source_code",
		"'PLATFORM'",
		"inferred.source_code_count = 1",
		"vehicle_data_source.source_code IS NULL OR TRIM(vehicle_data_source.source_code) = ''",
		"ELSE vehicle_data_source.source_code",
		"vehicle_data_source.source_kind IS NULL OR TRIM(vehicle_data_source.source_kind) = '' OR vehicle_data_source.source_kind = 'UNKNOWN'",
		"ELSE vehicle_data_source.source_kind",
		"vehicle_data_source.enabled = 0",
		"vehicle_data_source.remark LIKE 'auto-retired:%'",
		"vehicle_data_source.remark = 'auto-reenabled: source evidence restored'",
		"THEN 'auto-reenabled: source evidence restored'",
		"THEN 1",
		"LEFT JOIN vehicle_data_source ds",
		"ds.id IS NULL",
	} {
		if !strings.Contains(syncJT808DataSourcesSQL, want) {
			t.Fatalf("sync sql missing %q:\n%s", want, syncJT808DataSourcesSQL)
		}
	}
	if strings.Index(syncJT808DataSourcesSQL, "enabled = CASE") < 0 ||
		strings.Index(syncJT808DataSourcesSQL, "remark = CASE") < 0 ||
		strings.Index(syncJT808DataSourcesSQL, "enabled = CASE") > strings.Index(syncJT808DataSourcesSQL, "remark = CASE") {
		t.Fatalf("sync sql must restore enabled before updating remark because MySQL evaluates assignments in order:\n%s", syncJT808DataSourcesSQL)
	}
	for _, want := range []string{
		"LEFT JOIN vehicle_data_source ds",
		"ds.id IS NULL",
		"ds.source_code IS NULL OR TRIM(ds.source_code) = ''",
		"ds.enabled = 0 AND (ds.remark LIKE 'auto-retired:%'",
		"ds.remark = 'auto-reenabled: source evidence restored'",
	} {
		if !strings.Contains(syncJT808DataSourcesCandidateCountSQL, want) {
			t.Fatalf("candidate count sql missing %q:\n%s", want, syncJT808DataSourcesCandidateCountSQL)
		}
	}
	for _, want := range []string{
		"JOIN vehicle_data_source ds",
		"ds.source_code <> inferred.source_code",
	} {
		if !strings.Contains(syncJT808DataSourcesConflictCountSQL, want) {
			t.Fatalf("conflict count sql missing %q:\n%s", want, syncJT808DataSourcesConflictCountSQL)
		}
	}
	for _, want := range []string{
		"vehicle_data_source.platform_name IS NULL OR TRIM(vehicle_data_source.platform_name) = ''",
		"ELSE vehicle_data_source.platform_name",
	} {
		if !strings.Contains(syncJT808DataSourcesSQL, want) {
			t.Fatalf("sync sql missing %q:\n%s", want, syncJT808DataSourcesSQL)
		}
	}
	for _, want := range []string{
		"source_kind IS NULL OR TRIM(source_kind) = '' OR source_kind = 'UNKNOWN'",
		"source_code IS NOT NULL AND TRIM(source_code) <> ''",
		"platform_name IS NOT NULL AND TRIM(platform_name) <> ''",
		"SET source_kind = 'PLATFORM'",
	} {
		if !strings.Contains(classifyConfiguredDataSourcesSQL, want) && !strings.Contains(classifyConfiguredDataSourcesCountSQL, want) {
			t.Fatalf("configured-source classification sql missing %q:\n%s\n%s", want, classifyConfiguredDataSourcesSQL, classifyConfiguredDataSourcesCountSQL)
		}
	}
	for _, want := range []string{
		"SELECT DISTINCT s.vin, s.stat_date, s.protocol",
		"vehicle_daily_mileage_source s",
		"LEFT JOIN vehicle_data_source ds",
		"LEFT JOIN vehicle_daily_mileage m",
		"s.protocol = 'JT808'",
		"s.quality_status = 'OK'",
		"ds.enabled = 1",
		"m.vin IS NULL",
		"selected.is_selected = 1",
	} {
		if !strings.Contains(reprojectSelectableDailyMileageSourcesSQL, want) {
			t.Fatalf("reproject sql missing %q:\n%s", want, reprojectSelectableDailyMileageSourcesSQL)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestSyncJT808DataSourcesDryRunDoesNotWrite(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\\(\\*\\)").
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(4))
	mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
	mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
	mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(6))
	mock.ExpectQuery("SELECT DISTINCT s.vin, s.stat_date, s.protocol").
		WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date", "protocol"}).
			AddRow("LNXNEGRRXSR319449", "2026-07-13", "JT808"))

	report, err := syncJT808DataSourcesFromIdentifiers(context.Background(), db, false)
	if err != nil {
		t.Fatalf("syncJT808DataSourcesFromIdentifiers() error = %v", err)
	}
	if report.Apply || report.CandidateSources != 4 || report.SkippedSources != 2 || report.ConflictingSources != 1 || report.PlatformKindCandidates != 6 || report.PlatformKindClassified != 0 || report.Synced != 0 || report.ReprojectDailyMileageTargets != 1 || report.ReprojectedDailyMileageTargets != 0 {
		t.Fatalf("report = %#v", report)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMaintenanceModeNamesAllSourceMaintenanceSteps(t *testing.T) {
	tests := []struct {
		name   string
		apply  bool
		sync   bool
		prune  bool
		retire bool
		want   string
	}{
		{name: "dry run", want: "dry_run"},
		{name: "apply", apply: true, want: "apply"},
		{name: "sync dry run", sync: true, want: "sync_dry_run"},
		{name: "sync prune retire apply", apply: true, sync: true, prune: true, retire: true, want: "sync_prune_retire_apply"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := maintenanceMode(tt.apply, tt.sync, tt.prune, tt.retire); got != tt.want {
				t.Fatalf("maintenanceMode() = %q, want %q", got, tt.want)
			}
		})
	}
}

func TestPruneUnmanagedDataSourcesDryRunDoesNotDelete(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\\(\\*\\)").
		WithArgs(int64(3600)).
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(339))

	report, err := pruneUnmanagedDataSourcesWithoutEvidence(context.Background(), db, false, time.Hour)
	if err != nil {
		t.Fatalf("pruneUnmanagedDataSourcesWithoutEvidence() error = %v", err)
	}
	if report.Apply || report.MinAgeSeconds != 3600 || report.CandidateSources != 339 || report.Pruned != 0 {
		t.Fatalf("report = %#v", report)
	}
	for _, want := range []string{
		"vehicle_data_source ds",
		"vehicle_daily_mileage m",
		"m.source_id = ds.id",
		"jt808_registration r",
		"r.source_ip = ds.source_ip",
		"ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN'",
		"TIMESTAMPDIFF(SECOND",
	} {
		if !strings.Contains(pruneUnmanagedDataSourcesCountSQL, want) {
			t.Fatalf("prune count sql missing %q:\n%s", want, pruneUnmanagedDataSourcesCountSQL)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestRetireStaleUnmanagedDataSourcesDryRunDoesNotDisable(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\\(\\*\\)").
		WithArgs(int64(86400)).
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(83))

	report, err := retireStaleUnmanagedDataSourcesWithoutEvidence(context.Background(), db, false, 24*time.Hour)
	if err != nil {
		t.Fatalf("retireStaleUnmanagedDataSourcesWithoutEvidence() error = %v", err)
	}
	if report.Apply || report.MinAgeSeconds != 86400 || report.CandidateSources != 83 || report.Retired != 0 {
		t.Fatalf("report = %#v", report)
	}
	for _, want := range []string{
		"vehicle_data_source ds",
		"ds.enabled = 1",
		"jt808_registration r",
		"r.source_ip = ds.source_ip",
		"ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN'",
		"TIMESTAMPDIFF(SECOND",
	} {
		if !strings.Contains(retireStaleUnmanagedDataSourcesCountSQL, want) {
			t.Fatalf("retire count sql missing %q:\n%s", want, retireStaleUnmanagedDataSourcesCountSQL)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestRetireStaleUnmanagedDataSourcesApplyDisablesOnlyCandidates(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\\(\\*\\)").
		WithArgs(int64(7200)).
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(7))
	mock.ExpectExec("UPDATE vehicle_data_source ds").
		WithArgs(int64(7200)).
		WillReturnResult(driver.RowsAffected(7))

	report, err := retireStaleUnmanagedDataSourcesWithoutEvidence(context.Background(), db, true, 2*time.Hour)
	if err != nil {
		t.Fatalf("retireStaleUnmanagedDataSourcesWithoutEvidence() error = %v", err)
	}
	if !report.Apply || report.MinAgeSeconds != 7200 || report.CandidateSources != 7 || report.Retired != 7 {
		t.Fatalf("report = %#v", report)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestPruneUnmanagedDataSourcesApplyDeletesOnlyCandidates(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\\(\\*\\)").
		WithArgs(int64(1800)).
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(12))
	mock.ExpectExec("DELETE ds\\s+FROM vehicle_data_source ds").
		WithArgs(int64(1800)).
		WillReturnResult(driver.RowsAffected(12))

	report, err := pruneUnmanagedDataSourcesWithoutEvidence(context.Background(), db, true, 30*time.Minute)
	if err != nil {
		t.Fatalf("pruneUnmanagedDataSourcesWithoutEvidence() error = %v", err)
	}
	if !report.Apply || report.MinAgeSeconds != 1800 || report.CandidateSources != 12 || report.Pruned != 12 {
		t.Fatalf("report = %#v", report)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}
