package lifecycle

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/seaweedfs/seaweedfs/test/testutil"
	"github.com/seaweedfs/seaweedfs/weed/worker/tasks/iceberg"
)

const (
	pyicebergImage = "seaweedfs-lifecycle-pyiceberg"
	duckdbImage    = "duckdb/duckdb:latest"

	// The phase that installs the extension, and so the only one whose failure
	// can mean the image rather than the code.
	firstPhase = "write"

	// Three appends of this many rows, all inside one month so they land in
	// one partition and compaction has something to merge. The two string
	// columns hold few enough distinct values to be dictionary-encoded, which
	// is the encoding the merge destroyed.
	rowsPerBatch = 4000
	batches      = 3
	categories   = 7
	values       = 13
)

// tally is what a client reports about a table. Both halves of this suite
// speak it, and the point of the whole suite is that the one taken before
// maintenance equals the one taken after.
type tally struct {
	Rows       int    `json:"rows"`
	Categories int    `json:"categories"`
	Values     int    `json:"values"`
	Digest     string `json:"digest"`
	// Lance only: a compaction that merged nothing would otherwise let this
	// test pass without having tested anything.
	Fragments int `json:"fragments"`
	Version   int `json:"version"`
}

func TestIcebergTableLifecycle(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping integration test in short mode")
	}
	if shared == nil {
		t.Skip("no cluster")
	}
	if !testutil.HasDocker() {
		t.Skip("Docker not available")
	}

	// DuckDB is the client the bug was reported against, and the only one
	// here that writes the deprecated PLAIN_DICTIONARY encoding a Go writer
	// will not produce. PyIceberg writes the modern one, so between them the
	// merge is checked against both dictionary encodings in the spec.
	t.Run("DuckDB", testIcebergLifecycleWithDuckDB)
	t.Run("PyIceberg", testIcebergLifecycleWithPyIceberg)
}

func testIcebergLifecycleWithDuckDB(t *testing.T) {
	env := shared
	bucket := "lifecycle-duckdb-" + randomSuffix()
	namespace, table := "sales", "events"
	env.createTableBucket(t, bucket, "ICEBERG")

	inserts := make([]string, 0, batches)
	for i := 0; i < batches; i++ {
		inserts = append(inserts, fmt.Sprintf(
			"INSERT INTO cat.%s.%s SELECT g, TIMESTAMPTZ '2026-03-01 00:00:00' + INTERVAL (g %% 600) MINUTE, "+
				"'cat-' || (g %% %d), 'v-' || (g %% %d) FROM generate_series(%d, %d) t(g);",
			namespace, table, categories, values, i*rowsPerBatch+1, (i+1)*rowsPerBatch))
	}

	before := env.duckdb(t, bucket, "write", strings.Join([]string{
		fmt.Sprintf("CREATE SCHEMA cat.%s;", namespace),
		// Partitioned, the way the table in the report was. Compaction bins
		// per partition, so this is also what puts more than one bin in play.
		fmt.Sprintf("CREATE TABLE cat.%s.%s(id int, ts timestamptz, category text, value text) PARTITIONED BY (month(ts));", namespace, table),
		strings.Join(inserts, "\n"),
		duckdbTallySQL(namespace, table),
	}, "\n"))
	assertSeeded(t, before)

	env.maintainIcebergTable(t, bucket, namespace+"/"+table)

	after := env.duckdb(t, bucket, "verify", duckdbTallySQL(namespace, table))
	assertSameData(t, before, after)

	env.duckdb(t, bucket, "drop", fmt.Sprintf("DROP TABLE cat.%s.%s;\nDROP SCHEMA cat.%s;", namespace, table, namespace))
	if env.entryExists(t, fmt.Sprintf("/buckets/%s/%s/%s/", bucket, namespace, table)) {
		t.Fatal("the dropped table's data is still on disk")
	}
}

func testIcebergLifecycleWithPyIceberg(t *testing.T) {
	env := shared
	bucket := "lifecycle-pyiceberg-" + randomSuffix()
	namespace, table := "sales", "events"
	env.createTableBucket(t, bucket, "ICEBERG")
	buildClientImage(t, pyicebergImage, "Dockerfile.pyiceberg")

	before := env.pyiceberg(t, "write", bucket, namespace, table)
	assertSeeded(t, before)

	env.maintainIcebergTable(t, bucket, namespace+"/"+table)

	after := env.pyiceberg(t, "verify", bucket, namespace, table)
	assertSameData(t, before, after)

	env.pyiceberg(t, "drop", bucket, namespace, table)
	if env.entryExists(t, fmt.Sprintf("/buckets/%s/%s/%s/", bucket, namespace, table)) {
		t.Fatal("the dropped table's data is still on disk")
	}
}

