package ankersolix

// Local control over Modbus TCP, the one path Anker documents publicly for this
// charger ("Anker SOLIX V1 Smart EV Charger Modbus Protocol", V1.0.0,
// 30-11-2025). It is the counterpart to the read-only cloud plugin in
// ankersolix.go: same device, same status enum, but reached over the LAN with no
// account, no cloud round-trip and no inbound connection for the charger to make
// — which is what rules OCPP out wherever the charger cannot dial us.
//
// The owner enables it in the Anker app under Settings > Integrations > Modbus
// TCP; the app then shows the charger's local IP and port. Two caveats from the
// spec that shape the code here:
//
//   - At most two clients may be connected at once, and an operator's debugging
//     tool may well be one of them. Connections are therefore opened per
//     operation and closed again, never pooled.
//   - Charging pauses on its own whenever the current is set below 6 A, so a
//     limit under that floor is a pause, not a slow charge. SetMaxCurrent says so
//     rather than letting a caller discover it.
//
// The spec tabulates addresses, types and gains but does not name the function
// codes. An A5191 on firmware 1.0.6.1 answers the measurement block (20000-20100)
// on FC04 only — FC03 there is refused with an illegal-address exception, for
// every address in the range — while the controls at 21000-21005 do read back
// over FC03. So the map is two tables, not the one 2xxxx space it looks like:
// input registers for what the charger reports, holding registers for what it
// accepts, which is FC04 to read and FC06 to write.

import (
	"context"
	"encoding/binary"
	"fmt"
	"strings"
	"time"

	"drivervault/apiserver/internal/modbus"
)

// Register addresses from the protocol spec. Identity and configuration sit in
// 20000-20040, live measurements and state in 20041-20100, and the writable
// controls in 21000-21005.
const (
	regProductNumber   = 20000 // UINT16
	regModelName       = 20001 // STRING, 10 registers
	regSerialNumber    = 20011 // STRING, 12 registers
	regSoftwareVersion = 20023 // STRING, 6 registers
	regHardwareVersion = 20029 // STRING, 6 registers
	regRatedPower      = 20035 // INT32, W
	regMinOutCurrent   = 20037 // INT32
	regMaxOutCurrent   = 20039 // INT32

	regAlarm1 = 20041 // UINT16 x12, each bit an alarm

	regVoltageL1N  = 20053 // UINT16, gain 10
	regVoltageL1L2 = 20056 // UINT16, gain 10, line to line
	regCurrentL1   = 20059 // UINT16, gain 100
	regPowerL1     = 20062 // UINT32, W
	regPowerTotal  = 20068 // UINT32, W
	regReactiveL1  = 20070 // UINT32, var
	regApparentL1  = 20076 // UINT32, VA
	regSessionSec  = 20082 // UINT32, s
	regSessionWh   = 20084 // UINT32, Wh

	regPWMEnabled     = 20086
	regPhaseMode      = 20087
	regChargingMode   = 20088 // 0 solar+grid, 1 solar only
	regLoadBalancing  = 20089
	regSolarBalancing = 20090
	regCPVoltage      = 20091 // UINT16, millivolts — see decodeLive
	regCPSignal       = 20092
	regRelay1Temp     = 20093 // INT16, decidegC — see decodeLive on the gain
	regRelay2Temp     = 20094 // INT16, decidegC
	regBoostMode      = 20095
	regLEDBrightness  = 20096 // %
	regChargingStatus = 20097 // same 0-8 enum as the cloud's operating_state
	regOCPPStatus     = 20099 // 0 not connected, 1 connecting, 2 connected
	regMQTTStatus     = 20100 // 0 not connected, 1 connected

	regChargingCommand = 21000 // 1 start, 2 stop
	regMaxCurrentSet   = 21001 // gain 10, i.e. deciamps
	regBoostSet        = 21002 // 1 on, for the current session only
	regTimeoutSet      = 21003 // seconds, must exceed 5
	regPhaseCountSet   = 21005 // 0 automatic, 1 fixed single, 2 fixed three
)

// The control block, read back rather than written. It is six holding registers
// over FC03 — the one part of the map that answers that function code — and it
// is the only way to see what the charger is actually set to, as opposed to what
// it is doing.
const (
	settingsStart = regChargingCommand
	settingsCount = 6 // 21000-21005
)

// The two blocks read in one request each. Both are well inside the 125-register
// limit of a read, and splitting them keeps the hot path (live state) small.
const (
	identityStart = regProductNumber
	identityCount = 41 // 20000-20040
	liveStart     = regAlarm1
	liveCount     = 60 // 20041-20100
)

