package storage

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/seaweedfs/seaweedfs/weed/stats"
	"github.com/seaweedfs/seaweedfs/weed/storage/needle"
	"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
	"github.com/seaweedfs/seaweedfs/weed/util"
)

// fileExists is a tiny test helper for asserting file presence.
func mustNotExist(t *testing.T, path string) {
	t.Helper()
	if _, err := os.Stat(path); !os.IsNotExist(err) {
		t.Fatalf("expected %s to be absent, got err=%v", path, err)
	}
}

func mustExist(t *testing.T, path string) {
	t.Helper()
	if _, err := os.Stat(path); err != nil {
		t.Fatalf("expected %s to exist: %v", path, err)
	}
}

// A crash after the .idx was already renamed away but before the .cpx->.idx
// rename committed leaves only .cpd + .cpx + .cpc on disk. The directory
// pre-pass must roll the swap FORWARD: finish the renames and produce a
// loadable, writable volume holding the compacted needle count. Before the
// fix there was no marker and the loader simply discarded the orphan temp
// files, losing the entire compacted generation.
func TestReconcileRollForwardMarkerOnly(t *testing.T) {
	dir := t.TempDir()

	v, err := NewVolume(dir, dir, "", 1, NeedleMapLevelDb, &super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0)
	if err != nil {
		t.Fatalf("volume creation: %v", err)
	}

	const liveCount = 6
	for i := uint64(1); i <= liveCount; i++ {
		if _, _, _, err := v.writeNeedle2(newRandomNeedle(i), true, false, false); err != nil {
			t.Fatalf("write %d: %v", i, err)
		}
	}
	// Delete two so the compacted generation is strictly smaller than the source.
	for _, id := range []uint64{2, 5} {
		if _, err := v.deleteNeedle2(newEmptyNeedle(id)); err != nil {
			t.Fatalf("delete %d: %v", id, err)
		}
	}
	expectedLive := uint64(liveCount - 2)

	// Generate a valid .cpd/.cpx pair, then close the volume.
	if err := v.CompactByIndex(nil); err != nil {
		t.Fatalf("compact by index: %v", err)
	}
	v.Close()

	cpd := filepath.Join(dir, "1.cpd")
	cpx := filepath.Join(dir, "1.cpx")
	cpc := filepath.Join(dir, "1.cpc")
	datPath := filepath.Join(dir, "1.dat")
	idxPath := filepath.Join(dir, "1.idx")
	mustExist(t, cpd)
	mustExist(t, cpx)

	// Simulate the mid-commit crash: the original .dat/.idx are gone and only
	// the compacted temp files plus the commit marker survive.
	if err := os.Remove(datPath); err != nil {
		t.Fatalf("remove dat: %v", err)
	}
	if err := os.Remove(idxPath); err != nil {
		t.Fatalf("remove idx: %v", err)
	}
	os.RemoveAll(filepath.Join(dir, "1.ldb"))
	if err := os.WriteFile(cpc, nil, 0644); err != nil {
		t.Fatalf("write marker: %v", err)
	}

	// The directory pre-pass should roll the commit forward.
	location := NewDiskLocation(dir, 10, util.MinFreeSpace{}, dir, "", nil, stats.DefaultDiskIOProbeConfig())
	defer location.Close()
	location.loadExistingVolumes(NeedleMapLevelDb, 0)

	mustNotExist(t, cpd)
	mustNotExist(t, cpx)
	mustNotExist(t, cpc)
	mustExist(t, datPath)
	mustExist(t, idxPath)

	reloaded, found := location.FindVolume(needle.VolumeId(1))
	if !found {
		t.Fatalf("volume not loaded after roll-forward")
	}
	if reloaded.IsReadOnly() {
		t.Fatalf("rolled-forward volume is read-only")
	}
	if got := reloaded.FileCount(); got != expectedLive {
		t.Fatalf("rolled-forward volume has %d needles, want compacted count %d", got, expectedLive)
	}
}

