// Package admin contains integration tests for the admin UI's Iceberg
// catalog and data-preview pages. Tests start a real weed mini cluster
// (with the admin UI enabled), create a table bucket, namespace, and table
// via the S3 Tables manager, populate real Parquet data files, manifests,
// and snapshots via S3 and the filer gRPC API, and then assert on the HTML
// the admin server renders.
package admin

import (
	"bytes"
	"context"
	"crypto/rand"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/apache/iceberg-go"
	"github.com/apache/iceberg-go/table"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/credentials"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/parquet-go/parquet-go"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"

	"github.com/seaweedfs/seaweedfs/test/testutil"
	"github.com/seaweedfs/seaweedfs/weed/command"
	"github.com/seaweedfs/seaweedfs/weed/glog"
	"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
	"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
	"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)

// ---------------------------------------------------------------------------
// Cluster lifecycle (mirrors test/s3tables/maintenance)
// ---------------------------------------------------------------------------

type testCluster struct {
	dataDir       string
	ctx           context.Context
	cancel        context.CancelFunc
	wg            sync.WaitGroup
	filerGrpcPort int
	s3Port        int
	adminPort     int
	s3Endpoint    string
	adminEndpoint string
	isRunning     bool
}

var shared *testCluster

func TestMain(m *testing.M) {
	flag.Parse()
	if testing.Short() {
		os.Exit(m.Run())
	}

	testDir, err := os.MkdirTemp("", "seaweed-admin-iceberg-*")
	if err != nil {
		fmt.Fprintf(os.Stderr, "SKIP: failed to create temp dir: %v\n", err)
		os.Exit(0)
	}

	cluster, err := startCluster(testDir)
	if err != nil {
		fmt.Fprintf(os.Stderr, "SKIP: failed to start cluster: %v\n", err)
		os.RemoveAll(testDir)
		os.Exit(0)
	}
	shared = cluster

	code := m.Run()
	shared.stop()
	os.RemoveAll(testDir)
	os.Exit(code)
}

func startCluster(testDir string) (*testCluster, error) {
	ports, err := testutil.AllocatePorts(10)
	if err != nil {
		return nil, err
	}
	masterPort, masterGrpc := ports[0], ports[1]
	volumePort, volumeGrpc := ports[2], ports[3]
	filerPort, filerGrpc := ports[4], ports[5]
	s3Port, s3Grpc := ports[6], ports[7]
	adminPort, adminGrpc := ports[8], ports[9]

	// Empty security.toml disables JWT auth.
	if err := os.WriteFile(filepath.Join(testDir, "security.toml"), []byte("# test\n"), 0644); err != nil {
		return nil, err
	}
	if os.Getenv("AWS_ACCESS_KEY_ID") == "" {
		os.Setenv("AWS_ACCESS_KEY_ID", "admin")
	}
	if os.Getenv("AWS_SECRET_ACCESS_KEY") == "" {
		os.Setenv("AWS_SECRET_ACCESS_KEY", "admin")
	}

	ctx, cancel := context.WithCancel(context.Background())
	c := &testCluster{
		dataDir:       testDir,
		ctx:           ctx,
		cancel:        cancel,
		filerGrpcPort: filerGrpc,
		s3Port:        s3Port,
		adminPort:     adminPort,
		s3Endpoint:    fmt.Sprintf("http://127.0.0.1:%d", s3Port),
		adminEndpoint: fmt.Sprintf("http://127.0.0.1:%d", adminPort),
	}

	// Set on the package global before the goroutine starts so the mini
	// command picks it up; cleared in stop(). The global is how the mini
	// harness receives its cancellation context in tests.
	command.MiniClusterCtx = ctx //nolint:fatcontext // test harness handoff, not a per-request context

	c.wg.Add(1)
	go func() {
		defer c.wg.Done()
		oldDir, _ := os.Getwd()
		oldArgs := os.Args
		defer func() { os.Chdir(oldDir); os.Args = oldArgs }()
		os.Chdir(testDir)

		args := []string{
			"-dir=" + testDir,
			"-master.dir=" + testDir,
			"-master.port=" + strconv.Itoa(masterPort),
			"-master.port.grpc=" + strconv.Itoa(masterGrpc),
			"-volume.port=" + strconv.Itoa(volumePort),
			"-volume.port.grpc=" + strconv.Itoa(volumeGrpc),
			"-volume.port.public=" + strconv.Itoa(volumePort),
			"-volume.publicUrl=127.0.0.1:" + strconv.Itoa(volumePort),
			"-filer.port=" + strconv.Itoa(filerPort),
			"-filer.port.grpc=" + strconv.Itoa(filerGrpc),
			"-s3.port=" + strconv.Itoa(s3Port),
			"-s3.port.grpc=" + strconv.Itoa(s3Grpc),
			"-admin.port=" + strconv.Itoa(adminPort),
			"-admin.port.grpc=" + strconv.Itoa(adminGrpc),
			"-webdav.port=0",
			"-master.volumeSizeLimitMB=32",
			"-ip=127.0.0.1",
			"-master.peers=none",
			"-s3.iam.readOnly=false",
		}
		os.Args = append([]string{"weed"}, args...)
		glog.MaxSize = 1024 * 1024
		for _, cmd := range command.Commands {
			if cmd.Name() == "mini" && cmd.Run != nil {
				cmd.Flag.Parse(os.Args[1:])
				cmd.Run(cmd, cmd.Flag.Args())
				return
			}
		}
	}()

	if err := waitReady(c.s3Endpoint, 30*time.Second); err != nil {
		cancel()
		return nil, err
	}
	if err := waitReady(c.adminEndpoint, 30*time.Second); err != nil {
		cancel()
		return nil, err
	}
	c.isRunning = true
	return c, nil
}

func (c *testCluster) stop() {
	command.MiniClusterCtx = nil //nolint:fatcontext // clearing the test harness handoff
	if c.cancel != nil {
		c.cancel()
	}
	if c.isRunning {
		time.Sleep(500 * time.Millisecond)
	}
	done := make(chan struct{})
	go func() { c.wg.Wait(); close(done) }()
	select {
	case <-done:
	case <-time.After(2 * time.Second):
	}
}

func (c *testCluster) filerConn(t *testing.T) filer_pb.SeaweedFilerClient {
	t.Helper()
	addr := fmt.Sprintf("127.0.0.1:%d", c.filerGrpcPort)
	conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	require.NoError(t, err)
	t.Cleanup(func() { conn.Close() })
	return filer_pb.NewSeaweedFilerClient(conn)
}

func waitReady(endpoint string, timeout time.Duration) error {
	client := &http.Client{Timeout: 1 * time.Second}
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		resp, err := client.Get(endpoint)
		if err == nil {
			resp.Body.Close()
			time.Sleep(500 * time.Millisecond)
			return nil
		}
		time.Sleep(200 * time.Millisecond)
	}
	return fmt.Errorf("timeout waiting for %s", endpoint)
}

