package api

// Vehicle providers turn a manufacturer-service plugin into two user-facing
// things: a car you can create straight from your account with the service, and
// a per-car tab showing everything that service currently knows about it.
//
// The layer is deliberately generic. A provider is a small adapter (vehicleSource)
// over an existing plugin plus its per-user credential cascade, so adding the
// next manufacturer means writing one adapter and appending it to
// vehicleSources() — no new endpoints, no new UI plumbing. Toyota (MyToyota) is
// the first one; see vehicleproviders_toyota.go.
//
//	GET  /api/vehicle-providers                     — providers, with connect state
//	GET  /api/vehicle-providers/{provider}/vehicles  — the caller's vehicles
//	POST /api/vehicle-providers/{provider}/import    — create a car from one
//	GET  /api/cars/{id}/provider                     — live snapshot for the tab
//	POST /api/cars/{id}/provider                     — link / unlink an existing car
//	POST /api/cars/{id}/provider/sync                — re-apply provider data to the car
//
// Two properties are worth stating outright, because they shape the whole design:
//
// Credentials are always the *caller's*. Every call runs under the account the
// requesting user connected in Settings (resolved through the global → org → user
// cascade in integrations.go). A car shared with someone else therefore shows
// them provider data only if that vehicle is on their own manufacturer account —
// the owner's credentials are never borrowed, and never leave the server.
//
// Upstream shapes are not modelled. These are unofficial APIs whose payloads
// change without notice, so rather than hard-coding field paths this file walks
// whatever JSON comes back: findMeasure/findString locate the handful of readings
// worth promoting (odometer, fuel, battery, range), and flattenJSON turns the
// rest into dotted key/value pairs so the tab can show everything the plugin
// returned. A shape change degrades a field to "not shown" instead of breaking
// the page.

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math"
	"net/http"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"drivervault/apiserver/internal/models"
	"drivervault/apiserver/internal/plugins"
)

// vehicleSource adapts one plugin that can enumerate the caller's vehicles and
// read data about them.
type vehicleSource interface {
	// id is the URL segment and the value persisted on car.provider.
	id() string
	// label names the provider to the user — the car tab's title ("MyToyota").
	label() string
	// service is the upstream service behind it ("Toyota Connected Europe").
	service() string
	// gate resolves the caller's effective credentials for this provider from the
	// integration cascade. When ok is false nothing is called and detail says, in
	// one sentence, what the user has to do about it. userRaw is the caller's
	// pluginSettings blob, passed in so one request reads it once.
	gate(ctx context.Context, s *Server, who *callerIdentity, userRaw json.RawMessage) (cfg map[string]string, ok bool, detail string)
	// listAction is the plugin capability that enumerates vehicles.
	listAction() string
	// vehicles maps that capability's payload onto normalized entries.
	vehicles(raw json.RawMessage) []providerVehicle
	// sections are the per-vehicle capabilities the tab fetches, in display order.
	sections() []providerSection
}

// vehicleSources are the registered providers, in menu order.
func vehicleSources() []vehicleSource {
	return []vehicleSource{toyotaSource{}}
}

func vehicleSourceByID(id string) (vehicleSource, bool) {
	for _, src := range vehicleSources() {
		if src.id() == id {
			return src, true
		}
	}
	return nil, false
}

// providerSection is one per-vehicle capability, rendered as a card in the tab.
// The Web App localizes the heading from ID, so nothing here is English.
type providerSection struct {
	ID     string // stable id, e.g. "telemetry"
	Action string // the plugin capability to invoke
}

// providerField is one leaf of a provider payload, flattened to a dotted path.
// Everything a plugin returns becomes one of these, which is what lets the tab
// show a whole payload without this server modelling each upstream schema.
type providerField struct {
	Key   string `json:"key"`
	Value string `json:"value"`
}

// providerVehicle is one vehicle on the caller's provider account, normalized
// into the fields a Car is built from. Raw and Fields carry the upstream object
// verbatim and flattened, so the UI can show more than we map.
type providerVehicle struct {
	ID                    string          `json:"id"` // the provider's own id (VIN, for Toyota)
	VIN                   string          `json:"vin,omitempty"`
	Name                  string          `json:"name"`
	Make                  string          `json:"make,omitempty"`
	Model                 string          `json:"model,omitempty"`
	Year                  int             `json:"year,omitempty"`
	Registration          string          `json:"registration,omitempty"`
	FuelType              string          `json:"fuelType,omitempty"`
	BuildDate             string          `json:"buildDate,omitempty"`
	FirstRegistrationDate string          `json:"firstRegistrationDate,omitempty"`
	ImageURL              string          `json:"imageUrl,omitempty"`
	Fields                []providerField `json:"fields,omitempty"`
	Raw                   json.RawMessage `json:"raw,omitempty"`

	// LinkedCarID is set when the caller already has a car linked to this
	// vehicle, so the UI can offer to open it instead of importing it twice.
	LinkedCarID string `json:"linkedCarId,omitempty"`
}

