// Package toyota is a built-in connector for Toyota Connected Europe Services —
// the backend behind the MyToyota app. It is a Go re-implementation of the
// authentication and read-only data flow from the pytoyoda project
// (https://github.com/pytoyoda/pytoyoda), adapted to DriverVault's plugin
// contract.
//
// Scope & limitations (inherited from pytoyoda):
//   - Europe only. Other Toyota regions use a different backend and are not
//     supported.
//   - Read-only. Only vehicle information is retrieved; no remote control
//     (lock, climate, charging) commands are implemented, and neither are the
//     POST wake calls upstream uses to refresh a stale reading — climate and
//     status are read as the car last reported them.
//   - Unofficial. This talks to a private API with hardcoded app credentials
//     extracted from the MyToyota app; Toyota may change or break it at any time.
//
// Authentication is a three-legged OAuth2 (ForgeRock/OpenAM) flow:
//  1. POST the /authenticate endpoint in a callback loop, filling in the
//     username then password, until a tokenId is returned.
//  2. GET the /authorize endpoint with that tokenId as a cookie; the 302
//     redirect carries the authorization code in its Location query string.
//  3. POST the /access_token endpoint to exchange the code for an access +
//     refresh + id token. The id token (a JWT) carries the account UUID.
//
// Access tokens are short-lived and refreshed with the refresh token; a full
// re-authentication is the fallback when refresh fails.
package toyota

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"

	"drivervault/apiserver/internal/plugins"
)

// Upstream URLs and hardcoded app credentials. These mirror pytoyoda's const.py
// and are the values baked into the official MyToyota Android app (client
// "oneapp"); they are not user secrets.
const (
	apiBaseURL      = "https://ctpa-oneapi.tceu-ctp-prd.toyotaconnectedeurope.io"
	accessTokenURL  = "https://b2c-login.toyota-europe.com/oauth2/realms/root/realms/tme/access_token"
	authenticateURL = "https://b2c-login.toyota-europe.com/json/realms/root/realms/tme/authenticate?authIndexType=service&authIndexValue=oneapp"
	authorizeURL    = "https://b2c-login.toyota-europe.com/oauth2/realms/root/realms/tme/authorize?client_id=oneapp&scope=openid+profile+write&response_type=code&redirect_uri=com.toyota.oneapp:/oauth2Callback&code_challenge=plain&code_challenge_method=plain"

	redirectURI   = "com.toyota.oneapp:/oauth2Callback"
	clientVersion = "2.14.0"
	// apiKey is the app's public API-gateway key (not the user's secret).
	apiKey = "tTZipv6liF74PwMfk9Ed68AQ0bISswwf3iHQdqcF"
	// basicAuth is base64("oneapp:oneapp") — the OAuth client's basic credentials.
	basicAuth = "basic b25lYXBwOm9uZWFwcA=="
	userAgent = "okhttp/4.10.0"
)

// Read-only API endpoints exposed as capabilities.
//
// Toyota retired the /v1/global/remote/{status,climate-*} read routes in mid-2026
// — they now sit behind AWS SigV4 and answer a plain Bearer token with 403 — and
// the app reads that state from /v1/vehicle/* instead. epRemoteStatus and the two
// climate endpoints below follow pytoyoda onto the new namespace; the electric
// status route was not moved and stays where it was.
const (
	epVehicleGUID     = "/v2/vehicle/guid"
	epLocation        = "/v1/location"
	epHealthStatus    = "/v1/vehiclehealth/status"
	epRemoteStatus    = "/v1/vehicle/status"
	epElectricStatus  = "/v1/global/remote/electric/status"
	epClimateStatus   = "/v1/vehicle/climate-status"
	epClimateSettings = "/v1/vehicle/climate-settings"
	epTelemetry       = "/v3/telemetry"
	epNotifications   = "/v2/notification/history"
	epServiceHistory  = "/v1/servicehistory/vehicle/summary"
)

// tokenExpiryMargin is subtracted from the reported token lifetime so a request
// never fires with a token that expires mid-flight.
const tokenExpiryMargin = 30 * time.Second