func randomSuffix() string {
	b := make([]byte, 4)
	rand.Read(b)
	return fmt.Sprintf("%x", b)
}

// ---------------------------------------------------------------------------
// Catalog and table population
// ---------------------------------------------------------------------------

type previewRow struct {
	ID   int64  `parquet:"id"`
	Name string `parquet:"name"`
}

func buildParquet(t *testing.T, rows []previewRow) []byte {
	t.Helper()
	var buf bytes.Buffer
	writer := parquet.NewGenericWriter[previewRow](&buf)
	_, err := writer.Write(rows)
	require.NoError(t, err)
	require.NoError(t, writer.Close())
	return buf.Bytes()
}

func newS3Client(t *testing.T, endpoint string) *s3.Client {
	t.Helper()
	cfg, err := config.LoadDefaultConfig(context.Background(),
		config.WithRegion("us-east-1"),
		config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("admin", "admin", "")),
	)
	require.NoError(t, err)
	return s3.NewFromConfig(cfg, func(o *s3.Options) {
		o.BaseEndpoint = aws.String(endpoint)
		o.UsePathStyle = true
	})
}

type catalogFixture struct {
	bucket    string
	namespace string
	table     string
	snap1ID   int64
	snap2ID   int64
	file1Path string // manifest-listed location of the first data file (relative)
	file2Path string // manifest-listed location of the second data file (s3:// URI)
}

