package api

import (
	"context"
	"encoding/json"
	"net/http"
	"net/url"
	"strings"
)

// This file mirrors integrations.go for the Anker Solix V1 Smart EV Charger
// plugin: the same three-layer cascade (global → org → user) lets everyone run
// the integration under their own Anker account while a superadmin (and, in an
// organization, an org admin) can impose settings from above. See integrations.go
// for the full rationale; only the fields differ.
//
//   - global (L1): pluginSettings on the app_settings singleton, set in the API
//     Server panel by a superadmin — the top of the cascade for everyone.
//   - org    (L2): pluginSettings.ankerSolix on the caller's organization record.
//   - user   (L3): pluginSettings.ankerSolix on the caller's own user record.
//
// The Anker email + password resolve together as a *pair* from the highest layer
// that supplies an email, so credential halves are never mixed across layers. The
// country resolves on its own. Enablement is strictly per-user (L3), gated by the
// global master switch and, for org users, the org gate.

const (
	ankerPlugin     = "anker-solix"
	ankerSecretMask = "••••••••"

	// Control modes for the Anker Solix charger, in order of how much they demand
	// of the deployment. own and proxy are OCPP paths (see internal/ocpp) and need
	// the charger to dial in to us. modbus is the local path (the ankersolix
	// plugin's modbus.go), where we dial the charger — which needs the server on
	// the charger's network. mqtt is the remote path (cloudmqtt.go): both sides
	// meet at Anker's own broker, so the charger needs no reachability at all,
	// which is what makes it the mode for a charger behind a customer's router.
	ankerControlOff    = "off"    // monitoring only (default)
	ankerControlOwn    = "own"    // DriverVault is the charger's Central System
	ankerControlProxy  = "proxy"  // DriverVault relays to Anker's cloud and injects
	ankerControlModbus = "modbus" // DriverVault talks Modbus TCP to the charger on the LAN
	ankerControlMqtt   = "mqtt"   // DriverVault commands the charger over Anker's cloud broker
)

// normalizeControlMode maps a raw control-mode value to a recognized mode, or ""
// when unset/unknown so the cascade continues to the next layer. An explicit
// "off" is recognized (and thus wins its layer).
func normalizeControlMode(v string) string {
	switch strings.ToLower(strings.TrimSpace(v)) {
	case ankerControlOwn:
		return ankerControlOwn
	case ankerControlProxy:
		return ankerControlProxy
	case ankerControlModbus:
		return ankerControlModbus
	case ankerControlMqtt:
		return ankerControlMqtt
	case ankerControlOff:
		return ankerControlOff
	default:
		return ""
	}
}

// ankerConfig is one layer's Anker Solix settings.
type ankerConfig struct {
	Email    string `json:"email"`
	Password string `json:"password"`
	Country  string `json:"country"`
	// ControlMode is the control path: off | mqtt | modbus | own | proxy.
	// It resolves independently of the credentials, like Country.
	ControlMode string `json:"controlMode"`
}

// ankerChargerBinding is what we know about one charger the caller controls:
// its OCPP control token, its local Modbus address, or both. The operator
// installs the token into the charger (as its OCPP Basic-auth password); we keep
// only its SHA-256 hash and a short hint, never the plaintext — the token is
// shown to the owner exactly once, at generation. User-layer only, not a cascade
// credential.
type ankerChargerBinding struct {
	TokenHash string `json:"tokenHash,omitempty"` // sha256(token), lowercase hex
	TokenHint string `json:"tokenHint,omitempty"` // last 4 chars, for the UI
	AddedAt   string `json:"addedAt,omitempty"`

	// ModbusHost and ModbusPort address the charger's local Modbus TCP server,
	// which the owner enables in the Anker app (Settings > Integrations > Modbus
	// TCP; the app then shows this address). They are per charger rather than per
	// account because they are a LAN address, not a credential — and unlike the
	// OCPP token they are not a secret, so they are stored and shown verbatim.
	ModbusHost string `json:"modbusHost,omitempty"`
	ModbusPort int    `json:"modbusPort,omitempty"` // 0 means the standard 502
}

// ankerStored is what we persist per user/org under pluginSettings.ankerSolix.
type ankerStored struct {
	Config ankerConfig `json:"config"`
	// Enabled is the personal per-user opt-in (user layer). Default false.
	Enabled bool `json:"enabled"`
	// Disabled is the organization layer's off switch, stored inverted so that
	// absent == enabled. Only meaningful on the org record; ignored on user records.
	Disabled bool `json:"disabled,omitempty"`
	// ControlChargers maps a charger serial to its OCPP control token (user layer).
	ControlChargers map[string]ankerChargerBinding `json:"controlChargers,omitempty"`
}