// providerMetric is a headline reading lifted out of the sections — the few
// values worth showing large. Key is a stable id the Web App localizes.
type providerMetric struct {
	Key   string `json:"key"`
	Value string `json:"value"`
	Unit  string `json:"unit,omitempty"`
}

// sectionResult is one capability's outcome. A section that fails carries its
// error and the rest still render: half a snapshot beats an error page.
type sectionResult struct {
	ID     string          `json:"id"`
	Status string          `json:"status"` // ok | error | empty
	Error  string          `json:"error,omitempty"`
	Fields []providerField `json:"fields,omitempty"`
	// Truncated reports that Fields was capped (see maxSectionFields).
	Truncated bool            `json:"truncated,omitempty"`
	Raw       json.RawMessage `json:"raw,omitempty"`
}

// providerSnapshot is everything a provider can currently tell us about one car.
type providerSnapshot struct {
	Provider  string `json:"provider"`
	Label     string `json:"label"`
	Service   string `json:"service"`
	VehicleID string `json:"vehicleId,omitempty"`

	// Unavailable + Detail replace the payload when the caller cannot reach the
	// provider at all (not connected, or this vehicle is not on their account).
	Unavailable bool   `json:"unavailable,omitempty"`
	Detail      string `json:"detail,omitempty"`

	FetchedAt string           `json:"fetchedAt,omitempty"`
	Vehicle   *providerVehicle `json:"vehicle,omitempty"`
	Metrics   []providerMetric `json:"metrics,omitempty"`
	Sections  []sectionResult  `json:"sections,omitempty"`

	// SuggestedCurrentKm is the odometer the provider reports, when it differs
	// from the car's stored reading — what the tab's "update odometer" offers.
	SuggestedCurrentKm int `json:"suggestedCurrentKm,omitempty"`
}

// maxSectionFields caps how many flattened leaves one section returns. A
// notification history can run to hundreds of entries; the raw payload is still
// attached, so nothing is lost — only the pre-flattened list is bounded.
const maxSectionFields = 300

// ---- provider listing --------------------------------------------------------

// GET /api/vehicle-providers — the registered providers and whether the caller
// can currently use each one. Never 4xx for a provider that is merely not
// connected: the Web App shows those as "connect in Settings".
func (s *Server) handleListVehicleProviders(w http.ResponseWriter, r *http.Request) {
	who := caller(r)
	if who == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	userRaw := s.userPluginSettings(r.Context(), who.ID)

	out := make([]map[string]any, 0, len(vehicleSources()))
	for _, src := range vehicleSources() {
		_, ok, detail := src.gate(r.Context(), s, who, userRaw)
		out = append(out, map[string]any{
			"id":        src.id(),
			"label":     src.label(),
			"service":   src.service(),
			"connected": ok,
			"detail":    detail,
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{"providers": out})
}

// resolveSource looks up the provider named in the path and gates it for the
// caller. On any failure it writes the response and returns ok=false.
//
// softGate picks how a closed gate is reported. A listing answers 200 with an
// empty list plus a reason, so the UI can say "connect this in Settings" instead
// of showing a failure; a write (import, link) answers 400, because there the
// caller asked for something that did not happen and must not read the reply as
// success.
func (s *Server) resolveSource(w http.ResponseWriter, r *http.Request, softGate bool) (vehicleSource, map[string]string, bool) {
	who := caller(r)
	if who == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return nil, nil, false
	}
	src, found := vehicleSourceByID(r.PathValue("provider"))
	if !found {
		writeError(w, http.StatusNotFound, "unknown vehicle provider")
		return nil, nil, false
	}
	userRaw := s.userPluginSettings(r.Context(), who.ID)
	cfg, ok, detail := src.gate(r.Context(), s, who, userRaw)
	if !ok {
		if softGate {
			writeJSON(w, http.StatusOK, map[string]any{
				"provider": src.id(), "label": src.label(),
				"vehicles": []any{}, "unavailable": true, "detail": detail,
			})
		} else {
			writeError(w, http.StatusBadRequest, detail)
		}
		return nil, nil, false
	}
	return src, cfg, true
}

// ---- vehicle listing ---------------------------------------------------------

// GET /api/vehicle-providers/{provider}/vehicles — the vehicles on the caller's
// account with that provider, normalized into importable car fields, each
// annotated with the car it is already linked to (if any).
func (s *Server) handleProviderVehicles(w http.ResponseWriter, r *http.Request) {
	src, cfg, ok := s.resolveSource(w, r, true)
	if !ok {
		return
	}
	vehicles, err := s.fetchProviderVehicles(r.Context(), src, cfg)
	if err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
		return
	}

	// Annotate with the caller's existing links so the UI never offers to import
	// the same vehicle twice.
	linked := s.linkedVehicles(r.Context(), s.currentUserID(r), src.id())
	for i := range vehicles {
		vehicles[i].LinkedCarID = linked[vehicles[i].ID]
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"provider": src.id(),
		"label":    src.label(),
		"service":  src.service(),
		"vehicles": vehicles,
	})
}