// setupCatalog creates a table bucket, namespace, and two tables via the S3
// Tables manager: "events" with two snapshots backed by real Parquet files,
// and "empty" with no Iceberg metadata.
func setupCatalog(t *testing.T, client filer_pb.SeaweedFilerClient) catalogFixture {
	t.Helper()
	ctx := context.Background()

	fx := catalogFixture{
		bucket:    "admin-ui-" + randomSuffix(),
		namespace: "analytics",
		table:     "events",
		snap1ID:   3001,
		snap2ID:   3002,
	}
	fx.file1Path = "data/p1.parquet"
	fx.file2Path = fmt.Sprintf("s3://%s/%s/%s/data/p2.parquet", fx.bucket, fx.namespace, fx.table)

	mgr := s3tables.NewManager()
	mgr.SetAccountID(s3_constants.AccountAdminId)
	mc := s3tables.NewManagerClient(client)
	exec := func(op string, req, resp interface{}) {
		t.Helper()
		require.NoError(t, mgr.Execute(ctx, mc, op, req, resp, s3_constants.AccountAdminId), "s3tables %s", op)
	}

	var bucketResp s3tables.CreateTableBucketResponse
	exec("CreateTableBucket", &s3tables.CreateTableBucketRequest{Name: fx.bucket}, &bucketResp)
	exec("CreateNamespace", &s3tables.CreateNamespaceRequest{TableBucketARN: bucketResp.ARN, Namespace: []string{fx.namespace}}, &s3tables.CreateNamespaceResponse{})
	exec("CreateTable", &s3tables.CreateTableRequest{TableBucketARN: bucketResp.ARN, Namespace: []string{fx.namespace}, Name: fx.table, Format: "ICEBERG"}, &s3tables.CreateTableResponse{})
	exec("CreateTable", &s3tables.CreateTableRequest{TableBucketARN: bucketResp.ARN, Namespace: []string{fx.namespace}, Name: "empty", Format: "ICEBERG"}, &s3tables.CreateTableResponse{})

	// Upload real Parquet data files via S3 so they are chunk-backed entries.
	s3Client := newS3Client(t, shared.s3Endpoint)
	tableKey := path.Join(fx.namespace, fx.table)
	rows1 := []previewRow{{1, "p1-row-1"}, {2, "p1-row-2"}, {3, "p1-row-3"}, {4, "p1-row-4"}, {5, "p1-row-5"}}
	rows2 := []previewRow{{6, "p2-row-6"}, {7, "p2-row-7"}, {8, "p2-row-8"}}
	parquet1 := buildParquet(t, rows1)
	parquet2 := buildParquet(t, rows2)
	putObject(t, s3Client, fx.bucket, path.Join(tableKey, "data/p1.parquet"), parquet1)
	putObject(t, s3Client, fx.bucket, path.Join(tableKey, "data/p2.parquet"), parquet2)

	// Build table metadata with two snapshots. Snapshot 2's manifest list is
	// cumulative (both manifests) as engines write it.
	location := fmt.Sprintf("s3://%s/%s/%s", fx.bucket, fx.namespace, fx.table)
	schema := iceberg.NewSchema(0,
		iceberg.NestedField{ID: 1, Type: iceberg.PrimitiveTypes.Int64, Name: "id", Required: true},
		iceberg.NestedField{ID: 2, Type: iceberg.PrimitiveTypes.String, Name: "name", Required: false},
	)
	meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec, table.UnsortedSortOrder, location, nil)
	require.NoError(t, err)

	baseMs := time.Now().UnixMilli() + 100
	snap1 := table.Snapshot{SnapshotID: fx.snap1ID, SequenceNumber: 1, TimestampMs: baseMs, ManifestList: "metadata/snap-3001.avro"}
	snap2 := table.Snapshot{SnapshotID: fx.snap2ID, ParentSnapshotID: &fx.snap1ID, SequenceNumber: 2, TimestampMs: baseMs + 1, ManifestList: "metadata/snap-3002.avro"}

	builder, err := table.MetadataBuilderFromBase(meta, location)
	require.NoError(t, err)
	require.NoError(t, builder.AddSnapshot(&snap1))
	require.NoError(t, builder.AddSnapshot(&snap2))
	require.NoError(t, builder.SetSnapshotRef(table.MainBranch, fx.snap2ID, table.BranchRef))
	meta, err = builder.Build()
	require.NoError(t, err)

	// Write manifests and manifest lists into the table's metadata directory.
	metaDir := path.Join(s3tables.TablesPath, fx.bucket, fx.namespace, fx.table, "metadata")
	spec := meta.PartitionSpec()
	version := meta.Version()

	mf1 := writeManifest(t, ctx, client, metaDir, "manifest-3001.avro", version, spec, schema, fx.snap1ID, fx.file1Path, int64(len(rows1)), int64(len(parquet1)))
	mf2 := writeManifest(t, ctx, client, metaDir, "manifest-3002.avro", version, spec, schema, fx.snap2ID, fx.file2Path, int64(len(rows2)), int64(len(parquet2)))

	list1 := writeManifestList(t, ctx, client, metaDir, "snap-3001.avro", version, fx.snap1ID, nil, 1, []iceberg.ManifestFile{mf1})
	// Re-read snapshot 1's list so mf1 carries its assigned sequence number,
	// which the cumulative snapshot 2 list requires.
	assigned, err := iceberg.ReadManifestList(bytes.NewReader(list1))
	require.NoError(t, err)
	require.Len(t, assigned, 1)
	writeManifestList(t, ctx, client, metaDir, "snap-3002.avro", version, fx.snap2ID, &fx.snap1ID, 2, []iceberg.ManifestFile{assigned[0], mf2})

	// Point the table's catalog xattr at the new metadata.
	fullJSON, err := json.Marshal(meta)
	require.NoError(t, err)
	writeFile(t, ctx, client, metaDir, "v2.metadata.json", fullJSON)
	updateTableMetadataXattr(t, ctx, client, fx, fullJSON)

	// Snapshot timestamps sit slightly in the future; let them pass.
	time.Sleep(200 * time.Millisecond)
	return fx
}

