package ankersolix

// Control over Anker's own cloud MQTT broker — the path for a charger the server
// cannot reach.
//
// The other two transports each need something a remote customer does not have.
// OCPP needs the charger to dial in to us, which means a public endpoint, a
// certificate, and a charger whose firmware accepts our CSMS. Modbus TCP needs
// the server to dial the charger, which means sharing a network with it. Most
// chargers sit behind a customer's router with neither. What they *do* have is
// the connection they already hold open to Anker: the mobile app controls them
// through it from anywhere, and it is the same broker the charger's own
// mqttStatus register reports as connected.
//
// So this transport joins that broker as the account, exactly as the app does:
//
//  1. app/devicemanage/get_user_mqtt_info hands out a client certificate and key
//     for the signed-in account, the broker's address, and the AWS root the
//     broker is verified against. The certificate is the credential; there is no
//     username or password on the MQTT connection itself.
//  2. Commands are published to cmd/{app}/{model}/{serial}/req and the charger's
//     own messages arrive on dt/{app}/{model}/{serial}/#.
//  3. Both directions carry a JSON envelope whose payload holds a base64 binary
//     frame — the device's own protocol, encoded in mqttframe.go.
//
// Two consequences shape the code:
//
//   - A connection is expensive (a TLS handshake with a fetched certificate) and
//     the plugin manager builds a throwaway instance per request, so the broker
//     connection lives in the account's shared session alongside the auth token,
//     for the same reason (see session.go). It closes itself after an idle spell.
//   - The charger does not publish its live state unless asked. A realtime
//     trigger turns the stream on for a bounded window, after which it stops
//     again — so a status read arms the trigger and waits for the next frame,
//     and a second read inside the window answers from what has since arrived.
//
// Unofficial, like the rest of the cloud half: this is the mobile app's private
// transport, and Anker may change it at any time.

import (
	"context"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"encoding/base64"
	"encoding/binary"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"math/big"
	"net"
	"os"
	"strings"
	"sync"
	"time"

	"drivervault/apiserver/internal/mqtt"
)

// epMqttInfo hands out the account's broker address and client certificate.
const epMqttInfo = "app/devicemanage/get_user_mqtt_info"

// defaultChargerModel is the product code used when the account inventory has
// not named one. The topics carry the model, and this connector is scoped to the
// V1 Smart EV Charger, so it is the only sensible fallback.
const defaultChargerModel = "A5191"

// Broker timings.
const (
	// mqttCredsTTL re-fetches the certificate periodically. It is issued per
	// account and long-lived, but refetching costs one cloud call an hour and
	// means a revoked certificate is not held forever.
	mqttCredsTTL = 1 * time.Hour

	// mqttIdle closes a broker connection nothing has used for this long. The
	// charger keeps publishing only while a trigger is live, so an idle
	// connection is genuinely idle.
	mqttIdle = 5 * time.Minute

	// mqttConnectTimeout bounds the TLS handshake and the wait for CONNACK.
	mqttConnectTimeout = 15 * time.Second

	// triggerWindow is how long the charger is asked to keep streaming telemetry,
	// and triggerRenew is how close to the end of that window a status read
	// re-arms it rather than racing the last frame.
	triggerWindow = 180 * time.Second
	triggerRenew  = 30 * time.Second

	// statusWait is how long a status read waits for a frame from the charger. A
	// triggered charger publishes every 3-5 seconds; this allows for the trigger
	// having to reach it first.
	statusWait = 12 * time.Second

	// settingsMaxAge is how old the settings half may be before a status read
	// asks for it again; settingsWait is how long that read then waits for the
	// answer, and statusReqTries how many unanswered requests it takes before a
	// charger is treated as one whose firmware ignores the message — after which
	// the request still goes out, but no read pays the wait for it.
	settingsMaxAge = 10 * time.Minute
	settingsWait   = 4 * time.Second
	statusReqTries = 3

	// commandWait is how long a command waits for the charger's confirmation
	// message. A command is fire-and-forget on the wire, so this only decides
	// whether we can say the charger answered — not whether it was sent.
	commandWait = 5 * time.Second

	// deviceCacheTTL is how long the account's charger list (serial to model) is
	// trusted before the cloud is asked again.
	deviceCacheTTL = 1 * time.Hour
)