func init() {
	plugins.Register("toyota", func() plugins.Plugin { return &Plugin{} })
}

// tokenInfo holds the currently-held OAuth tokens and the derived account UUID.
type tokenInfo struct {
	accessToken  string
	refreshToken string
	uuid         string
	expiration   time.Time
}

// Plugin is the Toyota Connected Europe connector.
type Plugin struct {
	username string
	password string
	brand    string // "T" (Toyota) or "L" (Lexus)

	mu  sync.Mutex // guards tok and the login flow
	tok *tokenInfo

	// authClient does NOT follow redirects — the authorize step must see the
	// raw 302 to read the code out of the Location header.
	authClient *http.Client
	// dataClient follows redirects for ordinary data requests.
	dataClient *http.Client
}

// Descriptor returns the plugin's static metadata for the admin panel.
func (p *Plugin) Descriptor() plugins.Descriptor {
	return plugins.Descriptor{
		Name:     "toyota",
		Provider: "Toyota Connected Europe",
		Version:  "0.2.0",
		Kind:     plugins.KindBuiltin,
		Category: plugins.CategoryVehicles,
		AuthType: plugins.AuthOAuth2,
		Capabilities: []plugins.Capability{
			{ID: "vehicles", Method: "GET", Endpoint: epVehicleGUID, Description: "List vehicles registered to the account."},
			{ID: "telemetry", Method: "GET", Endpoint: epTelemetry, Description: "Odometer, fuel level and range for a VIN."},
			{ID: "location", Method: "GET", Endpoint: epLocation, Description: "Last known parked location for a VIN."},
			{ID: "health", Method: "GET", Endpoint: epHealthStatus, Description: "Vehicle health / dashboard warning lights for a VIN."},
			{ID: "status", Method: "GET", Endpoint: epRemoteStatus, Description: "General remote status (doors, windows, lights) for a VIN."},
			{ID: "electric", Method: "GET", Endpoint: epElectricStatus, Description: "Battery level, EV range and charging status for a VIN."},
			{ID: "climate", Method: "GET", Endpoint: epClimateStatus, Description: "Current climate state (cabin temperature, whether it is running) for a VIN."},
			{ID: "climate-settings", Method: "GET", Endpoint: epClimateSettings, Description: "Stored climate preset (target temperature, seat and mirror heating) for a VIN."},
			{ID: "notifications", Method: "GET", Endpoint: epNotifications, Description: "Notification history for a VIN."},
			{ID: "service-history", Method: "GET", Endpoint: epServiceHistory, Description: "Dealer service history summary for a VIN."},
		},
		ConfigFields: []plugins.ConfigField{
			// Credentials are intentionally NOT required at the global (panel) layer:
			// this connector runs under each user's own MyToyota account, supplied in
			// the Web App's per-user integration settings. A superadmin/org admin may
			// still set a shared (e.g. fleet) account here that users inherit. See the
			// cascade in internal/api/integrations.go.
			{Key: "username", Label: "MyToyota email", Type: "text",
				Help: "The email address for your MyToyota (Toyota Connected Europe) account."},
			{Key: "password", Label: "MyToyota password", Type: "password", Secret: true,
				Help: "Your MyToyota account password. Stored locally, sent only to Toyota's login endpoint."},
			{Key: "brand", Label: "Brand", Type: "select", Default: "T",
				Help: "Vehicle brand tied to the account.",
				Options: []plugins.SelectOption{
					{Value: "T", Label: "Toyota"},
					{Value: "L", Label: "Lexus"},
				}},
		},
	}
}

