package ankersolix

// The wire format Anker's cloud carries between the mobile app and the charger.
//
// An MQTT message is JSON, but only as an envelope: the part that means anything
// is a base64 field inside it holding a binary frame the device itself speaks.
// That frame is the same one the app sends, so a command is not a documented API
// call but a byte layout, reproduced here from the message maps in
// anker-solix-api (src/anker_solix_api/mqtttypes.py, mqttmap.py, mqttcmdmap.py at
// v3.8.1) and checked against a live A5191.
//
//	ff 09      2-byte marker on every Anker Solix frame
//	xx xx      total length in bytes, little endian, counting the checksum
//	03 00 0f   fixed pattern; the middle byte is 00 outbound, 01 inbound
//	xx xx      message type — what the frame is, per device model
//	[xx]       an optional counter, present on some inbound frames
//	<fields>   one or more data fields
//	xx         XOR of every preceding byte
//
// and each data field is
//
//	xx         field name (a1, a2, … — the model's message map names it)
//	xx         length of everything that follows in this field
//	[xx]       value type, present when the field carries more than one byte
//	xx …       the value
//
// The frame has no version number and no field-type registry: which name means
// what depends on the message type, which is why the maps below are per message
// type rather than one table.

import (
	"encoding/binary"
	"fmt"
	"math"
	"strings"
	"time"
)

// Frame markers. patternSend is what the app puts in an outbound frame; a device
// answers with 03 01 0f, which is not checked — the message type is what selects
// a decoder.
var (
	frameMarker = []byte{0xff, 0x09}
	patternSend = []byte{0x03, 0x00, 0x0f}
)

// frameHeaderLen is the marker, length, pattern and message type — everything
// before the first data field, and before any inbound counter byte.
const frameHeaderLen = 9

// Value types. A field longer than one byte starts with one of these; a
// single-byte field carries its value directly with no type at all.
const (
	typeString  byte = 0x00
	typeUint8   byte = 0x01
	typeInt16LE byte = 0x02
	typeInt32LE byte = 0x03 // "var": four bytes, though not always one value
	typeBytes   byte = 0x04 // bytes that are an identifier, not a number — an RFID UID
	typeFloat32 byte = 0x05
	typeNone    byte = 0xff // this package's marker for "no type byte"
)

// typeByteMax is the largest first-value-byte still read as a value type. Field
// names start at 0xa1 and value types stop at 0x06, so the gap is wide; the
// bound matches the reference implementation's rather than narrowing it, since a
// message type we have not seen may use a type we have not seen either.
const typeByteMax byte = 0x31

