package storeclient

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

// Closes R-003: storeclient was used by catalogd + vectord with zero
// tests. Coverage strategy: table-driven safeKey for the URL-escape
// edge cases; httptest.Server-backed tests for Put/Get/Delete/List
// covering both happy paths and the documented error contracts
// (404 → ErrKeyNotFound, non-200 → wrapped error with body preview).

func TestSafeKey(t *testing.T) {
	cases := []struct {
		name string
		in   string
		want string
	}{
		{"plain segments", "a/b/c", "a/b/c"},
		{"single slash", "/", "/"},
		{"empty string", "", ""},
		{"trailing slash preserved", "pre/fix/", "pre/fix/"},
		{"space gets escaped", "a/b c/d", "a/b%20c/d"},
		{"apostrophe gets escaped", "O'Reilly/key", "O%27Reilly/key"},
		{"plus sign escaped", "a+b/c", "a+b/c"}, // PathEscape leaves + alone
		{"unicode encoded", "café/x", "caf%C3%A9/x"},
		{"deep nesting", "datasets/proof_workers/abc.parquet",
			"datasets/proof_workers/abc.parquet"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := safeKey(tc.in)
			if got != tc.want {
				t.Errorf("safeKey(%q) = %q, want %q", tc.in, got, tc.want)
			}
		})
	}
}

func TestNew_TrimsTrailingSlash(t *testing.T) {
	c := New("http://127.0.0.1:3211/")
	if c.baseURL != "http://127.0.0.1:3211" {
		t.Errorf("baseURL = %q, want trailing-slash stripped", c.baseURL)
	}
}

// httptest server that records what the client sent + can be steered
// to return a specific status code per route.
type recordingServer struct {
	t          *testing.T
	srv        *httptest.Server
	gotPath    string
	gotMethod  string
	gotBody    []byte
	respStatus int
	respBody   string
}

func newRecordingServer(t *testing.T) *recordingServer {
	rs := &recordingServer{t: t, respStatus: http.StatusOK}
	rs.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		rs.gotPath = r.URL.Path + (func() string {
			if r.URL.RawQuery != "" {
				return "?" + r.URL.RawQuery
			}
			return ""
		})()
		rs.gotMethod = r.Method
		rs.gotBody, _ = io.ReadAll(r.Body)
		w.WriteHeader(rs.respStatus)
		if rs.respBody != "" {
			_, _ = w.Write([]byte(rs.respBody))
		}
	}))
	t.Cleanup(rs.srv.Close)
	return rs
}

func TestPut_HappyPath(t *testing.T) {
	rs := newRecordingServer(t)
	c := New(rs.srv.URL)
	body := []byte("hello world")

	if err := c.Put(context.Background(), "datasets/x/y.parquet", body); err != nil {
		t.Fatalf("Put: %v", err)
	}

	if rs.gotMethod != http.MethodPut {
		t.Errorf("method = %q, want PUT", rs.gotMethod)
	}
	if rs.gotPath != "/storage/put/datasets/x/y.parquet" {
		t.Errorf("path = %q, want /storage/put/datasets/x/y.parquet", rs.gotPath)
	}
	if string(rs.gotBody) != "hello world" {
		t.Errorf("body bytes mismatch: got %q want %q", rs.gotBody, body)
	}
}

func TestPut_NonOKStatusReturnsWrappedError(t *testing.T) {
	rs := newRecordingServer(t)
	rs.respStatus = http.StatusForbidden
	rs.respBody = "denied"
	c := New(rs.srv.URL)

	err := c.Put(context.Background(), "k", []byte{1})
	if err == nil {
		t.Fatal("expected error on 403, got nil")
	}
	if !strings.Contains(err.Error(), "status 403") {
		t.Errorf("error = %v, want status 403 in message", err)
	}
}

func TestGet_RoundTripsBody(t *testing.T) {
	rs := newRecordingServer(t)
	rs.respBody = "the bytes"
	c := New(rs.srv.URL)

	got, err := c.Get(context.Background(), "datasets/foo")
	if err != nil {
		t.Fatalf("Get: %v", err)
	}
	if string(got) != "the bytes" {
		t.Errorf("body = %q, want 'the bytes'", got)
	}
	if rs.gotMethod != http.MethodGet {
		t.Errorf("method = %q, want GET", rs.gotMethod)
	}
}

