package api

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"drivervault/apiserver/internal/config"
	"drivervault/apiserver/internal/pb"
)

func TestNormalizeControlMode(t *testing.T) {
	cases := map[string]string{
		"off":     "off",
		"own":     "own",
		"proxy":   "proxy",
		"modbus":  "modbus",
		"mqtt":    "mqtt",
		"OWN":     "own",
		"  Proxy": "proxy",
		"Modbus ": "modbus",
		" MQTT ":  "mqtt",
		"":        "", // unset — cascade continues to the next layer
		"bogus":   "", // unknown — treated as unset
	}
	for in, want := range cases {
		if got := normalizeControlMode(in); got != want {
			t.Errorf("normalizeControlMode(%q) = %q, want %q", in, got, want)
		}
	}
}

// binding builds a hashed binding the way handleAnkerControlToken does, so the
// index tests exercise the real hash-at-rest path.
func binding(token string) ankerChargerBinding {
	return ankerChargerBinding{TokenHash: hashToken(token), TokenHint: tokenHint(token)}
}

func TestControlIndexSetUserInvalidatesOldToken(t *testing.T) {
	ci := newControlIndex()
	ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("tok-old")})
	if _, ok := ci.lookup("tok-old"); !ok {
		t.Fatal("tok-old should resolve after first set")
	}
	// Regenerate: same charger, new token. The old token must stop resolving.
	ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("tok-new")})
	if _, ok := ci.lookup("tok-old"); ok {
		t.Error("tok-old should be invalidated after regeneration")
	}
	b, ok := ci.lookup("tok-new")
	if !ok || b.UserID != "u1" || b.Serial != "SN1" {
		t.Errorf("tok-new resolves to %+v (ok=%v), want u1/SN1", b, ok)
	}
}

func TestControlIndexRevoke(t *testing.T) {
	ci := newControlIndex()
	ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("tok")})
	// Revoke = setUser with the charger removed.
	ci.setUser("u1", map[string]ankerChargerBinding{})
	if _, ok := ci.lookup("tok"); ok {
		t.Error("token should not resolve after revocation")
	}
}

func TestControlIndexIsolatesUsers(t *testing.T) {
	ci := newControlIndex()
	ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("a")})
	ci.setUser("u2", map[string]ankerChargerBinding{"SN2": binding("b")})
	// Re-setting u1 must not touch u2's token.
	ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("a2")})
	if _, ok := ci.lookup("b"); !ok {
		t.Error("u2's token should be untouched when u1 is updated")
	}
}

func TestControlIndexStoresNoPlaintext(t *testing.T) {
	ci := newControlIndex()
	ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("super-secret-token")})
	// The plaintext must never appear as an index key.
	ci.mu.RLock()
	defer ci.mu.RUnlock()
	if _, ok := ci.byHash["super-secret-token"]; ok {
		t.Fatal("index must key on the hash, never the plaintext token")
	}
}

func TestAnkerBindingFor(t *testing.T) {
	raw := json.RawMessage(`{"ankerSolix":{"controlChargers":{"SN1":{"tokenHash":"abc","tokenHint":"1234"}}}}`)
	if got := ankerBindingFor(raw, "SN1").TokenHash; got != "abc" {
		t.Errorf("binding hash = %q, want abc", got)
	}
	if got := ankerBindingFor(raw, "other").TokenHash; got != "" {
		t.Errorf("unknown charger should have empty hash, got %q", got)
	}
	if got := ankerBindingFor(nil, "SN1").TokenHash; got != "" {
		t.Errorf("nil settings should have empty hash, got %q", got)
	}
}

func TestHashAndHint(t *testing.T) {
	tok := "0123456789abcdef"
	if h := hashToken(tok); len(h) != 64 || h == tok {
		t.Errorf("hashToken should be a 64-char hex digest distinct from input, got %q", h)
	}
	if hashToken("a") == hashToken("b") {
		t.Error("distinct tokens must hash differently")
	}
	if got := tokenHint(tok); got != "cdef" {
		t.Errorf("tokenHint = %q, want cdef", got)
	}
	if got := tokenHint("ab"); got != "ab" {
		t.Errorf("short-token hint = %q, want ab", got)
	}
}

func TestIsDestructiveAction(t *testing.T) {
	for _, a := range []string{"reset", "unlock"} {
		if !isDestructiveAction(a) {
			t.Errorf("%q should be destructive", a)
		}
	}
	for _, a := range []string{"start", "stop", "limit", "clear-limit", "availability", "trigger", "config"} {
		if isDestructiveAction(a) {
			t.Errorf("%q should not be destructive", a)
		}
	}
}

