package erasure_coding

import (
	"bytes"
	"crypto/rand"
	"fmt"
	"os"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/seaweedfs/seaweedfs/weed/storage/types"
)

// TestEcReadRoundTrip tests the EC encode→read cycle via LocateData for various
// .dat file sizes, paying special attention to the large/small block boundary.
//
// The nLargeBlockRows calculation must correctly distinguish between large and small
// blocks. A previous bug (issue #8947) caused an off-by-one error when
// shardDatSize was an exact multiple of largeBlockSize, leading to data corruption.
func TestEcReadRoundTrip(t *testing.T) {
	const (
		large = largeBlockSize // 10000
		small = smallBlockSize // 100
	)

	largeRowSize := large * DataShardsCount // 100000
	smallRowSize := small * DataShardsCount // 1000

	testCases := []struct {
		name    string
		datSize int64
	}{
		// Exact multiples of largeRowSize — triggers the nLargeBlockRows off-by-one bug
		{"1_large_row_exact", int64(largeRowSize)},
		{"2_large_rows_exact", int64(2 * largeRowSize)},
		{"3_large_rows_exact", int64(3 * largeRowSize)},

		// Just over a large row boundary — has small blocks
		{"1_large_row_plus_1", int64(largeRowSize + 1)},
		{"2_large_rows_plus_small", int64(2*largeRowSize + smallRowSize)},
		{"1_large_row_plus_half_small", int64(largeRowSize + smallRowSize/2)},

		// Just under a large row boundary — all small blocks
		{"just_under_1_large_row", int64(largeRowSize - 1)},
		{"just_under_2_large_rows", int64(2*largeRowSize - 1)},

		// Small data — no large blocks at all
		{"small_only", int64(smallRowSize * 3)},
		{"small_single_row", int64(smallRowSize)},

		// Boundary with mixed large and small
		{"boundary_spanning", int64(largeRowSize + smallRowSize*5 + 50)},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			testEcRead(t, large, small, tc.datSize)
		})
	}
}

// testEcRead creates a .dat file, EC-encodes it, then verifies LocateData-based reads
// return correct data at positions throughout the file (especially near the large/small
// block boundary).
func testEcRead(t *testing.T, large, small, datSize int64) {
	t.Helper()

	dir := t.TempDir()
	baseFileName := fmt.Sprintf("%s/rt_%d", dir, datSize)

	// 1. Create a .dat file with deterministic random data
	originalData := make([]byte, datSize)
	_, err := rand.Read(originalData)
	require.NoError(t, err, "generating random data")

	err = os.WriteFile(baseFileName+".dat", originalData, 0644)
	require.NoError(t, err, "writing .dat file")

	ctx := NewDefaultECContext("", 0)

	// 2. EC encode with test block sizes
	_, err = generateEcFiles(baseFileName, int(small), large, small, ctx)
	require.NoError(t, err, "EC encoding")

	// 3. Open EC shard files for reading
	ecFiles, err := openEcFiles(baseFileName, true, ctx)
	require.NoError(t, err, "opening EC files")
	defer closeEcFiles(ecFiles)

	ecStat, err := ecFiles[0].Stat()
	require.NoError(t, err)
	shardFileSize := ecStat.Size()

	// Compute shardDatSize as the production code does when datFileSize is known
	shardDatSizeFromDat := datSize / int64(ctx.DataShards)

	// 4. Verify EC reads at various positions
	largeRowSize := large * DataShardsCount
	encoderLargeRows := datSize / int64(largeRowSize)
	boundaryOffset := encoderLargeRows * int64(largeRowSize)

	readSize := types.Size(small / 2) // read half a small block
	testOffsets := collectTestOffsets(datSize, int64(readSize), boundaryOffset, large, small)

	for _, offset := range testOffsets {
		// Test with shardDatSize from datFileSize (the production path with fix)
		intervals := LocateData(large, small, shardDatSizeFromDat, offset, readSize)
		ecData, err := assembleFromIntervals(ecFiles, intervals, large, small)
		require.NoError(t, err, "reading EC data at offset %d (datFileSize path)", offset)

		expected := originalData[offset : offset+int64(readSize)]
		if !bytes.Equal(expected, ecData) {
			t.Errorf("EC read mismatch at offset %d (datFileSize path, shardDatSize=%d, nLargeBlockRows=%d)",
				offset, shardDatSizeFromDat, shardDatSizeFromDat/large)
		}

		// Test with shardDatSize from ecdFileSize-1 (the fallback path for old volumes)
		intervalsFallback := LocateData(large, small, shardFileSize-1, offset, readSize)
		ecDataFallback, err := assembleFromIntervals(ecFiles, intervalsFallback, large, small)
		if err == nil && !bytes.Equal(expected, ecDataFallback) {
			// The fallback path may fail for exact multiples — log as warning
			t.Logf("WARN: EC read mismatch at offset %d (fallback path, shardFileSize=%d)",
				offset, shardFileSize)
		}
	}
}

