package api

import (
	"context"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"

	"drivervault/apiserver/internal/config"
	"drivervault/apiserver/internal/pb"
	"drivervault/apiserver/internal/plugins"
)

// The vehicle-provider layer deliberately searches payloads by key name instead
// of by path, so these tests pin the behaviour that makes that safe: top-level
// keys win over nested ones, the three numeric shapes are all understood, and an
// unrecognized value is dropped rather than guessed at.

func decode(t *testing.T, s string) any {
	t.Helper()
	var v any
	if err := json.Unmarshal([]byte(s), &v); err != nil {
		t.Fatalf("bad test JSON: %v", err)
	}
	return v
}

func TestFindNodePrefersShallowKeys(t *testing.T) {
	// "model" appears at the top level and again inside a nested dealer record.
	// The shallow one has to win, or an import picks up the dealer's data.
	tree := decode(t, `{
		"model": "Yaris",
		"dealer": {"model": "Corolla", "name": "City Toyota"}
	}`)
	if got := findString(tree, "model"); got != "Yaris" {
		t.Fatalf("model = %q, want Yaris", got)
	}
}

func TestFindNodeDescendsWhenAbsentAtTop(t *testing.T) {
	tree := decode(t, `{"payload": {"vehicle": {"vin": "VIN123"}}}`)
	if got := findString(tree, "vin"); got != "VIN123" {
		t.Fatalf("vin = %q, want VIN123", got)
	}
}

func TestFindNodeMissingKey(t *testing.T) {
	tree := decode(t, `{"a": 1}`)
	if got := findString(tree, "vin"); got != "" {
		t.Fatalf("expected empty string for a missing key, got %q", got)
	}
	if _, _, ok := findMeasure(tree, "odometer"); ok {
		t.Error("expected findMeasure to report not-found")
	}
}

func TestFindMeasureShapes(t *testing.T) {
	cases := []struct {
		name  string
		json  string
		value float64
		unit  string
	}{
		{"bare number", `{"fuelLevel": 62}`, 62, ""},
		{"numeric string", `{"fuelLevel": "62"}`, 62, ""},
		{"value+unit object", `{"odometer": {"value": 270185, "unit": "km"}}`, 270185, "km"},
		{"snake case key", `{"fuel_level": 40}`, 40, ""},
		{"screaming key", `{"FUEL-LEVEL": 40}`, 40, ""},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			n, unit, ok := findMeasure(decode(t, tc.json), "fuelLevel", "odometer")
			if !ok {
				t.Fatal("expected a measure")
			}
			if n != tc.value {
				t.Errorf("value = %v, want %v", n, tc.value)
			}
			if unit != tc.unit {
				t.Errorf("unit = %q, want %q", unit, tc.unit)
			}
		})
	}
}

func TestOdometerKmConvertsMiles(t *testing.T) {
	km, ok := odometerKm(decode(t, `{"odometer": {"value": 100, "unit": "mi"}}`))
	if !ok {
		t.Fatal("expected an odometer reading")
	}
	if km != 161 {
		t.Fatalf("km = %d, want 161 (100 mi)", km)
	}

	km, ok = odometerKm(decode(t, `{"odometer": {"value": 270185, "unit": "km"}}`))
	if !ok || km != 270185 {
		t.Fatalf("km = %d (ok=%v), want 270185", km, ok)
	}

	// A zero or absent odometer is "no reading", not a reading of zero — it must
	// never overwrite a real stored value with 0.
	if _, ok := odometerKm(decode(t, `{"odometer": {"value": 0, "unit": "km"}}`)); ok {
		t.Error("a zero odometer should not count as a reading")
	}
}

