package ankersolix

// Writing the charger's settings over Anker's cloud.
//
// The control commands next door in cloudmqtt.go move a session along — start,
// stop, boost, skip the delay. These move the charger's own configuration: what
// it will draw, when it is allowed to, how bright its ring is, whether it speaks
// Modbus on the LAN at all. They are the same values mqttStatus already reads
// back, so a caller can read a snapshot, change one name in it, and send it.
//
// Two things about the wire format shape everything here.
//
// The first is that a setting is not a register write but a *command*, and a
// command owns a set of fields inside a message type. Most own exactly one, so
// writing them is a frame with one field in it. Five own several — the light-off
// schedule, the charging schedule's switch and times, load balancing and solar
// charging — and the charger takes the whole command as the new truth. A
// light-off schedule that arrives carrying only its switch is a schedule whose
// start and end have just been set to midnight. So a grouped write resends the
// siblings the caller did not name, using the values the charger itself last
// reported, and refuses rather than guesses when it has not reported them.
//
// The second is that those values arrive on a different message from the
// telemetry. The fast 0410 stream a realtime trigger turns on carries none of
// them; the settings come on 0405 / 0840 / 0900, which the charger sends when it
// has something to acknowledge. Which is why a grouped write may have to send a
// trigger first purely to make the charger talk about itself.
//
// Every setting below is the reference implementation's (anker-solix-api
// v3.8.1, src/anker_solix_api/mqttcmdmap.py and mqttmap.py, A5191 block) rather
// than anything Anker documents. The ranges and the two inverted switches — plug
// lock and the schedule switch, where 1 is on and 2 is off, unlike every other
// switch on the device — come from there.

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"time"
)

// ---- the writable settings ----------------------------------------------------

// settingField is one field of one setting command: where it sits in the frame,
// the name a caller gives it (empty for a field only the charger sets, which is
// carried along unchanged), the decoded state key its current value comes from,
// and how a value becomes bytes.
type settingField struct {
	wire   byte
	key    string
	state  string
	encode func(wire byte, v any) (cmdField, error)
}

// settingCmd is one command the charger accepts: a message type and the fields
// that go with it, in the order they go on the wire.
type settingCmd struct {
	msgType string
	fields  []settingField
}

// grouped reports whether writing one of this command's settings means resending
// the others.
func (c settingCmd) grouped() bool { return len(c.fields) > 1 }