func putObject(t *testing.T, client *s3.Client, bucket, key string, body []byte) {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
	defer cancel()
	_, err := client.PutObject(ctx, &s3.PutObjectInput{
		Bucket: aws.String(bucket),
		Key:    aws.String(key),
		Body:   bytes.NewReader(body),
	})
	require.NoError(t, err)
}

func writeManifest(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, metaDir, name string, version int, spec iceberg.PartitionSpec, schema *iceberg.Schema, snapID int64, dataFilePath string, recordCount, fileSize int64) iceberg.ManifestFile {
	t.Helper()
	dfBuilder, err := iceberg.NewDataFileBuilder(
		spec, iceberg.EntryContentData,
		dataFilePath, iceberg.ParquetFile,
		map[int]any{}, nil, nil, recordCount, fileSize,
	)
	require.NoError(t, err)
	entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfBuilder.Build())

	var buf bytes.Buffer
	mf, err := iceberg.WriteManifest(path.Join("metadata", name), &buf, version, spec, schema, snapID, []iceberg.ManifestEntry{entry})
	require.NoError(t, err)
	writeFile(t, ctx, client, metaDir, name, buf.Bytes())
	return mf
}

func writeManifestList(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, metaDir, name string, version int, snapID int64, parent *int64, seqNum int64, files []iceberg.ManifestFile) []byte {
	t.Helper()
	var buf bytes.Buffer
	require.NoError(t, iceberg.WriteManifestList(version, &buf, snapID, parent, &seqNum, 0, files))
	writeFile(t, ctx, client, metaDir, name, buf.Bytes())
	return buf.Bytes()
}