// Init applies resolved config and builds the HTTP clients. It performs no
// network I/O; login happens lazily on the first request or health check.
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
	p.mu.Lock()
	defer p.mu.Unlock()

	p.username = strings.TrimSpace(config["username"])
	p.password = config["password"]
	p.brand = strings.TrimSpace(config["brand"])
	if p.brand == "" {
		p.brand = "T"
	}
	// Config change invalidates any cached token.
	p.tok = nil

	p.authClient = &http.Client{
		Timeout: 30 * time.Second,
		CheckRedirect: func(*http.Request, []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
	p.dataClient = &http.Client{Timeout: 30 * time.Second}
	return nil
}

// HealthCheck logs in (if needed) and lists the account's vehicles.
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
	start := time.Now()
	body, _, err := p.apiRequest(ctx, http.MethodGet, epVehicleGUID, "", nil)
	lat := time.Since(start).Milliseconds()
	if err != nil {
		return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: shorten(err.Error())}
	}

	detail := "authenticated; vehicles reachable"
	if n, ok := countVehicles(body); ok {
		detail = fmt.Sprintf("authenticated; %d vehicle(s) on account", n)
	}
	return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: detail}
}

// Invoke runs a named read-only capability. Every capability except "vehicles"
// requires a "vin" in params. The upstream JSON is returned verbatim.
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
	var pp struct {
		VIN string `json:"vin"`
	}
	if len(params) > 0 {
		if err := json.Unmarshal(params, &pp); err != nil {
			return nil, fmt.Errorf("toyota: invalid params: %w", err)
		}
	}
	pp.VIN = strings.TrimSpace(pp.VIN)

	var endpoint string
	needVIN := true
	switch action {
	case "vehicles":
		endpoint, needVIN = epVehicleGUID, false
	case "telemetry":
		endpoint = epTelemetry
	case "location":
		endpoint = epLocation
	case "health":
		endpoint = epHealthStatus
	case "status":
		endpoint = epRemoteStatus
	case "electric":
		endpoint = epElectricStatus
	case "climate":
		endpoint = epClimateStatus
	case "climate-settings":
		endpoint = epClimateSettings
	case "notifications":
		endpoint = epNotifications
	case "service-history":
		endpoint = epServiceHistory
	default:
		return nil, fmt.Errorf("toyota: unknown action %q", action)
	}
	if needVIN && pp.VIN == "" {
		return nil, fmt.Errorf("toyota: action %q requires a vin", action)
	}

	body, _, err := p.apiRequest(ctx, http.MethodGet, endpoint, pp.VIN, nil)
	if err != nil {
		return nil, err
	}
	return json.RawMessage(body), nil
}

// Shutdown releases pooled connections.
func (p *Plugin) Shutdown(context.Context) error {
	p.mu.Lock()
	defer p.mu.Unlock()
	if p.dataClient != nil {
		p.dataClient.CloseIdleConnections()
	}
	if p.authClient != nil {
		p.authClient.CloseIdleConnections()
	}
	return nil
}

// ---- token management --------------------------------------------------------

// ensureToken guarantees a non-expired access token, refreshing or performing a
// full authentication as needed. Serialized by p.mu so concurrent requests
// trigger at most one login.
func (p *Plugin) ensureToken(ctx context.Context) (tokenInfo, error) {
	p.mu.Lock()
	defer p.mu.Unlock()

	if p.tok != nil && time.Now().UTC().Before(p.tok.expiration) {
		return *p.tok, nil
	}
	if p.tok != nil && p.tok.refreshToken != "" {
		if err := p.refreshTokens(ctx); err == nil {
			return *p.tok, nil
		}
		// fall through to a full re-authentication
	}
	if err := p.authenticate(ctx); err != nil {
		return tokenInfo{}, err
	}
	return *p.tok, nil
}

// authenticate runs the full three-legged login. Caller holds p.mu.
func (p *Plugin) authenticate(ctx context.Context) error {
	if p.username == "" || p.password == "" {
		return errors.New("toyota: username and password are required")
	}
	tokenID, err := p.performAuthentication(ctx)
	if err != nil {
		return err
	}
	code, err := p.performAuthorization(ctx, tokenID)
	if err != nil {
		return err
	}
	return p.retrieveTokens(ctx, code)
}