// The mode values the charger's 0105 message accepts, and the names this
// connector takes for them — the same names the cloud plugin already uses for
// SolixEvChargerMode.
var mqttModeValues = map[string]uint8{
	modeStartCharge: 1,
	modeStopCharge:  2,
	modeSkipDelay:   3,
	modeBoostCharge: 4,
}

// mqttEncodingMode is the payload's encoding_type for the mode command. It is
// not encryption — the frame is plain either way — but the charger expects the
// field on this message, so it is sent with a seed like the app's.
const mqttEncodingMode = 2

// mqttCredentials is what get_user_mqtt_info returns: an address to dial and a
// certificate to dial it with.
type mqttCredentials struct {
	UserID        string `json:"user_id"`
	AppName       string `json:"app_name"`
	ThingName     string `json:"thing_name"`
	CertificateID string `json:"certificate_id"`
	CertificatePE string `json:"certificate_pem"`
	PrivateKey    string `json:"private_key"`
	EndpointAddr  string `json:"endpoint_addr"`
	RootCA        string `json:"aws_root_ca1_pem"`
}

// valid reports whether the credentials carry everything a connection needs.
func (c mqttCredentials) valid() bool {
	return strings.TrimSpace(c.EndpointAddr) != "" &&
		strings.TrimSpace(c.CertificatePE) != "" &&
		strings.TrimSpace(c.PrivateKey) != ""
}

// address is the broker's host:port. The endpoint is returned without a port;
// 8883 is the MQTT-over-TLS port the app uses.
func (c mqttCredentials) address() string {
	host := strings.TrimSpace(c.EndpointAddr)
	if _, _, err := net.SplitHostPort(host); err == nil {
		return host
	}
	return net.JoinHostPort(host, "8883")
}

// appName is the topic segment identifying the app the account belongs to.
func (c mqttCredentials) appName() string {
	if n := strings.TrimSpace(c.AppName); n != "" {
		return n
	}
	return "anker_power"
}

// ---- credentials and device lookup -------------------------------------------

// mqttCreds returns the account's broker credentials, fetching them at most once
// per mqttCredsTTL. They are held on the shared session rather than the plugin
// instance for the same reason the auth token is: the instance does not outlive
// the request.
func (p *Plugin) mqttCreds(ctx context.Context) (mqttCredentials, error) {
	s := p.sess
	if s == nil {
		return mqttCredentials{}, errors.New("anker-solix: plugin not initialised")
	}
	s.mqttMu.Lock()
	defer s.mqttMu.Unlock()

	if s.mqttCreds != nil && time.Since(s.mqttCredsAt) < mqttCredsTTL {
		return *s.mqttCreds, nil
	}
	body, err := p.apiRequest(ctx, epMqttInfo, map[string]any{})
	if err != nil {
		return mqttCredentials{}, err
	}
	var env struct {
		Data mqttCredentials `json:"data"`
	}
	if err := json.Unmarshal(body, &env); err != nil {
		return mqttCredentials{}, fmt.Errorf("anker-solix: decode MQTT info: %w", err)
	}
	if !env.Data.valid() {
		return mqttCredentials{}, errors.New("anker-solix: the cloud returned no MQTT certificate for this account; cloud control is not available on it")
	}
	s.mqttCreds, s.mqttCredsAt = &env.Data, time.Now()
	return env.Data, nil
}

