package regionmedic

import (
	"archive/tar"
	"bytes"
	"compress/flate"
	"compress/gzip"
	"crypto/sha256"
	"encoding/binary"
	"os"
	"path/filepath"
	"testing"
	"time"
)

// --- fixture builders -------------------------------------------------------

func deflateBytes(b []byte) []byte {
	var buf bytes.Buffer
	w, _ := flate.NewWriter(&buf, flate.DefaultCompression)
	_, _ = w.Write(b)
	_ = w.Close()
	return buf.Bytes()
}

// buildRegion synthesizes a valid .7rg image for the given region coordinate
// with the listed chunk indices present. Each chunk's decompressed payload
// carries its correct GLOBAL coords so deep validation passes.
func buildRegion(region RegionCoord, present []int) []byte {
	header := make([]byte, SectorSize)
	copy(header, regionMagic)
	loctable := make([]byte, SectorSize)
	var data []byte
	nextSector := headerSectors // first data sector index = 2

	for _, idx := range present {
		lx, lz := idx%RegionSide, idx/RegionSide
		gx := region.X*RegionSide + lx
		gz := region.Z*RegionSide + lz
		payload := make([]byte, 16)
		binary.LittleEndian.PutUint32(payload[0:4], uint32(int32(gx)))
		binary.LittleEndian.PutUint32(payload[8:12], uint32(int32(gz)))
		payload[12] = 0xAB // arbitrary body
		comp := deflateBytes(payload)
		blockLen := 8 + len(comp)
		need := (24 + len(comp) + SectorSize - 1) / SectorSize
		blob := make([]byte, need*SectorSize)
		binary.LittleEndian.PutUint32(blob[0:4], uint32(blockLen))
		copy(blob[16:20], chunkMagic)
		binary.LittleEndian.PutUint32(blob[20:24], uint32(chunkConstVal))
		copy(blob[24:], comp)

		off := nextSector
		e := loctable[idx*4 : idx*4+4]
		e[0] = byte(off & 0xff)
		e[1] = byte((off >> 8) & 0xff)
		e[2] = byte((off >> 16) & 0xff)
		e[3] = byte(need)
		data = append(data, blob...)
		nextSector += need
	}
	out := append(append(append([]byte{}, header...), loctable...), data...)
	return out
}

// chunkBlobOffset returns the file byte offset of the "ttc" magic for a present
// chunk index — i.e. the exact byte the corruption recipe overwrites.
func chunkMagicOffset(data []byte, idx int) int {
	e := data[locTableOffset+idx*4 : locTableOffset+idx*4+4]
	off := int(e[0]) | int(e[1])<<8 | int(e[2])<<16
	return off*SectorSize + 16
}

// --- validation tests -------------------------------------------------------

func TestValidateGoodRegionOrigin(t *testing.T) {
	data := buildRegion(RegionCoord{0, 0}, []int{0, 1, 33, 1023})
	rep := ValidateRegionBytes(data, true)
	if !rep.Healthy() {
		t.Fatalf("expected healthy, got err=%q bad=%+v", rep.Err, rep.BadChunks)
	}
	if rep.PresentChunks != 4 {
		t.Fatalf("present=%d want 4", rep.PresentChunks)
	}
}

func TestValidateGoodRegionNegative(t *testing.T) {
	// Region r.-2.0 exercises floored-modulo coord checking for negative globals.
	data := buildRegion(RegionCoord{-2, 0}, []int{0, 1, 31, 500})
	rep := ValidateRegionBytes(data, true)
	if !rep.Healthy() {
		t.Fatalf("expected healthy negative region, got err=%q bad=%+v", rep.Err, rep.BadChunks)
	}
}

func TestWrongChunkHeaderDetected(t *testing.T) {
	data := buildRegion(RegionCoord{0, 0}, []int{0, 5})
	// Faithful corruption: clobber the "ttc" magic of chunk index 5.
	off := chunkMagicOffset(data, 5)
	data[off] = 0x00 // 't' -> 0x00
	rep := ValidateRegionBytes(data, true)
	if rep.Healthy() {
		t.Fatal("expected corruption to be detected")
	}
	if len(rep.BadChunks) != 1 || rep.BadChunks[0].Index != 5 || rep.BadChunks[0].Reason != ReasonWrongHeader {
		t.Fatalf("want one wrong-header bad chunk idx5, got %+v", rep.BadChunks)
	}
	// The other chunk (0) must still be fine and counted present.
	if rep.PresentChunks != 2 {
		t.Fatalf("present=%d want 2", rep.PresentChunks)
	}
}