// A crash AFTER .cpd->.dat but BEFORE .cpx->.idx leaves a compacted .dat, a
// stale .idx, and only .cpx + .cpc. The roll-forward must finish the pending
// .cpx->.idx rename, not abandon it -- abandoning pairs the fresh .dat with the
// stale .idx, which is index corruption.
func TestReconcileRollForwardPartialRename(t *testing.T) {
	dir := t.TempDir()

	v, err := NewVolume(dir, dir, "", 1, NeedleMapLevelDb, &super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0)
	if err != nil {
		t.Fatalf("volume creation: %v", err)
	}
	const liveCount = 6
	for i := uint64(1); i <= liveCount; i++ {
		if _, _, _, err := v.writeNeedle2(newRandomNeedle(i), true, false, false); err != nil {
			t.Fatalf("write %d: %v", i, err)
		}
	}
	for _, id := range []uint64{2, 5} {
		if _, err := v.deleteNeedle2(newEmptyNeedle(id)); err != nil {
			t.Fatalf("delete %d: %v", id, err)
		}
	}
	expectedLive := uint64(liveCount - 2)
	if err := v.CompactByIndex(nil); err != nil {
		t.Fatalf("compact by index: %v", err)
	}
	v.Close()

	cpd := filepath.Join(dir, "1.cpd")
	cpx := filepath.Join(dir, "1.cpx")
	cpc := filepath.Join(dir, "1.cpc")
	datPath := filepath.Join(dir, "1.dat")
	idxPath := filepath.Join(dir, "1.idx")

	// Crash after the .dat rename committed but before the .idx one: .cpd has
	// become .dat, the stale pre-compact .idx survives, .cpx and the marker remain.
	os.RemoveAll(filepath.Join(dir, "1.ldb"))
	if err := os.Remove(datPath); err != nil {
		t.Fatalf("remove stale dat: %v", err)
	}
	if err := os.Rename(cpd, datPath); err != nil {
		t.Fatalf("rename cpd->dat: %v", err)
	}
	if err := os.WriteFile(cpc, nil, 0644); err != nil {
		t.Fatalf("write marker: %v", err)
	}
	mustNotExist(t, cpd)
	mustExist(t, cpx)
	mustExist(t, idxPath)

	location := NewDiskLocation(dir, 10, util.MinFreeSpace{}, dir, "", nil, stats.DefaultDiskIOProbeConfig())
	defer location.Close()
	location.loadExistingVolumes(NeedleMapLevelDb, 0)

	mustNotExist(t, cpx)
	mustNotExist(t, cpc)
	mustExist(t, datPath)
	mustExist(t, idxPath)

	reloaded, found := location.FindVolume(needle.VolumeId(1))
	if !found {
		t.Fatalf("volume not loaded after partial roll-forward")
	}
	if got := reloaded.FileCount(); got != expectedLive {
		t.Fatalf("volume has %d needles, want compacted count %d (pending .cpx->.idx was not rolled forward)", got, expectedLive)
	}
}

// With no .cpc marker, leftover .cpd/.cpx are an abandoned generation. The
// pre-pass must roll BACK by deleting them, leaving the live .dat/.idx intact.
func TestReconcileRollBackNoMarker(t *testing.T) {
	dir := t.TempDir()

	v, err := NewVolume(dir, dir, "", 1, NeedleMapInMemory, &super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0)
	if err != nil {
		t.Fatalf("volume creation: %v", err)
	}
	for i := uint64(1); i <= 4; i++ {
		if _, _, _, err := v.writeNeedle2(newRandomNeedle(i), true, false, false); err != nil {
			t.Fatalf("write %d: %v", i, err)
		}
	}
	if err := v.CompactByIndex(nil); err != nil {
		t.Fatalf("compact by index: %v", err)
	}
	v.Close()

	cpd := filepath.Join(dir, "1.cpd")
	cpx := filepath.Join(dir, "1.cpx")
	datPath := filepath.Join(dir, "1.dat")
	idxPath := filepath.Join(dir, "1.idx")
	mustExist(t, cpd)
	mustExist(t, cpx)
	// No .cpc marker exists.

	location := NewDiskLocation(dir, 10, util.MinFreeSpace{}, dir, "", nil, stats.DefaultDiskIOProbeConfig())
	defer location.Close()
	location.loadExistingVolumes(NeedleMapInMemory, 0)

	mustNotExist(t, cpd)
	mustNotExist(t, cpx)
	mustExist(t, datPath)
	mustExist(t, idxPath)

	reloaded, found := location.FindVolume(needle.VolumeId(1))
	if !found {
		t.Fatalf("volume not loaded after roll-back")
	}
	if got := reloaded.FileCount(); got != 4 {
		t.Fatalf("rolled-back volume has %d needles, want original 4", got)
	}
}

