package api

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"

	"drivervault/apiserver/internal/ocpp"
)

// This file is the control half of the Anker Solix integration: the OCPP
// WebSocket endpoint a charger dials out to, the per-charger control-token index
// that authenticates it, and the REST endpoints the web app calls to issue OCPP
// commands. Monitoring (the read-only cloud plugin) lives in
// integrations_ankersolix.go and the OCPP protocol itself in internal/ocpp.

// ---- control-token index -----------------------------------------------------

// ankerControlBinding is what a per-charger control token resolves to.
type ankerControlBinding struct {
	UserID string
	Serial string
}

// controlIndex maps a control token's SHA-256 hash to its owning user + charger.
// Only hashes are held (in memory and at rest), so a leak of the index or the
// stored settings never yields a usable charger credential. It is seeded when a
// token is (re)generated and rebuilt from PocketBase the first time an unknown
// token connects (or lazily at startup).
type controlIndex struct {
	mu     sync.RWMutex
	byHash map[string]ankerControlBinding
	built  bool
}

func newControlIndex() *controlIndex {
	return &controlIndex{byHash: map[string]ankerControlBinding{}}
}

// lookup resolves the presented plaintext token by hashing it and matching a
// stored hash — so the plaintext is never compared or retained.
func (ci *controlIndex) lookup(token string) (ankerControlBinding, bool) {
	ci.mu.RLock()
	defer ci.mu.RUnlock()
	b, ok := ci.byHash[hashToken(token)]
	return b, ok
}

// setUser replaces all of a user's token entries with the given charger set, so
// a regenerated or revoked token immediately stops resolving.
func (ci *controlIndex) setUser(userID string, chargers map[string]ankerChargerBinding) {
	ci.mu.Lock()
	defer ci.mu.Unlock()
	for hash, b := range ci.byHash {
		if b.UserID == userID {
			delete(ci.byHash, hash)
		}
	}
	for serial, cb := range chargers {
		if cb.TokenHash != "" {
			ci.byHash[cb.TokenHash] = ankerControlBinding{UserID: userID, Serial: serial}
		}
	}
}

// ensureControlIndex builds the token index from every user's pluginSettings the
// first time it is needed. Best effort: a failure leaves built=false so a later
// connect retries.
func (s *Server) ensureControlIndex(ctx context.Context) {
	s.control.mu.RLock()
	built := s.control.built
	s.control.mu.RUnlock()
	if built || !s.pb.Configured() {
		return
	}
	q := url.Values{}
	q.Set("perPage", "500")
	q.Set("fields", "id,pluginSettings")
	lr, err := s.pb.List(ctx, s.usersCollection(), q)
	if err != nil {
		return
	}
	var items []struct {
		ID             string          `json:"id"`
		PluginSettings json.RawMessage `json:"pluginSettings"`
	}
	_ = json.Unmarshal(lr.Items, &items)
	for _, it := range items {
		var doc ankerSettingsDoc
		if len(it.PluginSettings) > 0 {
			_ = json.Unmarshal(it.PluginSettings, &doc)
		}
		if len(doc.AnkerSolix.ControlChargers) > 0 {
			s.control.setUser(it.ID, doc.AnkerSolix.ControlChargers)
		}
	}
	s.control.mu.Lock()
	s.control.built = true
	s.control.mu.Unlock()
}

// ---- rate limiting -----------------------------------------------------------

// rateLimiter is a simple fixed-window limiter keyed by an arbitrary string
// (here user id + charger serial). It bounds how fast control commands can be
// issued so a valid session can't hammer Start/Stop/Reset at a physical charger.
//
// It is intentionally in-memory and resets on restart: the window is one minute,
// so a restart clears at most a sub-minute budget, and persisting per-key
// counters would add write amplification for negligible security value.
type rateLimiter struct {
	mu     sync.Mutex
	window time.Duration
	max    int
	hits   map[string]*rlEntry
}

type rlEntry struct {
	start time.Time
	count int
}