// Message types this package speaks, for the A5191 (V1 Smart EV Charger).
// Outbound ones are commands, inbound ones are what the charger publishes back.
const (
	msgEVStatusReq     = "0040" // ask the charger to report its parameters
	msgRealtimeTrigger = "0057" // ask for the fast telemetry stream
	msgEVSettings      = "0100" // the settings group: current limit, brightness, …
	msgEVMode          = "0105" // start / stop / skip delay / boost
	msgEVSchedule      = "0106" // the charging schedule: switch, mode and times
	msgEVBalancing     = "010c" // load balancing and the main breaker limit
	msgEVSolar         = "010e" // solar charging
	msgEVPowerMode     = "0108" // the device power mode: the one value restarts it
	msgEVTelemetry     = "0410" // fast telemetry, only while a trigger is live
	msgEVParams        = "0405" // settings and identity, sent after a command
	msgEVParamsAlt     = "0840" // the same fields, in answer to a status request
	msgEVConfirm       = "0900" // the same fields again, confirming a control change
	msgEVCharging      = "0403" // a couple of charging parameters
	// The charger's own copy of the RFID card list, published when the account
	// asks it for one. Named from a live capture: nine UIDs in one frame, the
	// same nine the cloud's get_device_cards answered with, in reverse order —
	// newest first. The count is a field; the cards are one field each, so they
	// are collected rather than mapped (see decodeFrame).
	msgEVCards = "0904"
	// Which OCPP backend the charger is pointed at, from the charger rather than
	// from the account: the same address get_ocpp_info reports, and the source
	// number that view leaves as a bare integer.
	msgEVOcppInfo = "0911"
	// The three commands the app sends about cards, all captured from its own
	// traffic on the charger's command topic. The pattern is the connector's
	// everywhere: a command 01xx is answered by the data frame 09xx.
	//
	//	0103  write one card:  a2 = 1 add, 2 delete; a4 = the UID  -> 0903, then 0904
	//	0104  ask for the list                                     -> 0904
	//	0111  ask which OCPP backend                               -> 0911
	//
	// The fourth is not its own message: opening the reader is the power-mode
	// command with powerModeReadCard, which is answered by 0908.
	msgEVCardWrite   = "0103"
	msgEVCardListReq = "0104"
	msgEVOcppInfoReq = "0111"

	// The reader, reporting a card held against it. Captured during an
	// enrol-at-the-charger: the frame arrives the moment the card is tapped,
	// carrying the UID, and the charger publishes its updated card list a second
	// later. The same frame arrives without a UID when the twenty-second window
	// closes with nothing tapped, which is what makes the UID field the event.
	msgEVCardRead = "0908"

	// powerModeReadCard opens the card reader for a tap: the app sends the
	// power-mode command with this value and the charger answers 0908 — with the
	// UID if a card was held against it inside the twenty seconds, and without one
	// if the window closed empty. Captured from the app on an A5191; the same 7
	// comes back in the answer.
	powerModeReadCard uint8 = 7

	// powerModeRestart is the only other value the power-mode command is known to take.
	// The map documents 5 and nothing else, so nothing else is sent.
	powerModeRestart uint8 = 5
)

// mqttField is one named value inside a device message. factor scales the raw
// integer (0.1 for a decivolt, 0.001 for a watt-hour reported in kWh); unsigned
// marks the fields whose two- and four-byte types are *not* signed, which is the
// exception rather than the rule; clock marks the two-byte fields that hold a
// minute and an hour rather than a number.
type mqttField struct {
	name     string
	factor   float64
	unsigned bool
	clock    bool
	// version marks the three identity fields, which the reference calls
	// multi-value: four bytes that are the parts of a version rather than one
	// number, in the order they arrive.
	version bool
}

// evTelemetry decodes the 0410 message: the charger's live electrical state,
// published every few seconds but only while a realtime trigger is live. It is
// where the two signals the Modbus map has no register for — the plug and start
// countdowns — actually come from.
var evTelemetry = map[byte]mqttField{
	0xa2: {name: "voltageL1", factor: 0.1},
	0xa3: {name: "voltageL2", factor: 0.1},
	0xa4: {name: "voltageL3", factor: 0.1},
	0xa5: {name: "currentL1", factor: 0.1},
	0xa6: {name: "currentL2", factor: 0.1},
	0xa7: {name: "currentL3", factor: 0.1},
	0xa8: {name: "powerTotal"},
	0xa9: {name: "sessionSeconds"},
	0xaa: {name: "sessionWh"},
	0xab: {name: "sessionStartedAt", unsigned: true},
	// Where the charge is coming from: 0 off or paused, 1 grid, 7 solar. The
	// reference marks the reading uncertain, and it is not the Modbus map's
	// chargingMode — a third name for a fourth thing would be the same collision
	// the cloud's d9 already caused once.
	0xac: {name: "chargingSource"},
	0xad: {name: "plugCountdownSeconds"},
	0xae: {name: "startCountdownSeconds"},
	0xaf: {name: "chargingWindowSeconds"},
	0xb0: {name: "powerL1"},
	0xb1: {name: "powerL2"},
	0xb2: {name: "powerL3"},
	0xb3: {name: "sessionWhL1"},
	0xb4: {name: "sessionWhL2"},
	0xb5: {name: "sessionWhL3"},
	0xb6: {name: "orderId", unsigned: true}, // the session's id upstream; uncertain in the reference
	0xb8: {name: "ocppStatus"},
	0xba: {name: "phaseMode"},
	0xbb: {name: "status"},
}