func TestFlattenJSON(t *testing.T) {
	fields, truncated := flattenJSON(decode(t, `{
		"payload": {
			"odometer": {"value": 270185, "unit": "km"},
			"nothing": null,
			"blank": "  ",
			"flag": true,
			"warnings": ["oil", "tyre"]
		}
	}`), 100)
	if truncated {
		t.Error("should not be truncated")
	}

	got := map[string]string{}
	for _, f := range fields {
		got[f.Key] = f.Value
	}
	want := map[string]string{
		"payload.odometer.value": "270185",
		"payload.odometer.unit":  "km",
		"payload.flag":           "true",
		"payload.warnings[0]":    "oil",
		"payload.warnings[1]":    "tyre",
	}
	for k, v := range want {
		if got[k] != v {
			t.Errorf("field %q = %q, want %q", k, got[k], v)
		}
	}
	// Nulls and whitespace-only strings are omitted: they are the provider having
	// no value, and rendering them as rows would be noise.
	for _, k := range []string{"payload.nothing", "payload.blank"} {
		if _, present := got[k]; present {
			t.Errorf("field %q should have been omitted", k)
		}
	}
}

func TestFlattenJSONTruncates(t *testing.T) {
	fields, truncated := flattenJSON(decode(t, `{"a":1,"b":2,"c":3,"d":4}`), 2)
	if !truncated {
		t.Error("expected truncated=true")
	}
	if len(fields) != 2 {
		t.Fatalf("len(fields) = %d, want 2", len(fields))
	}
}

func TestHeadlineMetrics(t *testing.T) {
	trees := []any{
		decode(t, `{"payload": {"odometer": {"value": 270185, "unit": "km"}, "fuelLevel": 62}}`),
		decode(t, `{"payload": {"batteryLevel": 80, "chargingStatus": "charging",
			"evRange": {"value": 412, "unit": "km"}, "evRangeWithAc": {"value": 389, "unit": "km"}}}`),
		decode(t, `{"payload": {"vehicleLocation": {"latitude": 52.2297, "longitude": 21.0122}}}`),
	}
	got := map[string]providerMetric{}
	for _, m := range headlineMetrics(trees) {
		got[m.Key] = m
	}

	if got["odometer"].Value != "270185" || got["odometer"].Unit != "km" {
		t.Errorf("odometer = %+v", got["odometer"])
	}
	if got["fuelLevel"].Value != "62" || got["fuelLevel"].Unit != "%" {
		t.Errorf("fuelLevel = %+v", got["fuelLevel"])
	}
	if got["batteryLevel"].Value != "80" {
		t.Errorf("batteryLevel = %+v", got["batteryLevel"])
	}
	if got["chargingStatus"].Value != "charging" {
		t.Errorf("chargingStatus = %+v", got["chargingStatus"])
	}
	// Range with the climate control on is its own reading, so a payload that
	// reports both shows both rather than one standing in for the other.
	if got["evRange"].Value != "412" || got["evRange"].Unit != "km" {
		t.Errorf("evRange = %+v", got["evRange"])
	}
	if got["evRangeWithAc"].Value != "389" || got["evRangeWithAc"].Unit != "km" {
		t.Errorf("evRangeWithAc = %+v", got["evRangeWithAc"])
	}
	if got["location"].Value != "52.22970, 21.01220" {
		t.Errorf("location = %+v", got["location"])
	}
}

// The climate section contributes two readings, and the pair is the point: a
// cabin at 12° heading for 21° is a car still warming up, which neither figure
// says on its own.
func TestHeadlineMetricsClimateTemperatures(t *testing.T) {
	trees := []any{
		decode(t, `{"payload": {"status": "on", "currentTemperature": 12.5,
			"targetTemperature": 21, "duration": 10}}`),
	}
	got := map[string]providerMetric{}
	for _, m := range headlineMetrics(trees) {
		got[m.Key] = m
	}

	if got["cabinTemperature"].Value != "12.5" || got["cabinTemperature"].Unit != "°C" {
		t.Errorf("cabinTemperature = %+v, want 12.5 °C", got["cabinTemperature"])
	}
	if got["targetTemperature"].Value != "21" || got["targetTemperature"].Unit != "°C" {
		t.Errorf("targetTemperature = %+v, want 21 °C", got["targetTemperature"])
	}

	// A payload that names its own unit is believed over the fixed default, so a
	// service reporting Fahrenheit is not relabelled into a wrong Celsius.
	trees = []any{decode(t, `{"payload": {"currentTemperature": {"value": 68, "unit": "°F"}}}`)}
	got = map[string]providerMetric{}
	for _, m := range headlineMetrics(trees) {
		got[m.Key] = m
	}
	if got["cabinTemperature"].Value != "68" || got["cabinTemperature"].Unit != "°F" {
		t.Errorf("cabinTemperature = %+v, want 68 °F", got["cabinTemperature"])
	}
}

