package mount

import (
	"context"
	"fmt"
	"math"
	"math/rand/v2"
	"testing"

	"github.com/seaweedfs/seaweedfs/weed/filer"
	"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)

func TestShouldMergeChunks_NoChunks(t *testing.T) {
	_, _, merge := shouldMergeChunks(nil, nil)
	if merge {
		t.Fatal("empty file should not trigger merge")
	}
}

func TestShouldMergeChunks_SingleChunk(t *testing.T) {
	chunks := []*filer_pb.FileChunk{
		{Offset: 0, Size: 1000, FileId: "a", ModifiedTsNs: 1},
	}
	total, fileSize, merge := shouldMergeChunks(chunks, nil)
	if merge {
		t.Fatalf("single chunk should not merge (total=%d fileSize=%d)", total, fileSize)
	}
}

func TestShouldMergeChunks_NonOverlapping(t *testing.T) {
	chunks := []*filer_pb.FileChunk{
		{Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1},
		{Offset: 100, Size: 100, FileId: "b", ModifiedTsNs: 2},
		{Offset: 200, Size: 100, FileId: "c", ModifiedTsNs: 3},
	}
	total, fileSize, merge := shouldMergeChunks(chunks, nil)
	if merge {
		t.Fatalf("non-overlapping chunks should not merge (total=%d fileSize=%d)", total, fileSize)
	}
	if total != 300 || fileSize != 300 {
		t.Fatalf("expected total=300 fileSize=300, got total=%d fileSize=%d", total, fileSize)
	}
}

func TestShouldMergeChunks_ExactlyDouble(t *testing.T) {
	// totalChunkSize == 2 * fileSize: should NOT merge (condition is >2x)
	chunks := []*filer_pb.FileChunk{
		{Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1},
		{Offset: 0, Size: 100, FileId: "b", ModifiedTsNs: 2},
	}
	_, _, merge := shouldMergeChunks(chunks, nil)
	if merge {
		t.Fatal("exactly 2x should not trigger merge")
	}
}

func TestShouldMergeChunks_JustOverDouble(t *testing.T) {
	// Three chunks overwriting the same range: 300 stored, 100 content = 3x
	chunks := []*filer_pb.FileChunk{
		{Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1},
		{Offset: 0, Size: 100, FileId: "b", ModifiedTsNs: 2},
		{Offset: 0, Size: 100, FileId: "c", ModifiedTsNs: 3},
	}
	total, fileSize, merge := shouldMergeChunks(chunks, nil)
	if !merge {
		t.Fatalf("3x bloat should trigger merge (total=%d fileSize=%d)", total, fileSize)
	}
}

func TestShouldMergeChunks_ManifestExtendFileSize(t *testing.T) {
	// One manifest extends the file. Total stored = 100 (regular) + 900
	// (manifest) = 1000 vs 2*1000 = 2000 → no merge.
	compacted := []*filer_pb.FileChunk{
		{Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1},
	}
	manifest := []*filer_pb.FileChunk{
		{Offset: 100, Size: 900, FileId: "m", ModifiedTsNs: 2, IsChunkManifest: true},
	}
	_, _, merge := shouldMergeChunks(compacted, manifest)
	if merge {
		t.Fatal("single manifest extending file should not merge")
	}
}

func TestShouldMergeChunks_ManifestSizesCounted(t *testing.T) {
	// Manifest sizes must count toward totalChunkSize so that overlapping
	// manifests trigger merge.
	compacted := []*filer_pb.FileChunk{
		{Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1},
	}
	manifest := []*filer_pb.FileChunk{
		{Offset: 0, Size: 100, FileId: "m", ModifiedTsNs: 2, IsChunkManifest: true},
	}
	total, _, merge := shouldMergeChunks(compacted, manifest)
	if total != 200 {
		t.Fatalf("totalChunkSize should count compacted + manifests, got %d", total)
	}
	if merge {
		t.Fatal("2x total on 100-byte file should not merge (need >2x)")
	}
}

func TestShouldMergeChunks_AccumulatedManifests(t *testing.T) {
	// Simulates the real bug: multiple manifests each covering the full file
	// accumulate across flush cycles. Without counting manifest sizes, the
	// merge condition never fires and storage bloats indefinitely.
	compacted := []*filer_pb.FileChunk{
		{Offset: 0, Size: 1000, FileId: "a", ModifiedTsNs: 100},
	}
	// 5 manifests each covering the full file — successive flush cycles
	var manifests []*filer_pb.FileChunk
	for i := 0; i < 5; i++ {
		manifests = append(manifests, &filer_pb.FileChunk{
			Offset: 0, Size: 1000, FileId: string(rune('m' + i)),
			IsChunkManifest: true,
		})
	}
	total, fileSize, merge := shouldMergeChunks(compacted, manifests)
	// total = 1000 (regular) + 5*1000 (manifests) = 6000
	// fileSize = 1000
	// 6000 > 2*1000 → merge
	if !merge {
		t.Fatalf("accumulated overlapping manifests should trigger merge (total=%d fileSize=%d)", total, fileSize)
	}
}

func TestShouldMergeChunks_DoesNotMutateInput(t *testing.T) {
	compacted := make([]*filer_pb.FileChunk, 2, 4) // extra capacity
	compacted[0] = &filer_pb.FileChunk{Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1}
	compacted[1] = &filer_pb.FileChunk{Offset: 0, Size: 100, FileId: "b", ModifiedTsNs: 2}
	manifest := []*filer_pb.FileChunk{
		{Offset: 100, Size: 100, FileId: "m", ModifiedTsNs: 3, IsChunkManifest: true},
	}

	lenBefore := len(compacted)
	shouldMergeChunks(compacted, manifest)

	if len(compacted) != lenBefore {
		t.Fatalf("shouldMergeChunks mutated compactedChunks: len changed from %d to %d",
			lenBefore, len(compacted))
	}
}

// TestCompactThenMergeCondition verifies that after CompactFileChunks
// removes fully-superseded chunks, the merge condition correctly detects
// remaining bloat from partially-overlapping chunks.
func TestCompactThenMergeCondition(t *testing.T) {
	tests := []struct {
		name      string
		chunks    []*filer_pb.FileChunk
		wantMerge bool
	}{
		{
			name: "fully superseded chunks removed, no bloat",
			chunks: []*filer_pb.FileChunk{
				{Offset: 0, Size: 100, FileId: "old", ModifiedTsNs: 1, Fid: &filer_pb.FileId{FileKey: 1}},
				{Offset: 0, Size: 100, FileId: "new", ModifiedTsNs: 2, Fid: &filer_pb.FileId{FileKey: 2}},
			},
			wantMerge: false,
		},
		{
			name: "repeated full overwrites all compacted away",
			chunks: []*filer_pb.FileChunk{
				{Offset: 0, Size: 100, FileId: "a", ModifiedTsNs: 1, Fid: &filer_pb.FileId{FileKey: 1}},
				{Offset: 0, Size: 100, FileId: "b", ModifiedTsNs: 2, Fid: &filer_pb.FileId{FileKey: 2}},
				{Offset: 0, Size: 100, FileId: "c", ModifiedTsNs: 3, Fid: &filer_pb.FileId{FileKey: 3}},
				{Offset: 0, Size: 100, FileId: "d", ModifiedTsNs: 4, Fid: &filer_pb.FileId{FileKey: 4}},
			},
			wantMerge: false,
		},
		{
			name: "non-overlapping small chunks, no bloat",
			chunks: func() []*filer_pb.FileChunk {
				var c []*filer_pb.FileChunk
				for i := 0; i < 10; i++ {
					c = append(c, &filer_pb.FileChunk{
						Offset:       int64(i * 10),
						Size:         10,
						FileId:       string(rune('a' + i)),
						ModifiedTsNs: int64(i + 1),
						Fid:          &filer_pb.FileId{FileKey: uint64(i)},
					})
				}
				return c
			}(),
			wantMerge: false,
		},
		{
			name: "staggered partial overlaps create bloat",
			// 100-byte base + three 40-byte writes at staggered offsets.
			// All 4 survive compact (each partially visible).
			// total = 100+40+40+40 = 220, file = 100, ratio = 2.2.
			chunks: []*filer_pb.FileChunk{
				{Offset: 0, Size: 100, FileId: "orig", ModifiedTsNs: 1, Fid: &filer_pb.FileId{FileKey: 1}},
				{Offset: 10, Size: 40, FileId: "w1", ModifiedTsNs: 2, Fid: &filer_pb.FileId{FileKey: 2}},
				{Offset: 30, Size: 40, FileId: "w2", ModifiedTsNs: 3, Fid: &filer_pb.FileId{FileKey: 3}},
				{Offset: 50, Size: 40, FileId: "w3", ModifiedTsNs: 4, Fid: &filer_pb.FileId{FileKey: 4}},
			},
			wantMerge: true,
		},
		{
			name: "overlapping writes with 75pct overlap",
			// Writes of size 100 at step 25 -> each supersedes 75% of the
			// previous write, all survive compact because the leading 25
			// bytes are still visible.
			// total = N*100, file ~ N*25+75. Ratio approaches 4x.
			chunks: func() []*filer_pb.FileChunk {
				var c []*filer_pb.FileChunk
				for i := 0; i < 20; i++ {
					c = append(c, &filer_pb.FileChunk{
						Offset:       int64(i * 25),
						Size:         100,
						FileId:       string(rune(i)),
						ModifiedTsNs: int64(i + 1),
						Fid:          &filer_pb.FileId{FileKey: uint64(i)},
					})
				}
				return c
			}(),
			wantMerge: true,
		},
		{
			name: "many 4K writes at 1K step over large range",
			// 200 writes of 4096 bytes at 1024-byte step.
			// After compact all survive (each partially visible).
			// total = 200*4096 = 819200, file ~ 199*1024+4096 = 207872.
			// ratio ~ 3.9x.
			chunks: func() []*filer_pb.FileChunk {
				var c []*filer_pb.FileChunk
				for i := 0; i < 200; i++ {
					c = append(c, &filer_pb.FileChunk{
						Offset:       int64(i * 1024),
						Size:         4096,
						FileId:       string(rune(i)),
						ModifiedTsNs: int64(i + 1),
						Fid:          &filer_pb.FileId{FileKey: uint64(i)},
					})
				}
				return c
			}(),
			wantMerge: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			compacted, _ := filer.CompactFileChunks(context.Background(), nil, tt.chunks)
			_, _, merge := shouldMergeChunks(compacted, nil)
			if merge != tt.wantMerge {
				var total uint64
				for _, c := range compacted {
					total += c.Size
				}
				fileSize := filer.TotalSize(compacted)
				t.Fatalf("got merge=%v want %v (compacted=%d total=%d fileSize=%d ratio=%.2f)",
					merge, tt.wantMerge, len(compacted), total, fileSize,
					float64(total)/float64(fileSize))
			}
		})
	}
}