// fetchProviderVehicles invokes the provider's list capability and normalizes it.
func (s *Server) fetchProviderVehicles(ctx context.Context, src vehicleSource, cfg map[string]string) ([]providerVehicle, error) {
	raw, err := s.plugins.InvokeWith(ctx, src.id(), cfg, src.listAction(), nil)
	if err != nil {
		return nil, err
	}
	return src.vehicles(raw), nil
}

// linkedVehicles maps provider vehicle id -> car id for the cars a user owns
// under one provider. Best effort: an unreachable PocketBase yields an empty map
// and the UI simply shows nothing as linked.
func (s *Server) linkedVehicles(ctx context.Context, userID, provider string) map[string]string {
	out := map[string]string{}
	if userID == "" || provider == "" {
		return out
	}
	res, err := s.pb.List(ctx, colCars, url.Values{
		"filter":  {fmt.Sprintf("owner='%s' && provider='%s'", userID, provider)},
		"perPage": {"200"},
	})
	if err != nil {
		return out
	}
	var recs []carRecord
	if json.Unmarshal(res.Items, &recs) != nil {
		return out
	}
	for _, rec := range recs {
		if rec.ProviderVehicleID != "" {
			out[rec.ProviderVehicleID] = rec.ID
		}
	}
	return out
}

// findVehicle picks one vehicle out of the caller's provider account by id,
// falling back to a VIN match so a client may address it either way.
func findVehicle(vehicles []providerVehicle, id string) (providerVehicle, bool) {
	id = strings.TrimSpace(id)
	if id == "" {
		return providerVehicle{}, false
	}
	for _, v := range vehicles {
		if strings.EqualFold(v.ID, id) || (v.VIN != "" && strings.EqualFold(v.VIN, id)) {
			return v, true
		}
	}
	return providerVehicle{}, false
}

// ---- import ------------------------------------------------------------------

// importSelection is the caller's choice of what to pull from the provider. Each
// field is a tri-state: absent means "yes", which makes the plain request
// {"vehicleId": …} mean "fetch everything you can".
type importSelection struct {
	Identity *bool `json:"identity"` // make, model, year, registration, VIN
	FuelType *bool `json:"fuelType"`
	Dates    *bool `json:"dates"`    // build / first-registration dates
	Odometer *bool `json:"odometer"` // current km, from the telemetry capability
}

// resolvedSelection is an importSelection with the defaults applied.
type resolvedSelection struct{ identity, fuelType, dates, odometer bool }

func (in *importSelection) resolve() resolvedSelection {
	pick := func(v *bool) bool { return v == nil || *v }
	if in == nil {
		return resolvedSelection{true, true, true, true}
	}
	return resolvedSelection{pick(in.Identity), pick(in.FuelType), pick(in.Dates), pick(in.Odometer)}
}

// POST /api/vehicle-providers/{provider}/import — create a car from one vehicle
// on the caller's provider account. Body:
//
//	{vehicleId, name?, include?: {identity, fuelType, dates, odometer}}
//
// The new car is owned by the caller and linked to the provider, so its tab works
// immediately. Importing a vehicle the caller already has is refused with 409 and
// the existing car's id, so a double submit cannot duplicate a car.
func (s *Server) handleProviderImport(w http.ResponseWriter, r *http.Request) {
	var body struct {
		VehicleID string           `json:"vehicleId"`
		Name      string           `json:"name"`
		Include   *importSelection `json:"include"`
	}
	if err := decodeJSON(r, &body); err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}
	src, cfg, ok := s.resolveSource(w, r, false)
	if !ok {
		return
	}
	vehicles, err := s.fetchProviderVehicles(r.Context(), src, cfg)
	if err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
		return
	}
	vehicle, found := findVehicle(vehicles, body.VehicleID)
	if !found {
		writeError(w, http.StatusNotFound, "that vehicle is not on your "+src.label()+" account")
		return
	}

	me := s.currentUserID(r)
	if existing := s.linkedVehicles(r.Context(), me, src.id())[vehicle.ID]; existing != "" {
		writeJSON(w, http.StatusConflict, map[string]any{
			"error": "this vehicle is already in your garage",
			"carId": existing,
		})
		return
	}

	sel := body.Include.resolve()
	car := models.Car{Name: strings.TrimSpace(body.Name)}
	applyVehicleToCar(&car, vehicle, sel)
	if car.Name == "" {
		car.Name = vehicle.Name
	}
	if car.Name == "" {
		car.Name = src.label() + " vehicle"
	}

	// The odometer is not part of the vehicle list — it comes from a per-vehicle
	// capability, so it costs an extra call and is only made when asked for.
	var warnings []string
	if sel.odometer {
		if km, ok := s.providerOdometer(r.Context(), src, cfg, vehicle.ID); ok {
			car.CurrentKm = km
		} else {
			warnings = append(warnings, "odometer")
		}
	}

	applyCarDefaults(&car)
	payload := carPayload(car)
	payload["owner"] = me
	for k, v := range carProviderPayload(src.id(), vehicle.ID) {
		payload[k] = v
	}

	var rec carRecord
	if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil {
		writePBError(w, err)
		return
	}
	m := rec.toModel()
	m.Access = accessOwner
	writeJSON(w, http.StatusCreated, map[string]any{"car": m, "warnings": warnings})
}