// chargerModel returns the product code for a serial on this account, which the
// topics need. It doubles as the ownership check: a serial no view of the
// account reports is one this caller may not command, and saying so is better
// than publishing to a topic the broker will refuse anyway.
func (p *Plugin) chargerModel(ctx context.Context, sn string) (string, error) {
	s := p.sess
	if s == nil {
		return "", errors.New("anker-solix: plugin not initialised")
	}
	sn = strings.TrimSpace(sn)
	if sn == "" {
		return "", errors.New("anker-solix: a charger serial is required")
	}

	s.mqttMu.Lock()
	model, known := s.devices[sn]
	fresh := time.Since(s.devicesAt) < deviceCacheTTL
	s.mqttMu.Unlock()
	if known && fresh {
		return model, nil
	}

	// Unknown, or the list has gone stale: ask the cloud once and remember it.
	doc, err := p.chargerInventory(ctx)
	if err != nil {
		if known {
			return model, nil // the cloud is unreachable; the last list still stands
		}
		return "", err
	}
	found := map[string]string{}
	for _, c := range doc.Chargers {
		m := strings.ToUpper(strings.TrimSpace(c.Model))
		if m == "" {
			m = defaultChargerModel
		}
		found[c.SN] = m
	}
	s.mqttMu.Lock()
	s.devices, s.devicesAt = found, time.Now()
	s.mqttMu.Unlock()

	if m, ok := found[sn]; ok {
		return m, nil
	}
	return "", fmt.Errorf("anker-solix: charger %s is not on this Anker account", sn)
}

// ---- the broker connection ---------------------------------------------------

// mqttConn is one account's live broker connection, with the state it has
// collected from the chargers it is subscribed to.
type mqttConn struct {
	client  *mqtt.Client
	creds   mqttCredentials
	sessID  string
	started time.Time

	mu       sync.Mutex
	subs     map[string]bool         // topic filter -> subscribed
	devices  map[string]*deviceState // serial -> what it has told us
	lastUse  time.Time
	deadErr  error
	waiters  []chan struct{}
	shutdown chan struct{}
}

// deviceState is everything one charger has reported over this connection,
// merged across message types: telemetry overwrites telemetry, settings
// overwrite settings, and neither erases the other.
type deviceState struct {
	values         map[string]any
	telemetryAt    time.Time
	settingsAt     time.Time
	triggeredUntil time.Time

	// The card half, kept beside the readings rather than in them: when the
	// reader last reported a card and which, and when the charger last published
	// its card list and what was in it. Each carries its own time because both
	// are waited on — a value that was already there before the question was
	// asked is not an answer to it.
	cardReadAt time.Time
	cardRead   string
	cardsAt    time.Time
	cards      []string

	// How many status requests this charger has been asked and not answered.
	// The request is cheap to send and is sent regardless; what it buys is the
	// right to wait a few seconds for the reply, and a charger whose firmware
	// ignores the message should not cost every later read that wait.
	statusReqMisses int
}

// mqttClient returns the account's broker connection, opening one if there is
// none or the last one died.
func (p *Plugin) mqttClient(ctx context.Context) (*mqttConn, error) {
	s := p.sess
	if s == nil {
		return nil, errors.New("anker-solix: plugin not initialised")
	}
	creds, err := p.mqttCreds(ctx)
	if err != nil {
		return nil, err
	}

	s.mqttMu.Lock()
	defer s.mqttMu.Unlock()
	if c := s.mqttConn; c != nil {
		if c.alive() && c.creds.CertificateID == creds.CertificateID {
			c.touch()
			return c, nil
		}
		c.close()
		s.mqttConn = nil
	}
	c, err := dialBroker(ctx, creds)
	if err != nil {
		return nil, err
	}
	s.mqttConn = c
	return c, nil
}

