// Package ankersolix is a built-in connector for the Anker Power / Solix cloud,
// scoped to the Anker Solix V1 Smart EV Charger (product A5191). It is a Go
// re-implementation of the authentication and read-only data flow from the
// anker-solix-api project (https://github.com/thomluther/anker-solix-api),
// adapted to DriverVault's plugin contract. It tracks upstream v3.8.1
// (ha-anker-solix 3.8.1); the login exchange and every endpoint used here are
// unchanged since v3.7.0.
//
// Scope & limitations:
//   - The REST half is read-only. Every endpoint below retrieves EV-charger
//     information; nothing is started, stopped or configured through it, because
//     Anker's REST API has no such endpoint. Control runs over one of three
//     transports instead: OCPP (internal/ocpp), Modbus TCP on the local network
//     (modbus.go), or the account's cloud MQTT broker (cloudmqtt.go) — the one
//     path that reaches a charger behind a customer's router.
//   - Single device family. Capabilities target the V1 Smart EV Charger; other
//     Anker Power devices (solarbanks, power stations, HES) are out of scope.
//   - Unofficial. This talks to Anker's private mobile-app cloud API with the
//     app's public client identity; Anker may change or break it at any time.
//
// Authentication is a single login exchange, not OAuth:
//  1. An ephemeral ECDH key pair (NIST P-256 / SECP256R1) is generated and a
//     shared secret is derived against Anker's static server public key.
//  2. The account password is AES-256-CBC encrypted under that shared secret
//     (key = secret, IV = first 16 bytes of the secret, PKCS#7 padding) and
//     base64-encoded.
//  3. POST passport/login with the client public key and encrypted password
//     returns an auth_token (valid ~7 days), a user_id and an expiry. All
//     subsequent requests send x-auth-token plus gtoken = md5(user_id).
//
// The auth token is long-lived; when it nears expiry (or a request is rejected
// as unauthorized) the plugin performs a fresh login. There is no refresh token.
package ankersolix

import (
	"bytes"
	"context"
	"crypto/aes"
	"crypto/cipher"
	"crypto/ecdh"
	"crypto/md5"
	"crypto/rand"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	neturl "net/url"
	"sort"
	"strings"
	"sync"
	"time"

	"drivervault/apiserver/internal/plugins"
)

// Anker cloud servers. Country assignment decides which one an account lives on;
// a wrong assignment authenticates but returns no sites/devices. Countries in
// comCountries use the global server, everything else defaults to EU (mirroring
// anker-solix-api's API_SERVERS / API_COUNTRIES).
const (
	serverEU  = "https://ankerpower-api-eu.anker.com"
	serverCOM = "https://ankerpower-api.anker.com"

	// serverPublicKeyHex is Anker's static server public key (uncompressed P-256
	// point, 0x04 || X || Y). Identical for the EU and global servers. This is an
	// app constant, not a user secret.
	serverPublicKeyHex = "04c5c00c4f8d1197cc7c3167c52bf7acb054d722f0ef08dcd7e0883236e0d72a3868d9750cb47fa4619248f3d83f0f662671dadc6e2d31c2f41db0161651c7c076"
)

// Endpoints. Login plus the read-only endpoints relevant to an EV charger.
const (
	epLogin = "passport/login"

	epStandaloneChargers = "charging_hes_svc/get_user_bind_and_not_in_station_evchargers" // list account EV chargers not assigned to a station
	epStationInfo        = "charging_hes_svc/get_evcharger_station_info"                  // per-charger station/status info
	epSystemRunInfo      = "charging_hes_svc/get_system_running_info"                     // system runtime info; carries evChargerInfos[].evChargerStatus
	epSceneInfo          = "power_service/v1/site/get_scen_info"                          // site home view; carries charging_pile_info (note upstream's "scen" typo)
	epEnergyAnalysis     = "power_service/v1/site/energy_analysis"                        // interval energy for a site, device_type "ev_charger"
	epChargeStats        = "power_service/v1/app/order/get_charge_order_stats"            // charging totals for a charger
	epChargeStatsList    = "power_service/v1/app/order/get_charge_order_stats_list"       // per-order charging history
	epOcppInfo           = "power_service/v1/app/get_ocpp_info"                           // OCPP endpoint source info for a charger
	epOcppEndpoints      = "power_service/v1/app/get_ocpp_endpoint_list"                  // the OCPP endpoints Anker itself uses, with their source numbers
	epRfidCards          = "power_service/v1/rfid/get_device_cards"                       // RFID cards authorised on a charger
	epBindDevices        = "power_service/v1/app/get_relate_and_bind_devices"             // bound devices incl. firmware version
	epSiteList           = "power_service/v1/site/get_site_list"                          // sites (systems) on the account
	epUserVehicles       = "power_service/v1/app/vehicle/get_vehicle_list"                // vehicles registered for smart charging
	epVehicleDetail      = "power_service/v1/app/vehicle/get_vehicle_detail"              // details for one registered vehicle

	// The rest of the read surface an EV charger can reach, from the same source
	// as the endpoints above (anker-solix-api's apitypes.py, filtered to what a
	// charger touches) and catalogued in anker-api-map.html. Every one of these
	// reads; the account-level writes the app makes — vehicles, site prices,
	// sharing invites, quiet hours, auto-upgrade — are deliberately not here, and
	// neither is any endpoint whose payload was only ever read out of the app
	// package rather than called ("unmapped" in the map).

	// Finding and identifying a charger.
	epSiteDetailBySN    = "power_service/v1/site/get_site_detail_by_sn"  // the site a charger belongs to, from its serial alone
	epSiteDetail        = "power_service/v1/site/get_site_detail"        // site detail; answers for shared accounts too
	epSiteHomepage      = "power_service/v1/site/get_site_homepage"      // the app home screen's own view of every site
	epUserDevices       = "power_service/v1/site/list_user_devices"      // owned devices, lighter than the bound-device view
	epDeviceBindDetails = "power_service/v1/app/get_device_bind_details" // binding details for a list of serials
	epGroupDevices      = "power_service/v1/app/group/get_group_devices" // whether a charger is grouped with sub-devices
	epSiteWifiList      = "power_service/v1/site/get_wifi_info_list"     // networks the site can see
	epDeviceProductInfo = "charging_hes_svc/get_device_product_info"     // product info for HES-family devices
	epInstallInfo       = "charging_hes_svc/get_install_info"            // where the system was installed
	epChargerWifiInfo   = "charging_hes_svc/get_wifi_info"               // the charger's own Wi-Fi details

	// Charging sessions — where the completed history lives.
	epChargingOrders  = "power_service/v1/app/order/get_charging_order_list"        // every session in a date range
	epChargingOrder   = "power_service/v1/app/order/get_charging_order_detail"      // one session, with its chart points and vehicle
	epOrderSecDetail  = "power_service/v1/app/order/get_charging_order_sec_detail"  // second-resolution detail for a session
	epOrderSecPreview = "power_service/v1/app/order/get_charging_order_sec_preview" // preview of the same

	// Energy, price and what it cost.
	epEnergyStatistics = "charging_hes_svc/get_energy_statistics"         // energy stats with the EV charger as its own source
	epDeviceIncome     = "power_service/v1/app/device/get_device_income"  // income / savings figures per device
	epSitePrice        = "power_service/v1/site/get_site_price"           // the site's power price and CO2 factor
	epCurrencyList     = "power_service/v1/currency/get_list"             // supported currencies
	epSiteDataExport   = "power_service/v1/site/site_data_exported"       // filename and URL for a CSV export
	epDynPriceCheck    = "power_service/v1/dynamic_price/check_available" // which sites have dynamic pricing at all
	epDynPriceOptions  = "power_service/v1/dynamic_price/support_option"  // price providers for a product code
	epDynPriceDetail   = "power_service/v1/dynamic_price/price_detail"    // the actual price curve

	// The vehicle catalogue behind smart charging.
	epVehicleBrands = "power_service/v1/app/get_brand_list"  // vehicle brands
	epVehicleModels = "power_service/v1/app/get_models"      // models for a brand
	epVehicleYears  = "power_service/v1/app/get_model_years" // production years for a model
	epVehicleSpecs  = "power_service/v1/app/get_model_list"  // what Anker knows about that exact car

	// Sharing.
	epSharedDevice = "app/devicerelation/get_shared_device" // who a charger is currently shared with

	// Firmware.
	epOtaBatchCheck  = "app/ota/batch/check_update"                     // latest available version per serial
	epOtaInfo        = "power_service/v1/app/compatible/get_ota_info"   // current OTA status
	epOtaUpdate      = "power_service/v1/app/compatible/get_ota_update" // details of an available update
	epUpgradeRecords = "power_service/v1/app/get_upgrade_record"        // firmware update history
	epUpgradeRecord  = "power_service/v1/app/check_upgrade_record"      // one update record, three views of it
	epAutoUpgrade    = "power_service/v1/app/get_auto_upgrade"          // which devices auto-update

	// Notifications, and the charging events behind them. The first two are the
	// only endpoints in this connector Anker serves over GET.
	epMessageUnread     = "power_service/v1/get_message_unread"      // whether anything is waiting (GET)
	epMessages          = "power_service/v1/get_message"             // the messages themselves (GET, last_time)
	epMessageNotDisturb = "power_service/v1/get_message_not_disturb" // current quiet-hours settings
	epMessageSNList     = "power_service/v1/get_message_sn_list"     // which devices produce messages at all

	// Health and faults.
	epTamperRecords = "power_service/v1/device/get_tamper_records" // tamper records for a device

	// The cloud MQTT broker's own credentials endpoint is epMqttInfo, declared in
	// cloudmqtt.go next to the transport that uses it.
)