// applyVehicleToCar copies the selected groups of a provider vehicle onto a car.
// Only non-empty provider values are written, so a field the provider does not
// report is left as it was rather than blanked.
func applyVehicleToCar(car *models.Car, v providerVehicle, sel resolvedSelection) {
	set := func(dst *string, val string) {
		if strings.TrimSpace(val) != "" {
			*dst = val
		}
	}
	if sel.identity {
		set(&car.Make, v.Make)
		set(&car.Model, v.Model)
		set(&car.Registration, v.Registration)
		set(&car.VIN, v.VIN)
		if v.Year > 0 {
			car.Year = v.Year
		}
	}
	if sel.fuelType {
		set(&car.FuelType, v.FuelType)
	}
	if sel.dates {
		set(&car.BuildDate, v.BuildDate)
		set(&car.FirstRegistrationDate, v.FirstRegistrationDate)
	}
}

// providerOdometer reads the odometer for one vehicle out of whichever section
// reports it, walking the payload rather than assuming a path. Kilometres are the
// stored unit, so a reading in miles is converted.
//
// The sections go out as one batch rather than a call each: InvokeWith builds a
// fresh instance per call, which for a connector that authenticates lazily means
// a fresh login per call too. Sections are scanned in the provider's declared
// order, so the endpoint meant to carry the odometer wins over one that happens
// to mention a distance.
func (s *Server) providerOdometer(ctx context.Context, src vehicleSource, cfg map[string]string, vehicleID string) (int, bool) {
	defs := src.sections()
	calls := make([]plugins.BatchCall, 0, len(defs))
	for _, sec := range defs {
		calls = append(calls, plugins.BatchCall{ID: sec.ID, Action: sec.Action, Params: vehicleParams(vehicleID)})
	}

	results, err := s.plugins.InvokeBatchWith(ctx, src.id(), cfg, calls)
	if err != nil {
		return 0, false
	}
	for _, res := range results {
		if res.Err != nil || len(res.Result) == 0 {
			continue
		}
		var tree any
		if json.Unmarshal(res.Result, &tree) != nil {
			continue
		}
		if km, ok := odometerKm(tree); ok {
			return km, true
		}
	}
	return 0, false
}

// odometerKm extracts an odometer reading in kilometres from a decoded payload.
func odometerKm(tree any) (int, bool) {
	n, unit, ok := findMeasure(tree, "odometer", "mileage", "totalMileage", "odometerReading", "distanceTotal")
	if !ok || n <= 0 {
		return 0, false
	}
	if isMiles(unit) {
		n *= 1.609344
	}
	return int(math.Round(n)), true
}

func isMiles(unit string) bool {
	u := strings.ToLower(strings.TrimSpace(unit))
	return u == "mi" || u == "mile" || u == "miles" || u == "imperial"
}

// vehicleParams is the {"vin": …} params object every per-vehicle capability
// takes. The key is "vin" because that is what the plugin contract uses; the
// value is the provider's vehicle id, which for Toyota is the VIN.
func vehicleParams(vehicleID string) json.RawMessage {
	b, _ := json.Marshal(map[string]string{"vin": vehicleID})
	return b
}

// ---- per-car snapshot --------------------------------------------------------