// Charging command values for regChargingCommand.
const (
	cmdStartCharging = 1
	cmdStopCharging  = 2
)

// currentPauseFloor is the current below which the charger stops drawing power
// on its own, per the spec's note on the maximum-current register.
const currentPauseFloor = 6.0

// cpSignalNames decodes regCPSignal — the control-pilot state, which says what
// the vehicle side of the cable is doing independently of the charger's own
// status.
var cpSignalNames = map[int]string{
	0: "A (12V, not connected)", 3: "B1 (9V)", 4: "B2 (9V)",
	5: "C1 (6V, charging)", 6: "C2 (6V, charging)", 7: "error",
	8: "D1 (3V)", 9: "D2 (3V)", 10: "E (0V)", 11: "F (-12V)",
}

// The two connection-status registers do not share an encoding: OCPP has a
// three-state one (and matches SolixOcppConnectionStatus in the cloud plugin),
// while MQTT is a plain boolean. Decoding both through one table would report a
// connected broker as "connecting".
var (
	ocppStatusNames = map[int]string{0: "disconnected", 1: "connecting", 2: "connected"}
	mqttStatusNames = map[int]string{0: "disconnected", 1: "connected"}
)

// ModbusConfig addresses one charger's local Modbus TCP server.
type ModbusConfig struct {
	Host   string
	Port   int
	UnitID byte
}

// Address is the host:port this charger is dialled at, defaulting to Modbus's
// standard port when none was saved.
func (c ModbusConfig) Address() string {
	port := c.Port
	if port == 0 {
		port = 502
	}
	return fmt.Sprintf("%s:%d", strings.TrimSpace(c.Host), port)
}