// settingCmds is every setting this transport can write, in a stable order —
// which is also the order several settings in one request are sent in.
//
// The message types are the A5191's: 0100 is the device's own settings group,
// 0106 the charging schedule, 010c load balancing and 010e solar charging.
var settingCmds = []settingCmd{
	{msgType: msgEVSettings, fields: []settingField{
		// On this one field 1 is on and 2 is off. It is the charger's convention on
		// the plug lock and the schedule switch and on nothing else.
		{wire: 0xa3, key: "plugLock", state: "plugLockSwitch", encode: switchOnOff(1, 2)},
	}},
	{msgType: msgEVSettings, fields: []settingField{
		{wire: 0xa4, key: "autoStart", state: "autoStartSwitch", encode: switchOnOff(1, 0)},
	}},
	{msgType: msgEVSettings, fields: []settingField{
		{wire: 0xa8, key: "maxCurrentA", state: "maxCurrentSetA", encode: maxCurrentValue},
	}},
	{msgType: msgEVSettings, fields: []settingField{
		{wire: 0xaa, key: "ledBrightness", state: "ledBrightness", encode: wholeValue(0, 100, 10)},
	}},
	{msgType: msgEVSettings, fields: []settingField{
		{wire: 0xac, key: "autoRestart", state: "autoRestartSwitch", encode: switchOnOff(1, 0)},
	}},
	{msgType: msgEVSettings, fields: []settingField{
		{wire: 0xad, key: "randomDelay", state: "randomDelaySwitch", encode: switchOnOff(1, 0)},
	}},
	{msgType: msgEVSettings, fields: []settingField{
		{wire: 0xb4, key: "lightOffSchedule", state: "lightOffScheduleSwitch", encode: switchOnOff(1, 0)},
		{wire: 0xb5, key: "lightOffStart", state: "lightOffStart", encode: clockValue},
		{wire: 0xb6, key: "lightOffEnd", state: "lightOffEnd", encode: clockValue},
	}},
	{msgType: msgEVSettings, fields: []settingField{
		// Turning this off is the one setting that can cost you the Modbus control
		// mode: the charger stops serving the register map on the LAN.
		{wire: 0xb7, key: "modbusEnabled", state: "modbusSwitch", encode: switchOnOff(1, 0)},
	}},
	// The panel's own three: what a swipe up, a swipe down and a touch do. The
	// charger has always reported them and the card has always shown them; they
	// are settings like any other on this message, so they are writable here too.
	{msgType: msgEVSettings, fields: []settingField{
		{wire: 0xaf, key: "swipeUpMode", state: "swipeUpMode", encode: optionValue(0, 1, 2, 3)},
	}},
	{msgType: msgEVSettings, fields: []settingField{
		{wire: 0xb0, key: "swipeDownMode", state: "swipeDownMode", encode: optionValue(0, 1, 2, 3)},
	}},
	{msgType: msgEVSettings, fields: []settingField{
		{wire: 0xb2, key: "smartTouchMode", state: "smartTouchMode", encode: optionValue(0, 1)},
	}},
	{msgType: msgEVSchedule, fields: []settingField{
		{wire: 0xa2, key: "scheduleEnabled", state: "scheduleSwitch", encode: switchOnOff(1, 2)},
		{wire: 0xa8, key: "scheduleMode", state: "scheduleMode", encode: optionValue(0, 1)},
	}},
	{msgType: msgEVSchedule, fields: []settingField{
		{wire: 0xa3, key: "weekStart", state: "weekStart", encode: clockValue},
		{wire: 0xa4, key: "weekEnd", state: "weekEnd", encode: clockValue},
		{wire: 0xa5, key: "weekendStart", state: "weekendStart", encode: clockValue},
		{wire: 0xa6, key: "weekendEnd", state: "weekendEnd", encode: clockValue},
		{wire: 0xa7, key: "weekendMode", state: "weekendMode", encode: optionValue(1, 2)},
	}},
	{msgType: msgEVBalancing, fields: []settingField{
		{wire: 0xa3, key: "mainBreakerLimitA", state: "mainBreakerLimitA", encode: signedValue(10, 500, 1, 1)},
	}},
	{msgType: msgEVBalancing, fields: []settingField{
		{wire: 0xa2, key: "loadBalancing", state: "loadBalancing", encode: switchOnOff(1, 0)},
		// The meter this charger balances against, and how it watches it. Nothing
		// here can invent them, so they travel back exactly as they arrived.
		{wire: 0xa4, state: "loadBalanceMonitorMode", encode: wholeValue(0, 255, 1)},
		{wire: 0xa5, state: "loadBalanceMeterFlag", encode: wholeValue(0, 255, 1)},
		{wire: 0xa6, state: "loadBalanceMonitorSN", encode: serialValue},
	}},
	{msgType: msgEVSolar, fields: []settingField{
		{wire: 0xa2, key: "solarBalancing", state: "solarBalancing", encode: switchOnOff(1, 0)},
		{wire: 0xa3, key: "solarChargeMode", state: "solarChargeMode", encode: optionValue(0, 1)},
		{wire: 0xa4, key: "solarMinCurrentA", state: "solarMinCurrentA", encode: signedValue(currentPauseFloor, 32, 1, 1)},
		// This command offers automatic and single-phase only; the three-phase
		// setting the Modbus register takes has no place in it.
		{wire: 0xa5, key: "phaseMode", state: "phaseMode", encode: optionValue(0, 1)},
		{wire: 0xa6, state: "solarMonitoringMode", encode: wholeValue(0, 255, 1)},
		{wire: 0xa7, key: "autoPhaseSwitching", state: "autoPhaseSwitch", encode: switchOnOff(1, 0)},
		{wire: 0xa8, state: "solarMonitorSN", encode: serialValue},
	}},
}