// GET /api/cars/{id}/provider — everything the car's provider can currently tell
// us about it. Requires read access to the car; the provider call runs under the
// *caller's* own account, so a sharee sees data only for a vehicle that is also
// on their account (and a clear reason when it is not).
func (s *Server) handleCarProvider(w http.ResponseWriter, r *http.Request) {
	who := caller(r)
	if who == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	level, rec, err := s.carAccessLevel(r.Context(), who.ID, r.PathValue("id"))
	if err != nil {
		writePBError(w, err)
		return
	}
	if level == accessNone {
		writeError(w, http.StatusForbidden, "you do not have access to this car")
		return
	}
	if rec.Provider == "" {
		writeError(w, http.StatusNotFound, "this car is not linked to a connected service")
		return
	}
	src, found := vehicleSourceByID(rec.Provider)
	if !found {
		writeError(w, http.StatusNotFound, "unknown vehicle provider")
		return
	}

	snap := providerSnapshot{
		Provider:  src.id(),
		Label:     src.label(),
		Service:   src.service(),
		VehicleID: rec.ProviderVehicleID,
	}

	userRaw := s.userPluginSettings(r.Context(), who.ID)
	cfg, ok, detail := src.gate(r.Context(), s, who, userRaw)
	if !ok {
		snap.Unavailable, snap.Detail = true, detail
		writeJSON(w, http.StatusOK, snap)
		return
	}

	vehicles, err := s.fetchProviderVehicles(r.Context(), src, cfg)
	if err != nil {
		snap.Unavailable, snap.Detail = true, err.Error()
		writeJSON(w, http.StatusOK, snap)
		return
	}
	vehicle, found := findVehicle(vehicles, rec.ProviderVehicleID)
	if !found {
		snap.Unavailable = true
		snap.Detail = "this vehicle is not on your " + src.label() + " account"
		writeJSON(w, http.StatusOK, snap)
		return
	}
	snap.Vehicle = &vehicle

	snap.Sections, snap.Metrics = s.fetchSections(r.Context(), src, cfg, vehicle.ID)
	snap.FetchedAt = time.Now().UTC().Format(time.RFC3339)

	// Offer the provider's odometer when it is ahead of what the car has stored.
	for _, m := range snap.Metrics {
		if m.Key != "odometer" {
			continue
		}
		if km, err := strconv.Atoi(m.Value); err == nil && km > rec.CurrentKm {
			snap.SuggestedCurrentKm = km
		}
	}
	writeJSON(w, http.StatusOK, snap)
}

// fetchSections invokes every per-vehicle capability in one batch (so a connector
// that authenticates lazily logs in once) and turns each payload into a flattened
// section plus, across all of them, the headline metrics.
func (s *Server) fetchSections(ctx context.Context, src vehicleSource, cfg map[string]string, vehicleID string) ([]sectionResult, []providerMetric) {
	defs := src.sections()
	calls := make([]plugins.BatchCall, 0, len(defs))
	for _, sec := range defs {
		calls = append(calls, plugins.BatchCall{ID: sec.ID, Action: sec.Action, Params: vehicleParams(vehicleID)})
	}

	results, err := s.plugins.InvokeBatchWith(ctx, src.id(), cfg, calls)
	if err != nil {
		return nil, nil
	}

	out := make([]sectionResult, 0, len(results))
	trees := make([]any, 0, len(results))
	for _, res := range results {
		sec := sectionResult{ID: res.ID, Status: "ok"}
		switch {
		case res.Err != nil:
			sec.Status, sec.Error = "error", res.Err.Error()
		case len(res.Result) == 0:
			sec.Status = "empty"
		default:
			var tree any
			if err := json.Unmarshal(res.Result, &tree); err != nil {
				sec.Status, sec.Error = "error", "the service returned data this app could not read"
			} else {
				sec.Raw = res.Result
				sec.Fields, sec.Truncated = flattenJSON(tree, maxSectionFields)
				if len(sec.Fields) == 0 {
					sec.Status = "empty"
				}
				trees = append(trees, tree)
			}
		}
		out = append(out, sec)
	}
	return out, headlineMetrics(trees)
}

// metricSpec declares one headline reading: where to look for it and how to
// present it. The first section that reports a value wins.
type metricSpec struct {
	key      string
	keys     []string
	unit     string // fixed unit when the payload does not carry one
	distance bool   // convert an imperial reading to kilometres
}