// EV-charger operating states, mirroring anker-solix-api's SolixEvChargerStatus.
// The cloud reports the same code under two names: operating_state in a site's
// charging_pile_list, evChargerStatus in HES system running info.
const (
	stateStandby       = "standby"
	statePreparing     = "preparing"
	stateCharging      = "charging"
	stateChargerPaused = "charger_paused"
	stateVehiclePaused = "vehicle_paused"
	stateCompleted     = "completed"
	stateReserving     = "reserving"
	stateDisabled      = "disabled"
	stateError         = "error"
	stateUnknown       = "unknown"
)

var evChargerStatus = map[int]string{
	0: stateStandby, 1: statePreparing, 2: stateCharging, 3: stateChargerPaused,
	4: stateVehiclePaused, 5: stateCompleted, 6: stateReserving, 7: stateDisabled,
	8: stateError,
}

// Operational modes, mirroring SolixEvChargerMode. start/stop/skip/boost are the
// four real control values; wait_plug and wait_start are virtual states the
// charger passes through and cannot be commanded into.
const (
	modeStartCharge = "start_charge"
	modeStopCharge  = "stop_charge"
	modeSkipDelay   = "skip_delay"
	modeBoostCharge = "boost_charge"
	modeWaitStart   = "wait_start"
	modeWaitPlug    = "wait_plug"
)

// ocppConnStatus decodes ocpp_connect_status (SolixOcppConnectionStatus).
var ocppConnStatus = map[int]string{0: "disconnected", 1: "connecting", 2: "connected"}

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

// comCountries are the ISO country IDs served by the global (".com") server.
// Any country not listed here is served by the EU server.
var comCountries = map[string]bool{
	"DZ": true, "LB": true, "SY": true, "EG": true, "LY": true, "TN": true,
	"MA": true, "JO": true, "PS": true, "AR": true, "AU": true, "BR": true,
	"HK": true, "IN": true, "MX": true, "NG": true, "NZ": true, "RU": true,
	"SG": true, "ZA": true, "KR": true, "TW": true, "US": true, "CA": true,
}

func init() {
	plugins.Register("anker-solix", func() plugins.Plugin { return &Plugin{} })
}

// tokenInfo holds the currently-held auth token and derived values.
type tokenInfo struct {
	authToken  string
	gtoken     string // md5(user_id)
	nickname   string
	expiration time.Time
}

// Plugin is the Anker Solix EV-charger connector.
type Plugin struct {
	email     string
	password  string
	countryId string

	mu      sync.Mutex // guards the fields above and the client
	apiBase string
	client  *http.Client

	// sess holds the token and the login backoff, shared by every instance
	// configured for the same account (see session.go). It is what stops the
	// manager's per-request instances from signing in once each.
	sess *session
}