// evParams decodes the 0405 message (and the 0840 and 0900 that carry the same
// fields): what the charger is *set* to, plus its identity. The charger sends it
// after a control change rather than on a schedule, so these values arrive with
// a command rather than with the telemetry stream.
var evParams = map[byte]mqttField{
	0xa3: {name: "plugLockSwitch"},
	0xa4: {name: "autoStartSwitch"},
	0xa8: {name: "maxCurrentSetA", factor: 0.1},
	0xaa: {name: "ledBrightness"},
	0xac: {name: "autoRestartSwitch"},
	0xad: {name: "randomDelaySwitch"},
	0xaf: {name: "swipeUpMode"},
	0xb0: {name: "swipeDownMode"},
	0xb2: {name: "smartTouchMode"},
	0xb4: {name: "lightOffScheduleSwitch"},
	0xb5: {name: "lightOffStart", unsigned: true, clock: true},
	0xb6: {name: "lightOffEnd", unsigned: true, clock: true},
	0xb7: {name: "modbusSwitch"},
	0xcc: {name: "modbusTimeoutSeconds"},
	0xce: {name: "maxCurrentA", factor: 0.1},
	0xcf: {name: "modbusPort"},
	0xd0: {name: "ipAddress"},
	0xd3: {name: "loadBalancing"},
	0xd4: {name: "mainBreakerLimitA"},
	0xd5: {name: "loadBalanceMonitorMode"},
	0xd6: {name: "loadBalanceMeterFlag"},
	0xd7: {name: "loadBalanceMonitorSN"},
	0xd8: {name: "solarBalancing"},
	// The charger's solar charging mode (grid-assisted or solar only), not the
	// charging mode the Modbus map means by that name.
	0xd9: {name: "solarChargeMode"},
	0xda: {name: "solarMinCurrentA"},
	0xdb: {name: "phaseMode"},
	0xdc: {name: "solarMonitoringMode"},
	0xdd: {name: "autoPhaseSwitch"},
	0xde: {name: "solarMonitorSN"},
	0xdf: {name: "boostMode"},
	0xe0: {name: "cpSignal"},
	0xe2: {name: "plugged"},
	0xe3: {name: "status"},
	0xe6: {name: "scheduleSwitch"},
	0xe7: {name: "weekStart", unsigned: true, clock: true},
	0xe8: {name: "weekEnd", unsigned: true, clock: true},
	0xe9: {name: "weekendStart", unsigned: true, clock: true},
	0xea: {name: "weekendEnd", unsigned: true, clock: true},
	0xeb: {name: "weekendMode"},
	0xec: {name: "scheduleMode"},
	// The three identity fields. The reference decodes none of them, marking them
	// multi-value; four bytes read as the parts of a version is what the account
	// view's own firmware string looks like, so they are read that way and shown
	// as they arrive rather than rearranged into an order we cannot check.
	0xf1: {name: "softwareVersion", version: true},
	0xf2: {name: "controllerVersion", version: true},
	0xf3: {name: "hardwareVersion", version: true},
	0xfe: {name: "minCurrentA"},
}

// evCharging decodes the 0403 message, a pair of charging parameters the charger
// sends around a mode change.
var evCharging = map[byte]mqttField{
	0xa5: {name: "chargingWindowSeconds"},
	0xa6: {name: "solarMinCurrentA"},
}

// evCards names what is not a card in the card-list frame. The cards themselves
// start at 0xa3 and run one per field for as many as the charger holds, so they
// cannot be a map — decodeFrame collects them.
var evCards = map[byte]mqttField{
	0xa2: {name: "rfidCardCount"},
}