// headlineMetricSpecs are the measured readings, in the order they are reported
// when a car has no arrangement of its own. Package-level so the set of keys a
// car's arrangement may name (arrangeableCarMetrics, in cars.go) is derived from
// it and the two cannot drift apart.
var headlineMetricSpecs = []metricSpec{
	{key: "odometer", keys: []string{"odometer", "mileage", "totalMileage", "odometerReading", "distanceTotal"}, unit: "km", distance: true},
	{key: "fuelLevel", keys: []string{"fuelLevel", "fuelPercentage", "fuelRemainingPercent"}, unit: "%"},
	{key: "fuelRange", keys: []string{"fuelRange", "rangeRemaining", "drivingRange", "fuelRangeTotal"}, unit: "km", distance: true},
	{key: "batteryLevel", keys: []string{"batteryLevel", "chargeRemainingAmount", "stateOfCharge", "socLevel"}, unit: "%"},
	{key: "evRange", keys: []string{"evRange", "electricRange", "batteryRange"}, unit: "km", distance: true},
	// Range with the climate control running, which Toyota reports beside the
	// plain one. Its own reading rather than a fallback for evRange: the two
	// are different numbers and the gap between them is the point — a driver
	// deciding whether to run the A/C wants to see both.
	{key: "evRangeWithAc", keys: []string{"evRangeWithAc"}, unit: "km", distance: true},
	// Cabin temperature and the preset it is climbing towards, out of the climate
	// section. Two readings for the same reason the two ranges are two: the gap
	// between them is the answer to the question the driver actually has, which is
	// whether the car is warm yet. Unit is fixed rather than converted — Toyota
	// Connected is a European service reporting Celsius — but a payload that names
	// its own unit still wins, as it does for every other reading here.
	{key: "cabinTemperature", keys: []string{"currentTemperature", "cabinTemperature", "insideTemperature"}, unit: "°C"},
	{key: "targetTemperature", keys: []string{"targetTemperature"}, unit: "°C"},
}

// roundForDisplay trims a reading to the precision it actually has.
//
// A range converted from miles arrives as 99.744 km, which claims to know the
// remaining range to the metre and reads as false precision beside the 103.9 its
// untouched twin reports. Distances keep one decimal and percentages none;
// anything else is left alone, because without knowing what it measures there is
// no safe place to cut.
func roundForDisplay(n float64, spec metricSpec) float64 {
	switch {
	case spec.distance:
		return math.Round(n*10) / 10
	case spec.unit == "%":
		return math.Round(n)
	default:
		return n
	}
}

// unmeasuredMetricKeys are the headline readings that are not measures — a state
// and a pair of coordinates — picked out separately below.
var unmeasuredMetricKeys = []string{"chargingStatus", "location"}

// headlineMetrics lifts the readings worth showing large out of the section
// payloads. Everything not listed here still reaches the UI as a flattened
// field — this is about prominence, not about filtering.
func headlineMetrics(trees []any) []providerMetric {
	out := []providerMetric{}
	for _, spec := range headlineMetricSpecs {
		for _, tree := range trees {
			n, unit, ok := findMeasure(tree, spec.keys...)
			if !ok {
				continue
			}
			display := spec.unit
			if spec.distance && isMiles(unit) {
				n *= 1.609344
			} else if unit != "" && spec.unit != "%" {
				display = unit
			}
			out = append(out, providerMetric{Key: spec.key, Value: formatNumber(roundForDisplay(n, spec)), Unit: display})
			break
		}
	}

	// Charging state and the parked position are strings/pairs rather than
	// measures, so they are picked out separately.
	for _, tree := range trees {
		if v := findString(tree, "chargingStatus", "chargeStatus", "chargeType"); v != "" {
			out = append(out, providerMetric{Key: "chargingStatus", Value: v})
			break
		}
	}
	for _, tree := range trees {
		lat, _, latOK := findMeasure(tree, "latitude", "lat")
		lon, _, lonOK := findMeasure(tree, "longitude", "lon", "lng")
		if latOK && lonOK {
			out = append(out, providerMetric{
				Key:   "location",
				Value: strconv.FormatFloat(lat, 'f', 5, 64) + ", " + strconv.FormatFloat(lon, 'f', 5, 64),
			})
			break
		}
	}
	return out
}

// ---- link / unlink -----------------------------------------------------------

// POST /api/cars/{id}/provider — link this car to a vehicle on the caller's
// provider account, or unlink it. Body: {provider, vehicleId} — an empty
// provider unlinks. The vehicle must actually be on the caller's account, so a
// link can never point at a vehicle its owner cannot read.
//
// Write access is required, and the link is stored on its own (see
// carProviderPayload) so nothing else about the car changes.
func (s *Server) handleLinkCarProvider(w http.ResponseWriter, r *http.Request) {
	who := caller(r)
	if who == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	var body struct {
		Provider  string `json:"provider"`
		VehicleID string `json:"vehicleId"`
	}
	if err := decodeJSON(r, &body); err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}
	carID := r.PathValue("id")
	if !s.requireCarAccess(w, r, carID, accessWrite) {
		return
	}

	provider := strings.TrimSpace(body.Provider)
	vehicleID := strings.TrimSpace(body.VehicleID)

	if provider != "" {
		src, found := vehicleSourceByID(provider)
		if !found {
			writeError(w, http.StatusNotFound, "unknown vehicle provider")
			return
		}
		userRaw := s.userPluginSettings(r.Context(), who.ID)
		cfg, ok, detail := src.gate(r.Context(), s, who, userRaw)
		if !ok {
			writeError(w, http.StatusBadRequest, detail)
			return
		}
		vehicles, err := s.fetchProviderVehicles(r.Context(), src, cfg)
		if err != nil {
			writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
			return
		}
		vehicle, found := findVehicle(vehicles, vehicleID)
		if !found {
			writeError(w, http.StatusNotFound, "that vehicle is not on your "+src.label()+" account")
			return
		}
		vehicleID = vehicle.ID
	}

	var rec carRecord
	if err := s.pb.Update(r.Context(), colCars, carID, carProviderPayload(provider, vehicleID), &rec); err != nil {
		writePBError(w, err)
		return
	}
	m := rec.toModel()
	m.Access, _, _ = s.carAccessLevel(r.Context(), who.ID, carID)
	writeJSON(w, http.StatusOK, m)
}