func newRateLimiter(max int, window time.Duration) *rateLimiter {
	return &rateLimiter{window: window, max: max, hits: map[string]*rlEntry{}}
}

func (rl *rateLimiter) allow(key string) bool {
	rl.mu.Lock()
	defer rl.mu.Unlock()
	now := time.Now()
	e := rl.hits[key]
	if e == nil || now.Sub(e.start) > rl.window {
		rl.hits[key] = &rlEntry{start: now, count: 1}
		return true
	}
	if e.count >= rl.max {
		return false
	}
	e.count++
	return true
}

// ---- audit -------------------------------------------------------------------

// auditControl emits a single structured audit line for a control-plane event.
// Control commands move a physical actuator, so every one records who did what
// to which charger, with the outcome. (Durable persistence to PocketBase is a
// planned follow-up; this greppable line is the first-cut trail.)
func (s *Server) auditControl(who *callerIdentity, serial, action string, params map[string]any, result string, actErr error) {
	userID, orgID := "", ""
	if who != nil {
		userID, orgID = who.ID, who.OrgID
	}

	entry := map[string]any{
		"audit":  "anker-control",
		"ts":     time.Now().UTC().Format(time.RFC3339),
		"serial": serial,
		"action": action,
		"result": result,
		"userId": userID,
	}
	if orgID != "" {
		entry["orgId"] = orgID
	}
	if len(params) > 0 {
		entry["params"] = params
	}
	if actErr != nil {
		entry["error"] = actErr.Error()
	}
	b, _ := json.Marshal(entry)
	log.Printf("AUDIT %s", b)

	// Durable, best-effort: persist to the control_audit collection if present.
	// The structured log line above is the fallback when it is not, so a failure
	// here never blocks or fails the control action.
	if !s.pb.Configured() {
		return
	}
	rec := map[string]any{
		"user_id": userID,
		"org_id":  orgID,
		"serial":  serial,
		"action":  action,
		"result":  result,
	}
	auditParams := params
	if actErr != nil {
		auditParams = map[string]any{"error": actErr.Error()}
		for k, v := range params {
			auditParams[k] = v
		}
	}
	if len(auditParams) > 0 {
		rec["params"] = auditParams
	}
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := s.pb.Create(ctx, colControlAudit, rec, nil); err != nil {
			log.Printf("audit: could not persist control event (serial=%s action=%s): %v", serial, action, err)
		}
	}()
}

// ---- OCPP WebSocket endpoint -------------------------------------------------