// dialBroker opens the mutually-authenticated connection. The account's
// certificate is the credential, and the broker is verified against the AWS root
// the same response supplied — the connection is to Anker's own broker, so
// neither side is trusted on the strength of the other.
func dialBroker(ctx context.Context, creds mqttCredentials) (*mqttConn, error) {
	cert, err := tls.X509KeyPair([]byte(creds.CertificatePE), []byte(creds.PrivateKey))
	if err != nil {
		return nil, fmt.Errorf("anker-solix: the cloud's MQTT certificate could not be loaded: %w", err)
	}
	roots := x509.NewCertPool()
	if ca := strings.TrimSpace(creds.RootCA); ca != "" {
		if !roots.AppendCertsFromPEM([]byte(ca)) {
			return nil, errors.New("anker-solix: the cloud's MQTT root certificate could not be parsed")
		}
	} else {
		// No root supplied: fall back to the system pool rather than skipping
		// verification, which would let anything answer for the broker.
		if roots, err = x509.SystemCertPool(); err != nil {
			return nil, fmt.Errorf("anker-solix: no root certificates to verify the MQTT broker: %w", err)
		}
	}
	host, _, splitErr := net.SplitHostPort(creds.address())
	if splitErr != nil {
		host = creds.EndpointAddr
	}

	client, err := mqtt.Connect(ctx, mqtt.Options{
		Address: creds.address(),
		TLS:     true,
		TLSConfig: &tls.Config{
			ServerName:   host,
			MinVersion:   tls.VersionTLS12,
			RootCAs:      roots,
			Certificates: []tls.Certificate{cert},
		},
		// The broker keys a session by client id and evicts the older holder, so
		// the app's own connection must not be displaced: the app uses
		// "{thing_name}_{5 digits}", and a different suffix is a different session.
		ClientID:       clientIDFor(creds),
		Keepalive:      60 * time.Second,
		ConnectTimeout: mqttConnectTimeout,
		Buffer:         256,
	})
	if err != nil {
		return nil, err
	}

	c := &mqttConn{
		client:   client,
		creds:    creds,
		sessID:   randomSessionID(),
		started:  time.Now(),
		subs:     map[string]bool{},
		devices:  map[string]*deviceState{},
		lastUse:  time.Now(),
		shutdown: make(chan struct{}),
	}
	go c.readLoop()
	go c.idleLoop()
	return c, nil
}

// clientIDFor builds an identifier no other holder of this account's certificate
// is using, so joining the broker never evicts the owner's mobile app.
func clientIDFor(creds mqttCredentials) string {
	thing := strings.TrimSpace(creds.ThingName)
	if thing == "" {
		thing = strings.TrimSpace(creds.UserID)
	}
	return fmt.Sprintf("%s_%05d", thing, randomBelow(100000))
}

// randomSessionID mimics the app's sess_id, a pair of four-digit groups.
func randomSessionID() string {
	return fmt.Sprintf("%04d-%04d", randomBelow(10000), randomBelow(10000))
}

// randomBelow returns a non-negative integer below n, falling back to a
// clock-derived value if the system source fails.
func randomBelow(n int64) int64 {
	v, err := rand.Int(rand.Reader, big.NewInt(n))
	if err != nil {
		return time.Now().UnixNano() % n
	}
	return v.Int64()
}

// alive reports whether the connection is still usable.
func (c *mqttConn) alive() bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.deadErr == nil
}

// touch marks the connection as in use, so the idle sweep leaves it alone.
func (c *mqttConn) touch() {
	c.mu.Lock()
	c.lastUse = time.Now()
	c.mu.Unlock()
}

// close ends the connection and wakes anything waiting on a message.
func (c *mqttConn) close() {
	c.fail(mqtt.ErrClosed)
	_ = c.client.Close()
}

// fail records why the connection ended and releases every waiter.
func (c *mqttConn) fail(err error) {
	c.mu.Lock()
	if c.deadErr == nil {
		c.deadErr = err
		close(c.shutdown)
	}
	c.wakeLocked()
	c.mu.Unlock()
}

// wakeLocked releases everything waiting for a device message. Caller holds mu.
func (c *mqttConn) wakeLocked() {
	for _, ch := range c.waiters {
		close(ch)
	}
	c.waiters = nil
}