// ankerSettingsDoc is the pluginSettings JSON shape for the ankerSolix key.
type ankerSettingsDoc struct {
	AnkerSolix ankerStored `json:"ankerSolix"`
}

// ankerFieldView is one field's resolved state for the UI.
type ankerFieldView struct {
	Effective string `json:"effective"` // resolved value in force (secrets/inherited creds masked)
	Own       string `json:"own"`       // the caller's own editable-layer value (secret masked)
	Source    string `json:"source"`    // global | org | user | unset
	Locked    bool   `json:"locked"`    // set above the caller's editable layer
}

// ankerResolution is the fully-resolved Anker Solix state for one caller.
type ankerResolution struct {
	eff        ankerConfig       // effective (unmasked) — used only server-side (probes)
	userOwn    ankerConfig       // caller's personal (L3) values (unmasked)
	orgOwn     ankerConfig       // organization (L2) values (unmasked)
	source     map[string]string // field -> layer name (global|org|user|unset)
	isSuper    bool              // superadmin: manages the global layer in the panel
	canOrg     bool              // caller may edit the organization layer (org admin)
	available  bool              // global master switch (plugin enabled in the panel)
	orgEnabled bool              // org gate (default true; gates the org's users)
	enabled    bool              // caller's personal enable flag
}

// ankerLayerRank orders the cascade layers; a higher number is lower priority.
var ankerLayerRank = map[string]int{"global": 1, "org": 2, "user": 3}

// resolveAnker computes the cascade for a caller. userRaw is the caller's
// pluginSettings blob (read from their user record).
func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) ankerResolution {
	g, masterEnabled, _ := s.plugins.RawConfig(ankerPlugin)
	gc := ankerConfig{Email: g["email"], Password: g["password"], Country: g["country"], ControlMode: g["controlMode"]}

	var oStored ankerStored
	if who.OrgID != "" {
		oStored, _ = s.orgAnker(ctx, who.OrgID)
	}
	oc := oStored.Config

	var uStored ankerStored
	if len(userRaw) > 0 {
		var d ankerSettingsDoc
		_ = json.Unmarshal(userRaw, &d)
		uStored = d.AnkerSolix
	}
	uc := uStored.Config

	res := ankerResolution{
		source:  map[string]string{},
		userOwn: uc,
		orgOwn:  oc,
		isSuper: who.isSuperadmin(),
		// An org admin may edit the organization layer in addition to their own
		// personal layer. Requires the service account (org writes go through it).
		canOrg:     who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.pb.Configured(),
		available:  masterEnabled,
		orgEnabled: !oStored.Disabled, // default true; off only when the org disabled it
		enabled:    uStored.Enabled,
	}

	// Ordered layers, top (highest priority) first.
	type layer struct {
		name string
		c    ankerConfig
	}
	layers := []layer{{"global", gc}}
	if who.OrgID != "" {
		layers = append(layers, layer{"org", oc})
	}
	layers = append(layers, layer{"user", uc})

	// Country resolves independently: the highest layer that sets it wins.
	res.source["country"] = "unset"
	for _, l := range layers {
		if v := strings.TrimSpace(l.c.Country); v != "" {
			res.eff.Country, res.source["country"] = v, l.name
			break
		}
	}

	// Control mode resolves independently too, defaulting to off (monitoring
	// only). The highest layer that sets a recognized value wins; an explicit
	// "off" set above still wins (and locks lower layers to monitoring).
	res.eff.ControlMode = ankerControlOff
	res.source["controlMode"] = "unset"
	for _, l := range layers {
		if v := normalizeControlMode(l.c.ControlMode); v != "" {
			res.eff.ControlMode, res.source["controlMode"] = v, l.name
			break
		}
	}

	// Credentials resolve as a pair from the highest layer with an email, so the
	// email and password never come from different layers.
	credSrc := "unset"
	for _, l := range layers {
		if e := strings.TrimSpace(l.c.Email); e != "" {
			res.eff.Email, res.eff.Password, credSrc = e, l.c.Password, l.name
			break
		}
	}
	res.source["email"] = credSrc
	res.source["password"] = credSrc
	return res
}