// handleOCPPConnect is the WebSocket endpoint a charger dials out to. It
// authenticates the charger with OCPP Basic auth (username = serial, password =
// the per-charger control token), resolves the owner's control mode, and either
// registers the session (own) or bridges it to Anker's cloud (proxy).
func (s *Server) handleOCPPConnect(w http.ResponseWriter, r *http.Request) {
	serial := r.PathValue("serial")

	// A plaintext ws:// connection would carry the charger's Basic-auth token in
	// the clear, so reject it unless TLS was explicitly made optional (dev).
	s.mu.RLock()
	requireTLS := s.cfg.OCPPRequireTLS
	s.mu.RUnlock()
	if requireTLS && !requestIsSecure(r) {
		writeError(w, http.StatusForbidden, "OCPP connections must use TLS (wss)")
		return
	}

	user, token, ok := r.BasicAuth()
	if !ok || token == "" {
		w.Header().Set("WWW-Authenticate", `Basic realm="ocpp"`)
		writeError(w, http.StatusUnauthorized, "charger authentication required")
		return
	}
	binding, found := s.control.lookup(token)
	if !found {
		s.ensureControlIndex(r.Context())
		binding, found = s.control.lookup(token)
	}
	if !found || binding.Serial != serial || (user != "" && user != serial) {
		writeError(w, http.StatusUnauthorized, "invalid charger credentials")
		return
	}

	who, userRaw, err := s.callerForUser(r.Context(), binding.UserID)
	if err != nil || who == nil {
		writeError(w, http.StatusForbidden, "charger owner unavailable")
		return
	}
	res := s.resolveAnker(r.Context(), who, userRaw)
	if !res.available || !res.orgEnabled || !res.enabled {
		writeError(w, http.StatusForbidden, "the charger owner has not enabled the integration")
		return
	}
	mode := res.eff.ControlMode
	if mode != ankerControlOwn && mode != ankerControlProxy {
		writeError(w, http.StatusForbidden, "control mode is off for this charger")
		return
	}

	// Resolve the proxy upstream before upgrading so a failure is a clean HTTP
	// error rather than a dropped WebSocket.
	var upstreamURL, upstreamAuth string
	if mode == ankerControlProxy {
		upstreamURL, upstreamAuth, err = s.ankerProxyUpstream(r.Context(), res, serial)
		if err != nil {
			writeError(w, http.StatusBadGateway, "cannot reach Anker upstream for proxy mode: "+err.Error())
			return
		}
	}

	conn, err := ocpp.Upgrade(w, r)
	if err != nil {
		// The response may already be hijacked; just log and drop.
		log.Printf("ocpp: upgrade failed for %s: %v", serial, err)
		return
	}

	if mode == ankerControlOwn {
		if _, err := s.ocpp.Accept(serial, ocpp.ModeOwn, conn, nil); err != nil {
			log.Printf("ocpp: accept own %s: %v", serial, err)
			_ = conn.Close()
			return
		}
		s.auditControl(who, serial, "ocpp.connect.own", nil, "accepted", nil)
		return
	}

	up, err := ocpp.DialUpstream(r.Context(), upstreamURL, upstreamAuth)
	if err != nil {
		log.Printf("ocpp: proxy upstream dial failed for %s: %v", serial, err)
		_ = conn.Close()
		return
	}
	if _, err := s.ocpp.Accept(serial, ocpp.ModeProxy, conn, up); err != nil {
		log.Printf("ocpp: accept proxy %s: %v", serial, err)
		_ = conn.Close()
		_ = up.Close()
		return
	}
	s.auditControl(who, serial, "ocpp.connect.proxy", nil, "accepted", nil)
}

// callerForUser builds a callerIdentity (and returns the pluginSettings blob) for
// a user id, so the OCPP endpoint can resolve the cascade without a bearer token.
func (s *Server) callerForUser(ctx context.Context, userID string) (*callerIdentity, json.RawMessage, error) {
	if userID == "" || !s.pb.Configured() {
		return nil, nil, errors.New("not configured")
	}
	var rec struct {
		ID             string          `json:"id"`
		Role           string          `json:"role"`
		Organization   string          `json:"organization"`
		PluginSettings json.RawMessage `json:"pluginSettings"`
	}
	if err := s.pb.GetOne(ctx, s.usersCollection(), userID, &rec); err != nil {
		return nil, nil, err
	}
	role := rec.Role
	if role == "" {
		role = roleUser
	}
	return &callerIdentity{ID: rec.ID, Role: role, OrgID: rec.Organization}, rec.PluginSettings, nil
}

// ankerProxyUpstream asks the read-only cloud plugin for the charger's OCPP
// endpoint (its "ocpp-info" capability) and extracts a ws(s):// URL from the
// reply. Anker's own per-charger auth key is not available to us, so no
// Authorization header is set — if the upstream requires one it will reject the
// dial, surfaced as a clear error.
func (s *Server) ankerProxyUpstream(ctx context.Context, res ankerResolution, serial string) (string, string, error) {
	if strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "" {
		return "", "", errors.New("Anker account credentials are required for proxy mode")
	}
	cfg := map[string]string{"email": res.eff.Email, "password": res.eff.Password, "country": res.eff.Country}
	payload, _ := json.Marshal(map[string]string{"sn": serial})
	raw, err := s.plugins.InvokeWith(ctx, ankerPlugin, cfg, "ocpp-info", payload)
	if err != nil {
		return "", "", err
	}
	u := extractWSURL(raw)
	if u == "" {
		return "", "", errors.New("no OCPP endpoint URL in the Anker response")
	}
	if !ankerUpstreamAllowed(u) {
		return "", "", errors.New("Anker returned an OCPP endpoint on an untrusted host")
	}
	return u, "", nil
}