// maintainIcebergTable runs the worker's whole maintenance cycle against the
// live filer, in the order a scheduled worker would: merge the small files,
// drop the snapshots that referenced them, sweep what nothing references any
// more, then fold the manifests together.
func (env *environment) maintainIcebergTable(t *testing.T, bucket, tablePath string) {
	t.Helper()

	client := env.filerClient(t)
	handler := iceberg.NewHandler(nil)
	config := iceberg.Config{
		TargetFileSizeBytes:  256 << 20,
		MinInputFiles:        2,
		MaxCommitRetries:     3,
		SnapshotRetentionMs:  1,
		MaxSnapshotsToKeep:   1,
		OrphanOlderThanHours: 1,
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	operations := []struct {
		name string
		run  func() (string, map[string]int64, error)
	}{
		{"compact", func() (string, map[string]int64, error) {
			return handler.CompactDataFiles(ctx, client, bucket, tablePath, config)
		}},
		{"expire", func() (string, map[string]int64, error) {
			return handler.ExpireSnapshots(ctx, client, bucket, tablePath, config)
		}},
		{"orphans", func() (string, map[string]int64, error) {
			return handler.RemoveOrphans(ctx, client, bucket, tablePath, config)
		}},
		{"manifests", func() (string, map[string]int64, error) {
			return handler.RewriteManifests(ctx, client, bucket, tablePath, config)
		}},
	}
	for _, operation := range operations {
		result, metrics, err := operation.run()
		if err != nil {
			t.Fatalf("%s: %v", operation.name, err)
		}
		t.Logf("%s: %s %v", operation.name, result, metrics)

		// A compaction that merged nothing leaves the read below checking a
		// file the worker never wrote, which proves nothing at all.
		if operation.name == "compact" && metrics[iceberg.MetricFilesMerged] < batches {
			t.Fatalf("compaction merged %d files, want all %d written by the client: %s",
				metrics[iceberg.MetricFilesMerged], batches, result)
		}
	}
}

// duckdb runs a script against the catalog and returns whatever tally it
// printed. The prelude is the reporter's own ATTACH, SigV4 and all.
func (env *environment) duckdb(t *testing.T, bucket, phase, body string) tally {
	t.Helper()

	script := fmt.Sprintf(`INSTALL iceberg;
LOAD iceberg;
CREATE SECRET s3_secret (TYPE S3, KEY_ID '%s', SECRET '%s', ENDPOINT 'host.docker.internal:%d', URL_STYLE 'path', USE_SSL false);
ATTACH 's3://%s' AS cat (TYPE ICEBERG, ENDPOINT '%s', AUTHORIZATION_TYPE SigV4, SECRET 's3_secret', SIGV4_SERVICE 's3', SIGV4_REGION 'us-east-1', ACCESS_DELEGATION_MODE 'none', READ_ONLY false);
%s
`, accessKey, secretKey, env.s3Port, bucket, env.containerURL(env.icebergPort), body)

	name := fmt.Sprintf("duckdb-%s-%s.sql", bucket, phase)
	if err := os.WriteFile(filepath.Join(env.dataDir, name), []byte(script), 0644); err != nil {
		t.Fatalf("write the DuckDB script: %v", err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), clientTimeout)
	defer cancel()

	cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
		"-v", fmt.Sprintf("%s:/test", env.dataDir),
		"--add-host", "host.docker.internal:host-gateway",
		"--entrypoint", "duckdb",
		duckdbImage,
		"-init", "/test/"+name,
		"-c", "SELECT 1",
	)
	out, err := cmd.CombinedOutput()
	t.Logf("DuckDB %s:\n%s", phase, out)
	if err != nil {
		if phase == firstPhase && lacksIcebergExtension(string(out)) {
			t.Skipf("this DuckDB image has no iceberg extension: %v", err)
		}
		t.Fatalf("DuckDB %s: %v", phase, err)
	}
	if !strings.Contains(body, "TALLY") {
		return tally{}
	}
	return parseDuckDBTally(t, string(out))
}

func duckdbTallySQL(namespace, table string) string {
	// The digest covers whole rows - every column, not just the two the
	// cardinalities watch - because counting each column on its own passes a
	// merge that keeps every column's cardinality and hands the values to the
	// wrong rows. ts goes in as microseconds so the session's timezone cannot
	// change how it renders between the two runs.
	return fmt.Sprintf("SELECT 'TALLY ' || count(*) || ' ' || count(DISTINCT category) || ' ' || count(DISTINCT value)"+
		" || ' ' || md5(string_agg(id || '|' || epoch_us(ts) || '|' || category || '|' || value, chr(10) ORDER BY id)) AS marker"+
		" FROM cat.%s.%s;",
		namespace, table)
}

var duckdbTallyPattern = regexp.MustCompile(`TALLY (\d+) (\d+) (\d+) ([0-9a-f]{32})`)

func parseDuckDBTally(t *testing.T, out string) tally {
	t.Helper()

	fields := duckdbTallyPattern.FindStringSubmatch(out)
	if fields == nil {
		t.Fatalf("DuckDB printed no tally:\n%s", out)
	}
	number := func(s string) int {
		value, err := strconv.Atoi(s)
		if err != nil {
			t.Fatalf("parse %q: %v", s, err)
		}
		return value
	}
	return tally{
		Rows:       number(fields[1]),
		Categories: number(fields[2]),
		Values:     number(fields[3]),
		Digest:     fields[4],
	}
}