// readLoop owns the inbound stream for the life of the connection.
func (c *mqttConn) readLoop() {
	for msg := range c.client.Messages() {
		c.ingest(msg)
	}
	err := c.client.Err()
	if err == nil {
		err = mqtt.ErrClosed
	}
	c.fail(err)
}

// idleLoop closes a connection nothing has used for mqttIdle. The account keeps
// no state on the broker between commands, so dropping the socket costs only the
// next handshake.
func (c *mqttConn) idleLoop() {
	t := time.NewTicker(mqttIdle / 2)
	defer t.Stop()
	for {
		select {
		case <-c.shutdown:
			return
		case <-t.C:
			c.mu.Lock()
			idle := time.Since(c.lastUse)
			c.mu.Unlock()
			if idle >= mqttIdle {
				c.close()
				return
			}
		}
	}
}

// mqttFrameLog writes a line for every inbound frame — the ones this package can
// read and, the whole point, the ones it cannot. Off unless ANKER_MQTT_FRAME_LOG
// is set on the server: it exists to put a name to a frame nobody has named yet
// (a card held against the reader, say, or one of the two types the map lists as
// unnamed), not to write a line per telemetry message on a running server.
var mqttFrameLog = os.Getenv("ANKER_MQTT_FRAME_LOG") != ""

// frameLogLine is that line. The bytes are the part that matters — a frame this
// package drops is unreadable here but perfectly readable afterwards — so they
// are always in it, capped so one long frame cannot fill a log.
func frameLogLine(sn, topic string, data []byte, msgType string, values map[string]any, err error) string {
	hexed := hex.EncodeToString(data)
	if len(hexed) > 1024 {
		hexed = hexed[:1024] + "..."
	}
	if err != nil {
		return fmt.Sprintf("ANKER-MQTT FRAME sn=%s topic=%s len=%d unreadable=%q hex=%s",
			sn, topic, len(data), err.Error(), hexed)
	}
	return fmt.Sprintf("ANKER-MQTT FRAME sn=%s topic=%s len=%d type=%s fields=%d hex=%s values=%v",
		sn, topic, len(data), msgType, len(values), hexed, values)
}

// ingest decodes one inbound message and folds it into the sending charger's
// state. Anything it cannot read is dropped: these frames come from a cloud
// connection, and a malformed one must not be recorded as a reading.
func (c *mqttConn) ingest(msg mqtt.Message) {
	sn, data, ok := parseEnvelope(msg)
	if !ok {
		if mqttFrameLog {
			log.Printf("ANKER-MQTT ENVELOPE topic=%s unreadable payload=%s", msg.Topic, msg.Payload)
		}
		return
	}
	msgType, values, err := decodeFrame(data)
	// Logged before the drop below, because the frames worth naming are exactly
	// the ones this package throws away.
	if mqttFrameLog {
		log.Print(frameLogLine(sn, msg.Topic, data, msgType, values, err))
	}
	if err != nil || len(values) == 0 {
		return
	}

	c.mu.Lock()
	defer c.mu.Unlock()
	st := c.devices[sn]
	if st == nil {
		st = &deviceState{values: map[string]any{}}
		c.devices[sn] = st
	}
	for k, v := range values {
		st.values[k] = v
	}
	now := time.Now()
	// The card frames are stamped separately: a tap and a card list are answers
	// to questions this package asks one at a time, and each waits for its own.
	if s, ok := values["rfidCardRead"].(string); ok && s != "" {
		st.cardRead, st.cardReadAt = s, now
	} else if msgType == msgEVCardRead {
		// The window closed with nothing tapped. That is an answer too, and the
		// wait must end on it rather than run to its own timeout.
		st.cardRead, st.cardReadAt = "", now
	}
	if cards, ok := values["rfidCards"].([]string); ok {
		st.cards, st.cardsAt = cards, now
	}
	// Only a message this package can name counts as a report. An unnamed one
	// still leaves its fields behind, but it must not stamp settingsAt: that
	// timestamp is what a control command waits on to say the charger
	// acknowledged, and a frame we cannot read is not an acknowledgement.
	switch {
	case msgType == msgEVTelemetry:
		st.telemetryAt = now
	case evMessages[msgType] != nil:
		st.settingsAt = now
	}
	c.wakeLocked()
}