// settingIndex maps a caller's name to the command that carries it, built once
// from settingCmds so the table above stays the only place a setting is defined.
var settingIndex = func() map[string]int {
	idx := map[string]int{}
	for i, c := range settingCmds {
		for _, f := range c.fields {
			if f.key != "" {
				idx[f.key] = i
			}
		}
	}
	return idx
}()

// settingNames lists every writable setting, for the error a mistyped name gets.
func settingNames() []string {
	out := make([]string, 0, len(settingIndex))
	for k := range settingIndex {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}

// ---- value encoders -----------------------------------------------------------

// switchOnOff encodes a boolean field, given the values this particular switch
// uses for on and off — which are 1 and 0 everywhere except the plug lock and
// the schedule switch, where they are 1 and 2.
func switchOnOff(on, off uint8) func(byte, any) (cmdField, error) {
	return func(wire byte, v any) (cmdField, error) {
		b, err := asBool(v)
		if err != nil {
			return cmdField{}, err
		}
		if b {
			return uintField(wire, on), nil
		}
		return uintField(wire, off), nil
	}
}

// wholeValue encodes a one-byte number, checking the range and step the charger
// accepts for it.
func wholeValue(min, max, step float64) func(byte, any) (cmdField, error) {
	return func(wire byte, v any) (cmdField, error) {
		n, err := asNumber(v)
		if err != nil {
			return cmdField{}, err
		}
		if err := checkRange(n, min, max, step); err != nil {
			return cmdField{}, err
		}
		return uintField(wire, uint8(n)), nil
	}
}

// signedValue encodes a two-byte number. scale is what the field carries the
// value in: 10 for the current fields, which are deciamps on the wire.
func signedValue(min, max, step, scale float64) func(byte, any) (cmdField, error) {
	return func(wire byte, v any) (cmdField, error) {
		n, err := asNumber(v)
		if err != nil {
			return cmdField{}, err
		}
		if err := checkRange(n, min, max, step); err != nil {
			return cmdField{}, err
		}
		return intField(wire, int16(n*scale)), nil
	}
}

// optionValue encodes a one-byte field that takes a fixed set of values, and
// says which they were when it is given something else.
func optionValue(allowed ...uint8) func(byte, any) (cmdField, error) {
	return func(wire byte, v any) (cmdField, error) {
		n, err := asNumber(v)
		if err != nil {
			return cmdField{}, err
		}
		for _, a := range allowed {
			if float64(a) == n {
				return uintField(wire, a), nil
			}
		}
		names := make([]string, len(allowed))
		for i, a := range allowed {
			names[i] = strconv.Itoa(int(a))
		}
		return cmdField{}, fmt.Errorf("anker-solix: %v is not one of %s", v, strings.Join(names, ", "))
	}
}

// maxCurrentValue encodes the charging ceiling, applying the same rule the other
// two transports do — a limit below the charger's floor is a pause in disguise.
func maxCurrentValue(wire byte, v any) (cmdField, error) {
	amps, err := asNumber(v)
	if err != nil {
		return cmdField{}, err
	}
	if err := checkMaxCurrent(amps); err != nil {
		return cmdField{}, err
	}
	return intField(wire, int16(amps*10)), nil
}

// clockValue encodes a time of day, written the way the charger reports it.
func clockValue(wire byte, v any) (cmdField, error) {
	s, ok := v.(string)
	if !ok {
		return cmdField{}, fmt.Errorf("anker-solix: %v is not a time of day; write it as \"HH:MM\"", v)
	}
	h, m, err := parseClock(s)
	if err != nil {
		return cmdField{}, err
	}
	return clockField(wire, h, m), nil
}

// serialValue encodes a device serial, in the sixteen bytes the charger keeps
// one in.
func serialValue(wire byte, v any) (cmdField, error) {
	s, ok := v.(string)
	if !ok {
		return cmdField{}, fmt.Errorf("anker-solix: %v is not a device serial", v)
	}
	if len(s) > 16 {
		return cmdField{}, fmt.Errorf("anker-solix: device serial %q is longer than the charger's 16 characters", s)
	}
	return stringField(wire, s, 16), nil
}

// parseClock reads "HH:MM".
func parseClock(s string) (int, int, error) {
	t, err := time.Parse("15:04", strings.TrimSpace(s))
	if err != nil {
		return 0, 0, fmt.Errorf("anker-solix: %q is not a time of day; write it as \"HH:MM\"", s)
	}
	return t.Hour(), t.Minute(), nil
}

// checkRange holds a number to the range and step the charger takes.
func checkRange(n, min, max, step float64) error {
	if n < min || n > max {
		return fmt.Errorf("anker-solix: %g is outside the range the charger accepts (%g-%g)", n, min, max)
	}
	if step > 1 {
		if r := n - min; r != float64(int(r/step))*step {
			return fmt.Errorf("anker-solix: %g is not a step of %g between %g and %g", n, step, min, max)
		}
	}
	return nil
}

// asBool reads a switch value. JSON gives a bool; a caller echoing a snapshot
// back may give a number, and a form may give a word.
func asBool(v any) (bool, error) {
	switch t := v.(type) {
	case bool:
		return t, nil
	case float64:
		return t != 0, nil
	case string:
		switch strings.ToLower(strings.TrimSpace(t)) {
		case "on", "true", "yes", "1":
			return true, nil
		case "off", "false", "no", "0":
			return false, nil
		}
	}
	return false, fmt.Errorf("anker-solix: %v is not on or off", v)
}

// asNumber reads a numeric value, accepting the string form a query parameter or
// a form field would carry it in.
func asNumber(v any) (float64, error) {
	switch t := v.(type) {
	case float64:
		return t, nil
	case bool:
		if t {
			return 1, nil
		}
		return 0, nil
	case string:
		if n, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err == nil {
			return n, nil
		}
	}
	return 0, fmt.Errorf("anker-solix: %v is not a number", v)
}

// ---- writing ------------------------------------------------------------------

// mqttSettingsDoc is what a settings write answers with. As with a control
// command, publishing is fire-and-forget: confirmed says the charger sent its
// settings back within the window, not that the write either succeeded or was
// the only thing that could have changed them.
type mqttSettingsDoc struct {
	Serial    string   `json:"serial"`
	Applied   []string `json:"applied"`
	Commands  int      `json:"commands"`
	Status    string   `json:"status"`
	Confirmed bool     `json:"confirmed"`
	Detail    string   `json:"detail,omitempty"`
}

// mqttApplySettings writes one or more of the charger's settings over the cloud.
// Every value is validated before anything is published, so a request naming one
// bad setting changes nothing rather than half of what it asked for.
func (p *Plugin) mqttApplySettings(ctx context.Context, sn string, values map[string]any) (json.RawMessage, error) {
	if len(values) == 0 {
		return nil, fmt.Errorf("anker-solix: no settings given; send one or more of %s", strings.Join(settingNames(), ", "))
	}
	// Group the request by the command that carries each setting, so two settings
	// on the same command travel in one frame rather than overwriting each other.
	wanted := map[int]map[string]any{}
	var applied []string
	for k, v := range values {
		i, ok := settingIndex[k]
		if !ok {
			return nil, fmt.Errorf("anker-solix: %q is not a writable setting; the writable ones are %s", k, strings.Join(settingNames(), ", "))
		}
		if wanted[i] == nil {
			wanted[i] = map[string]any{}
		}
		wanted[i][k] = v
		applied = append(applied, k)
	}
	sort.Strings(applied)

	// Encode what the caller supplied before touching the cloud: a bad value
	// should cost a validation error, not a sign-in and a broker connection.
	needState := false
	for i, given := range wanted {
		cmd := settingCmds[i]
		for _, f := range cmd.fields {
			if v, ok := given[f.key]; ok && f.key != "" {
				if _, err := f.encode(f.wire, v); err != nil {
					return nil, settingError(f.key, err)
				}
			} else if cmd.grouped() {
				needState = true
			}
		}
	}

	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 writing: the charger answers with its settings, and a
	// subscription made afterwards would miss the answer.
	if err := conn.listen(ctx, model, sn); err != nil {
		return nil, err
	}

	state := map[string]any{}
	if needState {
		if state, err = p.mqttSettingsState(ctx, conn, model, sn); err != nil {
			return nil, err
		}
	}

	frames := make([][]byte, 0, len(wanted))
	for i := range settingCmds {
		given, ok := wanted[i]
		if !ok {
			continue
		}
		frame, err := encodeSettingCmd(settingCmds[i], given, state)
		if err != nil {
			return nil, err
		}
		frames = append(frames, frame)
	}

	_, _, before, _ := conn.snapshotOf(sn)
	for _, frame := range frames {
		if err := conn.publishFrame(ctx, model, sn, frame, 0); err != nil {
			return nil, err
		}
	}

	confirmed, waitErr := conn.waitFor(ctx, sn, func(st *deviceState) bool {
		return st.settingsAt.After(before)
	}, commandWait)
	doc := mqttSettingsDoc{
		Serial:    sn,
		Applied:   applied,
		Commands:  len(frames),
		Status:    "accepted",
		Confirmed: confirmed,
	}
	if waitErr != nil {
		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)
}

// encodeSettingCmd builds one command's frame: the fields the caller named
// carrying their new values, and the rest carrying what the charger last
// reported. A sibling the charger has never reported stops the write, because
// the alternative is sending a made-up value the charger would then adopt.
func encodeSettingCmd(cmd settingCmd, given, state map[string]any) ([]byte, error) {
	fields := []cmdField{rawField(0xa1, 0x22)}
	for _, f := range cmd.fields {
		name := f.key
		if name == "" {
			name = f.state
		}
		v, ok := any(nil), false
		if f.key != "" {
			v, ok = given[f.key]
		}
		if !ok {
			if v, ok = state[f.state]; !ok {
				return nil, fmt.Errorf(
					"anker-solix: this setting is sent together with %s, which the charger has not reported yet; read its status first", name)
			}
		}
		enc, err := f.encode(f.wire, v)
		if err != nil {
			return nil, settingError(name, err)
		}
		fields = append(fields, enc)
	}
	fields = append(fields, timestampField(time.Now()))
	return encodeFrame(cmd.msgType, fields)
}

// settingError names the setting a value was rejected for. The checks it wraps
// are shared with the other transports and carry the package's own prefix, so it
// is trimmed rather than repeated.
func settingError(name string, err error) error {
	return fmt.Errorf("anker-solix: %s: %s", name, strings.TrimPrefix(err.Error(), "anker-solix: "))
}

// mqttSettingsState returns what the charger last said it is set to, which a
// grouped write needs to leave the fields it is not changing alone. The settings
// arrive on their own message rather than with the telemetry stream, and the
// charger sends one when it has something to acknowledge — so a connection that
// has not heard any yet sends a trigger to prompt one.
func (p *Plugin) mqttSettingsState(ctx context.Context, conn *mqttConn, model, sn string) (map[string]any, error) {
	values, _, settingsAt, _ := conn.snapshotOf(sn)
	if !settingsAt.IsZero() {
		return values, nil
	}
	if err := p.mqttTrigger(ctx, conn, model, sn, triggerWindow); err != nil {
		return nil, err
	}
	ok, err := conn.waitFor(ctx, sn, func(st *deviceState) bool {
		return !st.settingsAt.IsZero()
	}, statusWait)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, errors.New("anker-solix: the charger has not reported its current settings, and this setting cannot be changed without them; it may be offline")
	}
	values, _, _, _ = conn.snapshotOf(sn)
	return values, nil
}