// ---- control REST endpoints --------------------------------------------------

// handleAnkerControlStatus reports the control state for one charger: the
// resolved mode, whether it is connected to our CSMS, the provisioning endpoint
// and token, and a live status snapshot when connected.
func (s *Server) handleAnkerControlStatus(w http.ResponseWriter, r *http.Request) {
	who := caller(r)
	if who == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	sn := r.PathValue("sn")
	userRaw := s.userPluginSettings(r.Context(), who.ID)
	res := s.resolveAnker(r.Context(), who, userRaw)
	binding := ankerBindingFor(userRaw, sn)

	body := map[string]any{
		"serial":      sn,
		"controlMode": res.eff.ControlMode,
		"available":   res.available && res.orgEnabled && res.enabled,
		"endpoint":    s.ocppEndpoint(r, sn),
		"hasToken":    binding.TokenHash != "",
		"tokenHint":   binding.TokenHint, // last-4 only; the token is shown once at generation
		"modbusHost":  binding.ModbusHost,
		"modbusPort":  binding.ModbusPort,
	}

	// Over the Anker cloud the charger holds its session with Anker, not with us,
	// so "connected" means it answered the broker just now.
	if res.eff.ControlMode == ankerControlMqtt {
		snap, why, ok := s.ankerMqttSnapshot(r.Context(), res, sn)
		body["connected"] = ok
		if ok {
			body["status"] = snap
		} else if strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "" {
			body["detail"] = "Enter your Anker account email and password in Settings; the cloud connection signs in as your account."
		} else {
			body["detail"] = "The charger did not answer over Anker's cloud. Check that it is powered on and online in the Anker app. (" + shortenDetail(why) + ")"
		}
		writeJSON(w, http.StatusOK, body)
		return
	}

	// In Modbus mode "connected" is something we find out by asking, not by
	// having been dialled: the charger holds no session with us between commands.
	if res.eff.ControlMode == ankerControlModbus {
		snap, ok := s.ankerModbusSnapshot(r.Context(), binding)
		body["connected"] = ok
		if ok {
			body["status"] = snap
		} else if strings.TrimSpace(binding.ModbusHost) == "" {
			body["detail"] = "No local address saved yet. Enable Modbus TCP in the Anker app under Settings > Integrations, then save the address it shows."
		} else {
			body["detail"] = "The charger did not answer on " + ankerModbusConfig(binding).Address() + ". Check that it is powered on, on this network, and that Modbus TCP is still enabled in the Anker app."
		}
		writeJSON(w, http.StatusOK, body)
		return
	}

	if sess, ok := s.ocpp.SessionFor(sn); ok {
		body["connected"] = true
		body["status"] = sess.Snapshot()
	} else {
		body["connected"] = false
	}
	writeJSON(w, http.StatusOK, body)
}