// parseEnvelope pulls the sending serial and the binary frame out of one MQTT
// message. The payload is a JSON string inside a JSON object, and the frame is
// base64 inside that — the shape the app both sends and receives.
func parseEnvelope(msg mqtt.Message) (string, []byte, bool) {
	var env struct {
		Head struct {
			DeviceSN string `json:"device_sn"`
		} `json:"head"`
		Payload string `json:"payload"`
	}
	if err := json.Unmarshal(msg.Payload, &env); err != nil {
		return "", nil, false
	}
	var inner struct {
		SN   string `json:"sn"`
		SN2  string `json:"device_sn"`
		Data string `json:"data"`
	}
	if err := json.Unmarshal([]byte(env.Payload), &inner); err != nil {
		return "", nil, false
	}
	sn := firstNonEmpty(inner.SN, inner.SN2, env.Head.DeviceSN, serialFromTopic(msg.Topic))
	if sn == "" || inner.Data == "" {
		return "", nil, false
	}
	data, err := base64.StdEncoding.DecodeString(inner.Data)
	if err != nil {
		return "", nil, false
	}
	return sn, data, true
}

// serialFromTopic reads the serial out of dt/{app}/{model}/{serial}/… , which is
// where it is when the payload does not repeat it.
func serialFromTopic(topic string) string {
	parts := strings.Split(topic, "/")
	if len(parts) < 4 {
		return ""
	}
	return parts[3]
}

func firstNonEmpty(vals ...string) string {
	for _, v := range vals {
		if s := strings.TrimSpace(v); s != "" {
			return s
		}
	}
	return ""
}

// ---- topics, subscribing and publishing --------------------------------------

// dataTopic is the filter carrying everything one charger publishes.
func dataTopic(creds mqttCredentials, model, sn string) string {
	return fmt.Sprintf("dt/%s/%s/%s/#", creds.appName(), model, sn)
}

// commandTopic is where one charger's commands are published.
func commandTopic(creds mqttCredentials, model, sn string) string {
	return fmt.Sprintf("cmd/%s/%s/%s/req", creds.appName(), model, sn)
}

// listen subscribes to a charger's data topic, once per connection.
func (c *mqttConn) listen(ctx context.Context, model, sn string) error {
	topic := dataTopic(c.creds, model, sn)
	c.mu.Lock()
	already := c.subs[topic]
	c.mu.Unlock()
	if already {
		return nil
	}
	if err := c.client.Subscribe(ctx, topic); err != nil {
		return fmt.Errorf("anker-solix: cannot listen to charger %s over the cloud: %w", sn, err)
	}
	c.mu.Lock()
	c.subs[topic] = true
	c.mu.Unlock()
	// With the frame log on, listen to the charger's command topic as well. The
	// data topic carries only what the charger says; the commands the Anker app
	// sends it — the half no capture here has ever seen — go to this one, and a
	// command nobody has a name for cannot be named without reading it first.
	// Off by default: it is a diagnostic subscription, not part of control.
	if mqttFrameLog {
		cmd := commandTopic(c.creds, model, sn)
		c.mu.Lock()
		seen := c.subs[cmd]
		c.mu.Unlock()
		if !seen {
			if err := c.client.Subscribe(ctx, cmd); err == nil {
				c.mu.Lock()
				c.subs[cmd] = true
				c.mu.Unlock()
				log.Printf("ANKER-MQTT listening to the command topic %s (frame log)", cmd)
			} else {
				log.Printf("ANKER-MQTT cannot listen to the command topic %s: %v", cmd, err)
			}
		}
	}
	return nil
}