// evCardRead names the reader's own event. a2 was 7 in every capture, with and
// without a card, so it is relayed under its own key rather than guessed at.
var evCardRead = map[byte]mqttField{
	0xa3: {name: "rfidCardRead"},
}

// evCardWrite names the write the app sends. a3 was 1 on both the add and the
// delete, so it keeps its own key rather than a guessed name.
var evCardWrite = map[byte]mqttField{
	0xa2: {name: "cardWriteAction"}, // 1 add, 2 delete
	0xa4: {name: "cardWritten"},
}

// evOcppInfo names the charger's own view of its OCPP backend.
var evOcppInfo = map[byte]mqttField{
	0xa2: {name: "ocppBackendName"},
	0xa3: {name: "ocppBackendUrl"},
	0xa4: {name: "ocppSource"},
	0xa5: {name: "ocppChargePointId"},
}

// evInfoMessages are messages this package can name but must not treat as a
// settings report: evMessages is what stamps the timestamp a control command
// waits on, and a card list is not an acknowledgement of anything.
var evInfoMessages = map[string]map[byte]mqttField{
	msgEVCards:     evCards,
	msgEVOcppInfo:  evOcppInfo,
	msgEVCardRead:  evCardRead,
	msgEVCardWrite: evCardWrite,
}

// evMessages selects a field map by message type. A type absent from here is one
// we have no map for; its frame is still parsed, but nothing is named.
var evMessages = map[string]map[byte]mqttField{
	msgEVTelemetry: evTelemetry,
	msgEVParams:    evParams,
	msgEVParamsAlt: evParams,
	msgEVConfirm:   evParams,
	msgEVCharging:  evCharging,
}

// ---- outbound frames ---------------------------------------------------------

// cmdField is one field of a command frame. typ is typeNone for the single-byte
// fields that carry no value type.
type cmdField struct {
	name  byte
	typ   byte
	value []byte
}

// rawField builds a field with no value type — the `a1 01 22` pattern that opens
// every command.
func rawField(name byte, value ...byte) cmdField {
	return cmdField{name: name, typ: typeNone, value: value}
}

// uintField builds a one-byte unsigned field.
func uintField(name byte, v uint8) cmdField {
	return cmdField{name: name, typ: typeUint8, value: []byte{v}}
}

// intField builds a two-byte little-endian signed field.
func intField(name byte, v int16) cmdField {
	b := make([]byte, 2)
	binary.LittleEndian.PutUint16(b, uint16(v))
	return cmdField{name: name, typ: typeInt16LE, value: b}
}

// varField builds a four-byte little-endian field.
func varField(name byte, v uint32) cmdField {
	b := make([]byte, 4)
	binary.LittleEndian.PutUint32(b, v)
	return cmdField{name: name, typ: typeInt32LE, value: b}
}

// bytesField builds a field holding bytes that are an identifier rather than a
// number — an RFID UID, four bytes or seven, exactly as the reader reported it.
func bytesField(name byte, b []byte) cmdField {
	return cmdField{name: name, typ: typeBytes, value: append([]byte(nil), b...)}
}

// timestampField is the `fe` field every command ends with: the sender's clock,
// in whole seconds.
func timestampField(now time.Time) cmdField {
	return varField(0xfe, uint32(now.Unix()))
}

// bareTimestampField is the timestamp the status request carries: the same
// clock as every other command, sent without its value type. The app sends it
// that way — the reference reads it as an Anker bug and keeps the whole command
// commented out because of it — and the charger answers what the app sends, so
// the oddity is reproduced rather than corrected.
func bareTimestampField(now time.Time) cmdField {
	b := make([]byte, 4)
	binary.LittleEndian.PutUint32(b, uint32(now.Unix()))
	return cmdField{name: 0xfe, typ: typeNone, value: b}
}

