package ankersolix

// The account's chargers do not all live in one place: which cloud view a
// charger shows up in depends on how it was set up in the Anker app, and no
// single endpoint sees all of them.
//
//   - get_user_bind_and_not_in_station_evchargers lists only the chargers that
//     are NOT part of a station/system. Its userBindEvChargersCount, though,
//     counts every charger bound to the account — so an account whose chargers
//     all sit in a station answers "2 chargers bound" with an empty list, which
//     is how a perfectly good login ends up showing nothing.
//   - a charger that belongs to a system appears in that system's view instead:
//     get_scen_info for power-service sites, get_system_running_info for HES
//     ones (the same split chargerState handles).
//   - get_relate_and_bind_devices knows every bound device with its model,
//     firmware and Wi-Fi state, but nothing about charging.
//   - get_evcharger_station_info answers for one charger at a time — the record
//     the mobile app opens when you tap a charger. It is the only view that says
//     anything live about a charger standing outside a station, which is exactly
//     the charger the three list views say least about.
//
// accountChargers therefore asks all four and merges the answers by serial, so
// the list matches what the mobile app shows however the chargers were
// registered. Merging keeps the fields the rest of DriverVault has names for,
// and keeps every other field the cloud sent as well, under the cloud's own key
// (see attrs): a value nothing here recognises is still a value the owner of the
// charger may want to read.

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

// evChargerPNPrefix matches the EV-charger product family (A5191 is the V1 Smart
// EV Charger); a bound device outside it is other Anker hardware on the account.
const evChargerPNPrefix = "A519"

