package pb

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"sync"
	"testing"
)

// PocketBase hides a record it will not let you read behind a 404 rather than a
// 401. A superuser call that goes out with no token therefore comes back
// "not found", and the client's 401 retry never fires — so these tests pin down
// that the client signs in *before* its first request whenever it holds no
// token: after a startup where the up-front Authenticate failed, and after a
// Reconfigure onto new credentials.

const (
	fakeSvcToken = "svc-token"
	fakeRecordID = "rec1"
)

// fakePB stands in for PocketBase: it serves one record, but only to a caller
// carrying the superuser token, and records how it was signed into.
type fakePB struct {
	mu sync.Mutex

	// Credentials that will be accepted. Everything else gets a 400, as
	// PocketBase gives for a bad identity/password.
	email    string
	password string

	authCalls     int
	lastIdentity  string
	guestAttempts int // record reads that arrived without the superuser token
}

func (f *fakePB) handler() http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("POST /api/collections/_superusers/auth-with-password", func(w http.ResponseWriter, r *http.Request) {
		var body struct{ Identity, Password string }
		_ = json.NewDecoder(r.Body).Decode(&body)
		f.mu.Lock()
		f.authCalls++
		f.lastIdentity = body.Identity
		ok := body.Identity == f.email && body.Password == f.password
		f.mu.Unlock()
		if !ok {
			writeTestJSON(w, 400, map[string]any{"message": "Failed to authenticate."})
			return
		}
		writeTestJSON(w, 200, map[string]any{"token": fakeSvcToken})
	})

	mux.HandleFunc("GET /api/collections/users/records/{id}", func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Authorization") != fakeSvcToken {
			f.mu.Lock()
			f.guestAttempts++
			f.mu.Unlock()
			writeTestJSON(w, 404, map[string]any{
				"data": map[string]any{}, "message": "The requested resource wasn't found.", "status": 404,
			})
			return
		}
		writeTestJSON(w, 200, map[string]any{"id": r.PathValue("id"), "email": "driver@test.local"})
	})

	return mux
}

func writeTestJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(body)
}

func newFakePB(t *testing.T, f *fakePB) string {
	t.Helper()
	srv := httptest.NewServer(f.handler())
	t.Cleanup(srv.Close)
	return srv.URL
}

func (f *fakePB) counts() (auth, guest int) {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.authCalls, f.guestAttempts
}

// A client that never managed to authenticate at startup must sign in on its
// first call rather than sending it as a guest and reporting PocketBase's 404.
func TestGetOneAuthenticatesWhenNoTokenCached(t *testing.T) {
	f := &fakePB{email: "admin@test.local", password: "pw"}
	c := New(newFakePB(t, f), f.email, f.password)

	var rec struct{ ID, Email string }
	if err := c.GetOne(context.Background(), "users", fakeRecordID, &rec); err != nil {
		t.Fatalf("GetOne: %v", err)
	}
	if rec.ID != fakeRecordID {
		t.Fatalf("record id = %q, want %q", rec.ID, fakeRecordID)
	}

	auth, guest := f.counts()
	if guest != 0 {
		t.Errorf("%d record read(s) went out unauthenticated, want 0", guest)
	}
	if auth != 1 {
		t.Errorf("authenticated %d time(s), want 1", auth)
	}
}

// The token is cached: a second call reuses it instead of signing in again.
func TestGetOneReusesCachedToken(t *testing.T) {
	f := &fakePB{email: "admin@test.local", password: "pw"}
	c := New(newFakePB(t, f), f.email, f.password)

	for i := 0; i < 3; i++ {
		if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
			t.Fatalf("GetOne #%d: %v", i, err)
		}
	}
	if auth, _ := f.counts(); auth != 1 {
		t.Errorf("authenticated %d time(s), want 1", auth)
	}
}

// Retargeting from the panel clears the cached token, so the next call has to
// sign in again — under the new credentials.
func TestReconfigureReauthenticates(t *testing.T) {
	f := &fakePB{email: "admin@test.local", password: "pw"}
	url := newFakePB(t, f)
	c := New(url, f.email, f.password)

	if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
		t.Fatalf("GetOne before reconfigure: %v", err)
	}

	f.mu.Lock()
	f.email, f.password = "new@test.local", "pw2"
	f.mu.Unlock()
	c.Reconfigure(url, "new@test.local", "pw2")

	if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err != nil {
		t.Fatalf("GetOne after reconfigure: %v", err)
	}

	auth, guest := f.counts()
	if guest != 0 {
		t.Errorf("%d record read(s) went out unauthenticated, want 0", guest)
	}
	if auth != 2 {
		t.Errorf("authenticated %d time(s), want 2", auth)
	}
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.lastIdentity != "new@test.local" {
		t.Errorf("signed in as %q, want the reconfigured account", f.lastIdentity)
	}
}

// Bad credentials must surface as the authentication failure they are, not as
// PocketBase's 404 for a record the client was never allowed to see.
func TestGetOneReportsAuthFailureNotNotFound(t *testing.T) {
	f := &fakePB{email: "admin@test.local", password: "pw"}
	c := New(newFakePB(t, f), "admin@test.local", "wrong")

	err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{}))
	if err == nil {
		t.Fatal("GetOne succeeded with bad credentials")
	}
	if apiErr, ok := err.(*APIError); ok && apiErr.Status == http.StatusNotFound {
		t.Fatalf("got PocketBase's 404 instead of an auth failure: %v", err)
	}
}

// With no service account configured there is nothing to sign in with, so the
// call goes out as before — the endpoints that need superuser access answer 503
// on their own rather than relying on this layer.
func TestNoServiceAccountSkipsAuthentication(t *testing.T) {
	f := &fakePB{email: "admin@test.local", password: "pw"}
	c := New(newFakePB(t, f), "", "")

	if err := c.GetOne(context.Background(), "users", fakeRecordID, new(struct{})); err == nil {
		t.Fatal("GetOne succeeded without a service account")
	}
	if auth, _ := f.counts(); auth != 0 {
		t.Errorf("attempted %d sign-in(s) with no credentials, want 0", auth)
	}
}
