package ankersolix

// What a charger reports over the cloud, and the two capabilities built on it.
//
// The snapshot below deliberately borrows the field names ModbusSnapshot uses
// for the same quantities — status, voltageL1, powerTotal, sessionWh, settings —
// because they are the same charger read two ways, and a view that can render
// one should not need a second layout for the other. Where the transports differ
// the names differ with them: the cloud carries the boost flag and the plug and
// start countdowns, which no register holds, while the register map carries the
// relay temperatures and the reactive and apparent power, which no cloud message
// sends.

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"
	"time"
)

// MqttSnapshot is one charger's state as the cloud reports it. A field the
// charger has not sent stays nil rather than zero, so "not reported" and "zero"
// stay distinguishable.
type MqttSnapshot struct {
	Serial string `json:"serial"`
	Model  string `json:"model,omitempty"`

	// What the charger says it is, on the same names the register map uses for
	// the same three answers. The reference decodes none of them; see the
	// version fields in mqttframe.go for how they are read.
	Firmware          string `json:"firmware,omitempty"`
	ControllerVersion string `json:"controllerVersion,omitempty"`
	Hardware          string `json:"hardware,omitempty"`

	Status     *int   `json:"status,omitempty"`
	StatusDesc string `json:"statusDesc,omitempty"`

	// Mode is the operational mode the charger is effectively in and ModeOptions
	// the ones it can be moved to, derived exactly as the cloud view derives
	// them — except that here the boost flag and the countdowns they depend on
	// are actually available.
	Mode        string   `json:"mode,omitempty"`
	ModeOptions []string `json:"modeOptions,omitempty"`

	VoltageL1 *float64 `json:"voltageL1,omitempty"`
	VoltageL2 *float64 `json:"voltageL2,omitempty"`
	VoltageL3 *float64 `json:"voltageL3,omitempty"`
	CurrentL1 *float64 `json:"currentL1,omitempty"`
	CurrentL2 *float64 `json:"currentL2,omitempty"`
	CurrentL3 *float64 `json:"currentL3,omitempty"`

	PowerL1    *float64 `json:"powerL1,omitempty"`
	PowerL2    *float64 `json:"powerL2,omitempty"`
	PowerL3    *float64 `json:"powerL3,omitempty"`
	PowerTotal *float64 `json:"powerTotal,omitempty"`

	SessionSeconds *float64 `json:"sessionSeconds,omitempty"`
	SessionWh      *float64 `json:"sessionWh,omitempty"`

	// The session's own three energies and when it began. The register map has
	// neither: a session is a cloud idea, and only this transport counts it.
	SessionWhL1      *float64 `json:"sessionWhL1,omitempty"`
	SessionWhL2      *float64 `json:"sessionWhL2,omitempty"`
	SessionWhL3      *float64 `json:"sessionWhL3,omitempty"`
	SessionStartedAt *float64 `json:"sessionStartedAt,omitempty"` // unix seconds
	OrderID          *float64 `json:"orderId,omitempty"`

	// The countdowns the charger runs before a session: how long it will wait for
	// a plug, and how long a start delay still has to go. They are why a charger
	// that has been told to start can sit in "preparing" without being broken.
	PlugCountdownSeconds  *float64 `json:"plugCountdownSeconds,omitempty"`
	StartCountdownSeconds *float64 `json:"startCountdownSeconds,omitempty"`
	ChargingWindowSeconds *float64 `json:"chargingWindowSeconds,omitempty"`

	PhaseMode *int  `json:"phaseMode,omitempty"`
	BoostMode *bool `json:"boostMode,omitempty"`
	Plugged   *bool `json:"plugged,omitempty"`

	// Where the charge is coming from — 0 off or paused, 1 grid, 7 solar. The
	// reference marks this reading uncertain, so it is reported as the number it
	// is and named for what it distinguishes rather than folded into a mode.
	ChargingSource *int `json:"chargingSource,omitempty"`

	CPSignal     *int   `json:"cpSignal,omitempty"`
	CPSignalDesc string `json:"cpSignalDesc,omitempty"`

	OcppStatus     *int   `json:"ocppStatus,omitempty"`
	OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`

	LoadBalancing  *bool    `json:"loadBalancing,omitempty"`
	SolarBalancing *bool    `json:"solarBalancing,omitempty"`
	LEDBrightness  *int     `json:"ledBrightness,omitempty"`
	MinCurrentA    *float64 `json:"minCurrentA,omitempty"`
	MaxCurrentA    *float64 `json:"maxCurrentA,omitempty"`

	// The panel's three gestures: what a swipe up, a swipe down and a touch do.
	SwipeUpMode    *int `json:"swipeUpMode,omitempty"`
	SwipeDownMode  *int `json:"swipeDownMode,omitempty"`
	SmartTouchMode *int `json:"smartTouchMode,omitempty"`

	// What the two balancing features are watching. The reference has not pinned
	// down what the two modes and the flag select, so they are reported as the
	// numbers they are; the serials name the meter and the monitor themselves,
	// and nothing outside the charger knows them.
	LoadBalanceMonitorMode *int   `json:"loadBalanceMonitorMode,omitempty"`
	LoadBalanceMeterFlag   *int   `json:"loadBalanceMeterFlag,omitempty"`
	LoadBalanceMonitorSN   string `json:"loadBalanceMonitorSN,omitempty"`
	SolarMonitoringMode    *int   `json:"solarMonitoringMode,omitempty"`
	SolarMonitorSN         string `json:"solarMonitorSN,omitempty"`

	Settings *MqttSettings `json:"settings,omitempty"`

	// Local reports what the charger says about its own LAN side: whether Modbus
	// TCP is switched on, and at which address. It is the one answer the Modbus
	// mode's setup screen otherwise has to be given by hand.
	Local *MqttLocalAccess `json:"local,omitempty"`

	// Extra is everything else the charger sent: the values its messages carry
	// that the fields above have no home for, and the fields no message map can
	// name at all, keyed by the message and name byte they arrived with. They
	// have no unit and no scaling here — naming one would be claiming to know
	// what it means — but they are what the charger actually said, so they are
	// carried rather than dropped.
	Extra map[string]any `json:"extra,omitempty"`

	// TelemetryAt and SettingsAt are when each half of the snapshot last arrived;
	// Live says the fast stream is currently flowing.
	TelemetryAt string `json:"telemetryAt,omitempty"`
	SettingsAt  string `json:"settingsAt,omitempty"`
	Live        bool   `json:"live"`
}

// MqttSettings is what the charger is set to, as opposed to what it is doing —
// the same distinction ModbusSettings draws over the register map.
type MqttSettings struct {
	MaxCurrentA        *float64 `json:"maxCurrentA,omitempty"`
	AutoStart          *bool    `json:"autoStart,omitempty"`
	AutoRestart        *bool    `json:"autoRestart,omitempty"`
	RandomDelay        *bool    `json:"randomDelay,omitempty"`
	PlugLock           *bool    `json:"plugLock,omitempty"`
	ScheduleEnabled    *bool    `json:"scheduleEnabled,omitempty"`
	WeekStart          string   `json:"weekStart,omitempty"`
	WeekEnd            string   `json:"weekEnd,omitempty"`
	WeekendStart       string   `json:"weekendStart,omitempty"`
	WeekendEnd         string   `json:"weekendEnd,omitempty"`
	MainBreakerLimitA  *float64 `json:"mainBreakerLimitA,omitempty"`
	SolarMinCurrentA   *float64 `json:"solarMinCurrentA,omitempty"`
	AutoPhaseSwitching *bool    `json:"autoPhaseSwitching,omitempty"`

	// The rest of what a settings write can change, so a caller can read a
	// snapshot, change one name in it and send it back (see mqttsettings.go).
	ScheduleMode     *int   `json:"scheduleMode,omitempty"`
	WeekendMode      *int   `json:"weekendMode,omitempty"`
	LightOffSchedule *bool  `json:"lightOffSchedule,omitempty"`
	LightOffStart    string `json:"lightOffStart,omitempty"`
	LightOffEnd      string `json:"lightOffEnd,omitempty"`
	// SolarChargeMode is 0 for solar with grid support and 1 for solar only. It
	// is not the Modbus snapshot's chargingMode, which is a different register.
	SolarChargeMode *int `json:"solarChargeMode,omitempty"`
}

// MqttLocalAccess is the charger's own view of its Modbus TCP server.
type MqttLocalAccess struct {
	ModbusEnabled  *bool  `json:"modbusEnabled,omitempty"`
	Host           string `json:"host,omitempty"`
	Port           *int   `json:"port,omitempty"`
	TimeoutSeconds *int   `json:"timeoutSeconds,omitempty"`
}

// ---- the capabilities --------------------------------------------------------

// mqttStatus reads one charger's state over the cloud. The charger publishes
// nothing unless asked, so this arms the telemetry trigger and waits for the
// next frame; inside an already-armed window the frame that has since arrived
// answers immediately.
func (p *Plugin) mqttStatus(ctx context.Context, sn string) (json.RawMessage, error) {
	model, err := p.chargerModel(ctx, sn)
	if err != nil {
		return nil, err
	}
	conn, err := p.mqttClient(ctx)
	if err != nil {
		return nil, err
	}
	if err := conn.listen(ctx, model, sn); err != nil {
		return nil, err
	}

	// Re-arm whenever the window is spent or close to it, so a poll never lands
	// in the gap between the last frame and the trigger expiring.
	_, _, settingsAt, triggered := conn.snapshotOf(sn)
	if time.Until(triggered) < triggerRenew {
		if err := p.mqttTrigger(ctx, conn, model, sn, triggerWindow); err != nil {
			return nil, err
		}
	}

	// The trigger buys telemetry only. What the charger is set to — its schedule,
	// its balancing, its Modbus server, its firmware — travels on its own message,
	// and without asking it never comes: a charger that has been read a hundred
	// times and commanded none reports amps and nothing else. So the read asks for
	// that half too whenever it is missing or has gone stale.
	askedSettings := false
	if time.Since(settingsAt) > settingsMaxAge {
		// A charger that will not answer this is not a failed read: the telemetry
		// half is still the answer, and the settings half is what it was.
		if err := p.mqttStatusRequest(ctx, conn, model, sn); err == nil {
			askedSettings = true
		}
	}

	// Anything older than the trigger's own interval is stale; wait for the next.
	cutoff := time.Now().Add(-triggerRenew)
	live, err := conn.waitFor(ctx, sn, func(st *deviceState) bool {
		return st.telemetryAt.After(cutoff)
	}, statusWait)
	if err != nil {
		return nil, err
	}

	// Give the parameter message a moment of its own, but only while this charger
	// still looks like one that answers: the reference reads the status request as
	// carrying an Anker bug, so a firmware that ignores it must not tax every read
	// with the same wait forever.
	if askedSettings && conn.statusReqAnswered(sn) {
		settled, werr := conn.waitFor(ctx, sn, func(st *deviceState) bool {
			return st.settingsAt.After(cutoff)
		}, settingsWait)
		if werr == nil && !settled {
			conn.noteStatusMiss(sn)
		}
	}

	values, telemetryAt, settingsAt, _ := conn.snapshotOf(sn)
	if len(values) == 0 {
		return nil, fmt.Errorf("anker-solix: charger %s did not answer over the cloud; it may be offline", sn)
	}
	snap := projectMqttSnapshot(sn, model, values)
	snap.Live = live
	if !telemetryAt.IsZero() {
		snap.TelemetryAt = telemetryAt.UTC().Format(time.RFC3339)
	}
	if !settingsAt.IsZero() {
		snap.SettingsAt = settingsAt.UTC().Format(time.RFC3339)
	}
	return json.Marshal(snap)
}

// mqttCommandDoc is what a cloud command answers with. Confirmed says the
// charger sent a message back within commandWait: publishing is fire-and-forget,
// so an unconfirmed command is not a failed one — it is one whose effect has not
// been seen yet.
type mqttCommandDoc struct {
	Serial    string `json:"serial"`
	Command   string `json:"command"`
	Status    string `json:"status"`
	Confirmed bool   `json:"confirmed"`
	Detail    string `json:"detail,omitempty"`
}

// mqttCommands maps the names this transport accepts to the charger mode each
// one asks for. The short names are what the control endpoint sends; the long
// ones are the mode names the snapshot reports in modeOptions, so a caller can
// send back what it was offered.
var mqttCommands = map[string]string{
	"start":         modeStartCharge,
	modeStartCharge: modeStartCharge,
	"stop":          modeStopCharge,
	modeStopCharge:  modeStopCharge,
	"boost":         modeBoostCharge,
	modeBoostCharge: modeBoostCharge,
	"skip-delay":    modeSkipDelay,
	modeSkipDelay:   modeSkipDelay,
}

// isRestart reports whether a command asks for a reboot. Both names answer to
// it: "restart" is what this transport calls the message, and "reset" is what
// the OCPP path has always called the same act, so a caller that knows one is
// not told the charger cannot do the other.
func isRestart(command string) bool { return command == "restart" || command == "reset" }

// mqttCommand issues one control command over the cloud.
func (p *Plugin) mqttCommand(ctx context.Context, sn, command string, amps float64) (json.RawMessage, error) {
	// Validate before touching the cloud: a mistyped command should not cost a
	// sign-in, a broker connection and a certificate fetch to be told no.
	command = strings.ToLower(strings.TrimSpace(command))
	mode, isMode := mqttCommands[command]
	switch {
	case isMode:
	case command == "limit":
		if err := checkMaxCurrent(amps); err != nil {
			return nil, err
		}
	case command == "trigger":
	case isRestart(command):
	default:
		return nil, fmt.Errorf("anker-solix: %q is not a cloud command (start, stop, boost, skip-delay, limit, trigger, restart)", command)
	}

	model, err := p.chargerModel(ctx, sn)
	if err != nil {
		return nil, err
	}
	conn, err := p.mqttClient(ctx)
	if err != nil {
		return nil, err
	}
	// Listen before commanding: the charger confirms a control change with a
	// message, and a subscription made afterwards would miss it.
	if err := conn.listen(ctx, model, sn); err != nil {
		return nil, err
	}
	_, _, before, _ := conn.snapshotOf(sn)

	switch {
	case isMode:
		err = p.mqttSetMode(ctx, conn, model, sn, mode)
	case command == "limit":
		err = p.mqttSetMaxCurrent(ctx, conn, model, sn, amps)
	case isRestart(command):
		err = p.mqttRestart(ctx, conn, model, sn)
	default:
		err = p.mqttTrigger(ctx, conn, model, sn, triggerWindow)
	}
	if err != nil {
		return nil, err
	}

	// A restart is the one command with nothing to wait for: the charger that
	// would send the confirmation is the charger that is rebooting. Saying so
	// beats waiting five seconds to report an unconfirmed command as if that
	// were news.
	if isRestart(command) {
		return json.Marshal(mqttCommandDoc{Serial: sn, Command: command, Status: "accepted",
			Detail: "sent; the charger reboots rather than confirming, and drops off the cloud for about a minute"})
	}

	// The charger answers a control change with a settings message. Waiting for
	// it turns "published" into "the charger has it".
	confirmed, waitErr := conn.waitFor(ctx, sn, func(st *deviceState) bool {
		return st.settingsAt.After(before)
	}, commandWait)
	doc := mqttCommandDoc{Serial: sn, Command: command, Status: "accepted", Confirmed: confirmed}
	if waitErr != nil {
		// The command left; only the confirmation did not. Say so rather than
		// reporting a failure the charger may well have acted on.
		doc.Detail = "sent, but the cloud connection dropped before the charger confirmed it"
	} else if !confirmed {
		doc.Detail = "sent; the charger has not confirmed it yet"
	}
	return json.Marshal(doc)
}

// ---- projection --------------------------------------------------------------

// projectMqttSnapshot turns the named values collected from a charger's messages
// into the snapshot. Every read is by name and optional: a message type we have
// not seen simply leaves its fields unset.
func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot {
	snap := MqttSnapshot{Serial: sn, Model: model}

	// Every read goes through one of the four below, and each one notes the key
	// it took. What is left over at the end is what this projection has no field
	// for — which is exactly what Extra is, and keeping the list that way means a
	// field added above stops appearing there without anyone having to remember.
	read := map[string]bool{}
	num := func(key string) *float64 {
		read[key] = true
		f, ok := v[key].(float64)
		if !ok {
			return nil
		}
		return &f
	}
	whole := func(key string) *int {
		read[key] = true
		f, ok := v[key].(float64)
		if !ok {
			return nil
		}
		n := int(f)
		return &n
	}
	flag := func(key string) *bool {
		read[key] = true
		f, ok := v[key].(float64)
		if !ok {
			return nil
		}
		b := f != 0
		return &b
	}
	text := func(key string) string {
		read[key] = true
		s, _ := v[key].(string)
		return strings.TrimSpace(s)
	}

	snap.VoltageL1, snap.VoltageL2, snap.VoltageL3 = num("voltageL1"), num("voltageL2"), num("voltageL3")
	snap.CurrentL1, snap.CurrentL2, snap.CurrentL3 = num("currentL1"), num("currentL2"), num("currentL3")
	snap.PowerL1, snap.PowerL2, snap.PowerL3 = num("powerL1"), num("powerL2"), num("powerL3")
	snap.PowerTotal = num("powerTotal")
	snap.SessionSeconds, snap.SessionWh = num("sessionSeconds"), num("sessionWh")
	snap.SessionWhL1, snap.SessionWhL2 = num("sessionWhL1"), num("sessionWhL2")
	snap.SessionWhL3 = num("sessionWhL3")
	snap.SessionStartedAt, snap.OrderID = num("sessionStartedAt"), num("orderId")
	snap.ChargingSource = whole("chargingSource")
	snap.Firmware, snap.Hardware = text("softwareVersion"), text("hardwareVersion")
	snap.ControllerVersion = text("controllerVersion")
	snap.SwipeUpMode, snap.SwipeDownMode = whole("swipeUpMode"), whole("swipeDownMode")
	snap.SmartTouchMode = whole("smartTouchMode")
	snap.LoadBalanceMonitorMode = whole("loadBalanceMonitorMode")
	snap.LoadBalanceMeterFlag = whole("loadBalanceMeterFlag")
	snap.LoadBalanceMonitorSN = text("loadBalanceMonitorSN")
	snap.SolarMonitoringMode = whole("solarMonitoringMode")
	snap.SolarMonitorSN = text("solarMonitorSN")
	snap.PlugCountdownSeconds = num("plugCountdownSeconds")
	snap.StartCountdownSeconds = num("startCountdownSeconds")
	snap.ChargingWindowSeconds = num("chargingWindowSeconds")
	snap.PhaseMode = whole("phaseMode")
	snap.BoostMode, snap.Plugged = flag("boostMode"), flag("plugged")
	snap.LoadBalancing, snap.SolarBalancing = flag("loadBalancing"), flag("solarBalancing")
	snap.LEDBrightness = whole("ledBrightness")
	snap.MinCurrentA, snap.MaxCurrentA = num("minCurrentA"), num("maxCurrentA")

	if s := whole("status"); s != nil {
		snap.Status, snap.StatusDesc = s, statusName(*s)
	}
	if s := whole("ocppStatus"); s != nil {
		snap.OcppStatus, snap.OcppStatusDesc = s, ocppStatusNames[*s]
	}
	if s := whole("cpSignal"); s != nil {
		snap.CPSignal, snap.CPSignalDesc = s, cpSignalNames[*s]
	}

	// The mode the cloud view can only guess at, with the two countdowns and the
	// boost flag it never sees.
	if snap.StatusDesc != "" {
		boost := snap.BoostMode != nil && *snap.BoostMode
		snap.Mode = chargerMode(snap.StatusDesc, boost, intOrZero(snap.PlugCountdownSeconds), intOrZero(snap.StartCountdownSeconds))
		snap.ModeOptions = chargerModeOptions(snap.Mode, snap.StatusDesc)
	}

	set := &MqttSettings{
		MaxCurrentA:        num("maxCurrentSetA"),
		AutoStart:          flag("autoStartSwitch"),
		AutoRestart:        flag("autoRestartSwitch"),
		RandomDelay:        flag("randomDelaySwitch"),
		MainBreakerLimitA:  num("mainBreakerLimitA"),
		SolarMinCurrentA:   num("solarMinCurrentA"),
		AutoPhaseSwitching: flag("autoPhaseSwitch"),
		WeekStart:          text("weekStart"),
		WeekEnd:            text("weekEnd"),
		WeekendStart:       text("weekendStart"),
		WeekendEnd:         text("weekendEnd"),
		ScheduleMode:       whole("scheduleMode"),
		WeekendMode:        whole("weekendMode"),
		LightOffSchedule:   flag("lightOffScheduleSwitch"),
		LightOffStart:      text("lightOffStart"),
		LightOffEnd:        text("lightOffEnd"),
		SolarChargeMode:    whole("solarChargeMode"),
	}
	// Both of these read 1 for on and 2 for off, which is the charger's own
	// convention on these two registers and nowhere else.
	if s := whole("plugLockSwitch"); s != nil {
		b := *s == 1
		set.PlugLock = &b
	}
	if s := whole("scheduleSwitch"); s != nil {
		b := *s == 1
		set.ScheduleEnabled = &b
	}
	if *set != (MqttSettings{}) {
		snap.Settings = set
	}

	local := &MqttLocalAccess{
		ModbusEnabled:  flag("modbusSwitch"),
		Host:           text("ipAddress"),
		Port:           whole("modbusPort"),
		TimeoutSeconds: whole("modbusTimeoutSeconds"),
	}
	if *local != (MqttLocalAccess{}) {
		snap.Local = local
	}

	// Whatever the projection did not take. The charger sent it, so it is part of
	// the reading — under the charger's own name for it, since this package has
	// none.
	for key, val := range v {
		if read[key] {
			continue
		}
		if snap.Extra == nil {
			snap.Extra = map[string]any{}
		}
		snap.Extra[key] = val
	}
	return snap
}

// intOrZero reads an optional number as an int, treating "not reported" as zero
// — which is what the mode derivation means by a countdown that is not running.
func intOrZero(v *float64) int {
	if v == nil {
		return 0
	}
	return int(*v)
}