// ModbusSnapshot is the charger's live state as the register map reports it. A
// field the charger did not supply stays nil rather than zero, matching how
// accountCharger treats a value no cloud view knew.
type ModbusSnapshot struct {
	// Identity, present only when the identity block was read too.
	Model         string `json:"model,omitempty"`
	Serial        string `json:"serial,omitempty"`
	Firmware      string `json:"firmware,omitempty"`
	Hardware      string `json:"hardware,omitempty"`
	ProductNumber *int   `json:"productNumber,omitempty"`
	RatedPowerW   *int   `json:"ratedPowerW,omitempty"`
	MinCurrentA   *int   `json:"minCurrentA,omitempty"`
	MaxCurrentA   *int   `json:"maxCurrentA,omitempty"`

	Status     *int   `json:"status,omitempty"`
	StatusDesc string `json:"statusDesc,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"`

	// Line-to-line voltages, which say something the phase voltages do not on a
	// three-phase supply and sit at zero on a single-phase one.
	VoltageL1L2 *float64 `json:"voltageL1L2,omitempty"`
	VoltageL2L3 *float64 `json:"voltageL2L3,omitempty"`
	VoltageL3L1 *float64 `json:"voltageL3L1,omitempty"`

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

	ReactiveL1 *uint32 `json:"reactiveL1,omitempty"`
	ReactiveL2 *uint32 `json:"reactiveL2,omitempty"`
	ReactiveL3 *uint32 `json:"reactiveL3,omitempty"`
	ApparentL1 *uint32 `json:"apparentL1,omitempty"`
	ApparentL2 *uint32 `json:"apparentL2,omitempty"`
	ApparentL3 *uint32 `json:"apparentL3,omitempty"`

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

	PhaseMode      *int     `json:"phaseMode,omitempty"`
	ChargingMode   *int     `json:"chargingMode,omitempty"`
	LoadBalancing  *bool    `json:"loadBalancing,omitempty"`
	SolarBalancing *bool    `json:"solarBalancing,omitempty"`
	BoostMode      *bool    `json:"boostMode,omitempty"`
	LEDBrightness  *int     `json:"ledBrightness,omitempty"`
	PWMEnabled     *bool    `json:"pwmEnabled,omitempty"`
	CPVoltage      *float64 `json:"cpVoltage,omitempty"`
	CPSignal       *int     `json:"cpSignal,omitempty"`
	CPSignalDesc   string   `json:"cpSignalDesc,omitempty"`

	Relay1TempC *float64 `json:"relay1TempC,omitempty"`
	Relay2TempC *float64 `json:"relay2TempC,omitempty"`

	OcppStatus     *int   `json:"ocppStatus,omitempty"`
	OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`
	MqttStatus     *int   `json:"mqttStatus,omitempty"`
	MqttStatusDesc string `json:"mqttStatusDesc,omitempty"`

	// Settings is what the charger is set to, read back from the control block.
	// It answers a different question from the live fields beside it: BoostMode
	// says a boost is running, Settings.Boost says one was asked for.
	Settings *ModbusSettings `json:"settings,omitempty"`

	// Alarms holds the twelve alarm words verbatim. The spec defers the bit
	// meanings to a separate alarm list, so they are surfaced undecoded rather
	// than guessed at; Alarm reports whether any bit is set at all.
	Alarms []uint16 `json:"alarms,omitempty"`
	Alarm  bool     `json:"alarm"`
}

// ModbusSettings mirrors the writable control block. Every field is what the
// charger reports for a register it also accepts writes on, so a view built from
// this shows the settings in force rather than the ones last sent.
type ModbusSettings struct {
	LastCommand    *int     `json:"lastCommand,omitempty"`    // 0 none, 1 start, 2 stop
	MaxCurrentA    *float64 `json:"maxCurrentA,omitempty"`    // deciamps on the wire
	Boost          *bool    `json:"boost,omitempty"`          // for the current session only
	TimeoutSeconds *int     `json:"timeoutSeconds,omitempty"` // control falls back after this silence
	PhaseSetting   *int     `json:"phaseSetting,omitempty"`   // 0 automatic, 1 single, 2 three
	PhaseDesc      string   `json:"phaseDesc,omitempty"`
}

// phaseSettingNames decodes the write register's 0/1/2, which is not the 1/3 the
// charger reports for the phase mode it is currently running in.
var phaseSettingNames = map[int]string{
	0: "automatic", 1: "fixed single-phase", 2: "fixed three-phase",
}

// ModbusDial opens a connection to one charger. Callers must Close it; the
// charger only tolerates two clients at a time.
func ModbusDial(ctx context.Context, cfg ModbusConfig) (*modbus.Client, error) {
	if strings.TrimSpace(cfg.Host) == "" {
		return nil, fmt.Errorf("anker-solix: the charger's local IP address is required for Modbus control")
	}
	return modbus.Connect(ctx, modbus.Options{
		Address:        cfg.Address(),
		UnitID:         cfg.UnitID,
		Timeout:        5 * time.Second,
		ConnectTimeout: 5 * time.Second,
	})
}

// ModbusRead returns the charger's live state. withIdentity also reads the
// slower-moving identity block (model, serial, firmware), which is worth a
// second request on a first poll but not on every one.
func ModbusRead(ctx context.Context, c *modbus.Client, withIdentity bool) (ModbusSnapshot, error) {
	var snap ModbusSnapshot

	live, err := c.ReadInput(ctx, liveStart, liveCount)
	if err != nil {
		return snap, err
	}
	decodeLive(&snap, live)

	// The control block is a second table on a second function code, so it is read
	// separately and best effort: a charger that refuses it still has live state
	// worth reporting.
	if set, err := c.ReadHolding(ctx, settingsStart, settingsCount); err == nil {
		decodeSettings(&snap, set)
	}

	if withIdentity {
		ident, err := c.ReadInput(ctx, identityStart, identityCount)
		if err != nil {
			// Identity is a nicety; live state is the point. Report what we have.
			return snap, nil
		}
		decodeIdentity(&snap, ident)
	}
	return snap, nil
}

// decodeLive fills the snapshot from the 20041-20100 block.
func decodeLive(snap *ModbusSnapshot, regs []uint16) {
	at := func(addr int) (uint16, bool) {
		i := addr - liveStart
		if i < 0 || i >= len(regs) {
			return 0, false
		}
		return regs[i], true
	}
	u32 := func(addr int) (uint32, bool) {
		hi, ok1 := at(addr)
		lo, ok2 := at(addr + 1)
		if !ok1 || !ok2 {
			return 0, false
		}
		return uint32(hi)<<16 | uint32(lo), true
	}
	scaled := func(addr int, gain float64) *float64 {
		v, ok := at(addr)
		if !ok {
			return nil
		}
		f := float64(v) / gain
		return &f
	}
	word := func(addr int) *uint32 {
		v, ok := u32(addr)
		if !ok {
			return nil
		}
		return &v
	}
	num := func(addr int) *int {
		v, ok := at(addr)
		if !ok {
			return nil
		}
		n := int(v)
		return &n
	}
	flag := func(addr int) *bool {
		v, ok := at(addr)
		if !ok {
			return nil
		}
		b := v != 0
		return &b
	}
	// The spec's gain column says 1 for the two relay temperatures, but a charger
	// idling with nothing plugged in reads 331 and 319 there, which is a gain of
	// 10 and not a pair of relays at 331 °C. The same table gives the maximum
	// current setting in watts and the timeout in amps, so its unit and gain
	// columns are not load-bearing; the LED brightness two registers earlier
	// reads exactly 100 at gain 1, which is what fixes the alignment.
	degrees := func(addr int) *float64 {
		v, ok := at(addr)
		if !ok {
			return nil
		}
		c := float64(int16(v)) / 10 // signed: the relays can read below zero
		return &c
	}

	snap.VoltageL1 = scaled(regVoltageL1N, 10)
	snap.VoltageL2 = scaled(regVoltageL1N+1, 10)
	snap.VoltageL3 = scaled(regVoltageL1N+2, 10)
	snap.VoltageL1L2 = scaled(regVoltageL1L2, 10)
	snap.VoltageL2L3 = scaled(regVoltageL1L2+1, 10)
	snap.VoltageL3L1 = scaled(regVoltageL1L2+2, 10)
	snap.CurrentL1 = scaled(regCurrentL1, 100)
	snap.CurrentL2 = scaled(regCurrentL1+1, 100)
	snap.CurrentL3 = scaled(regCurrentL1+2, 100)

	snap.PowerL1 = word(regPowerL1)
	snap.PowerL2 = word(regPowerL1 + 2)
	snap.PowerL3 = word(regPowerL1 + 4)
	snap.PowerTotal = word(regPowerTotal)
	snap.ReactiveL1 = word(regReactiveL1)
	snap.ReactiveL2 = word(regReactiveL1 + 2)
	snap.ReactiveL3 = word(regReactiveL1 + 4)
	snap.ApparentL1 = word(regApparentL1)
	snap.ApparentL2 = word(regApparentL1 + 2)
	snap.ApparentL3 = word(regApparentL1 + 4)
	snap.SessionSeconds = word(regSessionSec)
	snap.SessionWh = word(regSessionWh)

	snap.PWMEnabled = flag(regPWMEnabled)
	// The spec leaves this register's unit and gain blank. It reads 11873 while
	// the CP signal register reports state A, which that enum itself names as
	// 12 V — so the register is millivolts, and the state beside it is the
	// cross-check.
	snap.CPVoltage = scaled(regCPVoltage, 1000)
	snap.PhaseMode = num(regPhaseMode)
	snap.ChargingMode = num(regChargingMode)
	snap.LoadBalancing = flag(regLoadBalancing)
	snap.SolarBalancing = flag(regSolarBalancing)
	snap.BoostMode = flag(regBoostMode)
	snap.LEDBrightness = num(regLEDBrightness)
	snap.Relay1TempC = degrees(regRelay1Temp)
	snap.Relay2TempC = degrees(regRelay2Temp)

	if v := num(regCPSignal); v != nil {
		snap.CPSignal, snap.CPSignalDesc = v, cpSignalNames[*v]
	}
	if v := num(regChargingStatus); v != nil {
		snap.Status, snap.StatusDesc = v, statusName(*v)
	}
	if v := num(regOCPPStatus); v != nil {
		snap.OcppStatus, snap.OcppStatusDesc = v, ocppStatusNames[*v]
	}
	if v := num(regMQTTStatus); v != nil {
		snap.MqttStatus, snap.MqttStatusDesc = v, mqttStatusNames[*v]
	}

	for addr := regAlarm1; addr < regAlarm1+12; addr++ {
		v, ok := at(addr)
		if !ok {
			break
		}
		snap.Alarms = append(snap.Alarms, v)
		if v != 0 {
			snap.Alarm = true
		}
	}
}

// decodeIdentity fills the snapshot from the 20000-20040 block.
func decodeIdentity(snap *ModbusSnapshot, regs []uint16) {
	text := func(addr, count int) string {
		i := addr - identityStart
		if i < 0 || i+count > len(regs) {
			return ""
		}
		b := make([]byte, 0, count*2)
		for _, r := range regs[i : i+count] {
			b = binary.BigEndian.AppendUint16(b, r)
		}
		return strings.TrimSpace(strings.TrimRight(string(b), "\x00"))
	}
	// The identity block's numbers are INT32 pairs, big-endian across the two
	// registers like the power readings in the live block.
	num := func(addr int) *int {
		i := addr - identityStart
		if i < 0 || i+2 > len(regs) {
			return nil
		}
		v := int(int32(uint32(regs[i])<<16 | uint32(regs[i+1])))
		return &v
	}
	word := func(addr int) *int {
		i := addr - identityStart
		if i < 0 || i >= len(regs) {
			return nil
		}
		v := int(regs[i])
		return &v
	}

	snap.Model = text(regModelName, 10)
	snap.Serial = text(regSerialNumber, 12)
	snap.Firmware = text(regSoftwareVersion, 6)
	snap.Hardware = text(regHardwareVersion, 6)
	snap.ProductNumber = word(regProductNumber)
	snap.RatedPowerW = num(regRatedPower)
	// The spec's unit column calls these watts and kVA; they are amps, which is
	// what the charger reports and what the current limit is set in.
	snap.MinCurrentA = num(regMinOutCurrent)
	snap.MaxCurrentA = num(regMaxOutCurrent)
}

// decodeSettings fills the snapshot from the 21000-21005 control block.
func decodeSettings(snap *ModbusSnapshot, regs []uint16) {
	at := func(addr int) (uint16, bool) {
		i := addr - settingsStart
		if i < 0 || i >= len(regs) {
			return 0, false
		}
		return regs[i], true
	}
	set := &ModbusSettings{}
	if v, ok := at(regChargingCommand); ok {
		n := int(v)
		set.LastCommand = &n
	}
	if v, ok := at(regMaxCurrentSet); ok {
		a := float64(v) / 10
		set.MaxCurrentA = &a
	}
	if v, ok := at(regBoostSet); ok {
		b := v != 0
		set.Boost = &b
	}
	if v, ok := at(regTimeoutSet); ok {
		n := int(v)
		set.TimeoutSeconds = &n
	}
	if v, ok := at(regPhaseCountSet); ok {
		n := int(v)
		set.PhaseSetting, set.PhaseDesc = &n, phaseSettingNames[n]
	}
	snap.Settings = set
}

// ModbusStartCharging asks the charger to begin a session.
func ModbusStartCharging(ctx context.Context, c *modbus.Client) error {
	return c.WriteSingle(ctx, regChargingCommand, cmdStartCharging)
}

// ModbusStopCharging asks the charger to end the current session.
func ModbusStopCharging(ctx context.Context, c *modbus.Client) error {
	return c.WriteSingle(ctx, regChargingCommand, cmdStopCharging)
}

// checkMaxCurrent validates a charging current ceiling in amps. The limit is the
// charger's, not the transport's, so the cloud path applies the same rule (see
// cloudmqtt.go): below currentPauseFloor the charger stops rather than charging
// slowly, which makes a lower limit a pause in disguise — so it is refused, and
// a caller that means to pause is asked to say so.
func checkMaxCurrent(amps float64) error {
	if amps > 0 && amps < currentPauseFloor {
		return fmt.Errorf("anker-solix: %.1f A is below the charger's %.0f A floor, which pauses charging; stop the session instead", amps, currentPauseFloor)
	}
	if amps < 0 || amps > 32 {
		return fmt.Errorf("anker-solix: %.1f A is outside the charger's range (%.0f-32 A)", amps, currentPauseFloor)
	}
	return nil
}

// ModbusSetMaxCurrent sets the charging current ceiling, in amps. The register
// carries deciamps.
func ModbusSetMaxCurrent(ctx context.Context, c *modbus.Client, amps float64) error {
	if err := checkMaxCurrent(amps); err != nil {
		return err
	}
	return c.WriteSingle(ctx, regMaxCurrentSet, uint16(amps*10))
}

// ModbusSetBoost turns boost on for the current session; the charger clears it
// again when the session ends.
func ModbusSetBoost(ctx context.Context, c *modbus.Client, on bool) error {
	var v uint16
	if on {
		v = 1
	}
	return c.WriteSingle(ctx, regBoostSet, v)
}

// ModbusSetPhaseMode fixes the charger to single- or three-phase, or hands the
// choice back to it. 0 automatic, 1 fixed single-phase, 2 fixed three-phase.
func ModbusSetPhaseMode(ctx context.Context, c *modbus.Client, mode int) error {
	if mode < 0 || mode > 2 {
		return fmt.Errorf("anker-solix: phase mode %d is not one of 0 (automatic), 1 (single) or 2 (three)", mode)
	}
	return c.WriteSingle(ctx, regPhaseCountSet, uint16(mode))
}

// ModbusSetTimeout sets the control timeout: if no Modbus client writes within
// it, the charger falls back to its own strategy. The spec requires more than
// five seconds.
func ModbusSetTimeout(ctx context.Context, c *modbus.Client, seconds int) error {
	if seconds <= 5 {
		return fmt.Errorf("anker-solix: the Modbus timeout must be more than 5 seconds, not %d", seconds)
	}
	return c.WriteSingle(ctx, regTimeoutSet, uint16(seconds))
}