// A runtime reload (SIGHUP -> LoadNewVolumes -> reconcileCompactStates) must
// NOT roll back an in-flight vacuum of an already-loaded volume: its .cpd/.cpx
// are live, not crash leftovers. The pre-pass only reconciles vids not loaded.
func TestReconcileSkipsLoadedVolumeMidVacuum(t *testing.T) {
	dir := t.TempDir()

	v, err := NewVolume(dir, dir, "", 1, NeedleMapInMemory, &super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0)
	if err != nil {
		t.Fatalf("volume creation: %v", err)
	}
	for i := uint64(1); i <= 4; i++ {
		if _, _, _, err := v.writeNeedle2(newRandomNeedle(i), true, false, false); err != nil {
			t.Fatalf("write %d: %v", i, err)
		}
	}
	v.Close()

	// Load the volume as a running server would (populates l.volumes).
	location := NewDiskLocation(dir, 10, util.MinFreeSpace{}, dir, "", nil, stats.DefaultDiskIOProbeConfig())
	defer location.Close()
	location.loadExistingVolumes(NeedleMapInMemory, 0)
	loaded, found := location.FindVolume(needle.VolumeId(1))
	if !found {
		t.Fatalf("volume not loaded")
	}

	// In-flight vacuum: .cpd/.cpx written, commit (.cpc) not yet reached.
	if err := loaded.CompactByIndex(nil); err != nil {
		t.Fatalf("compact by index: %v", err)
	}
	cpd := filepath.Join(dir, "1.cpd")
	cpx := filepath.Join(dir, "1.cpx")
	mustExist(t, cpd)
	mustExist(t, cpx)

	// The reload pre-pass must leave the loaded volume's in-flight temp files alone.
	location.reconcileCompactStates()
	mustExist(t, cpd)
	mustExist(t, cpx)
}

// applyCompactSwap must abort without touching the live .dat/.idx when the
// .cpd/.cpx temp files are missing (a stale or duplicate commit). Before the
// fix a late commit on Windows would RemoveAll the .dat/.idx first and then
// fail the rename, destroying the volume.
func TestApplyCompactSwapMissingTempFilesPreservesLive(t *testing.T) {
	dir := t.TempDir()

	v, err := NewVolume(dir, dir, "", 1, NeedleMapInMemory, &super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0)
	if err != nil {
		t.Fatalf("volume creation: %v", err)
	}
	for i := uint64(1); i <= 3; i++ {
		if _, _, _, err := v.writeNeedle2(newRandomNeedle(i), true, false, false); err != nil {
			t.Fatalf("write %d: %v", i, err)
		}
	}
	v.Close()

	datPath := filepath.Join(dir, "1.dat")
	idxPath := filepath.Join(dir, "1.idx")
	mustExist(t, datPath)
	mustExist(t, idxPath)
	datBefore, _ := os.Stat(datPath)

	// No .cpd/.cpx present: the swap must refuse.
	if err := v.applyCompactSwap(); err == nil {
		t.Fatalf("applyCompactSwap should error when .cpd/.cpx are missing")
	}

	mustExist(t, datPath)
	mustExist(t, idxPath)
	if datAfter, _ := os.Stat(datPath); datAfter.Size() != datBefore.Size() {
		t.Fatalf("live .dat changed size: before=%d after=%d", datBefore.Size(), datAfter.Size())
	}
}

// Deleting a volume and recreating the same id must not leave a stale .cpc
// behind for the next load to roll forward against. removeVolumeFiles must
// sweep the marker along with the rest.
func TestDestroyRemovesCommitMarker(t *testing.T) {
	dir := t.TempDir()

	v, err := NewVolume(dir, dir, "", 1, NeedleMapInMemory, &super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0)
	if err != nil {
		t.Fatalf("volume creation: %v", err)
	}
	for i := uint64(1); i <= 3; i++ {
		if _, _, _, err := v.writeNeedle2(newRandomNeedle(i), true, false, false); err != nil {
			t.Fatalf("write %d: %v", i, err)
		}
	}

	// Strand a commit marker and its temp files as a crashed commit would.
	cpc := filepath.Join(dir, "1.cpc")
	cpd := filepath.Join(dir, "1.cpd")
	cpx := filepath.Join(dir, "1.cpx")
	for _, p := range []string{cpc, cpd, cpx} {
		if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
			t.Fatalf("write %s: %v", p, err)
		}
	}

	if err := v.Destroy(false, false); err != nil {
		t.Fatalf("destroy: %v", err)
	}

	mustNotExist(t, cpc)
	mustNotExist(t, cpd)
	mustNotExist(t, cpx)

	// Recreate the same id and load: there must be no marker to roll forward.
	v2, err := NewVolume(dir, dir, "", 1, NeedleMapInMemory, &super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0)
	if err != nil {
		t.Fatalf("recreate volume: %v", err)
	}
	defer v2.Close()

	location := NewDiskLocation(dir, 10, util.MinFreeSpace{}, dir, "", nil, stats.DefaultDiskIOProbeConfig())
	defer location.Close()
	location.reconcileCompactStates()
	mustNotExist(t, cpc)
}