// locateOffsetBuggy reimplements locateOffset with the old buggy formula:
//
//	nLargeBlockRows = (shardDatSize - 1) / largeBlockLength
//
// This caused an off-by-one error when shardDatSize was an exact multiple of
// largeBlockLength, miscounting the number of large block rows.
func locateOffsetBuggy(largeBlockLength, smallBlockLength int64, shardDatSize int64, offset int64) (blockIndex int, isLargeBlock bool, nLargeBlockRows int64, innerBlockOffset int64) {
	largeRowSize := largeBlockLength * DataShardsCount
	nLargeBlockRows = (shardDatSize - 1) / largeBlockLength // THE BUG

	if offset < nLargeBlockRows*largeRowSize {
		isLargeBlock = true
		blockIndex = int(offset / largeBlockLength)
		innerBlockOffset = offset % largeBlockLength
		return
	}

	isLargeBlock = false
	offset -= nLargeBlockRows * largeRowSize
	blockIndex = int(offset / smallBlockLength)
	innerBlockOffset = offset % smallBlockLength
	return
}

// locateDataBuggy is LocateData using the old buggy locateOffset.
func locateDataBuggy(largeBlockLength, smallBlockLength int64, shardDatSize int64, offset int64, size types.Size) []Interval {
	blockIndex, isLargeBlock, nLargeBlockRows, innerBlockOffset := locateOffsetBuggy(largeBlockLength, smallBlockLength, shardDatSize, offset)

	var intervals []Interval
	for size > 0 {
		blockRemaining := largeBlockLength - innerBlockOffset
		if !isLargeBlock {
			blockRemaining = smallBlockLength - innerBlockOffset
		}
		if blockRemaining <= 0 {
			blockIndex, isLargeBlock = moveToNextBlock(blockIndex, isLargeBlock, nLargeBlockRows)
			innerBlockOffset = 0
			continue
		}
		interval := Interval{
			BlockIndex:          blockIndex,
			InnerBlockOffset:    innerBlockOffset,
			IsLargeBlock:        isLargeBlock,
			LargeBlockRowsCount: int(nLargeBlockRows),
		}
		if int64(size) <= blockRemaining {
			interval.Size = size
			intervals = append(intervals, interval)
			return intervals
		}
		interval.Size = types.Size(blockRemaining)
		intervals = append(intervals, interval)
		size -= interval.Size
		blockIndex, isLargeBlock = moveToNextBlock(blockIndex, isLargeBlock, nLargeBlockRows)
		innerBlockOffset = 0
	}
	return intervals
}