// Descriptor returns the plugin's static metadata for the admin panel.
func (p *Plugin) Descriptor() plugins.Descriptor {
	return plugins.Descriptor{
		Name:     "anker-solix",
		Provider: "Anker Solix (V1 Smart EV Charger)",
		Version:  "0.2.0",
		Kind:     plugins.KindBuiltin,
		Category: plugins.CategoryChargers,
		AuthType: plugins.AuthBasic,
		Capabilities: []plugins.Capability{
			{ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "Every EV charger on the account, merged from the standalone, per-site, bound-device and per-charger station views, each charger carrying every field those views reported (see chargers.go)."},
			{ID: "charger-details", Method: "POST", Endpoint: epStationInfo, Description: "Every view the account holds about one charger — the station record, the totals, the history and sessions, the savings, the OCPP backend and the endpoints that name its source, the cards, the sharing, the binding, the group, the Wi-Fi, the firmware, the tamper log, and the site's own views when it belongs to one — each relayed as the fields it sent (needs sn)."},
			{ID: "charger-status", Method: "POST", Endpoint: epStationInfo, Description: "Live station/status info for one charger (needs sn; optional featuretype 1 or 2)."},
			{ID: "charger-state", Method: "POST", Endpoint: epSceneInfo, Description: "Normalized live state of a site's EV chargers: status, operational mode and the modes it can be switched to (needs siteId; optional sn)."},
			{ID: "site-status", Method: "POST", Endpoint: epSceneInfo, Description: "Live site view; EV chargers appear under charging_pile_info (needs siteId)."},
			{ID: "charge-stats", Method: "POST", Endpoint: epChargeStats, Description: "Cumulative charging statistics for one charger (needs sn)."},
			{ID: "charge-orders", Method: "POST", Endpoint: epChargeStatsList, Description: "Per-session charging history for one charger (needs sn)."},
			{ID: "charge-energy", Method: "POST", Endpoint: epEnergyAnalysis, Description: "Interval charging energy for a site's EV charger (needs siteId; optional sn, range, startDate, endDate)."},
			{ID: "rfid-cards", Method: "POST", Endpoint: epRfidCards, Description: "RFID cards authorised on one charger (needs sn)."},
			{ID: "rfid-card-save", Method: "POST", Endpoint: epRfidSaveCard, Description: "Add a card, at the charger over MQTT and on the account over REST, and answer with the list as it stands afterwards (needs sn, cardNumber; optional cardName). WRITES; the charger's half is the app's own message, the account's is inferred (see rfidcards.go, mqttcards.go)."},
			{ID: "rfid-card-delete", Method: "POST", Endpoint: epRfidDeleteCard, Description: "Remove one card, both places it is held, and answer with the list afterwards (needs sn, cardNumber). WRITES; same two halves."},
			{ID: "rfid-card-scan", Method: "MQTT", Endpoint: "0108 a2=7", Description: "Open the charger's card reader for twenty seconds and answer with the card tapped, or with the fact that none was (needs sn). This is what \"add through the charger\" in the Anker app does."},
			{ID: "rfid-cards-charger", Method: "MQTT", Endpoint: "0104", Description: "The cards the charger itself holds, asked of the device rather than of the account (needs sn)."},
			{ID: "ocpp-info", Method: "POST", Endpoint: epOcppInfo, Description: "OCPP endpoint source info for one charger (needs sn)."},
			{ID: "ocpp-endpoints", Method: "POST", Endpoint: epOcppEndpoints, Description: "The OCPP endpoints Anker itself uses, with their source numbers."},
			{ID: "devices", Method: "POST", Endpoint: epBindDevices, Description: "Bound devices on the account, incl. firmware version."},
			{ID: "sites", Method: "POST", Endpoint: epSiteList, Description: "Sites (systems) registered to the account."},
			{ID: "vehicles", Method: "POST", Endpoint: epUserVehicles, Description: "Vehicles registered for smart charging."},
			{ID: "vehicle", Method: "POST", Endpoint: epVehicleDetail, Description: "Details for one registered vehicle (needs vehicleId)."},
			// The rest of the read surface an EV charger can reach, one action per
			// endpoint, so anything the account will say can be asked for without
			// a new capability being invented for it first. The account-level
			// writes the app makes and the endpoints nobody has ever called stay
			// out (see the endpoint block above).
			{ID: "site-by-sn", Method: "POST", Endpoint: epSiteDetailBySN, Description: "The site a charger belongs to, from its serial alone (needs sn)."},
			{ID: "site-detail", Method: "POST", Endpoint: epSiteDetail, Description: "Site detail; answers for shared accounts too (needs siteId)."},
			{ID: "site-homepage", Method: "POST", Endpoint: epSiteHomepage, Description: "The app home screen's own view of every site."},
			{ID: "user-devices", Method: "POST", Endpoint: epUserDevices, Description: "Owned devices, a lighter list than the bound-device view."},
			{ID: "bind-details", Method: "POST", Endpoint: epDeviceBindDetails, Description: "Binding details for one or more serials (needs sn, or sns)."},
			{ID: "group-devices", Method: "POST", Endpoint: epGroupDevices, Description: "Whether a charger is grouped with sub-devices (needs sn)."},
			{ID: "site-wifi", Method: "POST", Endpoint: epSiteWifiList, Description: "The networks a site can see (needs siteId)."},
			{ID: "charger-wifi", Method: "POST", Endpoint: epChargerWifiInfo, Description: "The charger's own Wi-Fi details (needs sn)."},
			{ID: "product-info", Method: "POST", Endpoint: epDeviceProductInfo, Description: "Product info for HES-family devices (optional sn)."},
			{ID: "install-info", Method: "POST", Endpoint: epInstallInfo, Description: "Where the system was installed (optional sn, siteId)."},
			{ID: "charging-orders", Method: "POST", Endpoint: epChargingOrders, Description: "Every charging session in a date range - a plainer list than the paged stats one (needs sn; optional startTime)."},
			{ID: "charging-order", Method: "POST", Endpoint: epChargingOrder, Description: "One session with its chart points and the vehicle it charged (needs sn and orderId)."},
			{ID: "order-seconds", Method: "POST", Endpoint: epOrderSecDetail, Description: "Second-resolution detail for one session (needs orderId; optional startTime)."},
			{ID: "order-seconds-preview", Method: "POST", Endpoint: epOrderSecPreview, Description: "Preview of the second-resolution session detail (needs orderId)."},
			{ID: "energy-statistics", Method: "POST", Endpoint: epEnergyStatistics, Description: "Energy statistics with the EV charger as its own source (optional sn, siteId)."},
			{ID: "device-income", Method: "POST", Endpoint: epDeviceIncome, Description: "Income / savings figures for one charger (needs sn; optional startTime)."},
			{ID: "site-price", Method: "POST", Endpoint: epSitePrice, Description: "A site's power price and CO2 factor (needs siteId)."},
			{ID: "currencies", Method: "POST", Endpoint: epCurrencyList, Description: "The currencies the cloud supports."},
			{ID: "site-export", Method: "POST", Endpoint: epSiteDataExport, Description: "Filename and URL for a site's CSV export, where the system supports one (needs siteId; optional startTime, endTime)."},
			{ID: "dynamic-price-available", Method: "POST", Endpoint: epDynPriceCheck, Description: "Which sites have dynamic pricing at all."},
			{ID: "dynamic-price-options", Method: "POST", Endpoint: epDynPriceOptions, Description: "Price providers for a product code and the login country (needs devicePn)."},
			{ID: "dynamic-price", Method: "POST", Endpoint: epDynPriceDetail, Description: "The dynamic price curve smart charging would schedule against (needs area and company; optional date, sn)."},
			{ID: "vehicle-brands", Method: "POST", Endpoint: epVehicleBrands, Description: "Vehicle brands - the first picker behind adding a car."},
			{ID: "vehicle-models", Method: "POST", Endpoint: epVehicleModels, Description: "Models for a brand (needs brand)."},
			{ID: "vehicle-years", Method: "POST", Endpoint: epVehicleYears, Description: "Production years for a model (needs brand and model)."},
			{ID: "vehicle-specs", Method: "POST", Endpoint: epVehicleSpecs, Description: "What Anker knows about one exact car (needs brand, model and year)."},
			{ID: "shared-with", Method: "POST", Endpoint: epSharedDevice, Description: "Who a charger is currently shared with (needs sn)."},
			{ID: "ota-check", Method: "POST", Endpoint: epOtaBatchCheck, Description: "The latest firmware available per serial (needs sn, or sns)."},
			{ID: "ota-info", Method: "POST", Endpoint: epOtaInfo, Description: "Current OTA status for one or more serials (needs sn, or sns)."},
			{ID: "ota-update", Method: "POST", Endpoint: epOtaUpdate, Description: "Details of an available update (needs sn, or sns)."},
			{ID: "upgrade-records", Method: "POST", Endpoint: epUpgradeRecords, Description: "Firmware update history (optional sn)."},
			{ID: "upgrade-record", Method: "POST", Endpoint: epUpgradeRecord, Description: "One update record, in one of three views (needs sn; optional type 1-3)."},
			{ID: "auto-upgrade", Method: "POST", Endpoint: epAutoUpgrade, Description: "Which devices update themselves."},
			{ID: "messages-unread", Method: "GET", Endpoint: epMessageUnread, Description: "Whether any notification is waiting."},
			{ID: "messages", Method: "GET", Endpoint: epMessages, Description: "The notifications themselves (optional lastTime cursor)."},
			{ID: "quiet-hours", Method: "POST", Endpoint: epMessageNotDisturb, Description: "The account's quiet-hours settings, and the charging events they cover."},
			{ID: "message-devices", Method: "POST", Endpoint: epMessageSNList, Description: "Which devices produce notifications at all."},
			{ID: "tamper-records", Method: "POST", Endpoint: epTamperRecords, Description: "Tamper records for one device (needs sn; optional page, pageSize)."},
			{ID: "mqtt-status", Method: "POST", Endpoint: epMqttInfo, Description: "Live state of one charger over Anker's cloud MQTT broker — the path to a charger the server cannot reach (needs sn)."},
			{ID: "mqtt-command", Method: "POST", Endpoint: epMqttInfo, Description: "Control one charger over Anker's cloud MQTT broker: start, stop, boost, skip-delay, limit (with amps), trigger or restart (needs sn and command)."},
			{ID: "mqtt-settings", Method: "POST", Endpoint: epMqttInfo, Description: "Write one charger's settings over Anker's cloud MQTT broker — current ceiling, switches, schedules, load balancing and solar charging (needs sn and settings)."},
		},
		ConfigFields: []plugins.ConfigField{
			// Credentials are intentionally NOT required at the global (panel) layer,
			// matching the Toyota connector: an operator may set a shared account
			// here, or leave it blank for a future per-user cascade. The plugin
			// returns a clear error when a request is made without credentials.
			{Key: "email", Label: "Anker account email", Type: "text",
				Help: "The email address for your Anker / Solix (mobile app) account."},
			{Key: "password", Label: "Anker account password", Type: "password", Secret: true,
				Help: "Your Anker account password. Stored locally, sent only (encrypted) to Anker's login endpoint."},
			{Key: "country", Label: "Country", Type: "text", Default: "DE",
				Help: "ISO country code of your Anker account (e.g. DE, GB, US). Determines which Anker server the account lives on; a wrong value logs in but shows no devices."},
			// controlMode selects the OCPP control path. It does not affect this
			// (read-only) cloud plugin — the CSMS that acts on it lives in the api
			// package (internal/ocpp) — but it is advertised here so a superadmin can
			// set/lock it at the global layer, and it cascades like the other fields.
			{Key: "controlMode", Label: "Control mode", Type: "select", Default: "off",
				Help: "How DriverVault controls the charger. Off = monitoring only (default). Anker cloud = commands travel over the account's own MQTT broker, so the charger needs no reachability at all; this is the mode for a charger behind a customer's router. Modbus TCP = DriverVault connects to the charger on the local network (enable it in the Anker app under Settings > Integrations), which needs the server to share that network. The two OCPP modes need the charger to connect in to DriverVault: Own CSMS directly, Proxy CSMS relayed to Anker's cloud.",
				Options: []plugins.SelectOption{
					{Value: "off", Label: "Off (monitoring only)"},
					{Value: "mqtt", Label: "Anker cloud (works anywhere)"},
					{Value: "modbus", Label: "Modbus TCP (local network)"},
					{Value: "own", Label: "Own CSMS (full control)"},
					{Value: "proxy", Label: "Proxy CSMS (relay + control)"},
				}},
		},
	}
}

