// Integration tests for the Lance Namespace REST server, driven against a live
// gateway rather than an in-memory filer. The bugs this surface has produced -
// a deregister that deleted the dataset, an S3 door that refused every Lance
// file - all looked fine against a fake.
package catalog

import (
	"bytes"
	"encoding/json"
	"fmt"
	"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
	"io"
	"net/http"
	"strings"
	"sync"
	"testing"
)

// lanceCall posts to the Lance namespace and returns the status and body.
func lanceCall(t *testing.T, env *TestEnvironment, method, path, body string) (int, []byte) {
	t.Helper()
	req, err := http.NewRequest(method, env.LanceURL()+path, strings.NewReader(body))
	if err != nil {
		t.Fatalf("build request: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("%s %s: %v", method, path, err)
	}
	defer resp.Body.Close()
	payload, _ := io.ReadAll(resp.Body)
	return resp.StatusCode, payload
}

func lanceMust(t *testing.T, env *TestEnvironment, method, path, body string, want int) []byte {
	t.Helper()
	status, payload := lanceCall(t, env, method, path, body)
	if status != want {
		t.Fatalf("%s %s = %d (%s), want %d", method, path, status, payload, want)
	}
	return payload
}

// filerEntryExists reports whether a path exists on storage, which is how these
// tests tell "the catalog forgot the table" from "the data is gone".
func filerEntryExists(t *testing.T, env *TestEnvironment, path string) bool {
	t.Helper()
	url := fmt.Sprintf("http://127.0.0.1:%d%s", env.filerPort, path)
	resp, err := http.Get(url)
	if err != nil {
		t.Fatalf("filer GET %s: %v", path, err)
	}
	defer resp.Body.Close()
	io.Copy(io.Discard, resp.Body)
	return resp.StatusCode == http.StatusOK
}

// lanceTestBucket makes a uniquely named table bucket of the given format. Most
// tests want LANCE; the one that checks what the Lance surface hides needs an
// Iceberg bucket, because a declared bucket holds one format only.
func lanceTestBucket(t *testing.T, env *TestEnvironment, prefix, format string) string {
	t.Helper()
	bucket := prefix + "-" + randomSuffix()
	createTableBucket(t, env, bucket, format)
	return bucket
}

func TestLanceNamespaceLifecycle(t *testing.T) {
	if testing.Short() {
		t.Skip("Skipping integration test in short mode")
	}
	env := sharedEnv
	bucket := lanceTestBucket(t, env, "lance-ns", s3tables.FormatLance)

	lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$sales/create", `{}`, http.StatusOK)
	lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$sales/exists", `{}`, http.StatusOK)

	// The root lists table buckets, which is the first namespace level here.
	var roots struct {
		Namespaces []string `json:"namespaces"`
	}
	if err := json.Unmarshal(lanceMust(t, env, http.MethodGet, "/v1/namespace/$/list", "", http.StatusOK), &roots); err != nil {
		t.Fatalf("decode root listing: %v", err)
	}
	found := false
	for _, name := range roots.Namespaces {
		if name == bucket {
			found = true
		}
	}
	if !found {
		t.Fatalf("root listing %v does not include %s", roots.Namespaces, bucket)
	}

	var children struct {
		Namespaces []string `json:"namespaces"`
	}
	if err := json.Unmarshal(lanceMust(t, env, http.MethodGet, "/v1/namespace/"+bucket+"/list", "", http.StatusOK), &children); err != nil {
		t.Fatalf("decode namespace listing: %v", err)
	}
	if len(children.Namespaces) != 1 || children.Namespaces[0] != "sales" {
		t.Fatalf("namespace listing = %v, want [sales]", children.Namespaces)
	}

	// A namespace is never created as a side effect of naming one inside a
	// bucket that does not exist.
	status, _ := lanceCall(t, env, http.MethodPost, "/v1/namespace/nosuchbucket$ns/create", `{}`)
	if status != http.StatusNotFound {
		t.Fatalf("create under a missing bucket = %d, want 404", status)
	}
}

func TestLanceTableLifecyclePreservesData(t *testing.T) {
	if testing.Short() {
		t.Skip("Skipping integration test in short mode")
	}
	env := sharedEnv
	bucket := lanceTestBucket(t, env, "lance-tbl", s3tables.FormatLance)
	lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$ml/create", `{}`, http.StatusOK)

	table := "/v1/table/" + bucket + "$ml$vectors"
	var declared struct {
		Location       string            `json:"location"`
		StorageOptions map[string]string `json:"storage_options"`
	}
	if err := json.Unmarshal(lanceMust(t, env, http.MethodPost, table+"/declare", `{}`, http.StatusOK), &declared); err != nil {
		t.Fatalf("decode declare: %v", err)
	}
	want := fmt.Sprintf("s3://%s/ml/vectors", bucket)
	if declared.Location != want {
		t.Fatalf("declared location = %q, want %q", declared.Location, want)
	}

	datasetPath := fmt.Sprintf("/buckets/%s/ml/vectors/", bucket)
	if !filerEntryExists(t, env, datasetPath) {
		t.Fatal("declare did not create the dataset directory")
	}

	var listed struct {
		Tables []string `json:"tables"`
	}
	if err := json.Unmarshal(lanceMust(t, env, http.MethodGet, "/v1/namespace/"+bucket+"$ml/table/list", "", http.StatusOK), &listed); err != nil {
		t.Fatalf("decode listing: %v", err)
	}
	if len(listed.Tables) != 1 || listed.Tables[0] != bucket+"$ml$vectors" {
		t.Fatalf("table listing = %v, want the full identifier", listed.Tables)
	}

	// Deregistering forgets the table but keeps every byte. The catalog entry is
	// the dataset directory, so a drop of the entry would take the data too.
	lanceMust(t, env, http.MethodPost, table+"/deregister", `{}`, http.StatusOK)
	lanceMust(t, env, http.MethodPost, table+"/exists", `{}`, http.StatusNotFound)
	if !filerEntryExists(t, env, datasetPath) {
		t.Fatal("deregister deleted the dataset")
	}

	registerBody := fmt.Sprintf(`{"location":%q}`, want)
	lanceMust(t, env, http.MethodPost, table+"/register", registerBody, http.StatusOK)
	lanceMust(t, env, http.MethodPost, table+"/exists", `{}`, http.StatusOK)

	// Dropping is the operation that does remove the data.
	lanceMust(t, env, http.MethodPost, table+"/drop", `{}`, http.StatusOK)
	if filerEntryExists(t, env, datasetPath) {
		t.Fatal("drop left the dataset behind")
	}
}

// A Lance client must never resolve an Iceberg table's location, or it writes a
// dataset over a table another engine owns.
func TestLanceHidesIcebergTables(t *testing.T) {
	if testing.Short() {
		t.Skip("Skipping integration test in short mode")
	}
	env := sharedEnv
	// An Iceberg bucket, because a bucket that declares LANCE cannot hold an
	// Iceberg table at all now. The invariant still matters from this side: the
	// Lance surface can be pointed at any bucket, and must not describe or list
	// a table whose format it does not serve.
	bucket := lanceTestBucket(t, env, "lance-mixed", s3tables.FormatIceberg)
	lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$mixed/create", `{}`, http.StatusOK)

	createIcebergTable(t, env, bucket, "mixed", "ledger")

	status, _ := lanceCall(t, env, http.MethodPost, "/v1/table/"+bucket+"$mixed$ledger/describe", `{}`)
	if status != http.StatusNotFound {
		t.Fatalf("describing an iceberg table through lance = %d, want 404", status)
	}
	status, _ = lanceCall(t, env, http.MethodPost, "/v1/table/"+bucket+"$mixed$ledger/declare", `{}`)
	if status != http.StatusConflict {
		t.Fatalf("declaring over an iceberg table = %d, want 409", status)
	}
	// And a new Lance table cannot be smuggled in beside it either.
	status, _ = lanceCall(t, env, http.MethodPost, "/v1/table/"+bucket+"$mixed$vectors/declare", `{}`)
	if status != http.StatusConflict {
		t.Fatalf("declaring a lance table in an iceberg bucket = %d, want 409", status)
	}

	var listed struct {
		Tables []string `json:"tables"`
	}
	if err := json.Unmarshal(lanceMust(t, env, http.MethodGet, "/v1/namespace/"+bucket+"$mixed/table/list", "", http.StatusOK), &listed); err != nil {
		t.Fatalf("decode listing: %v", err)
	}
	if len(listed.Tables) != 0 {
		t.Fatalf("lance listing shows iceberg tables: %v", listed.Tables)
	}
}

// The S3 door validates every object written into a table bucket. A Lance
// dataset's files have to get through it, or the catalog is decorative.
func TestLanceFilesAreAcceptedByTheS3Door(t *testing.T) {
	if testing.Short() {
		t.Skip("Skipping integration test in short mode")
	}
	env := sharedEnv
	bucket := lanceTestBucket(t, env, "lance-layout", s3tables.FormatLance)
	lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$ml/create", `{}`, http.StatusOK)
	lanceMust(t, env, http.MethodPost, "/v1/table/"+bucket+"$ml$vectors/declare", `{}`, http.StatusOK)

	accepted := []string{
		"ml/vectors/data/0111111010111000110101116.lance",
		"ml/vectors/_versions/18446744073709551614.manifest",
		"ml/vectors/_versions/18446744073709551614.manifest-a3a292ad",
		"ml/vectors/_transactions/0-ddb27ab7.txn",
		"ml/vectors/_indices/85814508-fts/index.idx",
		"ml/vectors/.lance-reserved",
	}
	for _, object := range accepted {
		if status := putS3Object(t, env, bucket, object); status != http.StatusOK {
			t.Errorf("PUT %s = %d, want 200", object, status)
		}
	}

	// The guard still rejects files that belong to no table layout.
	for _, object := range []string{"ml/vectors/random.txt", "ml/vectors/notadir/x.lance"} {
		if status := putS3Object(t, env, bucket, object); status == http.StatusOK {
			t.Errorf("PUT %s = 200, want a rejection", object)
		}
	}
}

// A Lance commit is a conditional PUT of the next manifest: object_store turns
// PutMode::Create into If-None-Match: *, and lance treats the refusal as a
// commit conflict and rebases. So the whole safety of concurrent writers rests
// on this store answering that precondition atomically. It does, because the
// gateway reduces the header to a filer WriteCondition evaluated at the
// object's owner under a per-path lock.
//
// This is the reason the namespace does not manage versions itself.
func TestLanceCommitPreconditionAdmitsOneWriter(t *testing.T) {
	if testing.Short() {
		t.Skip("Skipping integration test in short mode")
	}
	env := sharedEnv
	bucket := lanceTestBucket(t, env, "lance-commit", s3tables.FormatLance)
	lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$ml/create", `{}`, http.StatusOK)
	lanceMust(t, env, http.MethodPost, "/v1/table/"+bucket+"$ml$vectors/declare", `{}`, http.StatusOK)

	const writers = 8
	var wg sync.WaitGroup
	statuses := make([]int, writers)
	failures := make([]error, writers)
	for i := 0; i < writers; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			// Not t.Fatalf: from a goroutine it ends only that goroutine, and
			// the status stays zero, which reads as a bogus response later.
			statuses[i], failures[i] = putIfAbsent(env, bucket, "ml/vectors/_versions/1.manifest")
		}(i)
	}
	wg.Wait()

	for i, err := range failures {
		if err != nil {
			t.Fatalf("writer %d could not reach the gateway: %v", i, err)
		}
	}

	won, refused := 0, 0
	for _, status := range statuses {
		switch status {
		case http.StatusOK:
			won++
		case http.StatusPreconditionFailed:
			refused++
		default:
			t.Fatalf("unexpected status %d committing a manifest", status)
		}
	}
	if won != 1 {
		t.Fatalf("%d writers committed version 1; exactly one may win, %d were refused", won, refused)
	}
}