// TestRandomWritesBloatDetection simulates random overwrites and verifies
// that the condition agrees with the actual stored-vs-visible ratio.
func TestRandomWritesBloatDetection(t *testing.T) {
	const (
		fileSize  = 1024 * 1024 // 1 MB logical file
		chunkSize = 64 * 1024   // 64 KB write size
	)

	for iter := 0; iter < 50; iter++ {
		chunks := []*filer_pb.FileChunk{
			{Offset: 0, Size: uint64(fileSize), FileId: "base", ModifiedTsNs: 1,
				Fid: &filer_pb.FileId{FileKey: 0}},
		}

		// Add random overwrites until stored > 3x file size.
		for i := 1; ; i++ {
			off := int64(rand.IntN(fileSize - chunkSize + 1))
			chunks = append(chunks, &filer_pb.FileChunk{
				Offset:       off,
				Size:         chunkSize,
				FileId:       string(rune(i)),
				ModifiedTsNs: int64(1 + i),
				Fid:          &filer_pb.FileId{FileKey: uint64(i)},
			})
			var total uint64
			for _, c := range chunks {
				total += c.Size
			}
			if total > uint64(fileSize)*3 {
				break
			}
		}

		compacted, _ := filer.CompactFileChunks(context.Background(), nil, chunks)
		totalCompacted, reportedFileSize, merge := shouldMergeChunks(compacted, nil)

		// Sanity: condition must be consistent with its own inputs.
		expectMerge := reportedFileSize > 0 && totalCompacted > 2*reportedFileSize
		if merge != expectMerge {
			t.Fatalf("iter %d: merge=%v but totalCompacted=%d reportedFileSize=%d",
				iter, merge, totalCompacted, reportedFileSize)
		}
	}
}