// lacksIcebergExtension reports whether DuckDB never got as far as this
// repository's code, which is the only failure worth skipping over.
//
// The extension ships separately from the image, so an environment that cannot
// fetch it has nothing to say about compaction. Everything past LOAD - a parse
// error on the ATTACH, a refusal from the catalog, a bad read - is ours, and
// has to fail: this is the one test covering the PLAIN_DICTIONARY encoding, and
// a skip nobody reads is how the corruption it was written for shipped.
func lacksIcebergExtension(out string) bool {
	for _, marker := range []string{
		"iceberg extension is not available",
		"Failed to download extension",
		`Extension "iceberg" not found`,
		"Unknown extension",
	} {
		if strings.Contains(out, marker) {
			return true
		}
	}
	return false
}

func (env *environment) pyiceberg(t *testing.T, phase, bucket, namespace, table string) tally {
	t.Helper()

	out := env.runClient(t, pyicebergImage, phase, "/app/iceberg_lifecycle.py",
		"--catalog-url", env.containerURL(env.icebergPort),
		"--s3-endpoint", env.containerURL(env.s3Port),
		"--bucket", bucket,
		"--namespace", namespace,
		"--table", table,
	)
	if phase == "drop" {
		return tally{}
	}
	return decodeTally(t, out)
}

// runClient runs one phase of a python client and returns its stdout.
func (env *environment) runClient(t *testing.T, image, phase, script string, args ...string) string {
	t.Helper()

	ctx, cancel := context.WithTimeout(context.Background(), clientTimeout)
	defer cancel()

	run := []string{"run", "--rm",
		"--add-host", "host.docker.internal:host-gateway",
		"-e", "AWS_ACCESS_KEY_ID=" + accessKey,
		"-e", "AWS_SECRET_ACCESS_KEY=" + secretKey,
		"-e", "AWS_REGION=us-east-1",
		"-e", "AWS_ALLOW_HTTP=true",
		image, "python3", script, "--phase", phase,
		"--access-key", accessKey, "--secret-key", secretKey,
	}
	cmd := exec.CommandContext(ctx, "docker", append(run, args...)...)
	var stderr strings.Builder
	cmd.Stderr = &stderr
	out, err := cmd.Output()
	if stderr.Len() > 0 {
		t.Logf("%s %s (stderr):\n%s", image, phase, stderr.String())
	}
	if err != nil {
		t.Fatalf("%s %s: %v\n%s", image, phase, err, out)
	}
	t.Logf("%s %s: %s", image, phase, strings.TrimSpace(string(out)))
	return string(out)
}

func decodeTally(t *testing.T, out string) tally {
	t.Helper()

	// The clients print the tally last; anything a library logged before it
	// is not JSON and not ours.
	lines := strings.Split(strings.TrimSpace(out), "\n")
	var decoded tally
	if err := json.Unmarshal([]byte(lines[len(lines)-1]), &decoded); err != nil {
		t.Fatalf("decode the tally from %q: %v", out, err)
	}
	return decoded
}

func buildClientImage(t *testing.T, image, dockerfile string) {
	t.Helper()

	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
	defer cancel()

	cmd := exec.CommandContext(ctx, "docker", "build", "-t", image, "-f", dockerfile, ".")
	cmd.Dir = shared.testDir
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("build %s: %v\n%s", image, err, out)
	}
}

// assertSeeded checks the client wrote what this suite assumes it wrote. Every
// comparison below is against these numbers, so a client that quietly seeded
// one row would otherwise make the whole test vacuous.
func assertSeeded(t *testing.T, seeded tally) {
	t.Helper()

	if want := rowsPerBatch * batches; seeded.Rows != want {
		t.Fatalf("seeded %d rows, want %d", seeded.Rows, want)
	}
	if seeded.Categories != categories || seeded.Values != values {
		t.Fatalf("seeded %d categories and %d values, want %d and %d",
			seeded.Categories, seeded.Values, categories, values)
	}
}

// assertSameData is the test. Maintenance rewrites files; it must not change a
// single row, and the cardinalities are called out separately because that is
// the shape the failure took: a dictionary column collapsed onto one entry,
// read back without an error, and answered wrongly.
func assertSameData(t *testing.T, before, after tally) {
	t.Helper()

	if after.Rows != before.Rows {
		t.Errorf("maintenance changed the row count: %d -> %d", before.Rows, after.Rows)
	}
	if after.Categories != before.Categories {
		t.Errorf("maintenance collapsed the category column: %d distinct values -> %d",
			before.Categories, after.Categories)
	}
	if after.Values != before.Values {
		t.Errorf("maintenance collapsed the value column: %d distinct values -> %d",
			before.Values, after.Values)
	}
	if after.Digest != before.Digest {
		t.Errorf("maintenance changed the rows: digest %s -> %s", before.Digest, after.Digest)
	}
}