func writeFile(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, dir, name string, content []byte) {
	t.Helper()
	resp, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
		Directory: dir,
		Entry: &filer_pb.Entry{
			Name: name,
			Attributes: &filer_pb.FuseAttributes{
				Mtime: time.Now().Unix(), Crtime: time.Now().Unix(),
				FileMode: uint32(0644), FileSize: uint64(len(content)),
			},
			Content: content,
		},
	})
	require.NoError(t, err, "writeFile(%s, %s)", dir, name)
	require.Empty(t, resp.Error, "writeFile(%s, %s)", dir, name)
}

func updateTableMetadataXattr(t *testing.T, ctx context.Context, client filer_pb.SeaweedFilerClient, fx catalogFixture, fullJSON []byte) {
	t.Helper()
	nsDir := path.Join(s3tables.TablesPath, fx.bucket, fx.namespace)
	resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{Directory: nsDir, Name: fx.table})
	require.NoError(t, err)
	require.NotNil(t, resp.Entry)

	var internalMeta map[string]json.RawMessage
	require.NoError(t, json.Unmarshal(resp.Entry.Extended[s3tables.ExtendedKeyMetadata], &internalMeta))
	metaObj := map[string]json.RawMessage{}
	if raw, ok := internalMeta["metadata"]; ok {
		require.NoError(t, json.Unmarshal(raw, &metaObj))
	}
	metaObj["fullMetadata"] = fullJSON
	metaJSON, err := json.Marshal(metaObj)
	require.NoError(t, err)
	internalMeta["metadata"] = metaJSON
	internalMeta["metadataVersion"] = json.RawMessage("2")
	internalMeta["metadataLocation"] = json.RawMessage(`"metadata/v2.metadata.json"`)
	xattr, err := json.Marshal(internalMeta)
	require.NoError(t, err)

	resp.Entry.Extended[s3tables.ExtendedKeyMetadata] = xattr
	resp.Entry.Extended[s3tables.ExtendedKeyMetadataVersion] = []byte("2")
	_, err = client.UpdateEntry(ctx, &filer_pb.UpdateEntryRequest{Directory: nsDir, Entry: resp.Entry})
	require.NoError(t, err)
}

// ---------------------------------------------------------------------------
// Page fetching
// ---------------------------------------------------------------------------

func fetchPage(t *testing.T, pagePath string) string {
	t.Helper()
	resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(shared.adminEndpoint + pagePath)
	require.NoError(t, err, "GET %s", pagePath)
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	require.NoError(t, err)
	require.Equal(t, http.StatusOK, resp.StatusCode, "GET %s: %s", pagePath, string(body))
	return string(body)
}

// fetchPageUntil re-fetches a page until the predicate passes, tolerating the
// admin server's filer-discovery delay right after startup.
func fetchPageUntil(t *testing.T, pagePath string, predicate func(string) bool) string {
	t.Helper()
	deadline := time.Now().Add(15 * time.Second)
	var body string
	for time.Now().Before(deadline) {
		body = fetchPage(t, pagePath)
		if predicate(body) {
			return body
		}
		time.Sleep(500 * time.Millisecond)
	}
	return body
}

func tablePagePath(fx catalogFixture, tableName, suffix string) string {
	return fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces/%s/tables/%s%s",
		url.PathEscape(fx.bucket), url.PathEscape(fx.namespace), url.PathEscape(tableName), suffix)
}