// Every headline reading must be nameable in a car's saved arrangement, or the
// tab would show a reading the user cannot move.
func TestArrangeableCarMetricsCoverHeadlines(t *testing.T) {
	for _, spec := range headlineMetricSpecs {
		if !arrangeableCarMetrics[spec.key] {
			t.Errorf("reading %q is shown but cannot be arranged", spec.key)
		}
	}
}

// A reading converted out of miles must not claim to know the range to the
// metre. 62 mi is 99.779136 km exactly; the tab shows 99.8, the way the same
// figure reported in km already would read.
func TestHeadlineMetricsRoundsConvertedReadings(t *testing.T) {
	trees := []any{
		decode(t, `{"payload": {"odometer": {"value": 12000, "unit": "mi"},
			"evRange": {"value": 62, "unit": "mi"},
			"evRangeWithAc": {"value": 99.744, "unit": "km"},
			"batteryLevel": 23.4}}`),
	}
	got := map[string]providerMetric{}
	for _, m := range headlineMetrics(trees) {
		got[m.Key] = m
	}

	if got["evRange"].Value != "99.8" || got["evRange"].Unit != "km" {
		t.Errorf("evRange = %+v, want 99.8 km", got["evRange"])
	}
	// Rounding is by the reading's kind, not by whether it was converted: a
	// provider reporting km with too many decimals gets the same treatment.
	if got["evRangeWithAc"].Value != "99.7" {
		t.Errorf("evRangeWithAc = %+v, want 99.7", got["evRangeWithAc"])
	}
	// A whole number stays whole rather than gaining a ".0".
	if got["odometer"].Value != "19312.1" {
		t.Errorf("odometer = %+v, want 19312.1", got["odometer"])
	}
	// A battery percentage is a whole number; tenths of a percent are noise.
	if got["batteryLevel"].Value != "23" || got["batteryLevel"].Unit != "%" {
		t.Errorf("batteryLevel = %+v, want 23 %%", got["batteryLevel"])
	}
}