// clockField builds the two-byte field the charger carries a time of day in:
// the hour and the minute, least significant byte first, which is the same
// layout the decoder reads back as "HH:MM".
func clockField(name byte, hour, minute int) cmdField {
	return intField(name, int16(uint16(hour)<<8|uint16(minute)))
}

// stringField builds a fixed-length text field, zero padded. The length is the
// charger's, not the value's: a serial field is sixteen bytes whether or not the
// serial fills them.
func stringField(name byte, s string, length int) cmdField {
	b := make([]byte, length)
	copy(b, s)
	return cmdField{name: name, typ: typeString, value: b}
}

// encodeFrame builds one command frame for a message type. The caller supplies
// every field in wire order, starting with the `a1 01 22` opener each command in
// the reference maps carries and ending with the timestamp.
func encodeFrame(msgType string, fields []cmdField) ([]byte, error) {
	mt, err := decodeHex(msgType)
	if err != nil || len(mt) < 2 || len(mt) > 3 {
		return nil, fmt.Errorf("anker-solix: %q is not a message type", msgType)
	}

	body := make([]byte, 0, 32)
	for _, f := range fields {
		n := len(f.value)
		if f.typ != typeNone {
			n++
		}
		if n < 1 || n > 255 {
			return nil, fmt.Errorf("anker-solix: field %02x does not fit one frame field", f.name)
		}
		body = append(body, f.name, byte(n))
		if f.typ != typeNone {
			body = append(body, f.typ)
		}
		body = append(body, f.value...)
	}

	// The length counts the whole frame, checksum byte included.
	total := frameHeaderLen + (len(mt) - 2) + len(body) + 1
	out := make([]byte, 0, total)
	out = append(out, frameMarker...)
	out = binary.LittleEndian.AppendUint16(out, uint16(total))
	out = append(out, patternSend...)
	out = append(out, mt...)
	out = append(out, body...)
	return append(out, xorChecksum(out)), nil
}

// xorChecksum is the frame's only integrity check: every byte XORed together.
func xorChecksum(b []byte) byte {
	var sum byte
	for _, x := range b {
		sum ^= x
	}
	return sum
}

// ---- inbound frames ----------------------------------------------------------

// decodeFrame parses one device frame and returns its message type together with
// the values it carries: the ones its field map names, under those names, and
// the ones no map names, under the message and name byte they arrived with (see
// rawFieldName). Nothing is guessed at — an unnamed field keeps its raw number
// and gains no unit, scaling or meaning — but nothing is thrown away either,
// because a field this package cannot name is still the only place some of what
// the charger knows ever appears. A frame whose checksum does not add up is
// rejected: these arrive over a cloud connection we do not control, so a
// truncated one must not be read as a charger reporting zeros.
func decodeFrame(data []byte) (string, map[string]any, error) {
	if len(data) < frameHeaderLen+2 {
		return "", nil, fmt.Errorf("anker-solix: device frame is %d bytes, too short to hold a header", len(data))
	}
	if data[0] != frameMarker[0] || data[1] != frameMarker[1] {
		return "", nil, fmt.Errorf("anker-solix: device frame does not start with the Anker marker (%02x%02x)", data[0], data[1])
	}
	if n := int(binary.LittleEndian.Uint16(data[2:4])); n != len(data) {
		return "", nil, fmt.Errorf("anker-solix: device frame says it is %d bytes but %d arrived", n, len(data))
	}
	if xorChecksum(data) != 0 {
		return "", nil, fmt.Errorf("anker-solix: device frame checksum does not match")
	}

	msgType := encodeHex(data[7:9])

	// Some inbound frames carry a counter byte between the header and the first
	// data field, and nothing in the frame says which kind this is. Rather than
	// guess from the byte's value — field names run high enough to be mistaken for
	// a counter — try both and keep the reading whose fields tile the frame
	// exactly, from the first field to the checksum with nothing left over.
	raw, ok := splitFields(data, frameHeaderLen)
	if !ok {
		if raw, ok = splitFields(data, frameHeaderLen+1); !ok {
			return "", nil, fmt.Errorf("anker-solix: device frame %s does not divide into whole data fields", msgType)
		}
	}

	fields := evMessages[msgType]
	if fields == nil {
		fields = evInfoMessages[msgType]
	}
	values := map[string]any{}
	for _, r := range raw {
		f, known := fields[r.name]
		if !known || f.name == "" {
			// No name for it: keep the value under the message and name byte, with
			// no scaling — a factor is part of a field's meaning, and we have none.
			if v, ok := decodeValue(r.typ, r.value, mqttField{}); ok {
				values[rawFieldName(msgType, r.name)] = v
			}
			continue
		}
		if v, ok := decodeValue(r.typ, r.value, f); ok {
			values[f.name] = v
		}
	}
	if msgType == msgEVCards {
		values["rfidCards"] = cardsFromFields(raw)
	}
	return msgType, values, nil
}