// Init applies resolved config and builds the HTTP client. 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.email = strings.TrimSpace(config["email"])
	p.password = config["password"]
	p.countryId = strings.ToUpper(strings.TrimSpace(config["country"]))
	if p.countryId == "" {
		p.countryId = "DE"
	}
	if comCountries[p.countryId] {
		p.apiBase = serverCOM
	} else {
		p.apiBase = serverEU
	}
	// Credentials pick the session, so re-configuring the same account keeps its
	// token and its backoff, and changing account or country starts a fresh one.
	p.sess = sessionFor(p.apiBase, p.email, p.password)
	p.client = &http.Client{Timeout: 30 * time.Second}
	return nil
}

// HealthCheck logs in (if needed) and counts the account's EV chargers. It takes
// the same inventory the chargers capability returns rather than the standalone
// list alone: that list omits every charger that belongs to a system, so probing
// it reported "0 chargers" for an account whose chargers the panel was, at the
// same moment, listing.
//
// Authenticated but holding no charger is degraded, not down — the half we
// address works, and the missing half is the account (or the country, which
// picks the regional server).
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
	start := time.Now()
	doc, err := p.chargerInventory(ctx)
	lat := time.Since(start).Milliseconds()
	if err != nil {
		return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: shorten(err.Error())}
	}
	if doc.Count == 0 {
		detail := "authenticated; no EV charger on the account — if it does own one, check the country setting, which picks the Anker server"
		if len(doc.Warnings) > 0 {
			detail = "authenticated; no EV charger found: " + strings.Join(doc.Warnings, "; ")
		}
		return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: lat, Detail: shorten(detail)}
	}
	detail := fmt.Sprintf("authenticated; %d EV charger(s) on the account", doc.Count)
	if len(doc.Warnings) > 0 {
		// Some view failed; the count still stands, but say it is a floor.
		detail += fmt.Sprintf(" (%d of the cloud's views did not answer)", len(doc.Warnings))
	}
	return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: detail}
}

// invokeParams is the union of everything an action may be given. Per-charger
// actions take "sn" (the charger serial); per-site actions take "siteId", which
// comes from the "sites" capability.
type invokeParams struct {
	SN          string `json:"sn"`
	SiteID      string `json:"siteId"`
	VehicleID   string `json:"vehicleId"`
	FeatureType int    `json:"featuretype"`
	Range       string `json:"range"`     // day | week | month | year
	StartDate   string `json:"startDate"` // YYYY-MM-DD, or YYYY-MM / YYYY for month / year
	EndDate     string `json:"endDate"`

	// The wider read surface. Session history addresses a session by id, the
	// vehicle catalogue walks brand -> model -> year, dynamic pricing is asked
	// per area and provider, and a few views take several serials at once. Each
	// field is read only by the actions that name it.
	SNs       []string `json:"sns"`       // several serials at once (binding details, OTA)
	OrderID   string   `json:"orderId"`   // one charging session
	StartTime string   `json:"startTime"` // upstream's own epoch/date field, passed through
	EndTime   string   `json:"endTime"`
	Page      int      `json:"page"`
	PageSize  int      `json:"pageSize"`
	Type      int      `json:"type"`     // upgrade-record view, 1-3
	LastTime  string   `json:"lastTime"` // message cursor, format undocumented upstream
	Brand     string   `json:"brand"`
	Model     string   `json:"model"`
	Year      string   `json:"year"`
	DevicePN  string   `json:"devicePn"` // product code, for dynamic-price providers
	Area      string   `json:"area"`
	Company   string   `json:"company"`
	Date      string   `json:"date"`

	// The two RFID card writes: which card, and what to call it.
	CardNumber string `json:"cardNumber"`
	CardName   string `json:"cardName"`

	// The cloud MQTT actions: which command to send, the current ceiling "limit"
	// carries, and the settings "mqtt-settings" writes, by the names the snapshot
	// reports them under.
	Command  string         `json:"command"`
	Amps     float64        `json:"amps"`
	Settings map[string]any `json:"settings"`
}