// ankerLockedFor reports whether a field whose value comes from source is locked
// for a caller whose editable layer is editable (i.e. the value is set above them).
func ankerLockedFor(source, editable string) bool {
	if editable == "none" {
		return true // superadmin edits the global layer in the panel, not here
	}
	sr, ok := ankerLayerRank[source]
	if !ok {
		return false // unset — the caller may be the first to set it
	}
	return sr < ankerLayerRank[editable]
}

// ankerMaskPresent returns the secret mask when v is non-empty, else "".
func ankerMaskPresent(v string) string {
	if strings.TrimSpace(v) != "" {
		return ankerSecretMask
	}
	return ""
}

// orgAnker reads an organization's stored Anker Solix settings (config + the org
// gate) and its raw pluginSettings blob via the service account. Best effort:
// zero values on any miss so callers proceed as if the org layer were empty.
func (s *Server) orgAnker(ctx context.Context, orgID string) (ankerStored, json.RawMessage) {
	if orgID == "" || !s.pb.Configured() {
		return ankerStored{}, nil
	}
	data, status, err := s.pb.Raw(ctx, http.MethodGet,
		"/api/collections/"+colOrgs+"/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
	if err != nil || status != http.StatusOK {
		return ankerStored{}, nil
	}
	var rec struct {
		PluginSettings json.RawMessage `json:"pluginSettings"`
	}
	_ = json.Unmarshal(data, &rec)
	var doc ankerSettingsDoc
	if len(rec.PluginSettings) > 0 {
		_ = json.Unmarshal(rec.PluginSettings, &doc)
	}
	return doc.AnkerSolix, rec.PluginSettings
}

// mergeAnker applies a mutation to the ankerSolix entry of a pluginSettings blob,
// preserving any other plugin keys, and returns the new blob.
func mergeAnker(existing json.RawMessage, apply func(*ankerStored)) json.RawMessage {
	doc := map[string]json.RawMessage{}
	if len(existing) > 0 {
		_ = json.Unmarshal(existing, &doc)
	}
	if doc == nil {
		doc = map[string]json.RawMessage{} // existing was JSON null
	}
	var as ankerStored
	if raw, ok := doc["ankerSolix"]; ok {
		_ = json.Unmarshal(raw, &as)
	}
	apply(&as)
	b, _ := json.Marshal(as)
	doc["ankerSolix"] = b
	out, _ := json.Marshal(doc)
	return out
}

// ankerScopeView builds the masked field set for one editable scope. editable is
// the layer the caller edits in this scope ("user" | "org" | "none"); a field is
// locked when its effective value is set above that layer.
func (s *Server) ankerScopeView(res ankerResolution, editable string) map[string]any {
	own := res.userOwn
	if editable == "org" {
		own = res.orgOwn
	}
	field := func(key, eff, ownv string, secret bool) ankerFieldView {
		src := res.source[key]
		locked := ankerLockedFor(src, editable)
		fv := ankerFieldView{Source: src, Locked: locked}
		switch {
		case secret:
			// Never expose a secret; show only presence.
			fv.Effective, fv.Own = ankerMaskPresent(eff), ankerMaskPresent(ownv)
		case key == "email" && locked:
			// Inherited email — hide the concrete value from a lower layer.
			fv.Effective, fv.Own = ankerMaskPresent(eff), ankerMaskPresent(ownv)
		default:
			fv.Effective, fv.Own = eff, ownv
		}
		return fv
	}
	return map[string]any{
		"editableLayer": editable,
		"fields": map[string]ankerFieldView{
			"email":       field("email", res.eff.Email, own.Email, false),
			"password":    field("password", res.eff.Password, own.Password, true),
			"country":     field("country", res.eff.Country, own.Country, false),
			"controlMode": field("controlMode", res.eff.ControlMode, own.ControlMode, false),
		},
	}
}

// ankerView builds the masked, client-safe response body from a resolution.
func (s *Server) ankerView(who *callerIdentity, res ankerResolution) map[string]any {
	out := map[string]any{
		"available":    res.available,
		"orgEnabled":   res.orgEnabled,
		"enabled":      res.enabled,
		"controlMode":  res.eff.ControlMode, // effective control mode (off|mqtt|modbus|own|proxy)
		"role":         who.Role,
		"orgId":        who.OrgID,
		"canEditOrg":   res.canOrg,
		"isSuperadmin": res.isSuper,
	}
	if res.isSuper {
		// Superadmin manages the global layer in the panel; here it is read-only.
		out["editableLayer"] = "none"
		out["scopes"] = map[string]any{"user": s.ankerScopeView(res, "none")}
		return out
	}
	scopes := map[string]any{"user": s.ankerScopeView(res, "user")}
	if res.canOrg {
		scopes["org"] = s.ankerScopeView(res, "org")
	}
	out["scopes"] = scopes
	return out
}

// ankerGate returns the reason the integration cannot run for this caller, or ""
// when it can. requireOptIn additionally demands the personal enable flag, which
// a live probe deliberately does not: the probe is how you check credentials
// before switching the integration on.
func ankerGate(res ankerResolution, requireOptIn bool) string {
	switch {
	case !res.available:
		return "The Anker Solix integration is disabled by the administrator"
	case !res.orgEnabled:
		return "The Anker Solix integration is disabled for your organization"
	case requireOptIn && !res.enabled:
		return "Enable the Anker Solix integration in Settings to load your chargers"
	case strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "":
		return "Enter your Anker account email and password to connect"
	}
	return ""
}

// GET /api/integrations/anker-solix — resolved Anker Solix view for the caller.
func (s *Server) handleGetAnker(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)
	res := s.resolveAnker(r.Context(), who, userRaw)
	writeJSON(w, http.StatusOK, s.ankerView(who, res))
}