// The namespace answers managed_versioning=false: the dataset owns its version
// history, and a reader that never goes through this catalog still sees all of
// it.
func TestLanceDoesNotManageVersions(t *testing.T) {
	if testing.Short() {
		t.Skip("Skipping integration test in short mode")
	}
	env := sharedEnv
	bucket := lanceTestBucket(t, env, "lance-ver", s3tables.FormatLance)
	lanceMust(t, env, http.MethodPost, "/v1/namespace/"+bucket+"$ml/create", `{}`, http.StatusOK)
	table := "/v1/table/" + bucket + "$ml$vectors"

	var declared struct {
		ManagedVersioning bool `json:"managed_versioning"`
	}
	if err := json.Unmarshal(lanceMust(t, env, http.MethodPost, table+"/declare", `{}`, http.StatusOK), &declared); err != nil {
		t.Fatalf("decode declare: %v", err)
	}
	if declared.ManagedVersioning {
		t.Fatal("managed_versioning must be false; the dataset owns its versions")
	}

	// The version ops answer with the spec's Unsupported code rather than a
	// bare 404, so a client that asks learns why.
	status, _ := lanceCall(t, env, http.MethodPost, table+"/version/list", `{}`)
	if status != http.StatusNotImplemented {
		t.Fatalf("version/list = %d, want 501", status)
	}
}