// publishFrame wraps a device frame in the app's envelope and publishes it to
// the charger's command topic.
func (c *mqttConn) publishFrame(ctx context.Context, model, sn string, frame []byte, encoding int) error {
	now := time.Now()
	seed := any(1)
	inner := map[string]any{
		"device_sn":  sn,
		"account_id": c.creds.UserID,
		"data":       base64.StdEncoding.EncodeToString(frame),
	}
	if encoding != 0 {
		inner["encoding_type"] = encoding
		seed = randomSeed()
	}
	payload, err := json.Marshal(inner)
	if err != nil {
		return err
	}
	envelope, err := json.Marshal(map[string]any{
		"head": map[string]any{
			"version":   "1.0.0.1",
			"client_id": fmt.Sprintf("android-%s-%s-%s", c.creds.appName(), c.creds.UserID, c.creds.CertificateID),
			"sess_id":   c.sessID,
			"msg_seq":   1,
			"seed":      seed,
			"timestamp": now.Unix(),
			// cmd_status 2 and cmd 17 are what the app sends on a control message;
			// the charger ignores neither, and a different pair goes unanswered.
			"cmd_status": 2,
			"cmd":        17,
			"sign_code":  1,
			"device_pn":  model,
			"device_sn":  sn,
		},
		"payload": string(payload),
	})
	if err != nil {
		return err
	}
	c.touch()
	return c.client.Publish(ctx, commandTopic(c.creds, model, sn), envelope)
}

// randomSeed is the 16-byte seed the app puts in the header of an encoded
// message.
func randomSeed() string {
	var b [16]byte
	if _, err := rand.Read(b[:]); err != nil {
		binary.LittleEndian.PutUint64(b[:8], uint64(time.Now().UnixNano()))
	}
	return encodeHex(b[:])
}

// ---- waiting for the charger to answer ----------------------------------------

// waitFor blocks until a charger's state satisfies ready, or the deadline
// passes. It reports whether ready was met; a connection that dies while waiting
// ends the wait with the reason.
func (c *mqttConn) waitFor(ctx context.Context, sn string, ready func(*deviceState) bool, timeout time.Duration) (bool, error) {
	deadline := time.After(timeout)
	for {
		c.mu.Lock()
		if err := c.deadErr; err != nil {
			c.mu.Unlock()
			return false, fmt.Errorf("anker-solix: the cloud connection dropped: %w", err)
		}
		if st := c.devices[sn]; st != nil && ready(st) {
			c.mu.Unlock()
			return true, nil
		}
		ch := make(chan struct{})
		c.waiters = append(c.waiters, ch)
		c.mu.Unlock()

		select {
		case <-ch:
		case <-deadline:
			return false, nil
		case <-ctx.Done():
			return false, ctx.Err()
		}
	}
}

// snapshotOf copies a charger's collected state out from under the lock.
func (c *mqttConn) snapshotOf(sn string) (map[string]any, time.Time, time.Time, time.Time) {
	c.mu.Lock()
	defer c.mu.Unlock()
	st := c.devices[sn]
	if st == nil {
		return nil, time.Time{}, time.Time{}, time.Time{}
	}
	out := make(map[string]any, len(st.values))
	for k, v := range st.values {
		out[k] = v
	}
	return out, st.telemetryAt, st.settingsAt, st.triggeredUntil
}

// noteStatusMiss records that a status request went unanswered, and reports how
// many have now in a row.
func (c *mqttConn) noteStatusMiss(sn string) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	st := c.devices[sn]
	if st == nil {
		return 0
	}
	st.statusReqMisses++
	return st.statusReqMisses
}

// statusReqAnswered reports whether this charger has answered a status request
// recently enough to be worth waiting for again.
func (c *mqttConn) statusReqAnswered(sn string) bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	st := c.devices[sn]
	return st == nil || st.statusReqMisses < statusReqTries
}

// noteTrigger records how long the charger has been asked to keep streaming.
func (c *mqttConn) noteTrigger(sn string, until time.Time) {
	c.mu.Lock()
	defer c.mu.Unlock()
	st := c.devices[sn]
	if st == nil {
		st = &deviceState{values: map[string]any{}}
		c.devices[sn] = st
	}
	st.triggeredUntil = until
}