// handleAnkerControlToken (re)generates the per-charger control token the
// operator installs into the charger, and updates the token index.
func (s *Server) handleAnkerControlToken(w http.ResponseWriter, r *http.Request) {
	who := caller(r)
	if who == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	if !s.pb.Configured() {
		writeError(w, http.StatusServiceUnavailable, "integration settings not configured on the server")
		return
	}
	sn := r.PathValue("sn")
	if strings.TrimSpace(sn) == "" {
		writeError(w, http.StatusBadRequest, "missing charger serial")
		return
	}
	token := genControlToken()
	userRaw := s.userPluginSettings(r.Context(), who.ID)

	var updated map[string]ankerChargerBinding
	newDoc := mergeAnker(userRaw, func(as *ankerStored) {
		if as.ControlChargers == nil {
			as.ControlChargers = map[string]ankerChargerBinding{}
		}
		as.ControlChargers[sn] = ankerChargerBinding{
			TokenHash: hashToken(token),
			TokenHint: tokenHint(token),
			AddedAt:   time.Now().UTC().Format(time.RFC3339),
		}
		updated = as.ControlChargers
	})
	if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID,
		map[string]any{"pluginSettings": newDoc}, nil); err != nil {
		writePBError(w, err)
		return
	}
	s.control.setUser(who.ID, updated)
	s.auditControl(who, sn, "token.generate", nil, "ok", nil)
	// The plaintext token is returned exactly once — it is never stored, and the
	// status endpoint only ever exposes the last-4 hint afterwards.
	writeJSON(w, http.StatusOK, map[string]any{
		"serial":   sn,
		"token":    token,
		"endpoint": s.ocppEndpoint(r, sn),
	})
}

// handleAnkerControlRevoke deletes a charger's control token, so it can no longer
// connect until a new token is generated.
func (s *Server) handleAnkerControlRevoke(w http.ResponseWriter, r *http.Request) {
	who := caller(r)
	if who == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return
	}
	if !s.pb.Configured() {
		writeError(w, http.StatusServiceUnavailable, "integration settings not configured on the server")
		return
	}
	sn := r.PathValue("sn")
	userRaw := s.userPluginSettings(r.Context(), who.ID)

	var updated map[string]ankerChargerBinding
	newDoc := mergeAnker(userRaw, func(as *ankerStored) {
		delete(as.ControlChargers, sn)
		updated = as.ControlChargers
	})
	if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID,
		map[string]any{"pluginSettings": newDoc}, nil); err != nil {
		writePBError(w, err)
		return
	}
	s.control.setUser(who.ID, updated)
	// Drop any live session for this charger so an already-connected charger using
	// the revoked token is disconnected too.
	if sess, ok := s.ocpp.SessionFor(sn); ok {
		sess.Close()
	}
	s.auditControl(who, sn, "token.revoke", nil, "ok", nil)
	writeJSON(w, http.StatusOK, map[string]any{"serial": sn, "revoked": true})
}

// ankerControlBody is the union of everything a control action may be given.
// Which fields matter depends on the action, and on the transport: the OCPP
// actions take connector ids and transaction ids, the Modbus ones do not.
type ankerControlBody struct {
	IdTag            string  `json:"idTag"`
	ConnectorID      int     `json:"connectorId"`
	TransactionID    int     `json:"transactionId"`
	Amps             float64 `json:"amps"`
	Operative        *bool   `json:"operative"`
	Hard             bool    `json:"hard"`
	RequestedMessage string  `json:"requestedMessage"`
	Key              string  `json:"key"`
	Value            string  `json:"value"`
	Confirm          bool    `json:"confirm"`
	Password         string  `json:"password"`
	On               *bool   `json:"on"`      // boost
	PhaseMode        *int    `json:"phase"`   // 0 automatic, 1 single, 2 three
	Seconds          int     `json:"seconds"` // Modbus control timeout

	// Settings carries the charger's own configuration for the "settings" action,
	// keyed by the names the cloud snapshot reports them under.
	Settings map[string]any `json:"settings"`
}