func TestNormalizeProviderFuelType(t *testing.T) {
	cases := map[string]string{
		"HV":               "hybrid",
		"hybrid":           "hybrid",
		"PETROL_HYBRID":    "hybrid", // hybrid wins over the petrol substring
		"phev":             "hybrid",
		"Diesel":           "diesel",
		"BEV":              "electric",
		"Battery Electric": "electric",
		"gasoline":         "petrol",
		"FCEV":             "hydrogen",
		"":                 "",
		"something else":   "",
	}
	for in, want := range cases {
		if got := normalizeProviderFuelType(in); got != want {
			t.Errorf("normalizeProviderFuelType(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestISODateOnly(t *testing.T) {
	cases := map[string]string{
		"2015-06-01":           "2015-06-01",
		"2015-06-01T00:00:00Z": "2015-06-01",
		// Ambiguous formats are dropped rather than guessed: 01/06/2015 is June
		// or January depending on who wrote it.
		"01/06/2015": "",
		"2015-06":    "",
		"":           "",
		"not a date": "",
	}
	for in, want := range cases {
		if got := isoDateOnly(in); got != want {
			t.Errorf("isoDateOnly(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestToyotaVehiclesNormalizes(t *testing.T) {
	raw := json.RawMessage(`{"payload": [{
		"vin": "JTDKB20U000000001",
		"alias": "Daily driver",
		"brand": "T",
		"modelName": "Yaris",
		"modelYear": 2015,
		"licensePlate": "ABC 1234",
		"fuelType": "HV",
		"firstRegistrationDate": "2015-06-01T00:00:00Z",
		"imageUrl": "https://example.invalid/yaris.png"
	}]}`)

	got := toyotaSource{}.vehicles(raw)
	if len(got) != 1 {
		t.Fatalf("len = %d, want 1", len(got))
	}
	v := got[0]
	if v.ID != "JTDKB20U000000001" || v.VIN != v.ID {
		t.Errorf("id/vin = %q/%q", v.ID, v.VIN)
	}
	if v.Name != "Daily driver" {
		t.Errorf("name = %q, want the owner's alias", v.Name)
	}
	if v.Make != "Toyota" || v.Model != "Yaris" || v.Year != 2015 {
		t.Errorf("make/model/year = %q/%q/%d", v.Make, v.Model, v.Year)
	}
	if v.Registration != "ABC 1234" {
		t.Errorf("registration = %q", v.Registration)
	}
	if v.FuelType != "hybrid" {
		t.Errorf("fuelType = %q, want hybrid", v.FuelType)
	}
	if v.FirstRegistrationDate != "2015-06-01" {
		t.Errorf("firstRegistrationDate = %q", v.FirstRegistrationDate)
	}
	if len(v.Fields) == 0 || len(v.Raw) == 0 {
		t.Error("the whole upstream object should still reach the UI via Fields/Raw")
	}
}

func TestToyotaVehiclesFallbacks(t *testing.T) {
	// No alias and no VIN: the name falls back to make + model and the id to
	// whatever other identifier the payload carries.
	got := toyotaSource{}.vehicles(json.RawMessage(`[{"guid": "abc", "brand": "L", "modelName": "IS 300h"}]`))
	if len(got) != 1 {
		t.Fatalf("len = %d, want 1", len(got))
	}
	if got[0].ID != "abc" {
		t.Errorf("id = %q, want abc", got[0].ID)
	}
	if got[0].Name != "Lexus IS 300h" {
		t.Errorf("name = %q, want Lexus IS 300h", got[0].Name)
	}

	// A vehicle with nothing to address it by is skipped, not imported blind.
	anonymous := toyotaSource{}.vehicles(json.RawMessage(`{"payload": [{"colour": "blue"}]}`))
	if len(anonymous) != 0 {
		t.Errorf("expected an unidentifiable vehicle to be skipped, got %d", len(anonymous))
	}
	malformed := toyotaSource{}.vehicles(json.RawMessage(`not json`))
	if len(malformed) != 0 {
		t.Errorf("expected no vehicles from malformed JSON, got %d", len(malformed))
	}
}

func TestImportSelectionDefaultsToEverything(t *testing.T) {
	// The plain request {"vehicleId": …} must mean "fetch everything you can".
	all := (*importSelection)(nil).resolve()
	if !all.identity || !all.fuelType || !all.dates || !all.odometer {
		t.Fatalf("nil selection = %+v, want everything on", all)
	}

	no := false
	partial := (&importSelection{Odometer: &no}).resolve()
	if partial.odometer {
		t.Error("odometer should be off")
	}
	if !partial.identity || !partial.fuelType || !partial.dates {
		t.Errorf("unspecified groups should stay on, got %+v", partial)
	}
}

func TestApplyVehicleToCarSkipsBlanks(t *testing.T) {
	car := carRecord{Make: "Toyota", Model: "Yaris", Registration: "OLD 111"}.toModel()
	// The provider reports a model but no registration: the stored plate must
	// survive rather than being blanked by an absent upstream field.
	applyVehicleToCar(&car, providerVehicle{Model: "Yaris Hybrid"}, resolvedSelection{identity: true})
	if car.Model != "Yaris Hybrid" {
		t.Errorf("model = %q, want the provider value", car.Model)
	}
	if car.Registration != "OLD 111" {
		t.Errorf("registration = %q, want the stored value kept", car.Registration)
	}

	// A group that was not selected is not written at all.
	car2 := carRecord{FuelType: "petrol"}.toModel()
	applyVehicleToCar(&car2, providerVehicle{FuelType: "hybrid"}, resolvedSelection{identity: true})
	if car2.FuelType != "petrol" {
		t.Errorf("fuelType = %q, want petrol (group not selected)", car2.FuelType)
	}
}

func TestFindVehicleMatchesIDOrVIN(t *testing.T) {
	vehicles := []providerVehicle{{ID: "abc", VIN: "VIN1"}, {ID: "VIN2", VIN: "VIN2"}}
	if v, ok := findVehicle(vehicles, "vin1"); !ok || v.ID != "abc" {
		t.Error("expected a case-insensitive VIN match")
	}
	if v, ok := findVehicle(vehicles, "VIN2"); !ok || v.ID != "VIN2" {
		t.Error("expected an id match")
	}
	if _, ok := findVehicle(vehicles, ""); ok {
		t.Error("an empty id must not match the first vehicle")
	}
	if _, ok := findVehicle(vehicles, "nope"); ok {
		t.Error("unexpected match")
	}
}

func TestVehicleSourceRegistry(t *testing.T) {
	src, ok := vehicleSourceByID("toyota")
	if !ok {
		t.Fatal("toyota should be a registered vehicle source")
	}
	if src.label() != "MyToyota" {
		t.Errorf("label = %q, want MyToyota", src.label())
	}
	if len(src.sections()) == 0 {
		t.Error("expected per-vehicle sections")
	}
	if _, ok := vehicleSourceByID("ford"); ok {
		t.Error("unregistered provider should not resolve")
	}
}

// ---- routing + gating, through the real Handler ------------------------------
//
// These go through the whole middleware chain against a stand-in PocketBase, with
// the Toyota plugin left disabled. That covers two things a unit test of the
// helpers cannot: that every route is actually registered under the pattern the
// clients call, and that a closed gate is reported the way each kind of endpoint
// needs — a listing answers 200 with a reason so the UI can say "connect this in
// Settings", while a write answers 4xx so a caller can never read the reply as a
// successful import.
//
// Note an unauthenticated probe proves neither: withAuth wraps the mux, so every
// path — registered or not — answers 401 before routing happens. Hence the bearer.

const provCarID = "car-1"

// fakeProviderPB answers the identity, user-record and car-record calls these
// endpoints make, and records the last car PATCH so a test can assert what was
// written. Nothing else is needed: the caller owns the car, so the access check
// never consults car_shares.
type fakeProviderPB struct {
	carProvider, carVehicleID string
	mu                        sync.Mutex
	lastCarPatch              map[string]any
}

func (f *fakeProviderPB) patch() map[string]any {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.lastCarPatch
}

func (f *fakeProviderPB) handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /api/collections/_superusers/auth-with-password", func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, 200, map[string]any{"token": "svc-token"})
	})
	mux.HandleFunc("POST /api/collections/users/auth-refresh", func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Authorization") == "" {
			writeJSON(w, 401, map[string]any{})
			return
		}
		writeJSON(w, 200, map[string]any{"record": map[string]any{
			"id": e2eUserID, "email": e2eUserMail, "name": "Owner", "role": "user", "organization": "",
		}})
	})
	mux.HandleFunc("GET /api/collections/users/records/"+e2eUserID, func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, 200, map[string]any{"id": e2eUserID, "email": e2eUserMail, "role": "user"})
	})
	mux.HandleFunc("GET /api/collections/cars/records/"+provCarID, func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, 200, f.carRecord())
	})
	mux.HandleFunc("PATCH /api/collections/cars/records/"+provCarID, func(w http.ResponseWriter, r *http.Request) {
		var in map[string]any
		_ = json.NewDecoder(r.Body).Decode(&in)
		f.mu.Lock()
		f.lastCarPatch = in
		// Apply the provider columns so a follow-up read reflects the write.
		if v, ok := in["provider"].(string); ok {
			f.carProvider = v
		}
		if v, ok := in["provider_vehicle_id"].(string); ok {
			f.carVehicleID = v
		}
		f.mu.Unlock()
		writeJSON(w, 200, f.carRecord())
	})
	return mux
}