// putIfAbsent writes an object only if the key is free, the way a Lance commit
// does. It returns an error rather than failing the test, so it is safe to call
// from the racing goroutines.
func putIfAbsent(env *TestEnvironment, bucket, object string) (int, error) {
	url := fmt.Sprintf("http://127.0.0.1:%d/%s/%s", env.s3Port, bucket, object)
	req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader([]byte("manifest")))
	if err != nil {
		return 0, err
	}
	req.Header.Set("If-None-Match", "*")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return 0, err
	}
	defer resp.Body.Close()
	io.Copy(io.Discard, resp.Body)
	return resp.StatusCode, nil
}

// putS3Object writes an object through the S3 gateway and returns the status.
func putS3Object(t *testing.T, env *TestEnvironment, bucket, object string) int {
	t.Helper()
	url := fmt.Sprintf("http://127.0.0.1:%d/%s/%s", env.s3Port, bucket, object)
	req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader([]byte("x")))
	if err != nil {
		t.Fatalf("build request: %v", err)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("PUT %s: %v", object, err)
	}
	defer resp.Body.Close()
	io.Copy(io.Discard, resp.Body)
	return resp.StatusCode
}

// createIcebergTable registers an ordinary Iceberg table so the mixed-catalog
// tests have one to be confused by.
func createIcebergTable(t *testing.T, env *TestEnvironment, bucket, namespace, name string) {
	t.Helper()
	body := fmt.Sprintf(`{"name":%q,"schema":{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"long"}]}}`, name)
	url := fmt.Sprintf("%s/v1/%s/namespaces/%s/tables", env.IcebergURL(), bucket, namespace)
	resp, err := http.Post(url, "application/json", strings.NewReader(body))
	if err != nil {
		t.Fatalf("create iceberg table: %v", err)
	}
	defer resp.Body.Close()
	payload, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("create iceberg table = %d: %s", resp.StatusCode, payload)
	}
}
