package ankersolix

// The account's other views of one charger.
//
// The merged inventory (chargers.go) answers "what chargers are there", and it
// asks the views that list them. Everything else the cloud will say about one
// charger answers only when a serial is named — the station record, the
// charging totals and the completed sessions, the OCPP backend, the RFID cards,
// the binding, the sharing, the firmware, the Wi-Fi, the tamper log — so none of
// them can be part of the merge, and none of them was reachable from the app at
// all until this capability.
//
// Every read the endpoint map lists for one charger is asked here, rather than a
// chosen few: which of them an account can answer is itself the finding, and a
// view nobody asks for is a field nobody can see. A charger that belongs to a
// site brings four more with it — the site is only known once the first pass has
// asked for it, so the site's own views are a second pass.
//
// What they answer with is not documented, by Anker or by the reference: the
// endpoints are known, their payloads are not. So each view is relayed as the
// fields it actually sent, flattened under the cloud's own keys, rather than
// projected onto names invented here. A field that turns out to matter can be
// named later, from evidence.

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

// chargerDetailView is one endpoint's answer about one charger. A view that
// fails carries its reason instead of its fields: an account that is not the
// owner cannot read the cards, which is a fact about the account rather than an
// error in the read. A view that answers with nothing carries neither, and says
// so by being empty — a standalone charger has no station record, and that is
// worth seeing too.
//
// Note is the one thing said in DriverVault's own words rather than the cloud's:
// a number the account elsewhere gives a meaning to, resolved. It never replaces
// a field, so the cloud's own keys stay exactly as they arrived.
type chargerDetailView struct {
	ID    string            `json:"id"`
	Attrs map[string]string `json:"attrs,omitempty"`
	Note  string            `json:"note,omitempty"`
	Error string            `json:"error,omitempty"`
}

type chargerDetailsDoc struct {
	SN    string              `json:"sn"`
	Views []chargerDetailView `json:"views"`
}

// detailRequest is one view to ask for: the id it is reported under, the
// endpoint, and the body it takes.
type detailRequest struct {
	id       string
	endpoint string
	payload  map[string]any
}

// detailFanout is how many of these are in flight at once. The cloud is a
// stranger's API being asked twenty questions about one charger; a handful at a
// time keeps the whole card under a couple of seconds without arriving as a
// burst that looks like something to rate-limit.
const detailFanout = 6

// chargerDetails asks every per-charger endpoint and returns what each answered.
// One failing view is reported in place; only losing all of them is an error,
// for the same reason the inventory works that way — a charger the cloud will
// half talk about is still worth showing.
func (p *Plugin) chargerDetails(ctx context.Context, sn string) (json.RawMessage, error) {
	if _, err := p.ensureToken(ctx); err != nil {
		return nil, err
	}

	// Everything that needs only the serial. Ordered as the card reads them:
	// what it is doing, what it has done, who may use it, what it is, and the
	// housekeeping the account keeps about it.
	views := []detailRequest{
		{"station", epStationInfo, map[string]any{"evChargerSn": sn, "featuretype": 1}},
		{"stationFeature", epStationInfo, map[string]any{"evChargerSn": sn, "featuretype": 2}},
		{"totals", epChargeStats, map[string]any{
			"device_sn": sn, "date_type": "all", "start_date": "", "end_date": ""}},
		{"orders", epChargeStatsList, map[string]any{
			"device_sn": sn, "order_status": 1, "date_type": "all",
			"start_date": "", "end_date": "", "page": 0, "page_size": 10}},
		{"sessions", epChargingOrders, map[string]any{"device_sn": sn, "start_time": ""}},
		{"income", epDeviceIncome, map[string]any{"device_sn": sn, "start_time": ""}},
		{"ocpp", epOcppInfo, map[string]any{"device_sn": sn}},
		{"ocppEndpoints", epOcppEndpoints, map[string]any{}},
		{"rfid", epRfidCards, map[string]any{"device_sn": sn}},
		{"shared", epSharedDevice, map[string]any{"device_sn": sn}},
		{"binding", epDeviceBindDetails, map[string]any{"device_sn_list": []string{sn}}},
		{"group", epGroupDevices, map[string]any{"device_sn": sn}},
		{"wifi", epChargerWifiInfo, map[string]any{"evChargerSn": sn}},
		{"ota", epOtaInfo, map[string]any{"device_sn_list": []string{sn}}},
		{"upgrades", epUpgradeRecords, map[string]any{"device_sn": sn}},
		{"tamper", epTamperRecords, map[string]any{"device_sn": sn, "page_num": 1, "page_size": 20}},
		{"siteBySn", epSiteDetailBySN, map[string]any{"device_sn": sn}},
	}

	out := p.askViews(ctx, views)

	// The site's own views, once the first pass has found which site this is.
	// A standalone charger has none, and asking anyway would be four errors
	// about a site that does not exist.
	if siteID := siteIDFrom(out); siteID != "" {
		out = append(out, p.askViews(ctx, []detailRequest{
			{"site", epSiteDetail, map[string]any{"site_id": siteID}},
			{"sitePrice", epSitePrice, map[string]any{"site_id": siteID}},
			{"siteWifi", epSiteWifiList, map[string]any{"site_id": siteID}},
			{"energy", epEnergyAnalysis, map[string]any{
				"site_id": siteID, "device_sn": sn, "device_type": "ev_charger",
				"type": energyRange(""), "start_time": "", "end_time": ""}},
		})...)
	}

	// Which OCPP backend "source 0" actually is, from the list the account
	// publishes for exactly that purpose.
	noteOcppSource(out)

	failed := 0
	for _, v := range out {
		if v.Error != "" {
			failed++
		}
	}
	if failed == len(out) {
		return nil, fmt.Errorf("anker-solix: charger %s: no per-charger view answered", sn)
	}
	return json.Marshal(chargerDetailsDoc{SN: sn, Views: out})
}