// PUT /api/integrations/anker-solix — save the caller's editable layer. Body:
// {enabled?: bool, scope?: "user"|"org", config?: {email, password, country}}.
// Fields locked above the caller are ignored; a password left at the mask is
// preserved.
func (s *Server) handlePutAnker(w http.ResponseWriter, r *http.Request) {
	who := caller(r)
	if who == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	if !s.pb.Configured() {
		writeError(w, http.StatusServiceUnavailable, "integration settings not configured on the server")
		return
	}
	var body struct {
		Enabled *bool             `json:"enabled"`
		Scope   string            `json:"scope"` // "user" (default) | "org" (admins only)
		Config  map[string]string `json:"config"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeError(w, http.StatusBadRequest, "invalid json")
		return
	}

	userRaw := s.userPluginSettings(r.Context(), who.ID)
	res := s.resolveAnker(r.Context(), who, userRaw)

	// Resolve which layer this write targets. Everyone edits their own personal
	// (user) layer by default; an org admin may target the organization layer by
	// asking for scope "org". Superadmins are read-only here (they manage global
	// in the panel) and may only toggle their personal enable flag.
	editable := "user"
	switch {
	case res.isSuper:
		editable = "none"
	case strings.EqualFold(strings.TrimSpace(body.Scope), "org"):
		if !res.canOrg {
			writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings")
			return
		}
		editable = "org"
	}

	// Build the new target-layer config from its current own values, overlaying
	// only fields the caller is allowed to change in this scope.
	newOwn := res.userOwn
	if editable == "org" {
		newOwn = res.orgOwn
	}
	applyField := func(key string, set func(*ankerConfig, string)) {
		v, ok := body.Config[key]
		if !ok || ankerLockedFor(res.source[key], editable) {
			return
		}
		if key == "password" && v == ankerSecretMask {
			return // keep current secret
		}
		set(&newOwn, strings.TrimSpace(v))
	}
	applyField("email", func(c *ankerConfig, v string) { c.Email = v })
	applyField("password", func(c *ankerConfig, v string) { c.Password = v })
	applyField("country", func(c *ankerConfig, v string) { c.Country = strings.ToUpper(v) })
	applyField("controlMode", func(c *ankerConfig, v string) { c.ControlMode = normalizeControlMode(v) })

	// Persist the organization layer (admins) via the service account.
	if editable == "org" {
		_, orgRaw := s.orgAnker(r.Context(), who.OrgID)
		newDoc := mergeAnker(orgRaw, func(as *ankerStored) {
			as.Config = newOwn
			// In the org scope the enable flag is the org master switch, stored
			// inverted (disabled) so absent means enabled.
			if body.Enabled != nil {
				as.Disabled = !*body.Enabled
			}
		})
		_, st, err := s.pb.Raw(r.Context(), http.MethodPatch,
			"/api/collections/"+colOrgs+"/records/"+url.PathEscape(who.OrgID),
			map[string]json.RawMessage{"pluginSettings": newDoc})
		if err != nil {
			writeUpstreamDown(w, err)
			return
		}
		if st != http.StatusOK {
			writeError(w, http.StatusBadGateway, "could not save organization settings")
			return
		}
	}

	// Persist the user record: the personal enable flag lives here (user/
	// superadmin scope — in the org scope it targets the org gate instead), and
	// so does the personal config layer when this write targets the user scope.
	personalEnable := body.Enabled != nil && editable != "org"
	if personalEnable || editable == "user" {
		newDoc := mergeAnker(userRaw, func(as *ankerStored) {
			if personalEnable {
				as.Enabled = *body.Enabled
			}
			if editable == "user" {
				as.Config = newOwn
			}
		})
		if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID,
			map[string]any{"pluginSettings": newDoc}, nil); err != nil {
			writePBError(w, err)
			return
		}
	}

	// Re-resolve and return the fresh view.
	fresh := s.userPluginSettings(r.Context(), who.ID)
	res2 := s.resolveAnker(r.Context(), who, fresh)
	writeJSON(w, http.StatusOK, s.ankerView(who, res2))
}

// POST /api/integrations/anker-solix/health — live probe using the caller's
// resolved config. Never returns secrets.
func (s *Server) handleAnkerHealth(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)
	res := s.resolveAnker(r.Context(), who, userRaw)

	if reason := ankerGate(res, false); reason != "" {
		writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{"status": "down", "detail": reason}})
		return
	}

	cfg := map[string]string{
		"email":    res.eff.Email,
		"password": res.eff.Password,
		"country":  res.eff.Country,
	}
	h, err := s.plugins.HealthCheckWith(r.Context(), ankerPlugin, cfg)
	if err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"health": h})
}

// GET /api/integrations/anker-solix/chargers — the caller's Anker EV chargers,
// fetched server-side under their resolved credentials. Gated by the same
// switches as the settings view (global master, org gate, personal opt-in,
// credentials present); when any gate is off it returns 200 with an empty list
// plus a reason, so the UI can degrade quietly rather than error.
func (s *Server) handleAnkerChargers(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)
	res := s.resolveAnker(r.Context(), who, userRaw)

	if reason := ankerGate(res, true); reason != "" {
		writeJSON(w, http.StatusOK, map[string]any{"chargers": []any{}, "unavailable": true, "detail": reason})
		return
	}

	cfg := map[string]string{
		"email":    res.eff.Email,
		"password": res.eff.Password,
		"country":  res.eff.Country,
	}
	raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, "chargers", nil)
	if err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
		return
	}
	// The plugin already answers {chargers, count, boundCount?, warnings?}; relay
	// that document as-is so the UI sees exactly what the capability produced.
	writeJSON(w, http.StatusOK, json.RawMessage(raw))
}

// GET /api/integrations/anker-solix/chargers/{sn}/details — every view the
// account holds about one charger: the station record, the totals, the history,
// the sessions, the OCPP backend, the cards, the sharing, the binding, the
// firmware and the rest, plus the site's own views when the charger belongs to
// one. Gated exactly like the charger list, and answered
// the same way when a gate is off: 200 with nothing and the reason, because a
// disconnected integration is a normal state with an answer.
func (s *Server) handleAnkerChargerDetails(w http.ResponseWriter, r *http.Request) {
	who := caller(r)
	if who == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	sn := strings.TrimSpace(r.PathValue("sn"))
	if sn == "" {
		writeError(w, http.StatusBadRequest, "charger serial required")
		return
	}
	userRaw := s.userPluginSettings(r.Context(), who.ID)
	res := s.resolveAnker(r.Context(), who, userRaw)

	if reason := ankerGate(res, true); reason != "" {
		writeJSON(w, http.StatusOK, map[string]any{"sn": sn, "views": []any{}, "unavailable": true, "detail": reason})
		return
	}

	cfg := map[string]string{
		"email":    res.eff.Email,
		"password": res.eff.Password,
		"country":  res.eff.Country,
	}
	raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, "charger-details",
		mustJSON(map[string]any{"sn": sn}))
	if err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, json.RawMessage(raw))
}

// The two card writes. They are the only calls in the Anker connector that
// change anything on the account, so both are gated exactly like the reads,
// rate-limited beside the control commands — a card is who may start a charge,
// which is the same actuator asked a slower question — and audited by serial and
// card, with the number kept out of the log line: it is the credential itself.
// Both answer with the card list as it stands after the write, so the caller
// sees what the account holds rather than what an undocumented endpoint claimed.

// ankerCardBody is what a card write is asked for.
type ankerCardBody struct {
	CardNumber string `json:"cardNumber"`
	CardName   string `json:"cardName"`
}

// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards — add a card to
// the charger, or rename one already on it.
func (s *Server) handleAnkerCardSave(w http.ResponseWriter, r *http.Request) {
	who, sn, cfg, ok := s.ankerCardGate(w, r)
	if !ok {
		return
	}
	var body ankerCardBody
	if r.Body != nil {
		_ = json.NewDecoder(r.Body).Decode(&body)
	}
	if strings.TrimSpace(body.CardNumber) == "" {
		writeError(w, http.StatusBadRequest, "card number required")
		return
	}
	s.ankerCardWrite(w, r, who, cfg, "rfid-card-save", sn, map[string]any{
		"sn": sn, "cardNumber": body.CardNumber, "cardName": body.CardName,
	})
}

// DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number} —
// remove one card, named in full.
func (s *Server) handleAnkerCardDelete(w http.ResponseWriter, r *http.Request) {
	who, sn, cfg, ok := s.ankerCardGate(w, r)
	if !ok {
		return
	}
	number := strings.TrimSpace(r.PathValue("number"))
	if number == "" {
		writeError(w, http.StatusBadRequest, "card number required")
		return
	}
	s.ankerCardWrite(w, r, who, cfg, "rfid-card-delete", sn, map[string]any{
		"sn": sn, "cardNumber": number,
	})
}

// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan — open the
// charger's card reader and answer with the card someone taps on it. Takes as
// long as the window does, about twenty seconds, and answers either way: a
// window that closed with nothing tapped is an answer, not a timeout.
func (s *Server) handleAnkerCardScan(w http.ResponseWriter, r *http.Request) {
	who, sn, cfg, ok := s.ankerCardGate(w, r)
	if !ok {
		return
	}
	s.ankerCardWrite(w, r, who, cfg, "rfid-card-scan", sn, map[string]any{"sn": sn})
}

// GET /api/integrations/anker-solix/chargers/{sn}/rfid-cards/charger — the list
// of cards the charger itself holds, asked of the device with 0104 rather than
// of the account.
//
// The two lists are written together and can still come apart: a card the
// account has forgotten still opens the charger until the device is told
// otherwise, and the account's copy is the only one every other view here
// draws. Asking the device is the only way to see the difference. It answers
// with UIDs and nothing else — the charger has no field for a card's name.
func (s *Server) handleAnkerChargerCards(w http.ResponseWriter, r *http.Request) {
	who, sn, cfg, ok := s.ankerCardGate(w, r)
	if !ok {
		return
	}
	s.ankerCardWrite(w, r, who, cfg, "rfid-cards-charger", sn, map[string]any{"sn": sn})
}

// ankerCardGate is everything a card call needs before it may run: a caller, a
// serial, an integration that is on and has credentials, and a rate limit. A
// gate that is off answers 409 rather than the reads' 200-with-a-reason: a write
// that did not happen is not a state to render, it is a request that failed.
func (s *Server) ankerCardGate(w http.ResponseWriter, r *http.Request) (*callerIdentity, string, map[string]string, bool) {
	who := caller(r)
	if who == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return nil, "", nil, false
	}
	sn := strings.TrimSpace(r.PathValue("sn"))
	if sn == "" {
		writeError(w, http.StatusBadRequest, "charger serial required")
		return nil, "", nil, false
	}
	userRaw := s.userPluginSettings(r.Context(), who.ID)
	res := s.resolveAnker(r.Context(), who, userRaw)
	if reason := ankerGate(res, true); reason != "" {
		writeError(w, http.StatusConflict, reason)
		return nil, "", nil, false
	}
	if !s.ctlRL.allow(who.ID + "|" + sn) {
		writeError(w, http.StatusTooManyRequests, "too many card requests; please slow down")
		return nil, "", nil, false
	}
	return who, sn, map[string]string{
		"email":    res.eff.Email,
		"password": res.eff.Password,
		"country":  res.eff.Country,
	}, true
}

// ankerCardWrite runs one card capability and relays its document. The audit
// line names the charger and how the write went, never the card number.
func (s *Server) ankerCardWrite(w http.ResponseWriter, r *http.Request, who *callerIdentity,
	cfg map[string]string, action, sn string, params map[string]any) {
	raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, action, mustJSON(params))
	if err != nil {
		s.auditControl(who, sn, action, nil, "failed", err)
		writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
		return
	}
	s.auditControl(who, sn, action, nil, "ok", nil)
	writeJSON(w, http.StatusOK, json.RawMessage(raw))
}