// Invoke runs a named capability. Every one of them reads, bar the two RFID
// card writes, which are the only calls in this connector that change anything
// on the account (see rfidcards.go). The upstream response body is returned
// verbatim, except for "charger-state" and those two, which are derived (see
// chargerState, rfidAfterWrite).
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
	var pp invokeParams
	if len(params) > 0 {
		if err := json.Unmarshal(params, &pp); err != nil {
			return nil, fmt.Errorf("anker-solix: invalid params: %w", err)
		}
	}
	pp.SN = strings.TrimSpace(pp.SN)
	pp.SiteID = strings.TrimSpace(pp.SiteID)
	pp.VehicleID = strings.TrimSpace(pp.VehicleID)

	// chargers and charger-state both fan out over several endpoints and return
	// a derived document, so they do not fit the single-endpoint dispatch below.
	if action == "chargers" {
		return p.accountChargers(ctx)
	}
	if action == "charger-details" {
		if pp.SN == "" {
			return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action)
		}
		return p.chargerDetails(ctx, pp.SN)
	}
	if action == "charger-state" {
		if pp.SiteID == "" {
			return nil, fmt.Errorf("anker-solix: action %q requires a siteId", action)
		}
		return p.chargerState(ctx, pp.SiteID, pp.SN)
	}
	// Reading a card at the charger and asking the charger for its list are the
	// device's own messages, not endpoints (see mqttcards.go).
	if action == "rfid-card-scan" || action == "rfid-cards-charger" {
		if pp.SN == "" {
			return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action)
		}
		if action == "rfid-card-scan" {
			return p.mqttReadCard(ctx, pp.SN)
		}
		return p.mqttChargerCards(ctx, pp.SN)
	}
	// The two card writes are a write followed by the read that checks it, so
	// neither fits the single-endpoint dispatch below either.
	if action == "rfid-card-save" || action == "rfid-card-delete" {
		if pp.SN == "" {
			return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action)
		}
		if action == "rfid-card-save" {
			return p.rfidSaveCard(ctx, pp.SN, pp.CardNumber, pp.CardName)
		}
		return p.rfidDeleteCard(ctx, pp.SN, pp.CardNumber)
	}
	// The cloud MQTT actions address the charger itself over the account's broker
	// rather than a REST endpoint, so they route to that transport instead of the
	// single-endpoint dispatch below.
	if action == "mqtt-status" || action == "mqtt-command" || action == "mqtt-settings" {
		if pp.SN == "" {
			return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action)
		}
		switch action {
		case "mqtt-status":
			return p.mqttStatus(ctx, pp.SN)
		case "mqtt-settings":
			return p.mqttApplySettings(ctx, pp.SN, pp.Settings)
		}
		return p.mqttCommand(ctx, pp.SN, pp.Command, pp.Amps)
	}

	var (
		endpoint string
		payload  map[string]any
		query    map[string]string // set instead of payload for the two GET views
		needSN   bool
		needSite bool
	)
	switch action {
	case "devices":
		endpoint, payload = epBindDevices, map[string]any{}
	case "sites":
		endpoint, payload = epSiteList, map[string]any{}
	case "vehicles":
		endpoint, payload = epUserVehicles, map[string]any{}
	case "ocpp-endpoints":
		endpoint, payload = epOcppEndpoints, map[string]any{}
	case "charger-status":
		// featuretype selects which slice of the station record is returned;
		// Anker accepts 1 and 2, and 1 is what the app asks for by default.
		ft := pp.FeatureType
		if ft != 1 && ft != 2 {
			ft = 1
		}
		endpoint, needSN = epStationInfo, true
		payload = map[string]any{"evChargerSn": pp.SN, "featuretype": ft}
	case "charge-stats":
		endpoint, needSN = epChargeStats, true
		payload = map[string]any{"device_sn": pp.SN, "date_type": "all", "start_date": "", "end_date": ""}
	case "charge-orders":
		endpoint, needSN = epChargeStatsList, true
		payload = map[string]any{"device_sn": pp.SN, "order_status": 1, "date_type": "all",
			"start_date": "", "end_date": "", "page": 0, "page_size": 10}
	case "rfid-cards":
		endpoint, needSN = epRfidCards, true
		payload = map[string]any{"device_sn": pp.SN}
	case "ocpp-info":
		endpoint, needSN = epOcppInfo, true
		payload = map[string]any{"device_sn": pp.SN}
	case "site-status":
		endpoint, needSite = epSceneInfo, true
		payload = map[string]any{"site_id": pp.SiteID}
	case "charge-energy":
		// Anker's energy_analysis is site-scoped; an empty device_sn returns the
		// site's EV-charger totals, a serial narrows it to one charger.
		endpoint, needSite = epEnergyAnalysis, true
		payload = map[string]any{
			"site_id": pp.SiteID, "device_sn": pp.SN, "device_type": "ev_charger",
			"type": energyRange(pp.Range), "start_time": pp.StartDate, "end_time": pp.EndDate,
		}
	case "vehicle":
		if pp.VehicleID == "" {
			return nil, fmt.Errorf("anker-solix: action %q requires a vehicleId", action)
		}
		endpoint = epVehicleDetail
		payload = map[string]any{"vehicle_id": pp.VehicleID}

	// --- the rest of the read surface ---------------------------------------
	// Bodies are upstream's own field names, taken from the endpoint map; a
	// field the caller left empty is still sent, because the cloud treats an
	// empty string as "no filter" on every one of these.
	case "site-by-sn":
		endpoint, needSN = epSiteDetailBySN, true
		payload = map[string]any{"device_sn": pp.SN}
	case "site-detail":
		endpoint, needSite = epSiteDetail, true
		payload = map[string]any{"site_id": pp.SiteID}
	case "site-homepage":
		endpoint, payload = epSiteHomepage, map[string]any{}
	case "user-devices":
		endpoint, payload = epUserDevices, map[string]any{}
	case "bind-details", "ota-check", "ota-info", "ota-update":
		// The four views that ask about a list of serials rather than one. Both
		// spellings are accepted: sns for several, sn for the usual one.
		sns := serialList(pp)
		if len(sns) == 0 {
			return nil, fmt.Errorf("anker-solix: action %q requires an sn (or sns)", action)
		}
		switch action {
		case "bind-details":
			endpoint = epDeviceBindDetails
		case "ota-check":
			endpoint = epOtaBatchCheck
		case "ota-info":
			endpoint = epOtaInfo
		default:
			endpoint = epOtaUpdate
		}
		payload = map[string]any{"device_sn_list": sns}
	case "group-devices":
		endpoint, needSN = epGroupDevices, true
		payload = map[string]any{"device_sn": pp.SN}
	case "site-wifi":
		endpoint, needSite = epSiteWifiList, true
		payload = map[string]any{"site_id": pp.SiteID}
	case "charger-wifi":
		endpoint, needSN = epChargerWifiInfo, true
		payload = map[string]any{"evChargerSn": pp.SN}
	case "product-info":
		endpoint = epDeviceProductInfo
		payload = map[string]any{"evChargerSn": pp.SN}
	case "install-info":
		endpoint = epInstallInfo
		payload = map[string]any{"evChargerSn": pp.SN, "siteId": pp.SiteID}
	case "charging-orders":
		endpoint, needSN = epChargingOrders, true
		payload = map[string]any{"device_sn": pp.SN, "start_time": pp.StartTime}
	case "charging-order":
		if pp.OrderID == "" {
			return nil, fmt.Errorf("anker-solix: action %q requires an orderId", action)
		}
		endpoint, needSN = epChargingOrder, true
		payload = map[string]any{"device_sn": pp.SN, "order_id": pp.OrderID}
	case "order-seconds", "order-seconds-preview":
		if pp.OrderID == "" {
			return nil, fmt.Errorf("anker-solix: action %q requires an orderId", action)
		}
		if action == "order-seconds" {
			endpoint = epOrderSecDetail
			payload = map[string]any{"order_id": pp.OrderID, "start_time": pp.StartTime}
		} else {
			endpoint = epOrderSecPreview
			payload = map[string]any{"order_id": pp.OrderID}
		}
	case "energy-statistics":
		endpoint = epEnergyStatistics
		payload = map[string]any{"sourceType": "evCharger", "evChargerSn": pp.SN, "siteId": pp.SiteID}
	case "device-income":
		endpoint, needSN = epDeviceIncome, true
		payload = map[string]any{"device_sn": pp.SN, "start_time": pp.StartTime}
	case "site-price":
		endpoint, needSite = epSitePrice, true
		payload = map[string]any{"site_id": pp.SiteID}
	case "currencies":
		endpoint, payload = epCurrencyList, map[string]any{}
	case "site-export":
		endpoint, needSite = epSiteDataExport, true
		payload = map[string]any{"site_id": pp.SiteID, "start_time": pp.StartTime, "end_time": pp.EndTime}
	case "dynamic-price-available":
		endpoint, payload = epDynPriceCheck, map[string]any{}
	case "dynamic-price-options":
		if pp.DevicePN == "" {
			return nil, fmt.Errorf("anker-solix: action %q requires a devicePn (the charger's product code)", action)
		}
		endpoint = epDynPriceOptions
		payload = map[string]any{"device_pn": pp.DevicePN}
	case "dynamic-price":
		if pp.Area == "" || pp.Company == "" {
			return nil, fmt.Errorf("anker-solix: action %q requires an area and a company (from dynamic-price-options)", action)
		}
		endpoint = epDynPriceDetail
		payload = map[string]any{"area": pp.Area, "company": pp.Company, "date": pp.Date, "device_sn": pp.SN}
	case "vehicle-brands":
		endpoint, payload = epVehicleBrands, map[string]any{}
	case "vehicle-models":
		if pp.Brand == "" {
			return nil, fmt.Errorf("anker-solix: action %q requires a brand", action)
		}
		endpoint = epVehicleModels
		payload = map[string]any{"brand_name": pp.Brand}
	case "vehicle-years":
		if pp.Brand == "" || pp.Model == "" {
			return nil, fmt.Errorf("anker-solix: action %q requires a brand and a model", action)
		}
		endpoint = epVehicleYears
		payload = map[string]any{"brand_name": pp.Brand, "model_name": pp.Model}
	case "vehicle-specs":
		if pp.Brand == "" || pp.Model == "" || pp.Year == "" {
			return nil, fmt.Errorf("anker-solix: action %q requires a brand, a model and a year", action)
		}
		endpoint = epVehicleSpecs
		payload = map[string]any{"brand_name": pp.Brand, "model_name": pp.Model, "productive_year": pp.Year}
	case "shared-with":
		endpoint, needSN = epSharedDevice, true
		payload = map[string]any{"device_sn": pp.SN}
	case "upgrade-records":
		endpoint = epUpgradeRecords
		payload = map[string]any{"device_sn": pp.SN}
	case "upgrade-record":
		endpoint, needSN = epUpgradeRecord, true
		payload = map[string]any{"device_sn": pp.SN, "type": upgradeRecordView(pp.Type)}
	case "auto-upgrade":
		endpoint, payload = epAutoUpgrade, map[string]any{}
	case "messages-unread":
		endpoint, query = epMessageUnread, map[string]string{}
	case "messages":
		endpoint, query = epMessages, map[string]string{}
		if pp.LastTime != "" {
			query["last_time"] = pp.LastTime
		}
	case "quiet-hours":
		endpoint, payload = epMessageNotDisturb, map[string]any{}
	case "message-devices":
		endpoint, payload = epMessageSNList, map[string]any{}
	case "tamper-records":
		endpoint, needSN = epTamperRecords, true
		payload = map[string]any{"device_sn": pp.SN, "page_num": pageNum(pp.Page), "page_size": pageSize(pp.PageSize)}
	default:
		return nil, fmt.Errorf("anker-solix: unknown action %q", action)
	}
	if needSN && pp.SN == "" {
		return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action)
	}
	if needSite && pp.SiteID == "" {
		return nil, fmt.Errorf("anker-solix: action %q requires a siteId", action)
	}

	// Two of these are GET views; everything else is a POST with a JSON body.
	if query != nil {
		body, err := p.apiGet(ctx, endpoint, query)
		if err != nil {
			return nil, err
		}
		return json.RawMessage(body), nil
	}
	body, err := p.apiRequest(ctx, endpoint, payload)
	if err != nil {
		return nil, err
	}
	return json.RawMessage(body), nil
}

