package ankersolix

// A login has to outlive the plugin instance that made it.
//
// The manager builds a fresh, throwaway instance for every per-user health check
// and every capability call (internal/plugins/manager.go: HealthCheckWith,
// InvokeWith, InvokeBatchWith), so a token held in the instance dies with the
// request that fetched it. Against Anker that is expensive: passport/login is
// throttled per IP per minute and answers code 26161 ("Failed to request") once
// tripped, five refusals in a row disable the account for ten minutes, and the
// cloud has historically kept one token per account, so each login can evict the
// one the mobile app is holding. Opening the panel — health, then the charger
// list — was two logins; a page that also asked for OCPP info, three.
//
// Sessions therefore live in the package, keyed by the account being signed in
// as, so every instance configured for that account shares one token and one
// login backoff.

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"strings"
	"sync"
	"time"
)

// session is one account's cloud state: the token it holds, how long to leave
// its login alone after a refusal, and the broker connection its chargers are
// commanded over.
type session struct {
	mu  sync.Mutex // guards tok and the backoff below, and serialises the login exchange
	tok *tokenInfo

	loginErr     error
	loginRetryAt time.Time
	loginFails   int

	// The cloud MQTT half (cloudmqtt.go), under its own lock: a broker connection
	// takes a TLS handshake and a fetched certificate to open, so it outlives the
	// plugin instance for the same reason the token does. mqttMu guards all four
	// fields and is never held while mu is.
	mqttMu      sync.Mutex
	mqttCreds   *mqttCredentials
	mqttCredsAt time.Time
	mqttConn    *mqttConn
	devices     map[string]string // charger serial -> product code, from the account's inventory
	devicesAt   time.Time

	// lastUse is touched and read under sessionsMu, never under mu.
	lastUse time.Time
}

// sessionIdle is how long an unused session is kept before being pruned — well
// beyond a token's ~7-day life, so pruning never costs a login.
const sessionIdle = 14 * 24 * time.Hour

var (
	sessionsMu sync.Mutex
	sessions   = map[string]*session{}
)

// sessionFor returns the shared session for one set of credentials, creating it
// on first use. Different credentials — or the same account on the other
// regional server — get their own.
func sessionFor(apiBase, email, password string) *session {
	// The password decides which account this is, but is not kept in the key.
	sum := sha256.Sum256([]byte(password))
	key := strings.Join([]string{
		apiBase,
		strings.ToLower(strings.TrimSpace(email)),
		hex.EncodeToString(sum[:]),
	}, "\x00")

	sessionsMu.Lock()
	defer sessionsMu.Unlock()
	pruneSessionsLocked()
	s := sessions[key]
	if s == nil {
		s = &session{}
		sessions[key] = s
	}
	s.lastUse = time.Now()
	return s
}

// pruneSessionsLocked drops sessions nothing has used for sessionIdle, so a
// long-lived server does not keep an entry for every credential it has ever
// seen — an edited password leaves its old session behind. Caller holds
// sessionsMu.
func pruneSessionsLocked() {
	cutoff := time.Now().Add(-sessionIdle)
	for k, s := range sessions {
		if s.lastUse.Before(cutoff) {
			// A pruned session must not leave its broker connection open; nothing
			// else holds a reference to close it afterwards.
			s.closeMqtt()
			delete(sessions, k)
		}
	}
}

// closeMqtt drops the session's broker connection, if it holds one.
func (s *session) closeMqtt() {
	s.mqttMu.Lock()
	c := s.mqttConn
	s.mqttConn = nil
	s.mqttMu.Unlock()
	if c != nil {
		c.close()
	}
}

// noteLogin records the outcome of a login attempt and, on failure, how long to
// leave the account alone: one Anker has already disabled gets the full penalty,
// anything else backs off exponentially up to loginRetryMax. Caller holds s.mu.
func (s *session) noteLogin(err error) {
	if err == nil {
		s.loginErr, s.loginRetryAt, s.loginFails = nil, time.Time{}, 0
		return
	}
	s.loginFails++
	wait := loginRetryBase << min(s.loginFails-1, 8)
	if wait > loginRetryMax {
		wait = loginRetryMax
	}
	var rejected *loginRejected
	if errors.As(err, &rejected) && rejected.code == codeSignInLocked && wait < loginLockoutWait {
		wait = loginLockoutWait
	}
	s.loginRetryAt = time.Now().Add(wait)
	s.loginErr = &loginFailure{err: err, retryAt: s.loginRetryAt}
}