// performAuthentication drives the ForgeRock callback loop and returns the
// tokenId. The response object is echoed back in full each round (preserving
// authId and any fields we don't model), with only the input values filled in.
func (p *Plugin) performAuthentication(ctx context.Context) (string, error) {
	var data map[string]any

	for i := 0; i < 10; i++ {
		if cbs, ok := data["callbacks"].([]any); ok {
			for _, c := range cbs {
				cb, ok := c.(map[string]any)
				if !ok {
					continue
				}
				switch typ, _ := cb["type"].(string); typ {
				case "NameCallback":
					if output0(cb) == "User Name" {
						setInput0(cb, p.username)
					}
				case "PasswordCallback":
					setInput0(cb, p.password)
				case "TextOutputCallback":
					if output0(cb) == "User Not Found" {
						return "", errors.New("toyota: authentication failed — user not found")
					}
				}
			}
		}

		payload := []byte("{}")
		if data != nil {
			b, err := json.Marshal(data)
			if err != nil {
				return "", fmt.Errorf("toyota: encode auth payload: %w", err)
			}
			payload = b
		}

		resp, err := p.postRaw(ctx, authenticateURL, "application/json", payload, false)
		if err != nil {
			return "", err
		}
		body, status := resp.body, resp.status
		if status != http.StatusOK {
			return "", fmt.Errorf("toyota: authentication failed (HTTP %d): %s", status, shorten(string(body)))
		}
		data = map[string]any{}
		if err := json.Unmarshal(body, &data); err != nil {
			return "", fmt.Errorf("toyota: decode auth response: %w", err)
		}
		if tid, ok := data["tokenId"].(string); ok && tid != "" {
			return tid, nil
		}
	}
	return "", errors.New("toyota: authentication failed — no tokenId after 10 rounds")
}

// performAuthorization exchanges the tokenId (as an SSO cookie) for an
// authorization code carried in the 302 Location header.
func (p *Plugin) performAuthorization(ctx context.Context, tokenID string) (string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("cookie", "iPlanetDirectoryPro="+tokenID)

	resp, err := p.authClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("toyota: authorize request: %w", err)
	}
	defer drain(resp)

	if resp.StatusCode != http.StatusFound {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
		return "", fmt.Errorf("toyota: authorization failed (HTTP %d): %s", resp.StatusCode, shorten(string(body)))
	}
	code := extractCode(resp.Header.Get("location"))
	if code == "" {
		return "", errors.New("toyota: authorization redirect carried no code")
	}
	return code, nil
}

// retrieveTokens exchanges the authorization code for the token set and stores
// it. Caller holds p.mu.
func (p *Plugin) retrieveTokens(ctx context.Context, code string) error {
	form := url.Values{}
	form.Set("client_id", "oneapp")
	form.Set("code", code)
	form.Set("redirect_uri", redirectURI)
	form.Set("grant_type", "authorization_code")
	form.Set("code_verifier", "plain")
	return p.tokenRequest(ctx, form)
}

// refreshTokens uses the stored refresh token to obtain a fresh access token.
// Caller holds p.mu.
func (p *Plugin) refreshTokens(ctx context.Context) error {
	if p.tok == nil || p.tok.refreshToken == "" {
		return errors.New("toyota: no refresh token")
	}
	form := url.Values{}
	form.Set("client_id", "oneapp")
	form.Set("redirect_uri", redirectURI)
	form.Set("grant_type", "refresh_token")
	form.Set("code_verifier", "plain")
	form.Set("refresh_token", p.tok.refreshToken)
	return p.tokenRequest(ctx, form)
}

// tokenRequest POSTs a form to the access-token endpoint and stores the result.
func (p *Plugin) tokenRequest(ctx context.Context, form url.Values) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, accessTokenURL, strings.NewReader(form.Encode()))
	if err != nil {
		return err
	}
	req.Header.Set("authorization", basicAuth)
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	resp, err := p.authClient.Do(req)
	if err != nil {
		return fmt.Errorf("toyota: token request: %w", err)
	}
	defer drain(resp)

	body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("toyota: token request failed (HTTP %d): %s", resp.StatusCode, shorten(string(body)))
	}
	return p.storeTokens(body)
}