// POST /api/cars/{id}/provider/sync — re-apply the provider's data to the car.
// Body: {include?: {identity, fuelType, dates, odometer}}; the same selection the
// import takes, so "refresh the odometer" and "refresh everything" are one
// endpoint. Returns the updated car.
func (s *Server) handleSyncCarProvider(w http.ResponseWriter, r *http.Request) {
	who := caller(r)
	if who == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	var body struct {
		Include *importSelection `json:"include"`
	}
	// An empty body is allowed and means "everything": "sync this car" needs no
	// arguments.
	if err := decodeJSON(r, &body); err != nil && !errors.Is(err, io.EOF) {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}
	carID := r.PathValue("id")
	if !s.requireCarAccess(w, r, carID, accessWrite) {
		return
	}
	var rec carRecord
	if err := s.pb.GetOne(r.Context(), colCars, carID, &rec); err != nil {
		writePBError(w, err)
		return
	}
	if rec.Provider == "" {
		writeError(w, http.StatusBadRequest, "this car is not linked to a connected service")
		return
	}
	src, found := vehicleSourceByID(rec.Provider)
	if !found {
		writeError(w, http.StatusNotFound, "unknown vehicle provider")
		return
	}
	userRaw := s.userPluginSettings(r.Context(), who.ID)
	cfg, ok, detail := src.gate(r.Context(), s, who, userRaw)
	if !ok {
		writeError(w, http.StatusBadRequest, detail)
		return
	}
	vehicles, err := s.fetchProviderVehicles(r.Context(), src, cfg)
	if err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
		return
	}
	vehicle, found := findVehicle(vehicles, rec.ProviderVehicleID)
	if !found {
		writeError(w, http.StatusNotFound, "that vehicle is not on your "+src.label()+" account")
		return
	}

	sel := body.Include.resolve()
	car := rec.toModel()
	applyVehicleToCar(&car, vehicle, sel)

	var warnings []string
	if sel.odometer {
		if km, ok := s.providerOdometer(r.Context(), src, cfg, vehicle.ID); ok && km > car.CurrentKm {
			// Only ever forward: an odometer that appears to go backwards is the
			// provider being stale, not the car having been driven in reverse.
			car.CurrentKm = km
		} else if !ok {
			warnings = append(warnings, "odometer")
		}
	}

	var updated carRecord
	if err := s.pb.Update(r.Context(), colCars, carID, carPayload(car), &updated); err != nil {
		writePBError(w, err)
		return
	}
	m := updated.toModel()
	m.Access, _, _ = s.carAccessLevel(r.Context(), who.ID, carID)
	writeJSON(w, http.StatusOK, map[string]any{"car": m, "warnings": warnings})
}

// ---- JSON walking ------------------------------------------------------------
//
// The helpers below are why this file survives an upstream schema change. None of
// them know a single Toyota field path: they search a decoded payload by key name
// and flatten what is left.

// maxWalkNodes bounds every walk, so a pathological payload cannot spin the
// server. It is far above any real vehicle response.
const maxWalkNodes = 20000

// normalizeKey folds a JSON key to its comparable form: lowercase, letters and
// digits only. That makes "fuelLevel", "fuel_level" and "FUEL-LEVEL" one key.
func normalizeKey(k string) string {
	var b strings.Builder
	for _, r := range strings.ToLower(k) {
		if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
			b.WriteRune(r)
		}
	}
	return b.String()
}

// sortedKeys returns a map's keys in order, so a walk is deterministic and two
// requests against the same payload never disagree about which field won.
func sortedKeys(m map[string]any) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}