func TestBadMagic(t *testing.T) {
	data := buildRegion(RegionCoord{0, 0}, []int{0})
	data[1] = 'X'
	if ValidateRegionBytes(data, false).Err == "" {
		t.Fatal("expected fatal err on bad magic")
	}
}

func TestNonSectorAligned(t *testing.T) {
	if ValidateRegionBytes(make([]byte, 5000), false).Err == "" {
		t.Fatal("expected fatal err on non-aligned size")
	}
}

func TestOutOfBoundsEntry(t *testing.T) {
	data := buildRegion(RegionCoord{0, 0}, []int{0})
	// Point chunk index 1's entry far past EOF.
	e := data[locTableOffset+1*4 : locTableOffset+1*4+4]
	e[0], e[1], e[2], e[3] = 0xFF, 0xFF, 0x00, 1 // offset 0xFFFF sectors
	rep := ValidateRegionBytes(data, false)
	if rep.Healthy() || len(rep.BadChunks) != 1 || rep.BadChunks[0].Reason != ReasonOutOfBounds {
		t.Fatalf("want out-of-bounds bad chunk, got err=%q bad=%+v", rep.Err, rep.BadChunks)
	}
}

func TestOverlapDetected(t *testing.T) {
	data := buildRegion(RegionCoord{0, 0}, []int{0, 1})
	// Make chunk 1's entry point at chunk 0's sector (overlap).
	e0 := data[locTableOffset : locTableOffset+4]
	e1 := data[locTableOffset+4 : locTableOffset+8]
	copy(e1, e0)
	rep := ValidateRegionBytes(data, false)
	foundOverlap := false
	for _, bc := range rep.BadChunks {
		if bc.Reason == ReasonOverlap {
			foundOverlap = true
		}
	}
	if !foundOverlap {
		t.Fatalf("expected overlap detection, got %+v", rep.BadChunks)
	}
}

// --- localize tests ---------------------------------------------------------

func TestParseErrorBackup(t *testing.T) {
	cases := []struct {
		name       string
		cx, cz     int
		ok         bool
	}{
		{"error_backup_-28_12.comp.bak", -28, 12, true},
		{"error_backup_-29_-13.uncomp.bak", -29, -13, true},
		{"error_backup_5_7.comp.bak", 5, 7, true},
		{"r.0.0.7rg", 0, 0, false},
		{"error_backup_5_7.txt", 0, 0, false},
	}
	for _, c := range cases {
		cx, cz, ok := ParseErrorBackup(c.name)
		if ok != c.ok || (ok && (cx != c.cx || cz != c.cz)) {
			t.Errorf("%s -> (%d,%d,%v) want (%d,%d,%v)", c.name, cx, cz, ok, c.cx, c.cz, c.ok)
		}
	}
}

func TestParseRegionFileName(t *testing.T) {
	cases := []struct {
		name string
		rc   RegionCoord
		ok   bool
	}{
		{"r.-2.0.7rg", RegionCoord{-2, 0}, true},
		{"r.10.-5.7rg", RegionCoord{10, -5}, true},
		{"r.0.0.7rg", RegionCoord{0, 0}, true},
		{"error_backup_5_7.comp.bak", RegionCoord{}, false},
	}
	for _, c := range cases {
		rc, ok := ParseRegionFileName(c.name)
		if ok != c.ok || (ok && rc != c.rc) {
			t.Errorf("%s -> (%v,%v) want (%v,%v)", c.name, rc, ok, c.rc, c.ok)
		}
	}
}

func TestRegionForChunk(t *testing.T) {
	cases := []struct {
		cx, cz int
		rc     RegionCoord
	}{
		{0, 0, RegionCoord{0, 0}},
		{31, 31, RegionCoord{0, 0}},
		{-1, -1, RegionCoord{-1, -1}},
		{-32, 0, RegionCoord{-1, 0}},
		{-33, 12, RegionCoord{-2, 0}},
		{-64, 0, RegionCoord{-2, 0}},
	}
	for _, c := range cases {
		if got := RegionForChunk(c.cx, c.cz); got != c.rc {
			t.Errorf("RegionForChunk(%d,%d)=%v want %v", c.cx, c.cz, got, c.rc)
		}
	}
}

// --- scan test --------------------------------------------------------------