// storeTokens parses a token response, decodes the account UUID from the id
// token, and updates p.tok. Caller holds p.mu.
func (p *Plugin) storeTokens(body []byte) error {
	var td struct {
		AccessToken  string `json:"access_token"`
		IDToken      string `json:"id_token"`
		RefreshToken string `json:"refresh_token"`
		ExpiresIn    int    `json:"expires_in"`
	}
	if err := json.Unmarshal(body, &td); err != nil {
		return fmt.Errorf("toyota: decode token response: %w", err)
	}
	var missing []string
	if td.AccessToken == "" {
		missing = append(missing, "access_token")
	}
	if td.IDToken == "" {
		missing = append(missing, "id_token")
	}
	if td.RefreshToken == "" {
		missing = append(missing, "refresh_token")
	}
	if td.ExpiresIn == 0 {
		missing = append(missing, "expires_in")
	}
	if len(missing) > 0 {
		return fmt.Errorf("toyota: token response missing fields: %s", strings.Join(missing, ", "))
	}

	uuid, err := jwtUUID(td.IDToken)
	if err != nil {
		return fmt.Errorf("toyota: %w", err)
	}

	lifetime := time.Duration(td.ExpiresIn)*time.Second - tokenExpiryMargin
	if lifetime < 0 {
		lifetime = 0
	}
	p.tok = &tokenInfo{
		accessToken:  td.AccessToken,
		refreshToken: td.RefreshToken,
		uuid:         uuid,
		expiration:   time.Now().UTC().Add(lifetime),
	}
	return nil
}

// ---- data requests -----------------------------------------------------------

// apiRequest performs an authenticated request against the data API, retrying
// transient failures (429 and 5xx) with exponential backoff. It returns the
// response body and status; a non-2xx final status is returned as an error.
func (p *Plugin) apiRequest(ctx context.Context, method, endpoint, vin string, params url.Values) ([]byte, int, error) {
	tok, err := p.ensureToken(ctx)
	if err != nil {
		return nil, 0, err
	}

	target := apiBaseURL + endpoint
	if len(params) > 0 {
		target += "?" + params.Encode()
	}

	backoffs := []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second}
	var lastBody []byte
	var lastStatus int

	for attempt := 0; attempt <= len(backoffs); attempt++ {
		req, err := http.NewRequestWithContext(ctx, method, target, nil)
		if err != nil {
			return nil, 0, err
		}
		p.applyHeaders(req.Header, vin, tok)

		resp, err := p.dataClient.Do(req)
		if err != nil {
			return nil, 0, fmt.Errorf("toyota: request %s: %w", endpoint, err)
		}
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
		drain(resp)
		lastBody, lastStatus = body, resp.StatusCode

		if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusAccepted {
			return body, resp.StatusCode, nil
		}

		transient := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError
		if !transient || attempt == len(backoffs) {
			break
		}
		select {
		case <-ctx.Done():
			return nil, lastStatus, ctx.Err()
		case <-time.After(backoffs[attempt]):
		}
	}
	return lastBody, lastStatus, fmt.Errorf("toyota: request %s failed (HTTP %d): %s", endpoint, lastStatus, shorten(string(lastBody)))
}

// applyHeaders writes the full set of headers Toyota's gateway expects. Keys are
// set directly on the map (lowercase, uncanonicalized) to match the app exactly
// on HTTP/1.1; HTTP/2 lowercases all header names regardless.
func (p *Plugin) applyHeaders(h http.Header, vin string, tok tokenInfo) {
	set := func(k, v string) { h[k] = []string{v} }
	set("x-api-key", apiKey)
	set("API_KEY", apiKey)
	set("x-guid", tok.uuid)
	set("guid", tok.uuid)
	set("x-client-ref", hmacSHA256(clientVersion, tok.uuid))
	set("x-correlationid", genUUID())
	set("x-appversion", clientVersion)
	set("x-channel", "ONEAPP")
	set("x-brand", p.brand)
	set("x-region", "EU")
	set("authorization", "Bearer "+tok.accessToken)
	set("user-agent", userAgent)
	if p.brand == "L" {
		set("x-appbrand", "L")
		set("brand", "L")
	}
	if vin != "" {
		set("vin", vin)
	}
}

// ---- small helpers -----------------------------------------------------------