// accountCharger is one charger, merged from every view that reported it. A
// field no view supplied stays empty rather than guessed.
type accountCharger struct {
	SN       string   `json:"sn"`
	Name     string   `json:"name,omitempty"`
	Model    string   `json:"model,omitempty"`
	Firmware string   `json:"firmware,omitempty"`
	SiteID   string   `json:"siteId,omitempty"`
	SiteName string   `json:"siteName,omitempty"`
	Sources  []string `json:"sources"` // standalone | site | bound

	Online         *bool  `json:"online,omitempty"`
	Status         *int   `json:"status,omitempty"`
	StatusDesc     string `json:"statusDesc,omitempty"`
	Power          string `json:"power,omitempty"`
	OcppStatus     *int   `json:"ocppStatus,omitempty"`
	OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`

	// What the account knows about the box on the wall, as opposed to the
	// charging: which networks it is on, where it thinks it is, when it was
	// bound, and the picture the app shows for it. Every one of these arrives
	// with the device views and none of them was ever read.
	WifiName  string   `json:"wifiName,omitempty"`
	WifiMac   string   `json:"wifiMac,omitempty"`
	WifiRSSI  *int     `json:"wifiRssi,omitempty"`
	BleMac    string   `json:"bleMac,omitempty"`
	TimeZone  string   `json:"timeZone,omitempty"`
	LinkedAt  *float64 `json:"linkedAt,omitempty"` // unix seconds
	ImageURL  string   `json:"imageUrl,omitempty"`
	RelatedBy []string `json:"relatedBy,omitempty"` // ble, wifi — how the app reaches it

	// Attrs is everything each view reported about this charger, under the key
	// the cloud used for it. The fields above are the ones DriverVault gives a
	// name and a meaning to; Attrs is the rest, relayed rather than dropped,
	// because Anker documents none of this and a field we have no name for today
	// is still the only place some of what the charger knows ever appears.
	Attrs map[string]string `json:"attrs,omitempty"`
}

// Two limits keep one charger's attributes to something a card can hold: a
// station record that carries a session list would otherwise arrive as hundreds
// of indexed keys, and a base64 blob as one unreadable page.
const (
	maxAttrs    = 200
	maxAttrsLen = 240
)

// note folds one view's raw record into the charger's attributes. A key already
// answered is left alone, so the first view to report a field wins — the same
// rule the typed fields merge by.
func (c *accountCharger) note(m map[string]any) {
	if c.Attrs == nil {
		c.Attrs = map[string]string{}
	}
	flattenInto(c.Attrs, "", m)
}

// flattenInto records every scalar under v in dst, keyed by the path the cloud
// nested it at: objects join with a dot, arrays carry their index. A null is not
// an answer and leaves no key behind.
func flattenInto(dst map[string]string, key string, v any) {
	switch t := v.(type) {
	case map[string]any:
		for k, sub := range t {
			flattenInto(dst, joinAttrKey(key, k), sub)
		}
	case []any:
		for i, sub := range t {
			flattenInto(dst, fmt.Sprintf("%s[%d]", key, i), sub)
		}
	case nil:
	default:
		if key == "" || len(dst) >= maxAttrs {
			return
		}
		if _, seen := dst[key]; seen {
			return
		}
		s := attrString(t)
		if s == "" {
			return // nothing said is not a value
		}
		// A device record carries the charger's own Bluetooth pairing password,
		// and the account views hand out more than one credential besides. That a
		// field exists is worth reporting; its value is not something a card
		// should put on a screen, or a screenshot should carry off one.
		if isSecretKey(key) {
			s = "\u2022\u2022\u2022\u2022\u2022\u2022"
		}
		// Cut by rune, not by byte: half a character is not shorter, it is
		// broken, and it would reach the UI as a replacement glyph.
		if r := []rune(s); len(r) > maxAttrsLen {
			s = string(r[:maxAttrsLen]) + "…"
		}
		dst[key] = s
	}
}

// secretKeyWords are the field names whose values are credentials rather than
// readings. Matched on the leaf name, so a nested one is caught too.
var secretKeyWords = []string{"password", "passwd", "secret", "token", "private_key", "privatekey", "certificate", "auth_key", "authkey"}

func isSecretKey(key string) bool {
	leaf := strings.ToLower(key)
	if i := strings.LastIndex(leaf, "."); i >= 0 {
		leaf = leaf[i+1:]
	}
	for _, word := range secretKeyWords {
		if strings.Contains(leaf, word) {
			return true
		}
	}
	return false
}

func joinAttrKey(prefix, key string) string {
	if prefix == "" {
		return key
	}
	return prefix + "." + key
}

// attrString words a scalar the way the wire did: a number keeps every digit it
// arrived with rather than gaining an exponent, a bool stays a bool.
func attrString(v any) string {
	switch t := v.(type) {
	case string:
		return strings.TrimSpace(t)
	case bool:
		return strconv.FormatBool(t)
	case float64:
		return strconv.FormatFloat(t, 'f', -1, 64)
	case json.Number:
		return t.String()
	default:
		return strings.TrimSpace(fmt.Sprint(t))
	}
}

// chargerInventory merges chargers by serial, keeping the order they were first
// seen in so the output is stable across polls.
type chargerInventory struct {
	order []string
	byID  map[string]*accountCharger
}

func newInventory() *chargerInventory {
	return &chargerInventory{byID: map[string]*accountCharger{}}
}

// get returns the record for sn, creating it on first sight, and notes the view
// it was seen in.
func (inv *chargerInventory) get(sn, source string) *accountCharger {
	c, ok := inv.byID[sn]
	if !ok {
		c = &accountCharger{SN: sn, Sources: []string{}}
		inv.byID[sn] = c
		inv.order = append(inv.order, sn)
	}
	for _, s := range c.Sources {
		if s == source {
			return c
		}
	}
	c.Sources = append(c.Sources, source)
	return c
}

// has reports whether a serial is already known — a bound device enriches a
// charger we have already found even when its product code is unfamiliar.
func (inv *chargerInventory) has(sn string) bool {
	_, ok := inv.byID[sn]
	return ok
}

// list returns the merged chargers in discovery order.
func (inv *chargerInventory) list() []accountCharger {
	out := make([]accountCharger, 0, len(inv.order))
	for _, sn := range inv.order {
		out = append(out, *inv.byID[sn])
	}
	return out
}

// addStandalone reads the chargers that sit outside any station.
func (inv *chargerInventory) addStandalone(body []byte) {
	for _, m := range dataList(body, "evChargers", "evChargerList", "chargerList", "list") {
		sn := pickString(m, "evChargerSn", "device_sn", "deviceSn", "sn")
		if sn == "" {
			continue
		}
		c := inv.get(sn, "standalone")
		c.note(m)
		fillDevice(c, m)
	}
}

// fillDevice reads the fields every device view shares. The three views name
// them alike where they can and differently where they cannot — the same status
// is evChargerStatus in one record and chargerStatus in the next — so each is
// looked up by candidate key, first answer winning, the way the rest of the
// merge works.
func fillDevice(c *accountCharger, m map[string]any) {
	fillString(&c.Name, pickString(m, "evChargerName", "device_name", "alias_name", "deviceName", "name"))
	fillString(&c.Model, pickString(m, "device_pn", "product_code", "evChargerPn", "pn"))
	fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version", "version"))
	fillString(&c.SiteID, pickString(m, "site_id", "siteId", "station_id", "stationId"))
	fillString(&c.SiteName, pickString(m, "site_name", "siteName", "station_name", "stationName"))
	fillString(&c.WifiName, pickString(m, "wifi_name", "wifiName", "ssid"))
	fillString(&c.WifiMac, pickString(m, "wifi_mac", "wifiMac"))
	fillString(&c.BleMac, pickString(m, "bt_ble_mac", "bleMac", "bt_mac"))
	fillString(&c.TimeZone, pickString(m, "time_zone", "timeZone", "timezone"))
	fillString(&c.ImageURL, pickString(m, "img_url", "imgUrl", "image_url"))
	if n, ok := pickInt(m, "rssi", "wifi_rssi", "signal"); ok && c.WifiRSSI == nil {
		c.WifiRSSI = &n
	}
	if t, ok := pickFloat(m, "link_time", "linkTime", "bind_time", "create_time"); ok && c.LinkedAt == nil {
		c.LinkedAt = &t
	}
	if len(c.RelatedBy) == 0 {
		c.RelatedBy = pickStrings(m, "relate_type", "relateType", "connect_type")
	}
	// The state, however the view spells it. A charger that answers one view and
	// not another is the normal case, which is why this is tried on all of them.
	if n, ok := pickInt(m, "evChargerStatus", "chargerStatus", "operating_state", "status"); ok {
		setChargerStatus(c, n)
	}
	if n, ok := pickInt(m, "ocpp_connect_status", "ocppConnectStatus"); ok && c.OcppStatus == nil {
		desc, named := ocppConnStatus[n]
		if !named {
			desc = stateUnknown
		}
		c.OcppStatus, c.OcppStatusDesc = &n, desc
	}
	if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok {
		c.Online = &b
	}
}

// addStates folds a site view's normalized chargers into the inventory and
// reports how many that view carried.
func (inv *chargerInventory) addStates(states []evChargerState, siteName string) int {
	for _, st := range states {
		c := inv.get(st.SN, "site")
		fillString(&c.Name, st.Name)
		fillString(&c.SiteID, st.SiteID)
		fillString(&c.SiteName, siteName)
		fillString(&c.Power, st.Power)
		if st.Status != nil && c.Status == nil {
			s := *st.Status
			c.Status, c.StatusDesc = &s, st.StatusDesc
		}
		if st.OcppStatus != nil && c.OcppStatus == nil {
			o := *st.OcppStatus
			c.OcppStatus, c.OcppStatusDesc = &o, st.OcppStatusDesc
		}
	}
	return len(states)
}

// addBound enriches known chargers from the account's bound-device list, and
// discovers any device whose product code is in the EV-charger family.
func (inv *chargerInventory) addBound(body []byte) {
	for _, m := range dataList(body, "data", "device_list", "devices", "list") {
		sn := pickString(m, "device_sn", "deviceSn", "sn")
		if sn == "" {
			continue
		}
		pn := pickString(m, "device_pn", "product_code", "pn")
		if !inv.has(sn) && !strings.HasPrefix(strings.ToUpper(pn), evChargerPNPrefix) {
			continue // some other Anker device on the same account
		}
		c := inv.get(sn, "bound")
		c.note(m)
		fillDevice(c, m)
	}
}

// noteRecords folds a view's raw records into the chargers already found. The
// site views reach the inventory as normalized state (addStates), which is the
// right shape for the fields we name and drops the rest — so the raw list is
// walked once more here, purely for what those records also carried. A serial
// nothing has found yet is skipped: this pass enriches chargers, it does not
// discover them.
func (inv *chargerInventory) noteRecords(records []map[string]any, snKeys ...string) {
	for _, m := range records {
		sn := pickString(m, snKeys...)
		if sn == "" {
			continue
		}
		if c, ok := inv.byID[sn]; ok {
			c.note(m)
		}
	}
}

// addStation folds the per-charger station record into one charger: everything
// it carries as attributes, and the fields we have names for where no earlier
// view answered. Anker does not document this record, so each field is looked up
// by candidate key the way the list views are.
func (inv *chargerInventory) addStation(sn string, body []byte) {
	c, ok := inv.byID[sn]
	if !ok {
		return
	}
	m := dataObject(body)
	if m == nil {
		return
	}
	c.note(m)
	fillDevice(c, m)
	fillString(&c.Power, pickString(m, "power", "charging_power", "chargingPower"))
}

// siteRef is one system (site) registered on the account.
type siteRef struct{ ID, Name string }

// siteList returns the account's systems — the entry point for every
// site-scoped view.
func (p *Plugin) siteList(ctx context.Context) ([]siteRef, error) {
	body, err := p.apiRequest(ctx, epSiteList, map[string]any{})
	if err != nil {
		return nil, err
	}
	var env struct {
		Data struct {
			SiteList []struct {
				SiteID   string `json:"site_id"`
				SiteName string `json:"site_name"`
			} `json:"site_list"`
		} `json:"data"`
	}
	if err := json.Unmarshal(body, &env); err != nil {
		return nil, fmt.Errorf("anker-solix: decode site list: %w", err)
	}
	out := make([]siteRef, 0, len(env.Data.SiteList))
	for _, s := range env.Data.SiteList {
		if s.SiteID != "" {
			out = append(out, siteRef{ID: s.SiteID, Name: s.SiteName})
		}
	}
	return out, nil
}

// chargersDoc is what the chargers capability answers, and what the health
// check counts. boundCount is the standalone view's own tally of the account's
// chargers, which is not the same number as len(chargers) — it misses whatever
// only the site and bound-device views can see, so it is reported alongside
// rather than instead.
type chargersDoc struct {
	Chargers   []accountCharger `json:"chargers"`
	Count      int              `json:"count"`
	BoundCount *int             `json:"boundCount,omitempty"`
	Warnings   []string         `json:"warnings,omitempty"`
	Detail     string           `json:"detail,omitempty"`
}

// accountChargers is the chargers capability: the inventory as a JSON document.
func (p *Plugin) accountChargers(ctx context.Context) (json.RawMessage, error) {
	doc, err := p.chargerInventory(ctx)
	if err != nil {
		return nil, err
	}
	return json.Marshal(doc)
}

// chargerInventory lists every EV charger on the account by merging the views
// described at the top of this file. A view that fails is recorded as a warning
// and the others still answer; only losing all of them is an error.
func (p *Plugin) chargerInventory(ctx context.Context) (chargersDoc, error) {
	// Sign in once, up front. Every view below needs the same token, and a login
	// Anker refuses is not one view failing — it is all of them, retried in turn,
	// five of which disable the account for ten minutes. Fail on the login
	// itself, and say so instead of blaming the views.
	if _, err := p.ensureToken(ctx); err != nil {
		return chargersDoc{}, err
	}

	inv := newInventory()
	var warnings []string
	views, failed := 0, 0
	fail := func(what string, err error) {
		failed++
		warnings = append(warnings, what+": "+shorten(err.Error()))
	}

	// 1. Chargers registered on their own, outside any station.
	views++
	boundCount := -1
	if body, err := p.apiRequest(ctx, epStandaloneChargers, map[string]any{}); err != nil {
		fail("standalone chargers", err)
	} else {
		if n, ok := countChargers(body); ok {
			boundCount = n
		}
		inv.addStandalone(body)
	}

	// 2. Chargers that belong to a system, one site at a time.
	views++
	sites, err := p.siteList(ctx)
	if err != nil {
		fail("sites", err)
	}
	for _, st := range sites {
		found := 0
		if body, err := p.apiRequest(ctx, epSceneInfo, map[string]any{"site_id": st.ID}); err == nil {
			found = inv.addStates(parseScenePiles(body, st.ID), st.Name)
			inv.noteRecords(rawRecords(body, "charging_pile_info", "charging_pile_list"), "device_sn", "deviceSn", "sn")
		}
		if found == 0 {
			// Not a power-service site, or it reported no pile: try the HES view.
			if body, err := p.apiRequest(ctx, epSystemRunInfo, map[string]any{"siteId": st.ID}); err == nil {
				inv.addStates(parseHesChargers(body, st.ID), st.Name)
				inv.noteRecords(rawRecords(body, "evChargerInfos"), "evChargerSn", "device_sn", "sn")
			}
		}
	}

	// 3. Bound devices: model, firmware and the Wi-Fi online flag.
	views++
	if body, err := p.apiRequest(ctx, epBindDevices, map[string]any{}); err != nil {
		fail("bound devices", err)
	} else {
		inv.addBound(body)
	}

	// 4. The station record, one charger at a time. The three list views above
	// answer for the account; this one answers for a charger, and for a charger
	// registered on its own it is the only view that says what it is doing. Each
	// call is its own request, so a charger the cloud will not talk about costs
	// that charger's row and no more — a station view that fails everywhere is
	// one warning, not one per charger.
	views++
	stationFailed, stationErr := 0, error(nil)
	for _, sn := range inv.order {
		body, err := p.apiRequest(ctx, epStationInfo, map[string]any{"evChargerSn": sn, "featuretype": 1})
		if err != nil {
			stationFailed, stationErr = stationFailed+1, err
			continue
		}
		inv.addStation(sn, body)
	}
	if stationErr != nil && stationFailed == len(inv.order) {
		fail("station info", stationErr)
	}

	if failed == views {
		return chargersDoc{}, fmt.Errorf("anker-solix: chargers: every cloud view failed: %s", strings.Join(warnings, "; "))
	}

	out := inv.list()
	doc := chargersDoc{Chargers: out, Count: len(out), Warnings: warnings}
	if boundCount >= 0 {
		doc.BoundCount = &boundCount
	}
	if len(out) == 0 {
		// An account that owns chargers but exposes none through any view is
		// almost always pointed at the wrong regional server.
		doc.Detail = "No EV charger was returned by any view. If the account does own one, check the country setting — it selects the Anker server, and the wrong one logs in but shows nothing."
	}
	return doc, nil
}

// setChargerStatus records a raw operating-state code and its name; the first
// view to report one wins.
func setChargerStatus(c *accountCharger, code int) {
	if c.Status != nil {
		return
	}
	c.Status, c.StatusDesc = &code, statusName(code)
}

// dataList returns the first array of objects found under one of keys inside a
// response's "data" object. The charger endpoints do not share a field name and
// Anker has renamed them before, so the lookup is by candidate key rather than
// by a fixed struct.
func dataList(body []byte, keys ...string) []map[string]any {
	var env struct {
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal(body, &env); err != nil || env.Data == nil {
		return nil
	}
	for _, k := range keys {
		raw, ok := env.Data[k].([]any)
		if !ok {
			continue
		}
		out := make([]map[string]any, 0, len(raw))
		for _, item := range raw {
			if m, ok := item.(map[string]any); ok {
				out = append(out, m)
			}
		}
		if len(out) > 0 {
			return out
		}
	}
	return nil
}

// dataObject returns a response's "data" object, or nil when it carries
// something else — a list endpoint's data is an array, and the per-charger views
// have been known to answer with a bare value.
func dataObject(body []byte) map[string]any {
	var env struct {
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal(body, &env); err != nil {
		return nil
	}
	return env.Data
}

// rawRecords returns the objects in the array at path inside a response's "data"
// object, descending through the objects named before the last element. It is
// dataList's answer for a list the cloud nests rather than puts at the top.
func rawRecords(body []byte, path ...string) []map[string]any {
	node := any(dataObject(body))
	for i, key := range path {
		obj, ok := node.(map[string]any)
		if !ok {
			return nil
		}
		node = obj[key]
		if i == len(path)-1 {
			break
		}
	}
	raw, ok := node.([]any)
	if !ok {
		return nil
	}
	out := make([]map[string]any, 0, len(raw))
	for _, item := range raw {
		if m, ok := item.(map[string]any); ok {
			out = append(out, m)
		}
	}
	return out
}

// pickString returns the first non-empty value among keys, as a string.
func pickString(m map[string]any, keys ...string) string {
	for _, k := range keys {
		switch v := m[k].(type) {
		case string:
			if s := strings.TrimSpace(v); s != "" {
				return s
			}
		case float64:
			return strconv.FormatFloat(v, 'f', -1, 64)
		}
	}
	return ""
}

// pickFloat returns the first numeric value among keys, unrounded — a unix
// timestamp does not fit an int on every platform and does not want scaling.
func pickFloat(m map[string]any, keys ...string) (float64, bool) {
	for _, k := range keys {
		switch v := m[k].(type) {
		case float64:
			return v, true
		case string:
			if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
				return f, true
			}
		}
	}
	return 0, false
}

// pickStrings returns the first list of strings among keys. The device views
// carry the ways the app can reach a charger this way — ble, wifi — as a plain
// array of names.
func pickStrings(m map[string]any, keys ...string) []string {
	for _, k := range keys {
		raw, ok := m[k].([]any)
		if !ok {
			continue
		}
		out := make([]string, 0, len(raw))
		for _, item := range raw {
			if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
				out = append(out, strings.TrimSpace(s))
			}
		}
		if len(out) > 0 {
			return out
		}
	}
	return nil
}

// pickInt returns the first numeric value among keys.
func pickInt(m map[string]any, keys ...string) (int, bool) {
	for _, k := range keys {
		switch v := m[k].(type) {
		case float64:
			return int(v), true
		case string:
			if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
				return n, true
			}
		}
	}
	return 0, false
}

// pickBool returns the first boolean-ish value among keys.
func pickBool(m map[string]any, keys ...string) (bool, bool) {
	for _, k := range keys {
		switch v := m[k].(type) {
		case bool:
			return v, true
		case float64:
			return v != 0, true
		case string:
			switch strings.ToLower(strings.TrimSpace(v)) {
			case "true", "1", "yes":
				return true, true
			case "false", "0", "no":
				return false, true
			}
		}
	}
	return false, false
}

// fillString sets dst only when it is still empty, so the first view that knows
// a value wins and later ones only fill gaps.
func fillString(dst *string, v string) {
	if *dst == "" {
		*dst = strings.TrimSpace(v)
	}
}