// ---------------------------------------------------------------------------
// Integration tests
// ---------------------------------------------------------------------------

func TestAdminIcebergPages(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping integration test in short mode")
	}

	client := shared.filerConn(t)
	fx := setupCatalog(t, client)

	t.Run("CatalogBrowsing", func(t *testing.T) {
		buckets := fetchPageUntil(t, "/object-store/s3tables/buckets", func(body string) bool {
			return strings.Contains(body, fx.bucket)
		})
		assert.Contains(t, buckets, fx.bucket)

		namespaces := fetchPage(t, fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces", url.PathEscape(fx.bucket)))
		assert.Contains(t, namespaces, fx.namespace)

		tables := fetchPage(t, fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces/%s/tables", url.PathEscape(fx.bucket), url.PathEscape(fx.namespace)))
		assert.Contains(t, tables, fx.table)
		assert.Contains(t, tables, "empty")
	})

	t.Run("TableDetails", func(t *testing.T) {
		body := fetchPage(t, tablePagePath(fx, fx.table, ""))
		assert.Contains(t, body, "Browse Data")
		assert.Contains(t, body, "/data")
		assert.Contains(t, body, ">id<")
		assert.Contains(t, body, ">name<")
		assert.Contains(t, body, strconv.FormatInt(fx.snap1ID, 10))
		assert.Contains(t, body, strconv.FormatInt(fx.snap2ID, 10))
	})

	t.Run("DataPreviewCurrentSnapshot", func(t *testing.T) {
		body := fetchPage(t, tablePagePath(fx, fx.table, "/data"))
		assert.Contains(t, body, "Showing 8 row(s) from 2 data file(s).")
		assert.Contains(t, body, "p1-row-1")
		assert.Contains(t, body, "p2-row-8")
		assert.Contains(t, body, fx.file1Path)
		assert.Contains(t, body, fx.file2Path)
		assert.Contains(t, body, "current")
	})

	t.Run("DataPreviewOldSnapshot", func(t *testing.T) {
		body := fetchPage(t, tablePagePath(fx, fx.table, "/data?snapshot="+strconv.FormatInt(fx.snap1ID, 10)))
		assert.Contains(t, body, "Showing 5 row(s) from 1 data file(s).")
		assert.Contains(t, body, "p1-row-5")
		assert.NotContains(t, body, "p2-row-6")
	})

	t.Run("DataPreviewSingleFile", func(t *testing.T) {
		body := fetchPage(t, tablePagePath(fx, fx.table, "/data?file="+url.QueryEscape(fx.file2Path)))
		assert.Contains(t, body, "Showing 3 row(s) from 1 data file(s).")
		assert.Contains(t, body, "p2-row-6")
		assert.NotContains(t, body, "p1-row-1")
	})

	t.Run("DataPreviewRowLimit", func(t *testing.T) {
		body := fetchPage(t, tablePagePath(fx, fx.table, "/data?limit=3"))
		assert.Contains(t, body, "Showing 3 row(s) from 1 data file(s).")
	})

	t.Run("DataPreviewUnknownFile", func(t *testing.T) {
		body := fetchPage(t, tablePagePath(fx, fx.table, "/data?file="+url.QueryEscape("s3://elsewhere/x.parquet")))
		assert.Contains(t, body, "Requested data file is not part of this snapshot.")
	})

	t.Run("DataPreviewUnknownSnapshot", func(t *testing.T) {
		body := fetchPage(t, tablePagePath(fx, fx.table, "/data?snapshot=42"))
		assert.Contains(t, body, "Snapshot 42 not found.")
	})

	t.Run("DataPreviewTableWithoutMetadata", func(t *testing.T) {
		body := fetchPage(t, tablePagePath(fx, "empty", "/data"))
		assert.Contains(t, body, "Table has no Iceberg metadata.")
	})
}