// cardsFromFields reads the card UIDs out of a card-list frame. Every field from
// 0xa3 up is one card, in the order the charger sent them, and a UID is however
// many bytes the card has — four for the older tags, seven for the newer ones —
// so the bytes are taken as they are and read as hex, which is how the account
// prints them and how a person reads one off a card.
func cardsFromFields(raw []rawFieldBytes) []string {
	out := []string{}
	for _, r := range raw {
		if r.name < 0xa3 || len(r.value) == 0 {
			continue
		}
		out = append(out, strings.ToUpper(encodeHex(r.value)))
	}
	return out
}

// rawFieldName keys a field no map names, by the message it arrived in and the
// name byte the charger gave it — "0410.b6". The message type belongs in the key
// because a name byte means whatever its message says it means: the same b6 is a
// different quantity in the telemetry stream and in the settings group, and one
// key for both would merge two readings into one wrong number.
func rawFieldName(msgType string, name byte) string {
	return msgType + "." + encodeHex([]byte{name})
}

// rawFieldBytes is one data field as it sat in the frame, before its map entry
// decides what it means.
type rawFieldBytes struct {
	name  byte
	typ   byte
	value []byte
}

// splitFields walks the data fields from start and reports them only if they end
// exactly at the checksum byte. A frame read from the wrong offset runs off the
// end or stops short, which is what makes the exact fit a usable test.
func splitFields(data []byte, start int) ([]rawFieldBytes, bool) {
	end := len(data) - 1 // the last byte is the checksum
	var out []rawFieldBytes
	for idx := start; idx < end; {
		if idx+2 > end {
			return nil, false
		}
		name := data[idx]
		flen := int(data[idx+1])
		if flen == 0 || idx+2+flen > end {
			return nil, false
		}
		body := data[idx+2 : idx+2+flen]
		idx += 2 + flen

		typ, value := typeNone, body
		if flen > 1 && body[0] <= typeByteMax {
			typ, value = body[0], body[1:]
		}
		out = append(out, rawFieldBytes{name: name, typ: typ, value: value})
	}
	return out, len(out) > 0
}