// TestEcOffByOneBug_Issue8947 directly demonstrates the off-by-one bug.
//
// It creates a .dat file whose size is an exact multiple of (largeBlockSize * DataShards),
// EC-encodes it, then shows that:
//   - The OLD buggy formula produces WRONG data (data corruption)
//   - The FIXED formula produces CORRECT data
func TestEcOffByOneBug_Issue8947(t *testing.T) {
	const (
		large = largeBlockSize // 10000
		small = smallBlockSize // 100
	)

	// datSize is exactly 2 large rows — each shard gets exactly 2*largeBlockSize bytes.
	// The encoder produces 2 large block rows and 0 small block rows.
	datSize := int64(2 * large * DataShardsCount) // 200000

	dir := t.TempDir()
	baseFileName := fmt.Sprintf("%s/bug_%d", dir, datSize)

	originalData := make([]byte, datSize)
	_, err := rand.Read(originalData)
	require.NoError(t, err)

	err = os.WriteFile(baseFileName+".dat", originalData, 0644)
	require.NoError(t, err)

	ctx := NewDefaultECContext("", 0)
	_, err = generateEcFiles(baseFileName, int(small), large, small, ctx)
	require.NoError(t, err, "EC encoding")

	ecFiles, err := openEcFiles(baseFileName, true, ctx)
	require.NoError(t, err)
	defer closeEcFiles(ecFiles)

	// shardDatSize = datFileSize / DataShards = 2 * largeBlockSize
	// This is an EXACT multiple of largeBlockSize.
	shardDatSize := datSize / int64(ctx.DataShards) // = 2 * large = 20000

	// The encoder used 2 large block rows, 0 small block rows.
	// Correct: nLargeBlockRows = 20000 / 10000 = 2
	// Buggy:   nLargeBlockRows = (20000 - 1) / 10000 = 1  ← OFF BY ONE
	fixedRows := shardDatSize / large
	buggyRows := (shardDatSize - 1) / large
	assert.Equal(t, int64(2), fixedRows, "fixed formula should give 2 large block rows")
	assert.Equal(t, int64(1), buggyRows, "buggy formula gives only 1 (the bug)")

	// Test reading from the 2nd large block row (offsets 100000–199999).
	// With the buggy formula (nLargeBlockRows=1), this region is incorrectly
	// treated as small blocks, causing reads from the WRONG shard positions.
	readSize := types.Size(small / 2)

	// Pick an offset well into the 2nd large block row so that the buggy formula
	// computes a different (shard, offset) than the correct formula.
	// At the very start of the 2nd row, both formulas coincidentally hit the same
	// shard position. But further in, the small-block vs large-block addressing diverges.
	offset := int64(large*DataShardsCount) + large + 50 // 110050: in 2nd large row, shard 1

	// --- Fixed formula: reads correct data ---
	fixedIntervals := LocateData(large, small, shardDatSize, offset, readSize)
	fixedData, err := assembleFromIntervals(ecFiles, fixedIntervals, large, small)
	require.NoError(t, err, "fixed LocateData read")

	expected := originalData[offset : offset+int64(readSize)]
	assert.True(t, bytes.Equal(expected, fixedData),
		"FIXED formula should read correct data from 2nd large block row")

	// --- Buggy formula: reads WRONG data ---
	buggyIntervals := locateDataBuggy(large, small, shardDatSize, offset, readSize)
	buggyData, err := assembleFromIntervals(ecFiles, buggyIntervals, large, small)
	// The buggy formula might read from wrong offsets (possibly out of bounds),
	// so an error is also evidence of the bug.
	if err != nil {
		t.Logf("Buggy formula caused read error (expected): %v", err)
	} else {
		assert.False(t, bytes.Equal(expected, buggyData),
			"BUGGY formula should return WRONG data from 2nd large block row (demonstrating the corruption)")
		n := 8
		if len(expected) < n {
			n = len(expected)
		}
		t.Logf("Buggy formula returned wrong data: expected first bytes %x, got %x",
			expected[:n], buggyData[:n])
	}

	// Verify the bug mechanism: buggy formula misclassifies the 2nd large row as small blocks
	assert.True(t, fixedIntervals[0].IsLargeBlock,
		"fixed: offset %d should be in large blocks", offset)
	assert.False(t, buggyIntervals[0].IsLargeBlock,
		"buggy: offset %d is incorrectly classified as small blocks (the bug)", offset)

	t.Logf("Fixed: nLargeBlockRows=%d, interval=%+v", fixedRows, fixedIntervals[0])
	t.Logf("Buggy: nLargeBlockRows=%d, interval=%+v", buggyRows, buggyIntervals[0])
}

// TestEcDecodeDatRoundTrip tests the full WriteDatFile decode path using the production
// block sizes with a small .dat file that fits within the small block region.
func TestEcDecodeDatRoundTrip(t *testing.T) {
	// With production sizes, datFileSize must be < DataShardsCount * ErasureCodingLargeBlockSize (10GB)
	// to avoid needing huge test files. We test small block decode only.
	// Each shard gets datSize/DataShards bytes in small 1MB blocks.
	datSizes := []int64{
		1000, // tiny
		int64(DataShardsCount) * ErasureCodingSmallBlockSize,     // exactly 1 small row (10MB)
		int64(DataShardsCount)*ErasureCodingSmallBlockSize + 500, // 1 small row + partial
	}

	for _, datSize := range datSizes {
		t.Run(fmt.Sprintf("size_%d", datSize), func(t *testing.T) {
			testDecodeDat(t, datSize)
		})
	}
}

