package plugins

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

	"drivervault/apiserver/internal/pb"
)

// fakePB is the slice of PocketBase the settings store touches: superuser auth,
// and list/create/update on one collection. It holds a single record so the
// singleton behaviour can be asserted directly.
type fakePB struct {
	mu sync.Mutex

	recordID   string          // "" while no record exists
	settings   json.RawMessage // the stored pluginSettings value
	listErr    bool            // make list calls fail (database unreachable)
	creates    int
	updates    int
	missingCol bool // answer list with a 404, as if bootstrap had not run
}

func (f *fakePB) server(t *testing.T) *httptest.Server {
	t.Helper()
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		f.mu.Lock()
		defer f.mu.Unlock()

		switch {
		case strings.HasSuffix(r.URL.Path, "/auth-with-password"):
			writeTestJSON(w, http.StatusOK, map[string]any{"token": "test-token"})

		case r.Method == http.MethodGet && r.URL.Path == "/api/collections/app_settings/records":
			if f.listErr {
				http.Error(w, "connection refused", http.StatusBadGateway)
				return
			}
			if f.missingCol {
				http.Error(w, `{"message":"Missing collection context."}`, http.StatusNotFound)
				return
			}
			items := []any{}
			if f.recordID != "" {
				items = append(items, map[string]any{
					"id":             f.recordID,
					"key":            globalSettingsKey,
					"pluginSettings": f.settings,
				})
			}
			writeTestJSON(w, http.StatusOK, map[string]any{
				"page": 1, "perPage": 1, "totalItems": len(items), "totalPages": 1,
				"items": items,
			})

		case r.Method == http.MethodPost && r.URL.Path == "/api/collections/app_settings/records":
			var body struct {
				Key            string          `json:"key"`
				PluginSettings json.RawMessage `json:"pluginSettings"`
			}
			_ = json.NewDecoder(r.Body).Decode(&body)
			if body.Key != globalSettingsKey {
				t.Errorf("create used key %q, want %q", body.Key, globalSettingsKey)
			}
			f.creates++
			f.recordID = "rec1"
			f.settings = body.PluginSettings
			writeTestJSON(w, http.StatusOK, map[string]any{"id": f.recordID})

		case r.Method == http.MethodPatch &&
			strings.HasPrefix(r.URL.Path, "/api/collections/app_settings/records/"):
			id := strings.TrimPrefix(r.URL.Path, "/api/collections/app_settings/records/")
			if id != f.recordID {
				http.Error(w, `{"message":"not found"}`, http.StatusNotFound)
				return
			}
			var body struct {
				PluginSettings json.RawMessage `json:"pluginSettings"`
			}
			_ = json.NewDecoder(r.Body).Decode(&body)
			f.updates++
			f.settings = body.PluginSettings
			writeTestJSON(w, http.StatusOK, map[string]any{"id": id})

		default:
			t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
			http.Error(w, "unexpected", http.StatusInternalServerError)
		}
	}))
	t.Cleanup(srv.Close)
	return srv
}

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

func newPBStore(t *testing.T, f *fakePB) Store {
	t.Helper()
	srv := f.server(t)
	return NewPocketBaseStore(pb.New(srv.URL, "admin@test.local", "pw"), "app_settings")
}

// The first save creates the singleton; later saves update it in place.
func TestPBStoreCreatesThenUpdatesOneRecord(t *testing.T) {
	f := &fakePB{}
	m := loadedManager(t, newPBStore(t, f))
	ctx := context.Background()

	if _, err := m.Upsert(ctx, "test-a", true, map[string]string{"user": "me"}); err != nil {
		t.Fatalf("first save: %v", err)
	}
	if _, err := m.Upsert(ctx, "test-b", true, nil); err != nil {
		t.Fatalf("second save: %v", err)
	}

	f.mu.Lock()
	creates, updates := f.creates, f.updates
	f.mu.Unlock()
	if creates != 1 {
		t.Fatalf("expected exactly one record to be created, got %d", creates)
	}
	if updates != 1 {
		t.Fatalf("expected the second save to update, got %d updates", updates)
	}
}

// Settings written through PocketBase must come back on the next boot.
func TestPBStoreRoundTrip(t *testing.T) {
	f := &fakePB{}
	store := newPBStore(t, f)
	m := loadedManager(t, store)

	if _, err := m.Upsert(context.Background(), "test-a", true, map[string]string{"user": "me"}); err != nil {
		t.Fatal(err)
	}

	// A fresh Manager on a fresh store, as after a restart.
	m2 := loadedManager(t, newPBStore(t, f))
	v, ok := m2.Get("test-a")
	if !ok || !v.Enabled || v.Config["user"] != "me" {
		t.Fatalf("settings did not survive: ok=%v enabled=%v config=%v", ok, v.Enabled, v.Config)
	}
}

// A fresh install (no record yet) loads cleanly as "nothing configured".
func TestPBStoreEmptyIsNotAnError(t *testing.T) {
	m := loadedManager(t, newPBStore(t, &fakePB{}))
	for _, v := range m.List() {
		if v.Enabled {
			t.Fatalf("%s should not be enabled on a fresh install", v.Name)
		}
	}
}

// An unreachable database must not read as "no plugins configured", and must
// not let anything be written over settings that were never read.
func TestPBStoreUnreachableStaysUnloaded(t *testing.T) {
	f := &fakePB{recordID: "rec1", settings: json.RawMessage(`{"test-a":{"enabled":true}}`), listErr: true}
	store := newPBStore(t, f)

	m := NewManager(store)
	err := m.Load(context.Background())
	if !IsNotReady(err) {
		t.Fatalf("expected not-ready, got %v", err)
	}
	if m.Ready() {
		t.Fatal("manager must not be ready")
	}
	if _, err := m.Upsert(context.Background(), "test-a", false, nil); !IsNotReady(err) {
		t.Fatalf("Upsert should refuse, got %v", err)
	}

	// The database comes back; the real settings load and are intact.
	f.mu.Lock()
	f.listErr = false
	f.mu.Unlock()

	if err := m.Load(context.Background()); err != nil {
		t.Fatalf("reload after recovery: %v", err)
	}
	if v, _ := m.Get("test-a"); !v.Enabled {
		t.Fatal("settings lost across the outage")
	}
}

// A missing collection (bootstrap has not run) is "not ready", never "empty" —
// otherwise the first save would write a fresh document over nothing. It is also
// tagged separately, because retrying alone can never resolve it.
func TestPBStoreMissingCollectionIsNotReady(t *testing.T) {
	m := NewManager(newPBStore(t, &fakePB{missingCol: true}))
	err := m.Load(context.Background())
	if !IsNotReady(err) {
		t.Fatalf("expected not-ready for a missing collection, got %v", err)
	}
	if !IsMissingCollection(err) {
		t.Fatalf("a missing collection must be distinguishable, got %v", err)
	}
	if m.Ready() {
		t.Fatal("manager must not be ready without its collection")
	}
}

// A plain outage must NOT look like a missing collection: creating the
// collection is not the remedy for a database that is merely unreachable.
func TestPBStoreOutageIsNotAMissingCollection(t *testing.T) {
	m := NewManager(newPBStore(t, &fakePB{listErr: true}))
	err := m.Load(context.Background())
	if !IsNotReady(err) {
		t.Fatalf("expected not-ready, got %v", err)
	}
	if IsMissingCollection(err) {
		t.Fatalf("an outage must not be reported as a missing collection: %v", err)
	}
}
