package ankersolix

import (
	"strings"
	"testing"
)

// A charger that sits in a station is absent from the standalone list even
// though the account count includes it — the case that made a good login look
// like an account with no chargers.
func TestStandaloneListMissesStationChargers(t *testing.T) {
	body := []byte(`{"code":0,"data":{"evChargers":[],"userBindEvChargersCount":2}}`)
	inv := newInventory()
	inv.addStandalone(body)
	if got := len(inv.list()); got != 0 {
		t.Fatalf("standalone view: got %d chargers, want 0", got)
	}
	if n, ok := countChargers(body); !ok || n != 2 {
		t.Fatalf("countChargers = %d, %v; want 2, true", n, ok)
	}

	// The site view is where those two actually live.
	scene := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[
		{"device_sn":"EVSN1","device_name":"Garage","operating_state":2,"power":"7.4","ocpp_connect_status":2},
		{"device_sn":"EVSN2","device_name":"Carport","operating_state":0}]}}}`)
	inv.addStates(parseScenePiles(scene, "site-1"), "Home")
	list := inv.list()
	if len(list) != 2 {
		t.Fatalf("after site view: got %d chargers, want 2", len(list))
	}
	if list[0].SN != "EVSN1" || list[0].SiteID != "site-1" || list[0].SiteName != "Home" {
		t.Fatalf("first charger = %+v", list[0])
	}
	if list[0].StatusDesc != stateCharging || list[0].Power != "7.4" {
		t.Fatalf("status/power not carried over: %+v", list[0])
	}
	if list[0].OcppStatusDesc != "connected" {
		t.Fatalf("ocpp status = %q, want connected", list[0].OcppStatusDesc)
	}
}

func TestAddStandaloneReadsHesFieldNames(t *testing.T) {
	body := []byte(`{"code":0,"data":{"evChargers":[
		{"evChargerSn":"EVSN1","evChargerName":"Garage","evChargerStatus":2,"wifi_online":true},
		{"evChargerName":"nameless"}]}}`)
	inv := newInventory()
	inv.addStandalone(body)
	list := inv.list()
	if len(list) != 1 {
		t.Fatalf("got %d chargers, want 1 (the serial-less one is skipped)", len(list))
	}
	c := list[0]
	if c.SN != "EVSN1" || c.Name != "Garage" || c.StatusDesc != stateCharging {
		t.Fatalf("charger = %+v", c)
	}
	if c.Online == nil || !*c.Online {
		t.Fatalf("online = %v, want true", c.Online)
	}
	if len(c.Sources) != 1 || c.Sources[0] != "standalone" {
		t.Fatalf("sources = %v", c.Sources)
	}
}

// Bound devices discover EV chargers by product family and enrich chargers the
// other views already found — without dragging in the rest of the account.
func TestAddBoundFiltersAndEnriches(t *testing.T) {
	inv := newInventory()
	scene := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[
		{"device_sn":"EVSN1","operating_state":0}]}}}`)
	inv.addStates(parseScenePiles(scene, "site-1"), "Home")

	bound := []byte(`{"code":0,"data":{"data":[
		{"device_sn":"EVSN1","device_pn":"A5191","device_name":"Garage","device_sw_version":"1.2.3","wifi_online":true},
		{"device_sn":"EVSN9","device_pn":"a5191","alias_name":"Carport"},
		{"device_sn":"SOLAR1","device_pn":"A17C0","device_name":"Solarbank"}]}}`)
	inv.addBound(bound)

	list := inv.list()
	if len(list) != 2 {
		t.Fatalf("got %d chargers, want 2 (the solarbank is not one)", len(list))
	}
	if list[0].Name != "Garage" || list[0].Firmware != "1.2.3" || list[0].Model != "A5191" {
		t.Fatalf("enriched charger = %+v", list[0])
	}
	if len(list[0].Sources) != 2 || list[0].Sources[1] != "bound" {
		t.Fatalf("sources = %v, want [site bound]", list[0].Sources)
	}
	if list[1].SN != "EVSN9" || list[1].Name != "Carport" {
		t.Fatalf("discovered charger = %+v", list[1])
	}
}

// The site view knows the live state; the standalone view saw the charger
// first. Merging must not let the later view blank what the earlier one knew.
func TestMergeKeepsFirstKnownValues(t *testing.T) {
	inv := newInventory()
	inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage"}]}}`))
	inv.addStates(parseScenePiles([]byte(`{"data":{"charging_pile_info":{"charging_pile_list":[
		{"device_sn":"EVSN1","operating_state":2,"power":"7.4"}]}}}`), "site-1"), "Home")

	list := inv.list()
	if len(list) != 1 {
		t.Fatalf("got %d chargers, want 1", len(list))
	}
	c := list[0]
	if c.Name != "Garage" {
		t.Fatalf("name = %q, want Garage (the nameless site record must not clear it)", c.Name)
	}
	if c.StatusDesc != stateCharging || c.Power != "7.4" || c.SiteID != "site-1" {
		t.Fatalf("site view did not fill the gaps: %+v", c)
	}
}