func testDecodeDat(t *testing.T, datSize int64) {
	t.Helper()

	dir := t.TempDir()
	baseFileName := fmt.Sprintf("%s/dec_%d", dir, datSize)

	// 1. Create .dat with random data
	originalData := make([]byte, datSize)
	_, err := rand.Read(originalData)
	require.NoError(t, err)

	err = os.WriteFile(baseFileName+".dat", originalData, 0644)
	require.NoError(t, err)

	ctx := NewDefaultECContext("", 0)

	// 2. EC encode with PRODUCTION block sizes
	_, err = generateEcFiles(baseFileName, 256*1024, ErasureCodingLargeBlockSize, ErasureCodingSmallBlockSize, ctx)
	require.NoError(t, err, "EC encoding")

	// 3. Decode via WriteDatFile
	decodedBase := baseFileName + "_decoded"
	shardFileNames := make([]string, DataShardsCount)
	for i := 0; i < DataShardsCount; i++ {
		shardFileNames[i] = fmt.Sprintf("%s%s", baseFileName, ctx.ToExt(i))
	}

	err = WriteDatFile(decodedBase, datSize, datSize, shardFileNames, ErasureCodingLargeBlockSize, ErasureCodingSmallBlockSize)
	require.NoError(t, err, "WriteDatFile")

	// The atomic publish must rename the temp file away, never leaving it behind.
	_, statErr := os.Stat(decodedBase + ".dat.tmp")
	require.True(t, os.IsNotExist(statErr), "WriteDatFile must not leave a .dat.tmp")

	// 4. Verify decoded .dat matches original
	decodedData, err := os.ReadFile(decodedBase + ".dat")
	require.NoError(t, err)

	assert.Equal(t, len(originalData), len(decodedData), "decoded .dat size mismatch")
	if !bytes.Equal(originalData, decodedData) {
		for i := 0; i < len(originalData) && i < len(decodedData); i++ {
			if originalData[i] != decodedData[i] {
				t.Fatalf("decoded .dat mismatch at byte %d (datSize=%d)", i, datSize)
			}
		}
	}
}

// collectTestOffsets generates offsets to test, focusing on the large/small block boundary.
func collectTestOffsets(datSize, readSize, boundaryOffset, large, small int64) []int64 {
	offsets := []int64{0}

	if datSize > readSize {
		offsets = append(offsets, datSize/2)
	}

	// Near the large/small block boundary
	if boundaryOffset > 0 && boundaryOffset < datSize {
		for _, delta := range []int64{-large, -small, -1, 0, 1, small, large} {
			off := boundaryOffset + delta
			if off >= 0 && off+readSize <= datSize {
				offsets = append(offsets, off)
			}
		}
	}

	// Near end of file
	if datSize > readSize {
		offsets = append(offsets, datSize-readSize)
	}

	return offsets
}

// assembleFromIntervals reads data from EC shard files according to the given intervals.
func assembleFromIntervals(ecFiles []*os.File, intervals []Interval, large, small int64) ([]byte, error) {
	var data []byte
	for _, interval := range intervals {
		shardId, shardOffset := interval.ToShardIdAndOffset(large, small)
		chunk := make([]byte, interval.Size)
		n, err := ecFiles[shardId].ReadAt(chunk, shardOffset)
		if err != nil {
			return nil, fmt.Errorf("read shard %d offset %d size %d: %v", shardId, shardOffset, interval.Size, err)
		}
		if n != int(interval.Size) {
			return nil, fmt.Errorf("short read from shard %d: got %d, want %d", shardId, n, interval.Size)
		}
		data = append(data, chunk...)
	}
	return data, nil
}