func (f *fakeProviderPB) carRecord() map[string]any {
	f.mu.Lock()
	defer f.mu.Unlock()
	return map[string]any{
		"id": provCarID, "name": "Yaris", "owner": e2eUserID,
		"provider": f.carProvider, "provider_vehicle_id": f.carVehicleID,
	}
}

// newProviderTestServer wires a Server against the fake PocketBase with the
// Toyota plugin present but disabled — the "administrator turned it off" state,
// which closes the gate without any network call to Toyota.
func newProviderTestServer(t *testing.T, fake *fakeProviderPB) *httptest.Server {
	t.Helper()
	pbSrv := httptest.NewServer(fake.handler())
	t.Cleanup(pbSrv.Close)

	s := New(config.Config{UsersCollection: "users"},
		pb.New(pbSrv.URL, "admin@test.local", "pw"))
	// The global layer normally lives in PocketBase; seed it in memory so this
	// test does not have to stand up an app_settings collection too.
	s.pluginStore = plugins.NewMemoryStore([]byte(`{"toyota":{"enabled":false}}`))
	s.plugins = plugins.NewManager(s.pluginStore)
	if err := s.plugins.Load(context.Background()); err != nil {
		t.Fatalf("load plugins: %v", err)
	}
	srv := httptest.NewServer(s.Handler())
	t.Cleanup(srv.Close)
	return srv
}