// serialList is the serials a multi-serial view is asked about: the several it
// was given, or the single one every other action takes, so a caller never has
// to know which spelling an endpoint wanted.
func serialList(pp invokeParams) []string {
	out := make([]string, 0, len(pp.SNs)+1)
	for _, sn := range pp.SNs {
		if sn = strings.TrimSpace(sn); sn != "" {
			out = append(out, sn)
		}
	}
	if len(out) == 0 && pp.SN != "" {
		out = append(out, pp.SN)
	}
	return out
}

// upgradeRecordView clamps check_upgrade_record's view selector, which upstream
// documents as 1-3 and nothing more; 1 is what the app asks for.
func upgradeRecordView(t int) int {
	if t < 1 || t > 3 {
		return 1
	}
	return t
}

// pageNum and pageSize give the paged views a first page and a readable page
// when the caller does not care, rather than sending a zero the cloud reads as
// "no page at all".
func pageNum(n int) int {
	if n < 1 {
		return 1
	}
	return n
}

func pageSize(n int) int {
	if n < 1 || n > 100 {
		return 20
	}
	return n
}

// energyRange normalizes the energy_analysis period, defaulting to a week like
// the reference implementation's EV-charger poll.
func energyRange(r string) string {
	switch strings.ToLower(strings.TrimSpace(r)) {
	case "day", "week", "month", "year":
		return strings.ToLower(strings.TrimSpace(r))
	default:
		return "week"
	}
}

// Shutdown releases pooled connections. The account's shared session — its token
// and its broker connection — is deliberately left alone: the manager builds and
// tears down an instance per request, and every instance for that account shares
// it (see session.go).
func (p *Plugin) Shutdown(context.Context) error {
	p.mu.Lock()
	defer p.mu.Unlock()
	if p.client != nil {
		p.client.CloseIdleConnections()
	}
	return nil
}

// ---- derived EV-charger state ------------------------------------------------