func TestScanRegionDir(t *testing.T) {
	dir := t.TempDir()
	// 3 salvage files in r.-2.0 (chunks -33..-64 / 0..31), 1 in r.2.1.
	writeEmpty(t, dir, "error_backup_-33_12.comp.bak")
	writeEmpty(t, dir, "error_backup_-33_12.uncomp.bak") // same chunk, dedup
	writeEmpty(t, dir, "error_backup_-40_20.comp.bak")
	writeEmpty(t, dir, "error_backup_70_40.comp.bak") // region r.2.1
	// a live region file for r.-2.0
	os.WriteFile(filepath.Join(dir, "r.-2.0.7rg"), buildRegion(RegionCoord{-2, 0}, []int{0}), 0o644)

	scan, err := ScanRegionDir(dir)
	if err != nil {
		t.Fatal(err)
	}
	if scan.ErrorBackups != 4 {
		t.Errorf("error_backups=%d want 4", scan.ErrorBackups)
	}
	if len(scan.Affected) != 2 {
		t.Fatalf("affected=%d want 2", len(scan.Affected))
	}
	top := scan.Affected[0]
	if top.Region != (RegionCoord{-2, 0}) || top.ErrorBackups != 3 {
		t.Errorf("top affected=%v files=%d want r.-2.0 files=3", top.Region, top.ErrorBackups)
	}
	if len(top.Chunks) != 2 {
		t.Errorf("distinct chunks=%d want 2", len(top.Chunks))
	}
	if !top.FilePresent {
		t.Error("expected r.-2.0 live file present")
	}
}

// --- backup extract + find-last-good ---------------------------------------

const navSave = "Saves/Navezgane/MyGame/Region"

// writeBackupTar writes a tarball whose Navezgane Region/<regionName> entry is
// regionBytes. If decoyBytes is non-nil it ALSO writes a second world
// (West Apeeni Mountains/MapRender) with the SAME region filename but different
// bytes — to prove world-qualified extraction picks the right one.
func writeBackupTar(t *testing.T, dir, name, regionName string, regionBytes, decoyBytes []byte) string {
	t.Helper()
	p := filepath.Join(dir, name)
	f, err := os.Create(p)
	if err != nil {
		t.Fatal(err)
	}
	defer f.Close()
	gw := gzip.NewWriter(f)
	tw := tar.NewWriter(gw)
	add := func(entry string, b []byte) {
		tw.WriteHeader(&tar.Header{Name: entry, Mode: 0o644, Size: int64(len(b)), Typeflag: tar.TypeReg})
		tw.Write(b)
	}
	if decoyBytes != nil {
		// Decoy world FIRST in tar order, so a naive bare-name match would grab it.
		add(".local/share/7DaysToDie/Saves/West Apeeni Mountains/MapRender/Region/"+regionName, decoyBytes)
	}
	if regionBytes != nil {
		add(".local/share/7DaysToDie/"+navSave+"/"+regionName, regionBytes)
	}
	add("serverconfig.xml", []byte("xml"))
	tw.Close()
	gw.Close()
	return p
}

func TestExtractRegionFromBackup(t *testing.T) {
	dir := t.TempDir()
	region := buildRegion(RegionCoord{0, 0}, []int{0, 1})
	decoy := buildRegion(RegionCoord{0, 0}, []int{0, 1, 2, 3}) // different bytes
	p := writeBackupTar(t, dir, "20260101-120000-bkp_aaaa.tar.gz", "r.0.0.7rg", region, decoy)

	// World-qualified entry must return the Navezgane copy, NOT the decoy.
	got, err := ExtractRegionFromBackup(p, navSave+"/r.0.0.7rg")
	if err != nil {
		t.Fatal(err)
	}
	if !bytes.Equal(got, region) {
		t.Fatal("world-qualified extract returned wrong world's region")
	}
	if bytes.Equal(got, decoy) {
		t.Fatal("extracted the decoy world's region — disambiguation failed")
	}
	if _, err := ExtractRegionFromBackup(p, navSave+"/r.9.9.7rg"); err == nil {
		t.Fatal("expected not-found error for absent region")
	}
}

func TestSaveRelEntry(t *testing.T) {
	got := SaveRelEntry("/var/lib/docker/volumes/x/_data/.local/share/7DaysToDie/Saves/Navezgane/MyGame/Region", "r.-2.0.7rg")
	if got != "Saves/Navezgane/MyGame/Region/r.-2.0.7rg" {
		t.Fatalf("got %q", got)
	}
}