// handleAnkerControlAction issues one command to a charger, over whichever
// transport the resolved control mode selects.
func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request) {
	who, res, binding, ok := s.ankerControlGate(w, r)
	if !ok {
		return
	}
	sn := r.PathValue("sn")
	action := r.PathValue("action")

	// Rate limit per user+charger so a valid session can't hammer the actuator.
	if !s.ctlRL.allow(who.ID + "|" + sn) {
		s.auditControl(who, sn, action, nil, "rate-limited", nil)
		writeError(w, http.StatusTooManyRequests, "too many control commands; please slow down")
		return
	}

	var body ankerControlBody
	if r.Body != nil {
		_ = json.NewDecoder(r.Body).Decode(&body)
	}

	// Destructive actions (reboot, cable unlock) demand an explicit confirmation
	// AND a fresh re-authentication (the caller re-enters their password), so they
	// can't fire by accident, from a replayed benign request, or from a session
	// left open on an unattended device.
	if isDestructiveAction(action) {
		if !body.Confirm {
			s.auditControl(who, sn, action, nil, "unconfirmed", nil)
			writeError(w, http.StatusBadRequest, "this action is destructive; resend with confirm:true")
			return
		}
		if !s.reauthenticate(r.Context(), who, body.Password) {
			s.auditControl(who, sn, action, nil, "reauth-failed", nil)
			writeError(w, http.StatusUnauthorized, "re-enter your password to confirm this action")
			return
		}
	}

	if res.eff.ControlMode == ankerControlMqtt {
		s.ankerMqttAction(w, r, who, res, sn, action, body)
		return
	}
	if res.eff.ControlMode == ankerControlModbus {
		s.ankerModbusAction(w, r, who, binding, sn, action, body)
		return
	}

	sess, ok := s.ocpp.SessionFor(sn)
	if !ok {
		writeError(w, http.StatusConflict, "charger is not connected to the control backend")
		return
	}

	ctx := r.Context()
	var (
		status string
		result any
		err    error
	)
	switch action {
	case "start":
		status, err = sess.RemoteStartTransaction(ctx, body.IdTag, body.ConnectorID)
	case "stop":
		status, err = sess.RemoteStopTransaction(ctx, body.TransactionID)
	case "limit":
		status, err = sess.SetCurrentLimit(ctx, body.ConnectorID, body.Amps)
	case "clear-limit":
		status, err = sess.ClearChargingProfile(ctx, body.ConnectorID)
	case "availability":
		operative := true
		if body.Operative != nil {
			operative = *body.Operative
		}
		status, err = sess.ChangeAvailability(ctx, body.ConnectorID, operative)
	case "reset", "restart":
		status, err = sess.Reset(ctx, body.Hard)
	case "unlock":
		status, err = sess.UnlockConnector(ctx, body.ConnectorID)
	case "trigger":
		status, err = sess.TriggerMessage(ctx, body.RequestedMessage, body.ConnectorID)
	case "settings":
		writeError(w, http.StatusBadRequest,
			"\"settings\" writes the charger's own configuration, which only the Anker cloud connection can do. Switch the control mode to Anker cloud (MQTT) to use it.")
		return
	case "config":
		if body.Key != "" {
			status, err = sess.ChangeConfiguration(ctx, body.Key, body.Value)
		} else {
			var conf ocpp.GetConfigurationResult
			conf, err = sess.GetConfiguration(ctx, nil)
			result = conf
		}
	default:
		writeError(w, http.StatusBadRequest, "unknown control action: "+action)
		return
	}

	// Audit the outcome (actor, charger, action, the meaningful params, result).
	outcome := status
	if err != nil {
		outcome = "error"
	}
	s.auditControl(who, sn, action, controlAuditParams(action, body.ConnectorID, body.Amps, body.Hard, body.Operative), outcome, err)

	if err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
		return
	}
	resp := map[string]any{"status": status}
	if result != nil {
		resp["result"] = result
	}
	writeJSON(w, http.StatusOK, resp)
}

// isDestructiveAction reports whether a control action reboots the charger or
// releases the cable lock — actions that require an explicit confirm:true and a
// password re-authentication.
func isDestructiveAction(action string) bool {
	// "restart" is the cloud's name for the same act as OCPP's "reset": both
	// reboot the charger, so both pass through the confirmation and the password
	// step-up rather than one slipping past because it is spelled differently.
	return action == "reset" || action == "restart" || action == "unlock"
}