// evChargerState is one charger's live state, normalized out of whichever cloud
// view reported it. Status and mode names match anker-solix-api so a consumer
// can read them against the reference implementation.
type evChargerState struct {
	SN     string `json:"sn"`
	Name   string `json:"name,omitempty"`
	SiteID string `json:"siteId,omitempty"`
	Source string `json:"source"` // scene | hes — which view the record came from

	Status     *int   `json:"status,omitempty"` // raw operating_state / evChargerStatus
	StatusDesc string `json:"statusDesc"`

	// Mode is the operational mode the charger is effectively in; ModeOptions are
	// the modes it can be switched to from there. Charging is the binary reading
	// of Mode — everything but stop_charge counts as on.
	Mode        string   `json:"mode,omitempty"`
	ModeOptions []string `json:"modeOptions"`
	Charging    *bool    `json:"charging,omitempty"`

	Power          string `json:"power,omitempty"` // charge power as reported, unit per upstream
	OcppStatus     *int   `json:"ocppStatus,omitempty"`
	OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`
}

// chargerState fetches a site's EV chargers from both cloud views and returns
// their normalized state. The two views cover different system families — the
// power_service scene for site systems, the HES service for HES systems — so a
// site answering only one of them is normal; the call fails only when neither
// view is reachable. An sn, when given, filters to that charger.
func (p *Plugin) chargerState(ctx context.Context, siteID, sn string) (json.RawMessage, error) {
	var (
		out      []evChargerState
		sceneErr error
		hesErr   error
	)

	if body, err := p.apiRequest(ctx, epSceneInfo, map[string]any{"site_id": siteID}); err != nil {
		sceneErr = err
	} else {
		out = append(out, parseScenePiles(body, siteID)...)
	}
	if body, err := p.apiRequest(ctx, epSystemRunInfo, map[string]any{"siteId": siteID}); err != nil {
		hesErr = err
	} else {
		out = append(out, parseHesChargers(body, siteID)...)
	}
	if sceneErr != nil && hesErr != nil {
		return nil, fmt.Errorf("anker-solix: charger-state: no EV-charger view for site %s: %v; %v",
			siteID, sceneErr, hesErr)
	}

	// A charger reported by both views is one charger. The scene record wins: it
	// is the richer of the two, carrying charge power and OCPP status.
	seen := make(map[string]bool, len(out))
	kept := out[:0]
	for _, c := range out {
		if seen[c.SN] || (sn != "" && c.SN != sn) {
			continue
		}
		seen[c.SN] = true
		kept = append(kept, c)
	}
	out = kept
	if out == nil {
		out = []evChargerState{}
	}
	return json.Marshal(map[string]any{"siteId": siteID, "chargers": out})
}

// parseScenePiles reads the EV chargers out of a get_scen_info response. The
// scene calls the state operating_state and the charge power just power; the
// reference implementation renames both on ingest, and so do we.
func parseScenePiles(body []byte, siteID string) []evChargerState {
	var env struct {
		Data struct {
			ChargingPileInfo struct {
				ChargingPileList []struct {
					DeviceSN       string      `json:"device_sn"`
					DeviceName     string      `json:"device_name"`
					OperatingState *int        `json:"operating_state"`
					Power          json.Number `json:"power"`
					OcppConnect    *int        `json:"ocpp_connect_status"`
				} `json:"charging_pile_list"`
			} `json:"charging_pile_info"`
		} `json:"data"`
	}
	if err := json.Unmarshal(body, &env); err != nil {
		return nil
	}
	list := env.Data.ChargingPileInfo.ChargingPileList
	out := make([]evChargerState, 0, len(list))
	for _, cp := range list {
		if cp.DeviceSN == "" {
			continue
		}
		out = append(out, newChargerState(cp.DeviceSN, cp.DeviceName, siteID, "scene",
			cp.OperatingState, cp.OcppConnect, cp.Power.String()))
	}
	return out
}

// parseHesChargers reads the EV chargers out of a get_system_running_info
// response, where they arrive as evChargerInfos under their own field names.
func parseHesChargers(body []byte, siteID string) []evChargerState {
	var env struct {
		Data struct {
			EvChargerInfos []struct {
				SN     string `json:"evChargerSn"`
				Name   string `json:"evChargerName"`
				Status *int   `json:"evChargerStatus"`
			} `json:"evChargerInfos"`
		} `json:"data"`
	}
	if err := json.Unmarshal(body, &env); err != nil {
		return nil
	}
	out := make([]evChargerState, 0, len(env.Data.EvChargerInfos))
	for _, ev := range env.Data.EvChargerInfos {
		if ev.SN == "" {
			continue
		}
		out = append(out, newChargerState(ev.SN, ev.Name, siteID, "hes", ev.Status, nil, ""))
	}
	return out
}

// newChargerState decorates a raw status code with its name, mode and options.
func newChargerState(sn, name, siteID, source string, status, ocpp *int, power string) evChargerState {
	c := evChargerState{
		SN: sn, Name: name, SiteID: siteID, Source: source,
		Status: status, StatusDesc: stateUnknown, Power: power,
		ModeOptions: []string{},
	}
	if status != nil {
		c.StatusDesc = statusName(*status)
		// The cloud views carry no boost flag and no countdowns — those reach the
		// reference implementation over MQTT only — so the mode derives from the
		// status alone and a charger never reads as boosting or waiting here.
		c.Mode = chargerMode(c.StatusDesc, false, 0, 0)
		c.ModeOptions = chargerModeOptions(c.Mode, c.StatusDesc)
		on := c.Mode != modeStopCharge
		c.Charging = &on
	}
	if ocpp != nil {
		c.OcppStatus = ocpp
		desc, ok := ocppConnStatus[*ocpp]
		if !ok {
			desc = stateUnknown
		}
		c.OcppStatusDesc = desc
	}
	return c
}

// statusName maps an operating-state code to its name.
func statusName(code int) string {
	if name, ok := evChargerStatus[code]; ok {
		return name
	}
	return stateUnknown
}

// chargerMode derives the operational mode from the charger's status, mirroring
// anker-solix-api's ev_charger_mode_state. boost and the two countdowns are MQTT
// signals; pass their zero values when only cloud data is available.
func chargerMode(statusDesc string, boost bool, plugCountdown, startCountdown int) string {
	if boost {
		return modeBoostCharge
	}
	switch statusDesc {
	case statePreparing:
		if plugCountdown > 0 {
			return modeWaitPlug
		}
		if startCountdown > 0 {
			return modeWaitStart
		}
		return modeStartCharge
	case stateCharging, stateChargerPaused, stateVehiclePaused:
		return modeStartCharge
	default:
		return modeStopCharge
	}
}

// chargerModeOptions lists the modes reachable from the current one, mirroring
// ev_charger_mode_options. The result is sorted so the output is stable.
func chargerModeOptions(mode, statusDesc string) []string {
	if mode == "" {
		return []string{}
	}
	set := map[string]bool{mode: true}
	switch mode {
	case modeWaitPlug, modeWaitStart, modeStartCharge:
		set[modeStopCharge] = true
		// A start delay can only be skipped while it is running; boost only applies
		// once charging has been commanded.
		if mode == modeWaitStart {
			set[modeSkipDelay] = true
		} else if mode == modeStartCharge {
			set[modeBoostCharge] = true
		}
	case modeBoostCharge:
		set[modeStopCharge] = true
	default:
		// Only a charger sitting in standby can be told to start.
		if statusDesc == stateStandby {
			set[modeStartCharge] = true
		}
	}
	opts := make([]string, 0, len(set))
	for m := range set {
		opts = append(opts, m)
	}
	sort.Strings(opts)
	return opts
}

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

// Login backoff. A refused sign-in is cached and replayed to every caller until
// its window passes, so credentials Anker rejects are offered once, not once per
// request.
const (
	loginRetryBase = 1 * time.Minute
	loginRetryMax  = 15 * time.Minute

	// codeSignInLocked is what Anker answers once it has already disabled the
	// account for repeated failures; it states a ten-minute penalty, so sit the
	// penalty out rather than spending attempts against a door that is shut.
	codeSignInLocked = 10019
	loginLockoutWait = 10 * time.Minute
)

// loginRejected is the login endpoint answering with a non-zero API code. The
// code is kept so an already-locked account can be told from an ordinary
// refusal.
type loginRejected struct {
	code int
	msg  string
}

func (e *loginRejected) Error() string {
	return fmt.Sprintf("anker-solix: login rejected (code %d): %s", e.code, e.msg)
}

// loginFailure wraps whatever made a login attempt fail, together with the time
// before which no further attempt is made.
type loginFailure struct {
	err     error
	retryAt time.Time
}

func (e *loginFailure) Error() string {
	wait := time.Until(e.retryAt).Round(time.Second)
	if wait < 0 {
		wait = 0
	}
	return fmt.Sprintf("%s (not retrying for %s)", e.err, wait)
}

func (e *loginFailure) Unwrap() error { return e.err }

// isLoginFailure reports whether err came from the login exchange rather than
// from the endpoint the caller was asking for — the difference between "this
// account cannot sign in" and "this one view said no".
func isLoginFailure(err error) bool {
	var lf *loginFailure
	return errors.As(err, &lf)
}

// ensureToken guarantees a non-expired auth token, logging in as needed.
// Serialized by p.mu so concurrent requests trigger at most one login.
func (p *Plugin) ensureToken(ctx context.Context) (tokenInfo, error) {
	s := p.sess
	if s == nil {
		return tokenInfo{}, errors.New("anker-solix: plugin not initialised")
	}
	s.mu.Lock()
	defer s.mu.Unlock()

	if s.tok != nil && time.Now().Before(s.tok.expiration) {
		return *s.tok, nil
	}
	if err := p.attemptLogin(ctx); err != nil {
		return tokenInfo{}, err
	}
	return *s.tok, nil
}

// attemptLogin logs in at most once per backoff window, handing every caller in
// between the failure that opened it. Caller holds p.sess.mu.
func (p *Plugin) attemptLogin(ctx context.Context) error {
	if p.email == "" || p.password == "" {
		// Nothing was sent to Anker, so there is nothing to back off from.
		return errors.New("anker-solix: email and password are required")
	}
	s := p.sess
	if s.loginErr != nil && time.Now().Before(s.loginRetryAt) {
		return s.loginErr
	}
	s.noteLogin(p.login(ctx))
	return s.loginErr
}

// login performs the ECDH + AES password exchange against passport/login and
// stores the returned token. Caller holds p.mu.
func (p *Plugin) login(ctx context.Context) error {
	// Fresh ephemeral P-256 key pair and shared secret against Anker's server key.
	curve := ecdh.P256()
	priv, err := curve.GenerateKey(rand.Reader)
	if err != nil {
		return fmt.Errorf("anker-solix: generate key: %w", err)
	}
	serverBytes, err := hex.DecodeString(serverPublicKeyHex)
	if err != nil {
		return fmt.Errorf("anker-solix: server key: %w", err)
	}
	serverPub, err := curve.NewPublicKey(serverBytes)
	if err != nil {
		return fmt.Errorf("anker-solix: server key: %w", err)
	}
	shared, err := priv.ECDH(serverPub)
	if err != nil {
		return fmt.Errorf("anker-solix: derive shared key: %w", err)
	}
	encPassword, err := encryptPassword(p.password, shared)
	if err != nil {
		return fmt.Errorf("anker-solix: %w", err)
	}

	gmt, offsetMs := timezone()
	reqBody := map[string]any{
		"ab":                 p.countryId,
		"client_secret_info": map[string]any{"public_key": hex.EncodeToString(priv.PublicKey().Bytes())},
		"enc":                0,
		"email":              p.email,
		"password":           encPassword,
		"time_zone":          offsetMs,
		"transaction":        fmt.Sprintf("%d", time.Now().UnixMilli()),
	}

	body, status, err := p.doRequest(ctx, epLogin, reqBody, "", "", gmt)
	if err != nil {
		return err
	}
	if status != http.StatusOK {
		return fmt.Errorf("anker-solix: login failed (HTTP %d): %s", status, shorten(string(body)))
	}
	return p.storeToken(body)
}

// storeToken parses the login envelope and updates the session's token. Caller
// holds p.sess.mu.
func (p *Plugin) storeToken(body []byte) error {
	var env struct {
		Code int    `json:"code"`
		Msg  string `json:"msg"`
		Data struct {
			AuthToken    string `json:"auth_token"`
			UserID       string `json:"user_id"`
			NickName     string `json:"nick_name"`
			TokenExpires int64  `json:"token_expires_at"`
		} `json:"data"`
	}
	if err := json.Unmarshal(body, &env); err != nil {
		return fmt.Errorf("anker-solix: decode login response: %w", err)
	}
	if env.Code != 0 {
		return &loginRejected{code: env.Code, msg: shorten(env.Msg)}
	}
	if env.Data.AuthToken == "" || env.Data.UserID == "" {
		return errors.New("anker-solix: login response missing auth_token or user_id")
	}

	// token_expires_at is a unix timestamp (seconds); Anker tokens live ~7 days.
	// Fall back to a conservative 6-day lifetime if the field is absent.
	exp := time.Now().Add(6 * 24 * time.Hour)
	if env.Data.TokenExpires > 0 {
		exp = time.Unix(env.Data.TokenExpires, 0)
	}
	exp = exp.Add(-tokenExpiryMargin)

	p.sess.tok = &tokenInfo{
		authToken:  env.Data.AuthToken,
		gtoken:     md5hex(env.Data.UserID),
		nickname:   env.Data.NickName,
		expiration: exp,
	}
	return nil
}

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

// apiRequest performs an authenticated POST, re-authenticating once if the token
// is rejected. It returns the raw response body; a non-zero API code or non-2xx
// status is returned as an error.
func (p *Plugin) apiRequest(ctx context.Context, endpoint string, payload map[string]any) ([]byte, error) {
	return p.apiCall(ctx, endpoint, func(tok tokenInfo, gmt string) ([]byte, int, error) {
		return p.doRequest(ctx, endpoint, payload, tok.authToken, tok.gtoken, gmt)
	})
}

// apiGet is apiRequest for the two message views, the only endpoints in this
// connector Anker serves over GET: same headers, same credential, same one
// retry after a fresh login — a query string instead of a body.
func (p *Plugin) apiGet(ctx context.Context, endpoint string, query map[string]string) ([]byte, error) {
	return p.apiCall(ctx, endpoint, func(tok tokenInfo, gmt string) ([]byte, int, error) {
		return p.doGet(ctx, endpoint, query, tok.authToken, tok.gtoken, gmt)
	})
}

// apiCall is what both of those share: a token, one attempt, and — when the
// cloud rejects the token — a fresh login and exactly one more. There is no
// refresh token, so a rejected token can only be answered with a new login.
func (p *Plugin) apiCall(ctx context.Context, endpoint string, send func(tokenInfo, string) ([]byte, int, error)) ([]byte, error) {
	tok, err := p.ensureToken(ctx)
	if err != nil {
		return nil, err
	}

	gmt, _ := timezone()
	body, status, err := send(tok, gmt)
	if err != nil {
		return nil, err
	}

	// A rejected token surfaces as 401/403 or an auth error code; log in afresh
	// and retry once.
	if status == http.StatusUnauthorized || status == http.StatusForbidden || isAuthCode(body) {
		p.sess.mu.Lock()
		p.sess.tok = nil
		lerr := p.attemptLogin(ctx)
		var newTok tokenInfo
		if lerr == nil {
			newTok = *p.sess.tok
		}
		p.sess.mu.Unlock()
		if lerr != nil {
			return nil, lerr
		}
		body, status, err = send(newTok, gmt)
		if err != nil {
			return nil, err
		}
	}

	if status != http.StatusOK {
		return nil, fmt.Errorf("anker-solix: request %s failed (HTTP %d): %s", endpoint, status, shorten(string(body)))
	}
	if code, msg, ok := apiError(body); ok {
		return nil, fmt.Errorf("anker-solix: request %s failed (code %d): %s", endpoint, code, shorten(msg))
	}
	return body, nil
}

// doRequest issues a single POST to an endpoint with the common Anker headers.
// When authToken is empty the auth headers are omitted (login request).
func (p *Plugin) doRequest(ctx context.Context, endpoint string, payload map[string]any, authToken, gtoken, gmt string) ([]byte, int, error) {
	buf, err := json.Marshal(payload)
	if err != nil {
		return nil, 0, fmt.Errorf("anker-solix: encode request: %w", err)
	}
	url := p.apiBase + "/" + endpoint
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf))
	if err != nil {
		return nil, 0, err
	}
	ankerHeaders(req, p.countryId, authToken, gtoken, gmt)

	resp, err := p.client.Do(req)
	if err != nil {
		return nil, 0, fmt.Errorf("anker-solix: request %s: %w", endpoint, err)
	}
	defer drain(resp)
	respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
	return respBody, resp.StatusCode, nil
}

// doGet issues a single GET with the same headers a POST carries. Only the two
// message views are served this way.
func (p *Plugin) doGet(ctx context.Context, endpoint string, query map[string]string, authToken, gtoken, gmt string) ([]byte, int, error) {
	url := p.apiBase + "/" + endpoint
	if len(query) > 0 {
		vals := neturl.Values{}
		for k, v := range query {
			vals.Set(k, v)
		}
		url += "?" + vals.Encode()
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, 0, err
	}
	ankerHeaders(req, p.countryId, authToken, gtoken, gmt)

	resp, err := p.client.Do(req)
	if err != nil {
		return nil, 0, fmt.Errorf("anker-solix: request %s: %w", endpoint, err)
	}
	defer drain(resp)
	respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
	return respBody, resp.StatusCode, nil
}

// ankerHeaders sets the identity every request carries. When authToken is empty
// the credential headers are omitted (the login request).
func ankerHeaders(req *http.Request, countryId, authToken, gtoken, gmt string) {
	req.Header.Set("content-type", "application/json")
	req.Header.Set("model-type", "DESKTOP")
	req.Header.Set("app-name", "anker_power")
	req.Header.Set("os-type", "android")
	req.Header.Set("country", countryId)
	req.Header.Set("timezone", gmt)
	if authToken != "" {
		req.Header.Set("x-auth-token", authToken)
		req.Header.Set("gtoken", gtoken)
	}
}

// ---- crypto & small helpers --------------------------------------------------

// encryptPassword AES-256-CBC encrypts the password under the ECDH shared secret
// (key = secret, IV = secret[:16], PKCS#7 padding) and base64-encodes it.
func encryptPassword(password string, shared []byte) (string, error) {
	if len(shared) < 32 {
		return "", fmt.Errorf("shared key too short: %d bytes", len(shared))
	}
	block, err := aes.NewCipher(shared)
	if err != nil {
		return "", fmt.Errorf("aes cipher: %w", err)
	}
	plain := pkcs7Pad([]byte(password), block.BlockSize())
	out := make([]byte, len(plain))
	cipher.NewCBCEncrypter(block, shared[:block.BlockSize()]).CryptBlocks(out, plain)
	return base64.StdEncoding.EncodeToString(out), nil
}

// pkcs7Pad appends PKCS#7 padding to a multiple of blockSize.
func pkcs7Pad(data []byte, blockSize int) []byte {
	pad := blockSize - len(data)%blockSize
	return append(data, bytes.Repeat([]byte{byte(pad)}, pad)...)
}

// md5hex returns the lowercase hex MD5 of s (Anker's gtoken derivation).
func md5hex(s string) string {
	sum := md5.Sum([]byte(s))
	return hex.EncodeToString(sum[:])
}

// timezone returns Anker's GMT offset string (e.g. "GMT+01:00") and the offset
// in milliseconds, both derived from the host's local zone.
func timezone() (string, int) {
	_, offsetSec := time.Now().Zone()
	sign := "+"
	abs := offsetSec
	if abs < 0 {
		sign, abs = "-", -abs
	}
	gmt := fmt.Sprintf("GMT%s%02d:%02d", sign, abs/3600, (abs%3600)/60)
	return gmt, offsetSec * 1000
}

// apiError reports a non-zero API code (and its message) from a response body.
func apiError(body []byte) (int, string, bool) {
	var env struct {
		Code int    `json:"code"`
		Msg  string `json:"msg"`
	}
	if err := json.Unmarshal(body, &env); err != nil {
		return 0, "", false
	}
	if env.Code != 0 {
		return env.Code, env.Msg, true
	}
	return 0, "", false
}

// isAuthCode reports whether a response body carries an authentication-failure
// API code (token invalid/expired), warranting a re-login.
func isAuthCode(body []byte) bool {
	code, _, ok := apiError(body)
	if !ok {
		return false
	}
	// Anker returns 401xx-family codes for token problems; treat the common
	// invalid/expired-token codes as auth failures.
	switch code {
	case 401, 40100, 40101, 40102, 40103:
		return true
	}
	return false
}

// countChargers best-effort extracts the bound EV-charger count from a
// get_user_bind_and_not_in_station_evchargers response.
func countChargers(body []byte) (int, bool) {
	var env struct {
		Data struct {
			EvChargers            []json.RawMessage `json:"evChargers"`
			UserBindEvChargersCnt *int              `json:"userBindEvChargersCount"`
		} `json:"data"`
	}
	if err := json.Unmarshal(body, &env); err != nil {
		return 0, false
	}
	if env.Data.UserBindEvChargersCnt != nil {
		return *env.Data.UserBindEvChargersCnt, true
	}
	if env.Data.EvChargers != nil {
		return len(env.Data.EvChargers), true
	}
	return 0, false
}

// 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
}