// rawResponse is a fully-read response used by the auth flow.
type rawResponse struct {
	status int
	body   []byte
}

// postRaw POSTs a body and reads the whole response. When followRedirects is
// false the no-redirect authClient is used.
func (p *Plugin) postRaw(ctx context.Context, u, contentType string, body []byte, followRedirects bool) (rawResponse, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
	if err != nil {
		return rawResponse{}, err
	}
	req.Header.Set("Content-Type", contentType)

	client := p.authClient
	if followRedirects {
		client = p.dataClient
	}
	resp, err := client.Do(req)
	if err != nil {
		return rawResponse{}, fmt.Errorf("toyota: POST %s: %w", u, err)
	}
	defer drain(resp)
	b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	return rawResponse{status: resp.StatusCode, body: b}, nil
}

// output0 returns callback.output[0].value.
func output0(cb map[string]any) string {
	out, _ := cb["output"].([]any)
	if len(out) == 0 {
		return ""
	}
	m, _ := out[0].(map[string]any)
	v, _ := m["value"].(string)
	return v
}

// setInput0 sets callback.input[0].value in place.
func setInput0(cb map[string]any, val string) {
	in, _ := cb["input"].([]any)
	if len(in) == 0 {
		return
	}
	if m, ok := in[0].(map[string]any); ok {
		m["value"] = val
	}
}

// extractCode pulls the ?code= parameter out of a redirect Location, tolerating
// the app's custom URI scheme (com.toyota.oneapp:/oauth2Callback?code=…).
func extractCode(location string) string {
	i := strings.IndexByte(location, '?')
	if i < 0 {
		return ""
	}
	vals, err := url.ParseQuery(location[i+1:])
	if err != nil {
		return ""
	}
	return vals.Get("code")
}

// jwtUUID base64url-decodes a JWT's payload (without signature verification, as
// pytoyoda does) and returns its "uuid" claim.
func jwtUUID(idToken string) (string, error) {
	parts := strings.Split(idToken, ".")
	if len(parts) < 2 {
		return "", errors.New("malformed id token")
	}
	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil {
		// tolerate padded encodings
		if payload, err = base64.URLEncoding.DecodeString(parts[1]); err != nil {
			return "", fmt.Errorf("decode id token: %w", err)
		}
	}
	var claims struct {
		UUID string `json:"uuid"`
	}
	if err := json.Unmarshal(payload, &claims); err != nil {
		return "", fmt.Errorf("decode id token claims: %w", err)
	}
	if claims.UUID == "" {
		return "", errors.New("id token has no uuid claim")
	}
	return claims.UUID, nil
}

// hmacSHA256 returns the lowercase hex HMAC-SHA256 of message under key.
func hmacSHA256(key, message string) string {
	m := hmac.New(sha256.New, []byte(key))
	m.Write([]byte(message))
	return hex.EncodeToString(m.Sum(nil))
}

// genUUID returns a random RFC-4122 v4 UUID string.
func genUUID() string {
	var b [16]byte
	if _, err := rand.Read(b[:]); err != nil {
		// crypto/rand should never fail; fall back to a fixed correlation id.
		return "00000000-0000-4000-8000-000000000000"
	}
	b[6] = (b[6] & 0x0f) | 0x40
	b[8] = (b[8] & 0x3f) | 0x80
	return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}

// countVehicles best-effort extracts the vehicle count from a /vehicle/guid
// response. The payload is an array of vehicle objects under "payload".
func countVehicles(body []byte) (int, bool) {
	var env struct {
		Payload []json.RawMessage `json:"payload"`
	}
	if err := json.Unmarshal(body, &env); err != nil || env.Payload == nil {
		return 0, false
	}
	return len(env.Payload), true
}

// drain closes a response body after discarding any remainder so the connection
// can be reused.
func drain(resp *http.Response) {
	if resp != nil && resp.Body != nil {
		_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
		_ = resp.Body.Close()
	}
}

// shorten trims long/multiline upstream messages for health details and errors.
func shorten(s string) string {
	s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
	if len(s) > 200 {
		return s[:200] + "…"
	}
	return s
}