// reauthenticate verifies the caller's password against PocketBase (a sudo-style
// step-up). It returns false on any failure — missing password, unknown email,
// unconfigured service account, or wrong password.
func (s *Server) reauthenticate(ctx context.Context, who *callerIdentity, password string) bool {
	if who == nil || who.Email == "" || password == "" || !s.pb.Configured() {
		return false
	}
	_, status, err := s.pb.LoginWithPassword(ctx, s.usersCollection(), who.Email, password)
	return err == nil && status == http.StatusOK
}

// controlAuditParams collects the parameters worth recording for an audited
// control action (none are sensitive).
func controlAuditParams(action string, connectorID int, amps float64, hard bool, operative *bool) map[string]any {
	p := map[string]any{}
	switch action {
	case "limit":
		p["amps"] = amps
		p["connectorId"] = connectorID
	case "reset":
		p["hard"] = hard
	case "availability":
		p["connectorId"] = connectorID
		if operative != nil {
			p["operative"] = *operative
		}
	case "start", "stop", "unlock", "clear-limit":
		if connectorID > 0 {
			p["connectorId"] = connectorID
		}
	}
	if len(p) == 0 {
		return nil
	}
	return p
}

// ankerControlGate applies everything a control request must satisfy before any
// transport is chosen: the cascade, a control mode that is not off, and a
// charger the caller actually owns. It answers the request itself on refusal.
//
// What "owns" means depends on the mode, because each transport binds a charger
// differently: OCPP by the control token the charger authenticates with, Modbus
// by the local address we dial, the Anker cloud by the account the charger is
// registered to. Requiring a token in Modbus or cloud mode would demand a
// credential neither path ever uses.
func (s *Server) ankerControlGate(w http.ResponseWriter, r *http.Request) (*callerIdentity, ankerResolution, ankerChargerBinding, bool) {
	var (
		res     ankerResolution
		binding ankerChargerBinding
	)
	who := caller(r)
	if who == nil {
		writeError(w, http.StatusUnauthorized, "not authenticated")
		return nil, res, binding, false
	}
	sn := r.PathValue("sn")
	userRaw := s.userPluginSettings(r.Context(), who.ID)
	res = s.resolveAnker(r.Context(), who, userRaw)

	switch {
	case !res.available:
		writeError(w, http.StatusForbidden, "the Anker Solix integration is disabled by the administrator")
		return nil, res, binding, false
	case !res.orgEnabled:
		writeError(w, http.StatusForbidden, "the Anker Solix integration is disabled for your organization")
		return nil, res, binding, false
	case !res.enabled:
		writeError(w, http.StatusForbidden, "enable the Anker Solix integration in Settings first")
		return nil, res, binding, false
	}
	if res.eff.ControlMode == ankerControlOff {
		writeError(w, http.StatusConflict, "control mode is off; choose Modbus TCP or a CSMS mode to send commands")
		return nil, res, binding, false
	}

	binding = ankerBindingFor(userRaw, sn)
	if res.eff.ControlMode == ankerControlMqtt {
		// The cloud path binds a charger by the account it is registered to, so
		// there is no token to install and no address to save. What it does need is
		// the credentials for that account — and the plugin refuses a serial the
		// account does not own, which is the ownership check the other two get from
		// their own binding.
		if strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "" {
			writeError(w, http.StatusBadRequest, "the Anker cloud connection signs in as your account; enter your Anker email and password in Settings first")
			return nil, res, binding, false
		}
		return who, res, binding, true
	}
	if res.eff.ControlMode == ankerControlModbus {
		if strings.TrimSpace(binding.ModbusHost) == "" {
			writeError(w, http.StatusNotFound, "no local address for this charger; enable Modbus TCP in the Anker app and save the address it shows")
			return nil, res, binding, false
		}
		return who, res, binding, true
	}
	// A serial alone can't be used to reach someone else's charger.
	if binding.TokenHash == "" {
		writeError(w, http.StatusNotFound, "no control token for this charger; generate one first")
		return nil, res, binding, false
	}
	return who, res, binding, true
}

