package ankersolix

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"testing"
	"time"
)

// TestChargerInventoryLoginRefusedOnce is the regression: one chargers poll asks
// four cloud views, and each of them holding no token used to try its own login.
// Anker disables the account after five refusals, so the poll that found a bad
// login also locked the account out for ten minutes. The poll must spend exactly
// one sign-in, and the next one must spend none until the backoff expires.
func TestChargerInventoryLoginRefusedOnce(t *testing.T) {
	logins := 0
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !strings.HasSuffix(r.URL.Path, epLogin) {
			t.Errorf("unexpected request to %s with no token", r.URL.Path)
			w.WriteHeader(http.StatusInternalServerError)
			return
		}
		logins++
		_, _ = w.Write([]byte(`{"code":26161,"msg":"Failed to request."}`))
	}))
	defer srv.Close()

	p := &Plugin{}
	if err := p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "pw"}); err != nil {
		t.Fatal(err)
	}
	// Point the instance at the test server, session key included, so this test
	// shares nothing with the others.
	p.apiBase = srv.URL
	p.sess = sessionFor(srv.URL, "u@example.com", "pw")

	_, err := p.chargerInventory(context.Background())
	if err == nil {
		t.Fatal("expected the refused login to fail the inventory")
	}
	if !isLoginFailure(err) {
		t.Errorf("error should be recognisable as a login failure, got %v", err)
	}
	if !strings.Contains(err.Error(), "26161") {
		t.Errorf("error should carry Anker's code, got %v", err)
	}
	if strings.Contains(err.Error(), "every cloud view failed") {
		t.Errorf("a refused login is not four failed views, got %v", err)
	}
	if logins != 1 {
		t.Fatalf("login attempts = %d, want 1", logins)
	}

	if _, err := p.chargerInventory(context.Background()); err == nil {
		t.Fatal("expected the cached failure to be replayed")
	}
	if logins != 1 {
		t.Fatalf("login attempts after a second poll = %d, want 1", logins)
	}
}

// TestLoginBackoffWindows checks how long each kind of refusal buys: an ordinary
// one starts at loginRetryBase and doubles, an account Anker has already
// disabled gets the full penalty it asked for.
func TestLoginBackoffWindows(t *testing.T) {
	s := &session{}

	s.noteLogin(errors.New("boom"))
	if wait := time.Until(s.loginRetryAt); wait > loginRetryBase || wait < loginRetryBase-time.Second {
		t.Errorf("first failure waits %s, want ~%s", wait, loginRetryBase)
	}
	s.noteLogin(errors.New("boom"))
	if wait := time.Until(s.loginRetryAt); wait <= loginRetryBase {
		t.Errorf("second failure waits %s, want more than %s", wait, loginRetryBase)
	}

	s.noteLogin(&loginRejected{code: codeSignInLocked, msg: "account disabled for 10 minutes"})
	if wait := time.Until(s.loginRetryAt); wait < loginLockoutWait-time.Second {
		t.Errorf("locked account waits %s, want at least %s", wait, loginLockoutWait)
	}

	s.noteLogin(nil)
	if s.loginErr != nil || s.loginFails != 0 || !s.loginRetryAt.IsZero() {
		t.Error("a successful login must clear the backoff")
	}
}

// TestLoginBackoffCapped: the wait never exceeds loginRetryMax however long the
// outage lasts.
func TestLoginBackoffCapped(t *testing.T) {
	s := &session{}
	for i := 0; i < 20; i++ {
		s.noteLogin(errors.New("boom"))
	}
	if wait := time.Until(s.loginRetryAt); wait > loginRetryMax {
		t.Errorf("wait %s exceeds the %s cap", wait, loginRetryMax)
	}
}

// TestSessionSharedAcrossInstances is the second half of the regression. The
// manager builds a throwaway plugin instance per HTTP call, so a token kept in
// the instance meant a fresh login for the health probe, another for the charger
// list, another for OCPP info — enough on its own to trip Anker's login throttle
// (code 26161) and then its five-failure lockout. Instances sharing an account
// must share the token; a different account must not.
func TestSessionSharedAcrossInstances(t *testing.T) {
	logins := 0
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if strings.HasSuffix(r.URL.Path, epLogin) {
			logins++
			_, _ = w.Write([]byte(`{"code":0,"msg":"success!","data":{"auth_token":"t","user_id":"u","token_expires_at":` +
				strconv.FormatInt(time.Now().Add(24*time.Hour).Unix(), 10) + `}}`))
			return
		}
		_, _ = w.Write([]byte(`{"code":0,"msg":"success!","data":{}}`))
	}))
	defer srv.Close()

	dial := func(email, password string) *Plugin {
		p := &Plugin{}
		if err := p.Init(context.Background(), map[string]string{"email": email, "password": password}); err != nil {
			t.Fatal(err)
		}
		p.apiBase = srv.URL
		p.sess = sessionFor(srv.URL, email, password)
		return p
	}

	// Three calls the panel makes as three separate instances, one account.
	for i := 0; i < 3; i++ {
		if _, err := dial("u@example.com", "pw").ensureToken(context.Background()); err != nil {
			t.Fatal(err)
		}
	}
	if logins != 1 {
		t.Fatalf("login attempts for one account = %d, want 1", logins)
	}

	// A different account is a different session, and signs in for itself.
	if _, err := dial("other@example.com", "pw").ensureToken(context.Background()); err != nil {
		t.Fatal(err)
	}
	if logins != 2 {
		t.Fatalf("login attempts after a second account = %d, want 2", logins)
	}
}

// TestPruneSessionsDropsIdle keeps the package-level cache from growing an entry
// for every credential the server has ever seen.
func TestPruneSessionsDropsIdle(t *testing.T) {
	sessionsMu.Lock()
	sessions["stale"] = &session{lastUse: time.Now().Add(-2 * sessionIdle)}
	sessions["fresh"] = &session{lastUse: time.Now()}
	pruneSessionsLocked()
	_, stale := sessions["stale"]
	_, fresh := sessions["fresh"]
	delete(sessions, "fresh")
	sessionsMu.Unlock()

	if stale {
		t.Error("a session idle beyond sessionIdle should have been pruned")
	}
	if !fresh {
		t.Error("a session in use must not be pruned")
	}
}