// TestWriteDatFileAfterTailDeletion decodes after deletions moved the live
// extent below the large-block row boundary. The shard block layout is fixed
// by the encode-time .dat size, so de-striping must not derive it from the
// shrunk live extent.
func TestWriteDatFileAfterTailDeletion(t *testing.T) {
	const (
		large = int64(largeBlockSize) // 10000
		small = int64(smallBlockSize) // 100
	)
	largeRowSize := large * DataShardsCount
	smallRowSize := small * DataShardsCount

	// one full large-block row plus a small-block tail
	datSize := largeRowSize + 2*smallRowSize + 530

	dir := t.TempDir()
	baseFileName := fmt.Sprintf("%s/tail_del", dir)

	originalData := make([]byte, datSize)
	_, err := rand.Read(originalData)
	require.NoError(t, err)
	require.NoError(t, os.WriteFile(baseFileName+".dat", originalData, 0644))

	ctx := NewDefaultECContext("", 0)
	_, err = generateEcFiles(baseFileName, 50, large, small, ctx)
	require.NoError(t, err, "EC encoding")

	shardFileNames := make([]string, DataShardsCount)
	for i := 0; i < DataShardsCount; i++ {
		shardFileNames[i] = baseFileName + ctx.ToExt(i)
	}
	shardFileInfo, err := os.Stat(shardFileNames[0])
	require.NoError(t, err)
	paddedSize := int64(DataShardsCount) * shardFileInfo.Size()

	decodedBase := baseFileName + "_decoded"
	decode := func(liveSize, encodedSize int64) []byte {
		require.NoError(t, writeDatFile(decodedBase, liveSize, encodedSize, shardFileNames, large, small))
		decoded, readErr := os.ReadFile(decodedBase + ".dat")
		require.NoError(t, readErr)
		require.NoError(t, os.Remove(decodedBase+".dat"))
		return decoded
	}

	liveSizes := []int64{
		large - 1,              // within the first large block
		large + 42,             // partial second large block
		largeRowSize / 2,       // mid large row
		largeRowSize,           // exactly the large row
		largeRowSize + 5*small, // into the small-block tail
		datSize,                // nothing deleted
	}
	for _, liveSize := range liveSizes {
		assert.Equal(t, originalData[:liveSize], decode(liveSize, datSize), "live size %d, encode-time layout", liveSize)
		assert.Equal(t, originalData[:liveSize], decode(liveSize, paddedSize), "live size %d, padded layout", liveSize)
	}

	// deriving the layout from the shrunk live extent reorders the data
	assert.NotEqual(t, originalData[:largeRowSize/2], decode(largeRowSize/2, largeRowSize/2))

	// the live extent can never exceed the encode-time size
	require.Error(t, writeDatFile(decodedBase, datSize+1, datSize, shardFileNames, large, small))
}

// TestWriteDatFileFallbackLayout covers decoding when .vif does not record the
// encode-time size: the layout is inferred from the shard size, except when
// that is an exact large-block multiple and the live extent reaches the
// ambiguous region.
func TestWriteDatFileFallbackLayout(t *testing.T) {
	const (
		large = int64(largeBlockSize) // 10000
		small = int64(smallBlockSize) // 100
	)
	largeRowSize := large * DataShardsCount

	dir := t.TempDir()

	encode := func(name string, datSize int64) (string, []string, []byte) {
		baseFileName := fmt.Sprintf("%s/%s", dir, name)
		originalData := make([]byte, datSize)
		_, err := rand.Read(originalData)
		require.NoError(t, err)
		require.NoError(t, os.WriteFile(baseFileName+".dat", originalData, 0644))
		ctx := NewDefaultECContext("", 0)
		_, err = generateEcFiles(baseFileName, 50, large, small, ctx)
		require.NoError(t, err, "EC encoding")
		shardFileNames := make([]string, DataShardsCount)
		for i := 0; i < DataShardsCount; i++ {
			shardFileNames[i] = baseFileName + ctx.ToExt(i)
		}
		return baseFileName, shardFileNames, originalData
	}

	// a small-block tail that is not a large-block multiple is unambiguous
	base, shards, original := encode("plain", largeRowSize+2530)
	liveSize := largeRowSize / 2
	require.NoError(t, writeDatFile(base+"_d", liveSize, 0, shards, large, small))
	decoded, err := os.ReadFile(base + "_d.dat")
	require.NoError(t, err)
	assert.Equal(t, original[:liveSize], decoded)

	// datSize just under one large row: the full small-block region makes each
	// shard exactly largeBlockSize, indistinguishable from one large row
	base, shards, _ = encode("ambiguous1", largeRowSize-1)
	err = writeDatFile(base+"_d", largeRowSize/2, 0, shards, large, small)
	require.ErrorContains(t, err, "does not identify the block layout")

	// two-row equivalent: decoding within the agreed prefix still works
	base, shards, original = encode("ambiguous2", 2*largeRowSize-1)
	require.NoError(t, writeDatFile(base+"_d", largeRowSize, 0, shards, large, small))
	decoded, err = os.ReadFile(base + "_d.dat")
	require.NoError(t, err)
	assert.Equal(t, original[:largeRowSize], decoded)
	err = writeDatFile(base+"_d2", largeRowSize+1, 0, shards, large, small)
	require.ErrorContains(t, err, "does not identify the block layout")
}