// TestFlushCycleManifestAccumulation simulates the flushMetadataToFiler
// pipeline over multiple cycles with a small manifest batch threshold.
// Each cycle adds a full pass of random writes, compacts, and manifestizes.
// Verifies that shouldMergeChunks detects manifest bloat within a few cycles.
func TestFlushCycleManifestAccumulation(t *testing.T) {
	const (
		fileSize  int64  = 10000
		chunkSize uint64 = 1000
		numSlots         = int(fileSize / int64(chunkSize)) // 10 offsets
		batchSize        = 5                                // small batch to trigger manifestize
	)

	var entryChunks []*filer_pb.FileChunk
	nextTs := int64(1)
	nextKey := uint64(1)

	for cycle := 0; cycle < 20; cycle++ {
		// Each cycle: new writes at every offset (simulates a full random-write pass)
		for slot := 0; slot < numSlots; slot++ {
			entryChunks = append(entryChunks, &filer_pb.FileChunk{
				Offset: int64(slot) * int64(chunkSize), Size: chunkSize,
				FileId: fmt.Sprintf("%d,%x00000000", cycle+1, nextKey),
				ModifiedTsNs: nextTs,
				Fid: &filer_pb.FileId{VolumeId: uint32(cycle + 1), FileKey: nextKey, Cookie: 0},
			})
			nextTs++
			nextKey++
		}

		// --- flush pipeline (mirrors flushMetadataToFiler) ---
		manifestChunks, nonManifestChunks := filer.SeparateManifestChunks(entryChunks)
		compacted, _ := filer.CompactFileChunks(context.Background(), nil, nonManifestChunks)

		_, _, merge := shouldMergeChunks(compacted, manifestChunks)
		if merge {
			t.Logf("cycle %d: merge correctly triggered (%d manifests, %d regular chunks)",
				cycle, len(manifestChunks), len(compacted))
			return
		}

		// Simulate MaybeManifestize with small batch:
		// pack groups of batchSize regular chunks into manifest chunks.
		numPacked := len(compacted) / batchSize
		var newEntry []*filer_pb.FileChunk
		newEntry = append(newEntry, manifestChunks...) // carry forward old manifests

		for i := 0; i < numPacked; i++ {
			batch := compacted[i*batchSize : (i+1)*batchSize]
			var minOff int64 = math.MaxInt64
			var maxEnd int64 = 0
			for _, c := range batch {
				if c.Offset < minOff {
					minOff = c.Offset
				}
				if end := c.Offset + int64(c.Size); end > maxEnd {
					maxEnd = end
				}
			}
			nextKey++
			newEntry = append(newEntry, &filer_pb.FileChunk{
				Offset: minOff, Size: uint64(maxEnd - minOff),
				FileId:          fmt.Sprintf("900,%x00000000", nextKey),
				IsChunkManifest: true,
				Fid:             &filer_pb.FileId{VolumeId: 900 + uint32(cycle), FileKey: nextKey, Cookie: 0},
			})
		}
		// Leftover regular chunks that didn't fill a batch
		remainder := compacted[numPacked*batchSize:]
		newEntry = append(newEntry, remainder...)
		entryChunks = newEntry
	}
	t.Fatal("merge never triggered after 20 flush cycles")
}