func TestDataListCandidateKeys(t *testing.T) {
	body := []byte(`{"data":{"evChargers":null,"chargerList":[{"sn":"A"},{"sn":"B"},"junk"]}}`)
	got := dataList(body, "evChargers", "chargerList")
	if len(got) != 2 {
		t.Fatalf("got %d objects, want 2 (the non-object is dropped)", len(got))
	}
	if dataList([]byte(`not json`), "data") != nil {
		t.Fatal("malformed body should yield no list")
	}
	if dataList([]byte(`{"data":{}}`), "missing") != nil {
		t.Fatal("absent key should yield no list")
	}
}

func TestPickers(t *testing.T) {
	m := map[string]any{"a": "  x  ", "b": "", "n": float64(7), "ns": "42", "t": true, "z": float64(0)}
	if got := pickString(m, "b", "a"); got != "x" {
		t.Fatalf("pickString = %q, want x", got)
	}
	if got := pickString(m, "n"); got != "7" {
		t.Fatalf("pickString(number) = %q, want 7", got)
	}
	if got := pickString(m, "missing"); got != "" {
		t.Fatalf("pickString(missing) = %q, want empty", got)
	}
	if n, ok := pickInt(m, "missing", "ns"); !ok || n != 42 {
		t.Fatalf("pickInt = %d, %v; want 42, true", n, ok)
	}
	if b, ok := pickBool(m, "t"); !ok || !b {
		t.Fatalf("pickBool = %v, %v; want true, true", b, ok)
	}
	if b, ok := pickBool(m, "z"); !ok || b {
		t.Fatalf("pickBool(0) = %v, %v; want false, true", b, ok)
	}
	if _, ok := pickBool(m, "a"); ok {
		t.Fatal("a non-boolean string must not read as a bool")
	}
}

func TestFillString(t *testing.T) {
	s := ""
	fillString(&s, "  first  ")
	fillString(&s, "second")
	if s != "first" {
		t.Fatalf("s = %q, want first", s)
	}
}

// Everything a view sent is kept, not only the fields the merge has a name for:
// nested objects and arrays arrive under the path the cloud nested them at, and
// a field that says nothing leaves no row behind.
func TestAttrsKeepEveryFieldTheCloudSent(t *testing.T) {
	inv := newInventory()
	inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage",
		"wifi_online":true,"rssi":-52,"grid":{"tariff":"day"},"tags":["a","b"],"blank":"  ","absent":null}]}}`))

	attrs := inv.list()[0].Attrs
	want := map[string]string{
		"evChargerSn": "EVSN1", "evChargerName": "Garage", "wifi_online": "true",
		"rssi": "-52", "grid.tariff": "day", "tags[0]": "a", "tags[1]": "b",
	}
	for k, v := range want {
		if attrs[k] != v {
			t.Fatalf("attrs[%q] = %q, want %q", k, attrs[k], v)
		}
	}
	for _, k := range []string{"blank", "absent"} {
		if _, ok := attrs[k]; ok {
			t.Fatalf("attrs[%q] = %q; a field that says nothing must not make a row", k, attrs[k])
		}
	}

	// A second view fills gaps without overwriting what the first one answered.
	inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191","rssi":-70,"bt_mac":"AA:BB"}]}}`))
	attrs = inv.list()[0].Attrs
	if attrs["rssi"] != "-52" {
		t.Fatalf("rssi = %q, want the first view's -52", attrs["rssi"])
	}
	if attrs["bt_mac"] != "AA:BB" {
		t.Fatalf("bt_mac = %q, want AA:BB", attrs["bt_mac"])
	}
}