func TestGet_404ReturnsErrKeyNotFound(t *testing.T) {
	rs := newRecordingServer(t)
	rs.respStatus = http.StatusNotFound
	c := New(rs.srv.URL)

	_, err := c.Get(context.Background(), "missing")
	if !errors.Is(err, ErrKeyNotFound) {
		t.Errorf("error = %v, want ErrKeyNotFound", err)
	}
}

func TestGet_500WrapsBodyPreview(t *testing.T) {
	rs := newRecordingServer(t)
	rs.respStatus = http.StatusInternalServerError
	rs.respBody = "boom"
	c := New(rs.srv.URL)

	_, err := c.Get(context.Background(), "k")
	if err == nil {
		t.Fatal("expected wrapped error on 500")
	}
	if !strings.Contains(err.Error(), "status 500") {
		t.Errorf("error = %v, want status 500 in message", err)
	}
}

func TestDelete_204IsSuccess(t *testing.T) {
	rs := newRecordingServer(t)
	rs.respStatus = http.StatusNoContent
	c := New(rs.srv.URL)

	if err := c.Delete(context.Background(), "k"); err != nil {
		t.Fatalf("Delete: %v", err)
	}
	if rs.gotMethod != http.MethodDelete {
		t.Errorf("method = %q, want DELETE", rs.gotMethod)
	}
}

func TestDelete_200IsSuccess(t *testing.T) {
	// S3 returns 204; some compatible stores return 200. Both should
	// be acceptable per the comment in client.go.
	rs := newRecordingServer(t)
	rs.respStatus = http.StatusOK
	c := New(rs.srv.URL)

	if err := c.Delete(context.Background(), "k"); err != nil {
		t.Fatalf("Delete with 200: %v", err)
	}
}

func TestDelete_400IsError(t *testing.T) {
	rs := newRecordingServer(t)
	rs.respStatus = http.StatusBadRequest
	rs.respBody = "bad key"
	c := New(rs.srv.URL)

	err := c.Delete(context.Background(), "k")
	if err == nil {
		t.Fatal("expected error on 400")
	}
}

func TestList_ParsesObjects(t *testing.T) {
	rs := newRecordingServer(t)
	rs.respBody = `{"prefix":"datasets/","objects":[
        {"Key":"datasets/a.parquet","Size":100},
        {"Key":"datasets/b.parquet","Size":200},
        {"Key":"datasets/c.parquet","Size":300}
    ]}`
	c := New(rs.srv.URL)

	keys, err := c.List(context.Background(), "datasets/")
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	want := []string{"datasets/a.parquet", "datasets/b.parquet", "datasets/c.parquet"}
	if len(keys) != len(want) {
		t.Fatalf("got %d keys, want %d", len(keys), len(want))
	}
	for i, k := range keys {
		if k != want[i] {
			t.Errorf("keys[%d] = %q, want %q", i, k, want[i])
		}
	}
	// And the prefix query-param made it across the wire.
	if !strings.Contains(rs.gotPath, "prefix=datasets") {
		t.Errorf("query path = %q, want prefix=datasets", rs.gotPath)
	}
}

func TestList_EmptyPrefix(t *testing.T) {
	rs := newRecordingServer(t)
	rs.respBody = `{"prefix":"","objects":[]}`
	c := New(rs.srv.URL)

	keys, err := c.List(context.Background(), "")
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(keys) != 0 {
		t.Errorf("got %d keys, want 0", len(keys))
	}
}

func TestList_BadJSON_ReturnsDecodeError(t *testing.T) {
	rs := newRecordingServer(t)
	rs.respBody = "not json"
	c := New(rs.srv.URL)

	_, err := c.List(context.Background(), "p")
	if err == nil {
		t.Fatal("expected decode error on non-JSON body")
	}
	if !strings.Contains(err.Error(), "list decode") {
		t.Errorf("error = %v, want 'list decode' wrapper", err)
	}
}

func TestPut_ContextCancellation(t *testing.T) {
	rs := newRecordingServer(t)
	c := New(rs.srv.URL)

	ctx, cancel := context.WithCancel(context.Background())
	cancel() // pre-cancel — request should fail without hitting server

	err := c.Put(ctx, "k", []byte{1})
	if err == nil {
		t.Fatal("expected error from canceled context")
	}
	if !errors.Is(err, context.Canceled) {
		t.Errorf("error = %v, want context.Canceled-wrapped", err)
	}
}