// findNode walks a decoded payload breadth-first and returns the first value
// stored under any of `keys`. Breadth-first is the point: a top-level "model"
// beats a "model" buried inside a dealer record, which is what makes searching by
// name tolerant of shapes this server has not modelled without picking the wrong
// field.
func findNode(root any, keys ...string) (any, bool) {
	want := make(map[string]bool, len(keys))
	for _, k := range keys {
		want[normalizeKey(k)] = true
	}

	queue, visited := []any{root}, 0
	for len(queue) > 0 && visited < maxWalkNodes {
		node := queue[0]
		queue = queue[1:]
		visited++

		switch v := node.(type) {
		case map[string]any:
			ks := sortedKeys(v)
			for _, k := range ks {
				if want[normalizeKey(k)] && v[k] != nil {
					return v[k], true
				}
			}
			for _, k := range ks {
				queue = append(queue, v[k])
			}
		case []any:
			queue = append(queue, v...)
		}
	}
	return nil, false
}

// findString returns the first value under `keys` that reads as a non-empty
// string.
func findString(root any, keys ...string) string {
	node, ok := findNode(root, keys...)
	if !ok {
		return ""
	}
	switch v := node.(type) {
	case string:
		return strings.TrimSpace(v)
	case float64:
		return formatNumber(v)
	case bool:
		return strconv.FormatBool(v)
	case map[string]any:
		// A wrapped value, e.g. {"value": "…"}.
		if inner, ok := findNode(v, "value", "name", "description", "label"); ok {
			if s, ok := inner.(string); ok {
				return strings.TrimSpace(s)
			}
		}
	}
	return ""
}

// findInt returns the first value under `keys` that reads as a whole number.
func findInt(root any, keys ...string) (int, bool) {
	n, _, ok := findMeasure(root, keys...)
	if !ok {
		return 0, false
	}
	return int(math.Round(n)), true
}

// findMeasure returns the first numeric value under `keys`, plus the unit that
// travelled with it. It accepts the three shapes these APIs mix freely: a bare
// number, a numeric string, and a {value, unit} object.
func findMeasure(root any, keys ...string) (value float64, unit string, ok bool) {
	node, found := findNode(root, keys...)
	if !found {
		return 0, "", false
	}
	switch v := node.(type) {
	case float64:
		return v, "", true
	case string:
		if n, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
			return n, "", true
		}
	case map[string]any:
		inner, hasInner := findNode(v, "value", "amount", "quantity", "level")
		if !hasInner {
			return 0, "", false
		}
		u := findString(v, "unit", "uom", "units")
		switch n := inner.(type) {
		case float64:
			return n, u, true
		case string:
			if parsed, err := strconv.ParseFloat(strings.TrimSpace(n), 64); err == nil {
				return parsed, u, true
			}
		}
	}
	return 0, "", false
}

// flattenJSON turns a decoded payload into dotted key/value leaves, in a stable
// order, dropping nulls and empty strings. This is what lets the provider tab
// show a whole payload — including fields nobody has mapped yet — rather than
// only the handful this server understands.
//
// It returns truncated=true when it stopped at `limit`; the caller still ships
// the raw payload, so nothing is actually lost.
func flattenJSON(root any, limit int) (fields []providerField, truncated bool) {
	var walk func(prefix string, node any)
	walk = func(prefix string, node any) {
		if len(fields) >= limit {
			truncated = true
			return
		}
		switch v := node.(type) {
		case map[string]any:
			for _, k := range sortedKeys(v) {
				walk(joinPath(prefix, k), v[k])
			}
		case []any:
			for i, e := range v {
				walk(prefix+"["+strconv.Itoa(i)+"]", e)
			}
		case nil:
			// A field the provider has no value for; omit rather than show "null".
		case string:
			if s := strings.TrimSpace(v); s != "" {
				fields = append(fields, providerField{Key: prefix, Value: truncateValue(s)})
			}
		case float64:
			fields = append(fields, providerField{Key: prefix, Value: formatNumber(v)})
		case bool:
			fields = append(fields, providerField{Key: prefix, Value: strconv.FormatBool(v)})
		}
	}
	walk("", root)
	return fields, truncated
}

func joinPath(prefix, key string) string {
	if prefix == "" {
		return key
	}
	return prefix + "." + key
}

// maxValueLength bounds one flattened value, so a base64 image blob in a payload
// cannot bloat the response.
const maxValueLength = 300

func truncateValue(s string) string {
	if len(s) <= maxValueLength {
		return s
	}
	return s[:maxValueLength] + "…"
}

// formatNumber renders a JSON number the way a person would write it: whole
// numbers without a decimal point, fractions without trailing zeros.
func formatNumber(n float64) string {
	if n == math.Trunc(n) && math.Abs(n) < 1e15 {
		return strconv.FormatInt(int64(n), 10)
	}
	return strconv.FormatFloat(n, 'f', -1, 64)
}