// ankerControlSession applies the gate and returns the charger's live OCPP
// session.
func (s *Server) ankerControlSession(w http.ResponseWriter, r *http.Request) (*ocpp.Session, bool) {
	if _, _, _, ok := s.ankerControlGate(w, r); !ok {
		return nil, false
	}
	sess, ok := s.ocpp.SessionFor(r.PathValue("sn"))
	if !ok {
		writeError(w, http.StatusConflict, "charger is not connected to the control backend")
		return nil, false
	}
	return sess, true
}

// ---- helpers -----------------------------------------------------------------

// ocppEndpoint builds the ws(s):// URL the operator points the charger at. When
// OCPP_PUBLIC_URL is configured it is used verbatim (scheme normalized to
// ws/wss), which is safer than trusting request headers; otherwise it is derived
// from the request.
func (s *Server) ocppEndpoint(r *http.Request, sn string) string {
	s.mu.RLock()
	publicURL := s.cfg.OCPPPublicURL
	s.mu.RUnlock()
	if publicURL != "" {
		base := publicURL
		if rest, ok := strings.CutPrefix(base, "https://"); ok {
			base = "wss://" + rest
		} else if rest, ok := strings.CutPrefix(base, "http://"); ok {
			base = "ws://" + rest
		}
		return strings.TrimRight(base, "/") + "/ocpp/" + url.PathEscape(sn)
	}
	scheme := "ws"
	if requestIsSecure(r) {
		scheme = "wss"
	}
	return scheme + "://" + r.Host + "/ocpp/" + url.PathEscape(sn)
}

// requestIsSecure reports whether a request arrived over TLS, either directly or
// via a TLS-terminating reverse proxy (X-Forwarded-Proto). The header is only
// trustworthy behind such a proxy — the deployment model here (see the plan).
func requestIsSecure(r *http.Request) bool {
	return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}

// ankerUpstreamAllowed reports whether a proxy-mode upstream URL points at an
// Anker host, so a spoofed ocpp-info response can't steer the proxy elsewhere.
func ankerUpstreamAllowed(raw string) bool {
	u, err := url.Parse(raw)
	if err != nil {
		return false
	}
	host := strings.ToLower(u.Hostname())
	return host == "anker.com" || strings.HasSuffix(host, ".anker.com")
}

// ankerBindingFor reads one charger's stored control binding from a user's
// pluginSettings blob.
func ankerBindingFor(userRaw json.RawMessage, sn string) ankerChargerBinding {
	var doc ankerSettingsDoc
	if len(userRaw) > 0 {
		_ = json.Unmarshal(userRaw, &doc)
	}
	return doc.AnkerSolix.ControlChargers[sn]
}

func genControlToken() string {
	var b [24]byte
	_, _ = rand.Read(b[:])
	return hex.EncodeToString(b[:])
}

// hashToken returns the lowercase hex SHA-256 of a control token. Only this hash
// is ever stored or indexed.
func hashToken(token string) string {
	sum := sha256.Sum256([]byte(token))
	return hex.EncodeToString(sum[:])
}

// tokenHint returns the last 4 characters of a token, shown in the UI so an owner
// can tell which token is installed without exposing it.
func tokenHint(token string) string {
	if len(token) <= 4 {
		return token
	}
	return token[len(token)-4:]
}

// extractWSURL walks an arbitrary JSON value and returns the first ws:// or
// wss:// string it finds.
func extractWSURL(raw json.RawMessage) string {
	var v any
	if err := json.Unmarshal(raw, &v); err != nil {
		return ""
	}
	return walkForWS(v)
}

func walkForWS(v any) string {
	switch t := v.(type) {
	case string:
		s := strings.TrimSpace(t)
		if strings.HasPrefix(s, "ws://") || strings.HasPrefix(s, "wss://") {
			return s
		}
	case map[string]any:
		for _, val := range t {
			if u := walkForWS(val); u != "" {
				return u
			}
		}
	case []any:
		for _, val := range t {
			if u := walkForWS(val); u != "" {
				return u
			}
		}
	}
	return ""
}