// call makes an authenticated request and returns the status and decoded body.
func call(t *testing.T, srv *httptest.Server, method, path, body string) (int, map[string]any) {
	t.Helper()
	var rdr io.Reader
	if body != "" {
		rdr = strings.NewReader(body)
	}
	req, err := http.NewRequest(method, srv.URL+path, rdr)
	if err != nil {
		t.Fatal(err)
	}
	req.Header.Set("Authorization", "Bearer "+e2eBearer)
	req.Header.Set("Content-Type", "application/json")

	resp, err := srv.Client().Do(req)
	if err != nil {
		t.Fatalf("%s %s: %v", method, path, err)
	}
	defer resp.Body.Close()
	out := map[string]any{}
	_ = json.NewDecoder(resp.Body).Decode(&out)
	return resp.StatusCode, out
}

func TestVehicleProviderListing(t *testing.T) {
	srv := newProviderTestServer(t, &fakeProviderPB{})

	status, body := call(t, srv, http.MethodGet, "/api/vehicle-providers", "")
	if status != http.StatusOK {
		t.Fatalf("status = %d, want 200", status)
	}
	list, _ := body["providers"].([]any)
	if len(list) == 0 {
		t.Fatal("expected at least the Toyota provider")
	}
	first, _ := list[0].(map[string]any)
	if first["id"] != "toyota" || first["label"] != "MyToyota" {
		t.Errorf("provider = %+v, want toyota/MyToyota", first)
	}
	// Disabled globally: reported as not connected, with a reason to show the user
	// rather than an error.
	if first["connected"] != false {
		t.Errorf("connected = %v, want false", first["connected"])
	}
	if first["detail"] == "" {
		t.Error("a disconnected provider must explain why")
	}
}

func TestVehicleProviderGateSoftOnListHardOnWrite(t *testing.T) {
	srv := newProviderTestServer(t, &fakeProviderPB{})

	// Listing: 200 + unavailable, so the UI degrades to "connect in Settings".
	status, body := call(t, srv, http.MethodGet, "/api/vehicle-providers/toyota/vehicles", "")
	if status != http.StatusOK {
		t.Errorf("vehicles status = %d, want 200", status)
	}
	if body["unavailable"] != true || body["detail"] == "" {
		t.Errorf("vehicles body = %+v, want unavailable with a reason", body)
	}

	// Import: 400, so a client can never mistake the reply for a created car.
	status, body = call(t, srv, http.MethodPost, "/api/vehicle-providers/toyota/import", `{"vehicleId":"VIN1"}`)
	if status != http.StatusBadRequest {
		t.Errorf("import status = %d, want 400", status)
	}
	if body["error"] == "" {
		t.Error("a refused import must say why")
	}
	if _, created := body["car"]; created {
		t.Error("a refused import must not return a car")
	}
}