// The station record is the only view that answers for a charger standing
// outside a station, so it fills the live fields the list views left empty —
// without claiming the charger is registered anywhere new.
func TestAddStationFillsGapsWithoutAddingASource(t *testing.T) {
	inv := newInventory()
	inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage"}]}}`))
	inv.addStation("EVSN1", []byte(`{"code":0,"data":{"evChargerSn":"EVSN1","evChargerStatus":2,"power":"7.4",
		"ocpp_connect_status":2,"station_name":"Home-DK","device_sw_version":"v1.0.6.1","plug_temperature":41}}`))

	c := inv.list()[0]
	if c.StatusDesc != stateCharging || c.Power != "7.4" {
		t.Fatalf("station view did not fill the live fields: %+v", c)
	}
	if c.OcppStatusDesc != "connected" || c.SiteName != "Home-DK" || c.Firmware != "v1.0.6.1" {
		t.Fatalf("station view did not fill the named fields: %+v", c)
	}
	if c.Attrs["plug_temperature"] != "41" {
		t.Fatalf("plug_temperature = %q, want 41", c.Attrs["plug_temperature"])
	}
	if len(c.Sources) != 1 || c.Sources[0] != "standalone" {
		t.Fatalf("sources = %v; the station record is a view, not a registration", c.Sources)
	}

	// A serial no view found is not a charger this pass may invent.
	inv.addStation("EVSN9", []byte(`{"code":0,"data":{"evChargerSn":"EVSN9"}}`))
	if len(inv.list()) != 1 {
		t.Fatalf("got %d chargers, want 1", len(inv.list()))
	}
}

func TestRawRecordsDescendsToANestedList(t *testing.T) {
	body := []byte(`{"data":{"charging_pile_info":{"charging_pile_list":[{"device_sn":"EVSN1","power":"7.4"}]}}}`)
	got := rawRecords(body, "charging_pile_info", "charging_pile_list")
	if len(got) != 1 || got[0]["power"] != "7.4" {
		t.Fatalf("rawRecords = %v", got)
	}
	if rawRecords(body, "charging_pile_info") != nil {
		t.Fatal("an object where a list was asked for should yield nothing")
	}
	if rawRecords([]byte(`{"data":[]}`), "missing") != nil {
		t.Fatal("a data that is not an object should yield nothing")
	}
}

// The bound-device view carries the charger's state and its OCPP link under
// names of its own — chargerStatus, not evChargerStatus — which is how a card
// ends up printing a dash beside a value the same response contains.
func TestBoundViewNamesTheStateItsOwnWay(t *testing.T) {
	inv := newInventory()
	inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191","alias_name":"Home-DK",
		"chargerStatus":2,"ocpp_connect_status":2,"wifi_online":true,"wifi_name":"AP_1_IOT",
		"wifi_mac":"7CE91373C236","bt_ble_mac":"7CE91373C238","rssi":-63,"time_zone":"Europe/Copenhagen",
		"link_time":1788186620,"img_url":"https://example.invalid/a5191.png","relate_type":["ble","wifi"]}]}}`))

	c := inv.list()[0]
	if c.StatusDesc != stateCharging {
		t.Errorf("statusDesc = %q, want %q — the bound view spells it chargerStatus", c.StatusDesc, stateCharging)
	}
	if c.OcppStatusDesc != "connected" {
		t.Errorf("ocppStatusDesc = %q, want connected", c.OcppStatusDesc)
	}
	if c.WifiName != "AP_1_IOT" || c.WifiMac != "7CE91373C236" || c.BleMac != "7CE91373C238" {
		t.Errorf("networks = %+v", c)
	}
	if c.WifiRSSI == nil || *c.WifiRSSI != -63 {
		t.Errorf("wifiRssi = %v, want -63", c.WifiRSSI)
	}
	if c.TimeZone != "Europe/Copenhagen" || c.ImageURL == "" {
		t.Errorf("timeZone/imageUrl = %q / %q", c.TimeZone, c.ImageURL)
	}
	if c.LinkedAt == nil || *c.LinkedAt != 1788186620 {
		t.Errorf("linkedAt = %v, want 1788186620", c.LinkedAt)
	}
	if len(c.RelatedBy) != 2 || c.RelatedBy[0] != "ble" || c.RelatedBy[1] != "wifi" {
		t.Errorf("relatedBy = %v, want [ble wifi]", c.RelatedBy)
	}
}

// A device record carries the charger's Bluetooth pairing password. That the
// field exists is reportable; its value is not something a card should carry.
func TestAttrsMaskCredentials(t *testing.T) {
	inv := newInventory()
	inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191",
		"blue_password":"AT200025277*!","wifi_name":"AP_1_IOT","cloud":{"auth_token":"abc"}}]}}`))

	attrs := inv.list()[0].Attrs
	for _, key := range []string{"blue_password", "cloud.auth_token"} {
		if attrs[key] == "" {
			t.Fatalf("attrs[%q] is absent; the field should still be reported", key)
		}
		if strings.ContainsAny(attrs[key], "ATabc") {
			t.Errorf("attrs[%q] = %q, want it masked", key, attrs[key])
		}
	}
	if attrs["wifi_name"] != "AP_1_IOT" {
		t.Errorf("wifi_name = %q; masking must not reach a value that is not a credential", attrs["wifi_name"])
	}
}
