package dictadmin

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"scrabble/backend/internal/engine"
)

// dawgNames are the three committed dictionary filenames a release archive holds.
func dawgNames() []string {
	out := make([]string, 0, 3)
	for _, name := range engine.DictFiles() {
		out = append(out, name)
	}
	return out
}

// fullArchive builds a gzip+tar archive holding all three dictionary files with
// dummy content, the shape of a real release archive.
func fullArchive(t *testing.T) []byte {
	t.Helper()
	files := map[string][]byte{}
	for _, n := range dawgNames() {
		files[n] = []byte("dawg:" + n)
	}
	return tarGz(t, files, nil)
}

// tarGz builds a gzip+tar archive. regular maps a member name to its bytes; extra
// lets a test inject crafted headers (symlinks, oversized members, junk entries).
func tarGz(t *testing.T, regular map[string][]byte, extra []*tar.Header) []byte {
	t.Helper()
	var buf bytes.Buffer
	gz := gzip.NewWriter(&buf)
	tw := tar.NewWriter(gz)
	for name, data := range regular {
		if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil {
			t.Fatalf("write header %s: %v", name, err)
		}
		if _, err := tw.Write(data); err != nil {
			t.Fatalf("write %s: %v", name, err)
		}
	}
	for _, hdr := range extra {
		if err := tw.WriteHeader(hdr); err != nil {
			t.Fatalf("write extra header %s: %v", hdr.Name, err)
		}
		if hdr.Typeflag == tar.TypeReg && hdr.Size > 0 {
			if _, err := tw.Write(bytes.Repeat([]byte("x"), int(hdr.Size))); err != nil {
				t.Fatalf("write extra %s: %v", hdr.Name, err)
			}
		}
	}
	if err := tw.Close(); err != nil {
		t.Fatalf("close tar: %v", err)
	}
	if err := gz.Close(); err != nil {
		t.Fatalf("close gzip: %v", err)
	}
	return buf.Bytes()
}

func TestParseVersionFromName(t *testing.T) {
	cases := []struct {
		name    string
		want    string
		wantErr bool
	}{
		{"scrabble-dawg-v1.2.3.tar.gz", "v1.2.3", false},
		{"scrabble-dawg-v10.0.1.tar.gz", "v10.0.1", false},
		{"scrabble-dawg-v1.2.tar.gz", "", true},
		{"scrabble-dawg-1.2.3.tar.gz", "", true},
		{"scrabble-dawg-v1.2.3.zip", "", true},
		{"dawg-v1.2.3.tar.gz", "", true},
		{"scrabble-dawg-v1.2.3.tar.gz.exe", "", true},
		{"", "", true},
	}
	for _, c := range cases {
		got, err := ParseVersionFromName(c.name)
		if c.wantErr {
			if err == nil {
				t.Errorf("ParseVersionFromName(%q) = %q, want error", c.name, got)
			}
			continue
		}
		if err != nil || got != c.want {
			t.Errorf("ParseVersionFromName(%q) = %q, %v; want %q", c.name, got, err, c.want)
		}
	}
}

func TestValidVersion(t *testing.T) {
	valid := []string{"v1.0.0", "v10.20.30", "v0.0.1"}
	invalid := []string{"v1.0", "1.0.0", "v1.0.0.0", "v1.0.0/..", "v1.0.0 ", "", "vx.y.z", "../v1.0.0"}
	for _, v := range valid {
		if !ValidVersion(v) {
			t.Errorf("ValidVersion(%q) = false, want true", v)
		}
	}
	for _, v := range invalid {
		if ValidVersion(v) {
			t.Errorf("ValidVersion(%q) = true, want false", v)
		}
	}
}

func TestExtractAcceptsFullArchive(t *testing.T) {
	dir := t.TempDir()
	variants, err := Extract(bytes.NewReader(fullArchive(t)), dir)
	if err != nil {
		t.Fatalf("Extract: %v", err)
	}
	if len(variants) != len(engine.Variants()) {
		t.Errorf("variants = %v, want all %d", variants, len(engine.Variants()))
	}
	for _, n := range dawgNames() {
		if _, err := os.Stat(filepath.Join(dir, n)); err != nil {
			t.Errorf("missing extracted %s: %v", n, err)
		}
	}
}

