package api

// The remote half of the Anker Solix control plane.
//
// The other two transports each assume a route that a customer's charger usually
// does not have. OCPP (integrations_ankersolix_control.go) waits for the charger
// to dial in to us, which needs a public endpoint the charger can reach and a
// firmware willing to talk to our CSMS. Modbus TCP
// (integrations_ankersolix_modbus.go) dials the charger, which needs the server
// on the charger's own network. Between them they cover a charger we host and a
// charger we stand next to — and neither covers the ordinary case: a charger
// behind a customer's router, somewhere else entirely.
//
// This one goes the way the owner's phone already does. The charger holds a
// connection open to Anker's MQTT broker (it is the mqttStatus register the
// Modbus snapshot reports), and the account's own certificate lets us publish on
// the same topics the app publishes on. Nothing has to be reachable, forwarded
// or certificated on the customer's side; what it costs instead is a dependency
// on Anker's cloud being up, and on an unofficial protocol.
//
// The command set is the charger's, not OCPP's: start, stop, boost, skip-delay,
// a current limit and a restart, plus the one thing neither other transport can
// do at all — writing the charger's own configuration, which is what "settings"
// is for. Everything the register map or the CSMS can do that this cannot is
// refused by name rather than as an unknown action.

import (
	"context"
	"encoding/json"
	"net/http"
	"strings"
	"time"

	"drivervault/apiserver/internal/plugins/builtin/ankersolix"
)

// ankerMqttTimeout bounds one command or status read end to end. It is generous
// because the path is: our broker connection, Anker's cloud, the customer's
// link, the charger — and back again for the confirmation. The plugin's own
// waits are shorter, so this only catches a request that is going nowhere.
const ankerMqttTimeout = 45 * time.Second

// ankerCloudConfig is the plugin config one caller's resolved credentials make.
// The cloud transport signs in as the account, so unlike Modbus it needs them.
func ankerCloudConfig(res ankerResolution) map[string]string {
	return map[string]string{
		"email":    res.eff.Email,
		"password": res.eff.Password,
		"country":  res.eff.Country,
	}
}

// ankerMqttAction issues one control command over Anker's cloud broker. The
// gate, rate limit, destructive-action confirmation and audit have already run
// in handleAnkerControlAction; this decides what to send and reports the result.
func (s *Server) ankerMqttAction(w http.ResponseWriter, r *http.Request, who *callerIdentity,
	res ankerResolution, sn, action string, body ankerControlBody) {

	// Actions this transport has no equivalent for. Naming the transport that
	// does have them beats a bare "unknown action" the caller cannot act on.
	switch action {
	case "unlock", "availability", "trigger", "config":
		writeError(w, http.StatusBadRequest,
			"\""+action+"\" is an OCPP command; the Anker cloud connection cannot send it. Switch the control mode to a CSMS mode to use it.")
		return
	case "phase", "timeout":
		writeError(w, http.StatusBadRequest,
			"\""+action+"\" is set through the charger's Modbus registers; the Anker cloud connection cannot send it. Switch the control mode to Modbus TCP to use it.")
		return
	case "clear-limit":
		// As over Modbus: "no limit" would mean writing a ceiling we would have to
		// invent, and the charger clamps to its own rating anyway.
		writeError(w, http.StatusBadRequest,
			"the Anker cloud connection has no \"clear limit\" command; send \"limit\" with the amps you want instead")
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), ankerMqttTimeout)
	defer cancel()

	var (
		capability = "mqtt-command"
		params     = map[string]any{"transport": "mqtt"}
		payload    = map[string]any{"sn": sn}
	)
	switch action {
	case "start", "stop", "boost", "skip-delay":
		payload["command"] = action
		if action == "boost" && body.On != nil && !*body.On {
			// Boost is a one-way command on this transport: the charger clears it
			// when the session ends, and there is no message to cancel it early.
			writeError(w, http.StatusBadRequest,
				"boost cannot be switched off over the Anker cloud; it ends with the charging session, or stop the session to end it now")
			return
		}
	case "limit":
		params["amps"] = body.Amps
		payload["command"], payload["amps"] = "limit", body.Amps
	case "reset", "restart":
		// The charger's own restart, the cloud's answer to the OCPP reset. It has
		// already been through the confirmation and the password step-up upstairs,
		// like any other reboot.
		payload["command"] = "restart"
	case "settings":
		// The values themselves are audited, not just the fact of a write: a
		// setting that changes what the charger will draw, or whether it answers on
		// the LAN at all, is worth being able to trace afterwards.
		if len(body.Settings) == 0 {
			writeError(w, http.StatusBadRequest,
				"settings requires a \"settings\" object, e.g. {\"settings\":{\"ledBrightness\":50}}")
			return
		}
		capability = "mqtt-settings"
		params["settings"] = body.Settings
		payload["settings"] = body.Settings
	case "status":
		capability = "mqtt-status"
	default:
		writeError(w, http.StatusBadRequest, "unknown control action: "+action)
		return
	}

	raw, err := s.plugins.InvokeWith(ctx, ankerPlugin, ankerCloudConfig(res), capability, mustJSON(payload))

	outcome := "accepted"
	if err != nil {
		outcome = "error"
	}
	s.auditControl(who, sn, action, params, outcome, err)
	if err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
		return
	}

	if action == "status" {
		writeJSON(w, http.StatusOK, map[string]any{"status": outcome, "result": json.RawMessage(raw)})
		return
	}
	// The plugin answers {serial, command|applied, status, confirmed, detail?};
	// relay it so the caller sees whether the charger acknowledged, not just that
	// we sent.
	writeJSON(w, http.StatusOK, json.RawMessage(raw))
}

// ankerMqttSnapshot reads a charger's live state for the status endpoint. Like
// its Modbus counterpart it is best effort: a charger that is offline, or an
// account the cloud will not hand a broker certificate for, simply has no
// snapshot — which is a fact to report, not an error to fail on.
func (s *Server) ankerMqttSnapshot(ctx context.Context, res ankerResolution, sn string) (ankersolix.MqttSnapshot, string, bool) {
	var snap ankersolix.MqttSnapshot
	if strings.TrimSpace(sn) == "" {
		return snap, "", false
	}
	ctx, cancel := context.WithTimeout(ctx, ankerMqttTimeout)
	defer cancel()

	raw, err := s.plugins.InvokeWith(ctx, ankerPlugin, ankerCloudConfig(res), "mqtt-status", mustJSON(map[string]any{"sn": sn}))
	if err != nil {
		return snap, err.Error(), false
	}
	if err := json.Unmarshal(raw, &snap); err != nil {
		return snap, err.Error(), false
	}
	return snap, "", true
}

// mustJSON encodes a small, known-good map for a plugin call. The values are
// built here from typed fields, so an encoding failure is not a runtime case.
func mustJSON(v map[string]any) json.RawMessage {
	b, err := json.Marshal(v)
	if err != nil {
		return json.RawMessage(`{}`)
	}
	return b
}

// shortenDetail trims an upstream failure to something that fits in a status
// card without hiding what went wrong.
func shortenDetail(s string) string {
	s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
	if s == "" {
		return "no detail"
	}
	if len(s) > 200 {
		return s[:200] + "…"
	}
	return s
}