func TestVehicleProviderUnknownProvider(t *testing.T) {
	srv := newProviderTestServer(t, &fakeProviderPB{})
	if status, _ := call(t, srv, http.MethodGet, "/api/vehicle-providers/ford/vehicles", ""); status != http.StatusNotFound {
		t.Errorf("status = %d, want 404 for an unregistered provider", status)
	}
}

func TestCarProviderRoutesOnUnlinkedCar(t *testing.T) {
	srv := newProviderTestServer(t, &fakeProviderPB{}) // car has no provider link

	// Reaching these messages proves the routes are registered and the car's
	// access check ran — a missing route would 404 from the mux with no message.
	status, body := call(t, srv, http.MethodGet, "/api/cars/"+provCarID+"/provider", "")
	if status != http.StatusNotFound {
		t.Errorf("snapshot status = %d, want 404", status)
	}
	if !strings.Contains(toStr(body["error"]), "not linked") {
		t.Errorf("snapshot error = %v, want the not-linked message", body["error"])
	}

	status, body = call(t, srv, http.MethodPost, "/api/cars/"+provCarID+"/provider/sync", `{}`)
	if status != http.StatusBadRequest {
		t.Errorf("sync status = %d, want 400", status)
	}
	if !strings.Contains(toStr(body["error"]), "not linked") {
		t.Errorf("sync error = %v, want the not-linked message", body["error"])
	}

	// Linking needs the vehicle to be on the caller's account, so a closed gate
	// refuses it outright rather than storing a link that could never be read.
	status, body = call(t, srv, http.MethodPost, "/api/cars/"+provCarID+"/provider",
		`{"provider":"toyota","vehicleId":"VIN1"}`)
	if status != http.StatusBadRequest {
		t.Errorf("link status = %d, want 400 while the provider is unusable", status)
	}
}

func TestCarProviderUnlinkWorksWithProviderUnreachable(t *testing.T) {
	// Unlinking must not depend on the provider being reachable — otherwise a car
	// could stay stuck to an account the user has since removed. It writes only
	// the two link columns, leaving the rest of the car alone.
	fake := &fakeProviderPB{carProvider: "toyota", carVehicleID: "VIN1"}
	srv := newProviderTestServer(t, fake)

	status, body := call(t, srv, http.MethodPost, "/api/cars/"+provCarID+"/provider", `{"provider":"","vehicleId":""}`)
	if status != http.StatusOK {
		t.Fatalf("unlink status = %d (%v), want 200", status, body["error"])
	}
	if body["provider"] != nil {
		t.Errorf("returned car still reports provider %v", body["provider"])
	}

	patch := fake.patch()
	if patch["provider"] != "" || patch["provider_vehicle_id"] != "" {
		t.Errorf("patch = %+v, want both link columns cleared", patch)
	}
	if len(patch) != 2 {
		t.Errorf("patch touched %d columns (%+v); unlinking must write only the link", len(patch), patch)
	}
}

func TestCarProviderSnapshotGateClosed(t *testing.T) {
	// A linked car whose provider the caller cannot currently use: the snapshot is
	// still a 200 with a reason, so the tab renders an explanation instead of an
	// error page.
	srv := newProviderTestServer(t, &fakeProviderPB{carProvider: "toyota", carVehicleID: "VIN1"})

	status, body := call(t, srv, http.MethodGet, "/api/cars/"+provCarID+"/provider", "")
	if status != http.StatusOK {
		t.Fatalf("status = %d, want 200", status)
	}
	if body["provider"] != "toyota" || body["label"] != "MyToyota" {
		t.Errorf("body = %+v, want the provider identified", body)
	}
	if body["unavailable"] != true || body["detail"] == "" {
		t.Errorf("body = %+v, want unavailable with a reason", body)
	}
	if _, fetched := body["sections"]; fetched {
		t.Error("no sections should be fetched when the gate is closed")
	}
}

func TestFormatNumber(t *testing.T) {
	cases := map[float64]string{270185: "270185", 62.5: "62.5", 0: "0", -3: "-3"}
	for in, want := range cases {
		if got := formatNumber(in); got != want {
			t.Errorf("formatNumber(%v) = %q, want %q", in, got, want)
		}
	}
}