// askViews asks a batch of views at once and returns them in the order given —
// concurrency is for the waiting, not for the reading, and a card whose boxes
// reorder between refreshes is a card nobody can read against itself.
func (p *Plugin) askViews(ctx context.Context, reqs []detailRequest) []chargerDetailView {
	out := make([]chargerDetailView, len(reqs))
	sem := make(chan struct{}, detailFanout)
	var wg sync.WaitGroup
	for i, req := range reqs {
		wg.Add(1)
		go func(i int, req detailRequest) {
			defer wg.Done()
			sem <- struct{}{}
			defer func() { <-sem }()

			view := chargerDetailView{ID: req.id}
			body, err := p.apiRequest(ctx, req.endpoint, req.payload)
			if err != nil {
				view.Error = shorten(err.Error())
			} else {
				attrs := map[string]string{}
				flattenInto(attrs, "", dataValue(body))
				if len(attrs) > 0 {
					view.Attrs = attrs
				}
			}
			out[i] = view
		}(i, req)
	}
	wg.Wait()
	return out
}

// siteIDFrom reads the site this charger belongs to out of the by-serial lookup,
// under whichever key that view used for it.
func siteIDFrom(views []chargerDetailView) string {
	for _, v := range views {
		if v.ID != "siteBySn" {
			continue
		}
		for _, key := range []string{"site_id", "siteId", "station_id", "site_info.site_id"} {
			if id := strings.TrimSpace(v.Attrs[key]); id != "" {
				return id
			}
		}
	}
	return ""
}

// noteOcppSource names the OCPP backend the charger is pointed at. The per-
// charger view reports it as a bare number ("source 0"); the account's endpoint
// list is where those numbers have addresses, so the two are read together and
// the answer is said once, as the view's note. When the list does not carry the
// number, nothing is said — a guessed backend is worse than a plain number.
func noteOcppSource(views []chargerDetailView) {
	var ocpp *chargerDetailView
	var endpoints map[string]string
	for i := range views {
		switch views[i].ID {
		case "ocpp":
			ocpp = &views[i]
		case "ocppEndpoints":
			endpoints = views[i].Attrs
		}
	}
	if ocpp == nil || len(ocpp.Attrs) == 0 || len(endpoints) == 0 {
		return
	}
	source := strings.TrimSpace(ocpp.Attrs["source"])
	if source == "" {
		return
	}
	if url := endpointForSource(endpoints, source); url != "" {
		ocpp.Note = "source " + source + " = " + url
	}
}

// endpointForSource finds the address listed under a source number. The list
// arrives flattened — list[0].source, list[0].url and whatever else it carries —
// so the entry whose source matches is found first, and its address taken from
// that same entry.
func endpointForSource(endpoints map[string]string, source string) string {
	prefix := ""
	for key, val := range endpoints {
		if !strings.HasSuffix(key, ".source") || strings.TrimSpace(val) != source {
			continue
		}
		prefix = strings.TrimSuffix(key, ".source")
		break
	}
	if prefix == "" {
		return ""
	}
	for _, field := range []string{".url", ".endpoint", ".address", ".ocpp_url", ".server_url", ".name"} {
		if v := strings.TrimSpace(endpoints[prefix+field]); v != "" {
			return v
		}
	}
	return ""
}

// dataValue returns a response's "data", whatever shape it came in: these views
// answer with an object, and the card list answers with an array.
func dataValue(body []byte) any {
	var env struct {
		Data any `json:"data"`
	}
	if err := json.Unmarshal(body, &env); err != nil {
		return nil
	}
	return env.Data
}