// TestVisibleContentPreservedAfterCompact verifies that CompactFileChunks
// never drops a chunk that contributes visible bytes. This is the invariant
// that maybeMergeChunks relies on: the compacted set covers the same logical
// content as the original.
func TestVisibleContentPreservedAfterCompact(t *testing.T) {
	const dataSize = 4096

	for iter := 0; iter < 100; iter++ {
		var chunks []*filer_pb.FileChunk
		numWrites := 5 + rand.IntN(20)
		for i := 0; i < numWrites; i++ {
			start := rand.IntN(dataSize)
			maxSize := 256
			if dataSize-start < maxSize {
				maxSize = dataSize - start
			}
			size := 1 + rand.IntN(maxSize)
			chunks = append(chunks, &filer_pb.FileChunk{
				Offset:       int64(start),
				Size:         uint64(size),
				FileId:       string(rune(i)),
				ModifiedTsNs: int64(i + 1),
				Fid:          &filer_pb.FileId{FileKey: uint64(i)},
			})
		}

		origViews := filer.ViewFromChunks(context.Background(), nil, chunks, 0, math.MaxInt64)
		compacted, _ := filer.CompactFileChunks(context.Background(), nil, chunks)
		compViews := filer.ViewFromChunks(context.Background(), nil, compacted, 0, math.MaxInt64)

		// Collect all (offset, size) pairs from views.
		type viewKey struct {
			offset int64
			size   uint64
		}
		origSet := make(map[viewKey]bool)
		for x := origViews.Front(); x != nil; x = x.Next {
			v := x.Value
			origSet[viewKey{v.ViewOffset, v.ViewSize}] = true
		}
		compSet := make(map[viewKey]bool)
		for x := compViews.Front(); x != nil; x = x.Next {
			v := x.Value
			compSet[viewKey{v.ViewOffset, v.ViewSize}] = true
		}

		// Every view in the compacted set must exist in the original.
		for k := range compSet {
			if !origSet[k] {
				t.Fatalf("iter %d: compacted has view {offset=%d size=%d} not in original",
					iter, k.offset, k.size)
			}
		}
		// Every view in the original must exist after compaction.
		for k := range origSet {
			if !compSet[k] {
				t.Fatalf("iter %d: original view {offset=%d size=%d} lost after compaction",
					iter, k.offset, k.size)
			}
		}
	}
}