func TestRateLimiter(t *testing.T) {
	rl := newRateLimiter(2, time.Minute)
	if !rl.allow("k") || !rl.allow("k") {
		t.Fatal("first two calls should be allowed")
	}
	if rl.allow("k") {
		t.Error("third call in the window should be blocked")
	}
	if !rl.allow("other") {
		t.Error("a different key must have its own budget")
	}
}

func TestReauthenticateGuards(t *testing.T) {
	// With PocketBase unconfigured, re-auth must fail closed regardless of input,
	// and never treat a missing password/email as success.
	s := &Server{pb: pb.New("", "", "")}
	ctx := context.Background()
	cases := []struct {
		name string
		who  *callerIdentity
		pass string
	}{
		{"nil caller", nil, "pw"},
		{"empty email", &callerIdentity{ID: "u1"}, "pw"},
		{"empty password", &callerIdentity{ID: "u1", Email: "a@b.c"}, ""},
		{"pb unconfigured", &callerIdentity{ID: "u1", Email: "a@b.c"}, "pw"},
	}
	for _, c := range cases {
		if s.reauthenticate(ctx, c.who, c.pass) {
			t.Errorf("%s: reauthenticate should fail closed", c.name)
		}
	}
}

func TestAnkerUpstreamAllowed(t *testing.T) {
	ok := []string{
		"wss://ankerpower-api-eu.anker.com/ocpp/CP1",
		"wss://ankerpower-api.anker.com/x",
		"ws://anker.com/y",
	}
	bad := []string{
		"wss://evil.example.com/ocpp/CP1",
		"wss://anker.com.evil.net/x",
		"not a url at all ::::",
		"",
	}
	for _, u := range ok {
		if !ankerUpstreamAllowed(u) {
			t.Errorf("%q should be allowed", u)
		}
	}
	for _, u := range bad {
		if ankerUpstreamAllowed(u) {
			t.Errorf("%q should be rejected", u)
		}
	}
}

func TestExtractWSURL(t *testing.T) {
	cases := map[string]string{
		`{"data":{"ocppUrl":"wss://ocpp.anker.com/CP1"}}`: "wss://ocpp.anker.com/CP1",
		`{"a":{"b":[{"endpoint":"ws://x/y"}]}}`:           "ws://x/y",
		`{"data":{"note":"https://not-a-ws-url"}}`:        "",
		`{"empty":true}`: "",
	}
	for in, want := range cases {
		if got := extractWSURL(json.RawMessage(in)); got != want {
			t.Errorf("extractWSURL(%s) = %q, want %q", in, got, want)
		}
	}
}

func TestOCPPEndpoint(t *testing.T) {
	s := &Server{}
	// Plain HTTP request → ws://
	r := httptest.NewRequest("GET", "http://host.example/x", nil)
	r.Host = "host.example"
	if got := s.ocppEndpoint(r, "SN 1"); got != "ws://host.example/ocpp/SN%201" {
		t.Errorf("endpoint = %q", got)
	}
	// Behind a TLS-terminating proxy → wss://
	r2 := httptest.NewRequest("GET", "http://host.example/x", nil)
	r2.Host = "host.example"
	r2.Header.Set("X-Forwarded-Proto", "https")
	if got := s.ocppEndpoint(r2, "SN1"); got != "wss://host.example/ocpp/SN1" {
		t.Errorf("tls endpoint = %q", got)
	}
}

// The four card routes are reachable at all. A capability the plugin implements
// and the action catalogue advertises is still unusable if nothing routes to it,
// and that is not a failure any other test here would notice: the plugin's own
// tests pass, and the panel simply has no button. Each route is asked for
// without a token, so what is being checked is that the request reached the
// authentication middleware rather than a 404.
func TestAnkerCardRoutesAreRegistered(t *testing.T) {
	h := New(config.Config{}, nil).Handler()
	for _, tc := range []struct{ method, path string }{
		{"POST", "/api/integrations/anker-solix/chargers/SN1/rfid-cards"},
		{"POST", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/scan"},
		{"GET", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/charger"},
		{"DELETE", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/AABBCCDD"},
	} {
		rr := httptest.NewRecorder()
		h.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
		if rr.Code == http.StatusNotFound {
			t.Errorf("%s %s is not routed", tc.method, tc.path)
		}
	}
}