func TestFindLastGoodRegion(t *testing.T) {
	dir := t.TempDir()
	region := RegionCoord{-2, 0}
	good := buildRegion(region, []int{0, 1, 2})
	corrupt := buildRegion(region, []int{0, 1, 2})
	corrupt[chunkMagicOffset(corrupt, 1)] = 0x00 // break chunk 1

	// oldest clean, middle clean, newest corrupt (each also carries a decoy world)
	writeBackupTar(t, dir, "20260101-010000-bkp_old.tar.gz", region.FileName(), good, good)
	writeBackupTar(t, dir, "20260102-010000-bkp_mid.tar.gz", region.FileName(), good, good)
	writeBackupTar(t, dir, "20260103-010000-bkp_new.tar.gz", region.FileName(), corrupt, good)

	refs, err := ListPanelBackups(dir)
	if err != nil {
		t.Fatal(err)
	}
	if len(refs) != 3 {
		t.Fatalf("refs=%d want 3", len(refs))
	}
	// newest first
	if !refs[0].CreatedAt.After(refs[1].CreatedAt) {
		t.Fatal("backups not sorted newest-first")
	}

	best, evaluated, err := FindLastGoodRegion(refs, navSave+"/"+region.FileName(), true, time.Time{})
	if err != nil {
		t.Fatal(err)
	}
	if !best.Found || !best.Clean() {
		t.Fatalf("expected a clean best, got found=%v clean=%v", best.Found, best.Clean())
	}
	// Best must be the middle (newest CLEAN), not the corrupt newest.
	if best.Backup.ID != "bkp_mid" {
		t.Fatalf("best=%s want bkp_mid (newest clean)", best.Backup.ID)
	}
	if len(evaluated) != 3 {
		t.Fatalf("evaluated=%d want 3", len(evaluated))
	}
}

// --- full plan + apply heal -------------------------------------------------

func TestPlanAndApplyHeal(t *testing.T) {
	base := t.TempDir()
	// Realistic region dir so SaveRelEntry yields a world-qualified suffix.
	regionDir := filepath.Join(base, ".local/share/7DaysToDie", navSave)
	backupDir := filepath.Join(base, "backups")
	os.MkdirAll(regionDir, 0o755)
	os.MkdirAll(backupDir, 0o755)

	region := RegionCoord{-2, 0}
	clean := buildRegion(region, []int{0, 1, 2, 33})

	// Live (corrupt) region file: break one chunk header.
	corrupt := append([]byte{}, clean...)
	corrupt[chunkMagicOffset(corrupt, 2)] = 0x00
	liveFile := filepath.Join(regionDir, region.FileName())
	if err := os.WriteFile(liveFile, corrupt, 0o644); err != nil {
		t.Fatal(err)
	}
	// Salvage files for this region.
	writeEmpty(t, regionDir, "error_backup_-64_0.comp.bak")
	writeEmpty(t, regionDir, "error_backup_-64_0.uncomp.bak")

	// A clean backup containing the good region (plus a decoy world that must
	// NOT be chosen).
	decoy := buildRegion(RegionCoord{99, 99}, []int{0})
	writeBackupTar(t, backupDir, "20260101-120000-bkp_clean.tar.gz", region.FileName(), clean, decoy)

	refs, _ := ListPanelBackups(backupDir)
	plan, err := PlanHeal(regionDir, region, refs, true, time.Time{})
	if err != nil {
		t.Fatal(err)
	}
	if plan.LiveReport.Healthy() {
		t.Fatal("expected live report to flag corruption")
	}
	if !plan.Ready() {
		t.Fatalf("plan not ready: source found=%v clean=%v file=%q", plan.Source.Found, plan.Source.Clean(), plan.RegionFile)
	}
	if len(plan.ErrorBackups) != 2 {
		t.Fatalf("error backups in plan=%d want 2", len(plan.ErrorBackups))
	}

	snapDir := filepath.Join(base, "snap")
	qDir := filepath.Join(base, "quarantine")
	res, err := ApplyHeal(plan, snapDir, qDir)
	if err != nil {
		t.Fatal(err)
	}

	// Live file now equals the clean bytes.
	healed, _ := os.ReadFile(liveFile)
	if sha256.Sum256(healed) != sha256.Sum256(clean) {
		t.Fatal("healed live file does not match clean source")
	}
	if !ValidateRegionFile(liveFile, true).Healthy() {
		t.Fatal("healed file failed revalidation")
	}
	// Snapshot holds the corrupt original.
	snap, _ := os.ReadFile(res.SnapshotPath)
	if sha256.Sum256(snap) != sha256.Sum256(corrupt) {
		t.Fatal("snapshot does not match the corrupt original")
	}
	// Salvage files moved out of the region dir.
	if _, err := os.Stat(filepath.Join(regionDir, "error_backup_-64_0.comp.bak")); !os.IsNotExist(err) {
		t.Fatal("error_backup still present in region dir after heal")
	}
	if len(res.QuarantinedFiles) != 2 {
		t.Fatalf("quarantined=%d want 2", len(res.QuarantinedFiles))
	}
}

func writeEmpty(t *testing.T, dir, name string) {
	t.Helper()
	if err := os.WriteFile(filepath.Join(dir, name), []byte{}, 0o644); err != nil {
		t.Fatal(err)
	}
}