// ---- commands ----------------------------------------------------------------

// mqttTrigger asks a charger to publish live telemetry for a while. Without it
// the charger is silent, so every status read arms one.
func (p *Plugin) mqttTrigger(ctx context.Context, c *mqttConn, model, sn string, window time.Duration) error {
	frame, err := encodeFrame(msgRealtimeTrigger, []cmdField{
		rawField(0xa1, 0x22),
		uintField(0xa2, 1),
		varField(0xa3, uint32(window/time.Second)),
		timestampField(time.Now()),
	})
	if err != nil {
		return err
	}
	if err := c.publishFrame(ctx, model, sn, frame, 0); err != nil {
		return err
	}
	c.noteTrigger(sn, time.Now().Add(window))
	return nil
}

// mqttStatusRequest asks the charger to publish what it is set to. The trigger
// above buys telemetry and nothing else: the settings half arrives on its own
// message, and otherwise only after a command, which is why a charger that has
// been read but never commanded reports live amps and knows nothing about its
// own schedule, its Modbus server or its firmware. This is the message the app
// uses for that — a status request the charger answers with 0840.
func (p *Plugin) mqttStatusRequest(ctx context.Context, c *mqttConn, model, sn string) error {
	frame, err := encodeFrame(msgEVStatusReq, []cmdField{bareTimestampField(time.Now())})
	if err != nil {
		return err
	}
	return c.publishFrame(ctx, model, sn, frame, 0)
}

// mqttSetMode sends the start / stop / skip-delay / boost command.
func (p *Plugin) mqttSetMode(ctx context.Context, c *mqttConn, model, sn, mode string) error {
	v, ok := mqttModeValues[mode]
	if !ok {
		return fmt.Errorf("anker-solix: %q is not one of %s, %s, %s or %s",
			mode, modeStartCharge, modeStopCharge, modeSkipDelay, modeBoostCharge)
	}
	frame, err := encodeFrame(msgEVMode, []cmdField{
		rawField(0xa1, 0x22),
		uintField(0xa2, v),
		timestampField(time.Now()),
	})
	if err != nil {
		return err
	}
	return c.publishFrame(ctx, model, sn, frame, mqttEncodingMode)
}

// mqttRestart reboots the charger. It is the cloud's answer to the OCPP reset —
// the one thing the phone can do to a charger that no register holds and no CSMS
// reaches when the charger is not on one. The charger goes away and comes back,
// so nothing confirms it: the acknowledgement would have to arrive from a device
// that is rebooting.
func (p *Plugin) mqttRestart(ctx context.Context, c *mqttConn, model, sn string) error {
	frame, err := encodeFrame(msgEVPowerMode, []cmdField{
		rawField(0xa1, 0x22),
		uintField(0xa2, powerModeRestart),
		timestampField(time.Now()),
	})
	if err != nil {
		return err
	}
	// The same encoding_type the mode command carries: the charger expects the
	// field on these two messages and on no others.
	return c.publishFrame(ctx, model, sn, frame, mqttEncodingMode)
}

// mqttSetMaxCurrent sets the charging current ceiling, in amps. The limit is
// checked by the same rule the Modbus path uses, because the rule is the
// charger's: the transport differs, the charger does not.
func (p *Plugin) mqttSetMaxCurrent(ctx context.Context, c *mqttConn, model, sn string, amps float64) error {
	if err := checkMaxCurrent(amps); err != nil {
		return err
	}
	// It is the same wire field the "maxCurrentA" setting writes, so it is built
	// from the same table rather than encoded a second time here (mqttsettings.go).
	frame, err := encodeSettingCmd(settingCmds[settingIndex["maxCurrentA"]],
		map[string]any{"maxCurrentA": amps}, nil)
	if err != nil {
		return err
	}
	return c.publishFrame(ctx, model, sn, frame, 0)
}