func TestExtractIgnoresUnexpectedAndTraversal(t *testing.T) {
	dir := t.TempDir()
	files := map[string][]byte{
		"pkg/en_sowpods.dawg": []byte("ok"),   // a subdir path is stripped to its base
		"README.txt":          []byte("junk"), // an unexpected name is ignored
	}
	// A traversal name with a recognised base must still land inside destDir.
	traversal := &tar.Header{Name: "../ru_scrabble.dawg", Mode: 0o644, Size: 2, Typeflag: tar.TypeReg}
	// A symlink, even named like a DAWG, is ignored.
	symlink := &tar.Header{Name: "erudit_ru.dawg", Linkname: "/etc/passwd", Typeflag: tar.TypeSymlink}

	if _, err := Extract(bytes.NewReader(tarGz(t, files, []*tar.Header{traversal, symlink})), dir); err != nil {
		t.Fatalf("Extract: %v", err)
	}
	if _, err := os.Stat(filepath.Join(dir, "README.txt")); err == nil {
		t.Error("README.txt was extracted; unexpected files must be ignored")
	}
	if _, err := os.Stat(filepath.Join(dir, "en_sowpods.dawg")); err != nil {
		t.Errorf("en_sowpods.dawg not extracted from a subdir entry: %v", err)
	}
	if _, err := os.Stat(filepath.Join(dir, "ru_scrabble.dawg")); err != nil {
		t.Errorf("ru_scrabble.dawg not extracted from a traversal entry: %v", err)
	}
	// Nothing escaped the destination directory.
	if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "ru_scrabble.dawg")); err == nil {
		t.Error("path traversal wrote outside the destination directory")
	}
	if _, err := os.Stat(filepath.Join(dir, "erudit_ru.dawg")); err == nil {
		t.Error("a symlink member was materialised")
	}
}

func TestExtractRejectsOversizeMember(t *testing.T) {
	old := maxMemberBytes
	maxMemberBytes = 8
	defer func() { maxMemberBytes = old }()

	files := map[string][]byte{"en_sowpods.dawg": bytes.Repeat([]byte("x"), 64)}
	if _, err := Extract(bytes.NewReader(tarGz(t, files, nil)), t.TempDir()); err == nil {
		t.Error("Extract accepted an oversize member, want error")
	}
}

func TestExtractRejectsTooManyEntries(t *testing.T) {
	var extra []*tar.Header
	for i := 0; i < maxEntries+1; i++ {
		extra = append(extra, &tar.Header{Name: fmt.Sprintf("junk-%d.bin", i), Mode: 0o644, Size: 1, Typeflag: tar.TypeReg})
	}
	if _, err := Extract(bytes.NewReader(tarGz(t, nil, extra)), t.TempDir()); err == nil {
		t.Error("Extract accepted an archive with too many entries, want error")
	}
}

func TestManagerStageInstall(t *testing.T) {
	dir := t.TempDir()
	m := New(dir)

	token, variants, err := m.Stage(bytes.NewReader(fullArchive(t)))
	if err != nil {
		t.Fatalf("Stage: %v", err)
	}
	if len(variants) != len(engine.Variants()) {
		t.Fatalf("staged variants = %v, want all", variants)
	}
	staged, err := m.StagedDir(token)
	if err != nil {
		t.Fatalf("StagedDir: %v", err)
	}
	if !strings.Contains(staged, filepath.Join(".staging", token)) {
		t.Errorf("staged dir %q not under .staging/%s", staged, token)
	}

	if m.VersionExists("v1.0.0") {
		t.Fatal("v1.0.0 should not exist before install")
	}
	dest, err := m.Install(token, "v1.0.0")
	if err != nil {
		t.Fatalf("Install: %v", err)
	}
	if dest != filepath.Join(dir, "v1.0.0") {
		t.Errorf("install dest = %q, want %q", dest, filepath.Join(dir, "v1.0.0"))
	}
	if !m.VersionExists("v1.0.0") {
		t.Error("VersionExists(v1.0.0) = false after install")
	}
	for _, n := range dawgNames() {
		if _, err := os.Stat(filepath.Join(dest, n)); err != nil {
			t.Errorf("installed %s missing: %v", n, err)
		}
	}
}

func TestManagerInstallRejectsExistingVersion(t *testing.T) {
	dir := t.TempDir()
	m := New(dir)
	token, _, err := m.Stage(bytes.NewReader(fullArchive(t)))
	if err != nil {
		t.Fatalf("Stage: %v", err)
	}
	if _, err := m.Install(token, "v2.0.0"); err != nil {
		t.Fatalf("first Install: %v", err)
	}
	token2, _, err := m.Stage(bytes.NewReader(fullArchive(t)))
	if err != nil {
		t.Fatalf("second Stage: %v", err)
	}
	if _, err := m.Install(token2, "v2.0.0"); err == nil {
		t.Error("Install overwrote an existing version, want error (versions are immutable)")
	}
}

func TestStagedDirRejectsBadToken(t *testing.T) {
	m := New(t.TempDir())
	for _, bad := range []string{"", "../etc", "ABC", "zz", strings.Repeat("g", 32)} {
		if _, err := m.StagedDir(bad); err == nil {
			t.Errorf("StagedDir(%q) = nil error, want rejection", bad)
		}
	}
}