// decodeValue turns one field's bytes into a value, following the type byte the
// field carries and the scaling its map entry gives. It reports false for a
// field whose bytes do not fit its type, so a short value is dropped rather than
// read as a smaller number.
func decodeValue(typ byte, b []byte, f mqttField) (any, bool) {
	if len(b) == 0 {
		return nil, false
	}
	if f.version {
		return versionString(b), true
	}
	factor := f.factor
	if factor == 0 {
		factor = 1
	}
	scale := func(n int64) any {
		if factor == 1 {
			return float64(n)
		}
		return round(float64(n)*factor, factor)
	}

	switch typ {
	case typeString:
		return printable(b), true
	case typeUint8:
		return scale(int64(b[0])), true
	case typeInt16LE:
		if len(b) < 2 {
			return nil, false
		}
		if f.clock {
			// Two bytes holding a minute and an hour, least significant first.
			return fmt.Sprintf("%02d:%02d", b[1], b[0]), true
		}
		if f.unsigned {
			return scale(int64(binary.LittleEndian.Uint16(b))), true
		}
		return scale(int64(int16(binary.LittleEndian.Uint16(b)))), true
	case typeInt32LE:
		if len(b) < 4 {
			return nil, false
		}
		if f.unsigned {
			return scale(int64(binary.LittleEndian.Uint32(b))), true
		}
		return scale(int64(int32(binary.LittleEndian.Uint32(b)))), true
	case typeBytes:
		// An identifier rather than a quantity: a 4- or 7-byte card UID, which is
		// the same hex the account prints on the card list. Read as a number it
		// would be a different string on every screen it reached.
		return strings.ToUpper(encodeHex(b)), true
	case typeFloat32:
		if len(b) < 4 {
			return nil, false
		}
		return float64(math.Float32frombits(binary.LittleEndian.Uint32(b))), true
	default:
		// No value type: the bytes are the number, most significant first.
		var n int64
		for _, x := range b {
			n = n<<8 | int64(x)
		}
		return scale(n), true
	}
}

// versionString reads an identity field. Four bytes are the parts of a version,
// in wire order; anything else is text the charger padded, or — failing that —
// the bytes themselves, because a version we cannot shape is still better shown
// than dropped.
func versionString(b []byte) string {
	if len(b) == 4 {
		return fmt.Sprintf("%d.%d.%d.%d", b[0], b[1], b[2], b[3])
	}
	if s := printable(b); s != "" {
		return s
	}
	return encodeHex(b)
}

// round trims the floating-point noise a factor introduces, to the precision the
// factor itself implies — 0.1 keeps one decimal, 0.001 keeps three.
func round(v, factor float64) float64 {
	digits := 0
	for f := factor; f < 1 && digits < 6; f *= 10 {
		digits++
	}
	p := math.Pow(10, float64(digits))
	return math.Round(v*p) / p
}

// printable keeps the readable part of a string field; the charger pads some of
// them with control bytes.
func printable(b []byte) string {
	var sb strings.Builder
	for _, r := range string(b) {
		if r >= 0x20 && r != 0x7f {
			sb.WriteRune(r)
		}
	}
	return strings.TrimSpace(sb.String())
}

// ---- small hex helpers -------------------------------------------------------

const hexDigits = "0123456789abcdef"

// encodeHex renders bytes as lowercase hex, which is how message types are keyed.
func encodeHex(b []byte) string {
	out := make([]byte, 0, len(b)*2)
	for _, x := range b {
		out = append(out, hexDigits[x>>4], hexDigits[x&0x0f])
	}
	return string(out)
}

// decodeHex parses a lowercase or uppercase hex string.
func decodeHex(s string) ([]byte, error) {
	if len(s)%2 != 0 {
		return nil, fmt.Errorf("hex string %q has an odd length", s)
	}
	out := make([]byte, 0, len(s)/2)
	for i := 0; i < len(s); i += 2 {
		hi, err1 := hexNibble(s[i])
		lo, err2 := hexNibble(s[i+1])
		if err1 != nil || err2 != nil {
			return nil, fmt.Errorf("hex string %q holds a non-hex character", s)
		}
		out = append(out, hi<<4|lo)
	}
	return out, nil
}

func hexNibble(c byte) (byte, error) {
	switch {
	case c >= '0' && c <= '9':
		return c - '0', nil
	case c >= 'a' && c <= 'f':
		return c - 'a' + 10, nil
	case c >= 'A' && c <= 'F':
		return c - 'A' + 10, nil
	}
	return 0, fmt.Errorf("not a hex digit: %q", string(rune(c)))
}
