package infra

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

// mkVaultDir creates a temporary directory tree for tests.
// entries is a list of relative paths to create.
// Paths ending in "/" are directories; others are files with placeholder content.
func mkVaultDir(t *testing.T, entries []string) string {
	t.Helper()
	root := t.TempDir()
	for _, e := range entries {
		full := filepath.Join(root, filepath.FromSlash(e))
		if e[len(e)-1] == '/' {
			if err := os.MkdirAll(full, 0o755); err != nil {
				t.Fatalf("mkVaultDir: mkdir %q: %v", full, err)
			}
		} else {
			if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
				t.Fatalf("mkVaultDir: mkdir parent %q: %v", full, err)
			}
			if err := os.WriteFile(full, []byte("test\n"), 0o644); err != nil {
				t.Fatalf("mkVaultDir: write %q: %v", full, err)
			}
		}
	}
	return root
}

func TestVaultLayoutEnsure_DryRun_NoChange(t *testing.T) {
	root := mkVaultDir(t, []string{
		"raw/",
		"raw/file1.csv",
		"processed/",
	})

	before := snapshotDir(t, root)
	report, err := VaultLayoutEnsure(root, true)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !report.DryRun {
		t.Error("DryRun flag not set in report")
	}
	after := snapshotDir(t, root)
	if !mapEqual(before, after) {
		t.Errorf("dry-run modified disk: before=%v after=%v", before, after)
	}
	// Should have planned a migration for raw and processed.
	if len(report.Migrated) == 0 {
		t.Error("expected Migrated to be non-empty in dry-run plan")
	}
}

func TestVaultLayoutEnsure_FreshDir_CreatesLayout(t *testing.T) {
	root := mkVaultDir(t, []string{}) // empty vault

	report, err := VaultLayoutEnsure(root, false)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// All standard dirs should be created.
	wantCreated := []string{
		"data", "knowledge",
		filepath.Join("data", "raw"),
		filepath.Join("data", "processed"),
		filepath.Join("data", "exports"),
		filepath.Join("knowledge", "decisions"),
		filepath.Join("knowledge", "domains"),
		filepath.Join("knowledge", "models"),
		filepath.Join("knowledge", "benchmarks"),
		filepath.Join("knowledge", "test_documents"),
	}
	createdSet := toSet(report.Created)
	for _, w := range wantCreated {
		if _, ok := createdSet[w]; !ok {
			t.Errorf("expected Created to contain %q, got %v", w, report.Created)
		}
	}

	// All directories must actually exist on disk.
	for _, w := range wantCreated {
		full := filepath.Join(root, w)
		info, err := os.Stat(full)
		if err != nil {
			t.Errorf("expected %q to exist: %v", full, err)
			continue
		}
		if !info.IsDir() {
			t.Errorf("%q should be a directory", full)
		}
	}
}

func TestVaultLayoutEnsure_LegacyDataLayout_Migrates(t *testing.T) {
	root := mkVaultDir(t, []string{
		"raw/",
		"raw/file1.parquet",
		"raw/file2.parquet",
		"processed/",
		"processed/clean.csv",
		"exports/",
	})

	report, err := VaultLayoutEnsure(root, false)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// raw and processed should appear in Migrated (as dirs, top-level rename).
	migratedSet := toSet(report.Migrated)
	for _, pair := range []string{
		"raw -> " + filepath.Join("data", "raw"),
		"processed -> " + filepath.Join("data", "processed"),
	} {
		if _, ok := migratedSet[pair]; !ok {
			t.Errorf("expected Migrated to contain %q, got %v", pair, report.Migrated)
		}
	}

	// Files must have moved.
	for _, f := range []string{
		filepath.Join("data", "raw", "file1.parquet"),
		filepath.Join("data", "raw", "file2.parquet"),
		filepath.Join("data", "processed", "clean.csv"),
	} {
		if _, err := os.Stat(filepath.Join(root, f)); err != nil {
			t.Errorf("expected %q to exist after migration: %v", f, err)
		}
	}
	// Old dirs must be gone.
	for _, d := range []string{"raw", "processed"} {
		if pathExists(filepath.Join(root, d)) {
			t.Errorf("expected legacy dir %q to be removed", d)
		}
	}
}

func TestVaultLayoutEnsure_LegacyKnowledgeLayout_Migrates(t *testing.T) {
	root := mkVaultDir(t, []string{
		"decisions/",
		"decisions/2024-01.md",
		"models/",
		"models/ner_v1.pkl",
		"README.md",
	})

	report, err := VaultLayoutEnsure(root, false)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// decisions and models should appear in Migrated.
	migratedSet := toSet(report.Migrated)
	for _, pair := range []string{
		"decisions -> " + filepath.Join("knowledge", "decisions"),
		"models -> " + filepath.Join("knowledge", "models"),
		"README.md -> " + filepath.Join("knowledge", "README.md"),
	} {
		if _, ok := migratedSet[pair]; !ok {
			t.Errorf("expected Migrated to contain %q, got %v", pair, report.Migrated)
		}
	}

	// Files must be at new location.
	for _, f := range []string{
		filepath.Join("knowledge", "decisions", "2024-01.md"),
		filepath.Join("knowledge", "models", "ner_v1.pkl"),
		filepath.Join("knowledge", "README.md"),
	} {
		if _, err := os.Stat(filepath.Join(root, f)); err != nil {
			t.Errorf("expected %q to exist after migration: %v", f, err)
		}
	}
}

func TestVaultLayoutEnsure_AlreadyMigrated_Idempotent(t *testing.T) {
	root := mkVaultDir(t, []string{
		"data/",
		"data/raw/",
		"data/raw/file.csv",
		"data/processed/",
		"data/exports/",
		"knowledge/",
		"knowledge/decisions/",
		"knowledge/domains/",
		"knowledge/models/",
		"knowledge/benchmarks/",
		"knowledge/test_documents/",
	})

	report1, err := VaultLayoutEnsure(root, false)
	if err != nil {
		t.Fatalf("first run error: %v", err)
	}
	if len(report1.Migrated) != 0 {
		t.Errorf("first run on fully-migrated vault should have no migrations, got %v", report1.Migrated)
	}

	before := snapshotDir(t, root)
	report2, err := VaultLayoutEnsure(root, false)
	if err != nil {
		t.Fatalf("second run error: %v", err)
	}
	after := snapshotDir(t, root)

	if !mapEqual(before, after) {
		t.Error("second run modified disk (not idempotent)")
	}
	if len(report2.Migrated) != 0 {
		t.Errorf("second run should produce no migrations, got %v", report2.Migrated)
	}
	if len(report2.AlreadyOK) == 0 {
		t.Error("second run should report existing dirs as AlreadyOK")
	}
}

func TestVaultLayoutEnsure_Mixed_PartialMigration(t *testing.T) {
	// data/raw already migrated; exports still at root; knowledge dirs in legacy positions.
	root := mkVaultDir(t, []string{
		"data/",
		"data/raw/",
		"data/raw/already_here.csv",
		"exports/",
		"exports/report.pdf",
		"decisions/",
		"decisions/2023-note.md",
	})

	report, err := VaultLayoutEnsure(root, false)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// data/raw should be AlreadyOK.
	if !sliceContains(report.AlreadyOK, filepath.Join("data", "raw")) {
		t.Errorf("data/raw should be AlreadyOK, got AlreadyOK=%v", report.AlreadyOK)
	}
	// exports should be migrated.
	exportsMigrated := false
	for _, m := range report.Migrated {
		if m == "exports -> "+filepath.Join("data", "exports") {
			exportsMigrated = true
		}
	}
	if !exportsMigrated {
		t.Errorf("exports should be migrated, Migrated=%v", report.Migrated)
	}
	// decisions should be migrated.
	decisionsMigrated := false
	for _, m := range report.Migrated {
		if m == "decisions -> "+filepath.Join("knowledge", "decisions") {
			decisionsMigrated = true
		}
	}
	if !decisionsMigrated {
		t.Errorf("decisions should be migrated, Migrated=%v", report.Migrated)
	}
}

func TestVaultLayoutEnsure_MergeConflict_Errors(t *testing.T) {
	// Both src (raw/) and dst (data/raw/) exist and have a file with the same name.
	root := mkVaultDir(t, []string{
		"raw/",
		"raw/collision.csv",
		"data/",
		"data/raw/",
		"data/raw/collision.csv", // same name -> conflict
	})

	_, err := VaultLayoutEnsure(root, false)
	if err == nil {
		t.Fatal("expected error for merge conflict, got nil")
	}
	if !contains(err.Error(), "conflict") && !contains(err.Error(), "collision.csv") {
		t.Errorf("error should mention conflict or the file name, got: %v", err)
	}
}

func TestVaultLayoutEnsure_UnknownFiles_Skipped(t *testing.T) {
	root := mkVaultDir(t, []string{
		".git/",
		"vault_index.db",
		"my_custom_notes.txt",
		"raw/",
	})

	report, err := VaultLayoutEnsure(root, false)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	skippedSet := toSet(report.Skipped)
	for _, name := range []string{".git", "vault_index.db", "my_custom_notes.txt"} {
		if _, ok := skippedSet[name]; !ok {
			t.Errorf("expected %q in Skipped, got %v", name, report.Skipped)
		}
	}
	// raw should NOT be in Skipped (it's a known bucket).
	if _, ok := skippedSet["raw"]; ok {
		t.Error("raw should not appear in Skipped — it is a known bucket")
	}
}

func TestVaultLayoutEnsure_NotADir_Errors(t *testing.T) {
	t.Run("non-existent path", func(t *testing.T) {
		_, err := VaultLayoutEnsure("/tmp/does_not_exist_fn_registry_test_xyz", false)
		if err == nil {
			t.Fatal("expected error for non-existent path")
		}
	})

	t.Run("path is a file", func(t *testing.T) {
		f, err := os.CreateTemp("", "vault_layout_*.txt")
		if err != nil {
			t.Fatal(err)
		}
		f.Close()
		defer os.Remove(f.Name())

		_, err = VaultLayoutEnsure(f.Name(), false)
		if err == nil {
			t.Fatal("expected error when vaultPath is a file, not a dir")
		}
		if !contains(err.Error(), "not a directory") {
			t.Errorf("error should mention 'not a directory', got: %v", err)
		}
	})
}

// --- helpers ---

// snapshotDir returns a map of relative path -> exists for all entries under root.
func snapshotDir(t *testing.T, root string) map[string]bool {
	t.Helper()
	snap := make(map[string]bool)
	err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return err
		}
		rel, _ := filepath.Rel(root, path)
		snap[rel] = true
		return nil
	})
	if err != nil {
		t.Fatalf("snapshotDir: %v", err)
	}
	return snap
}

func mapEqual(a, b map[string]bool) bool {
	if len(a) != len(b) {
		return false
	}
	for k := range a {
		if !b[k] {
			return false
		}
	}
	return true
}

func toSet(ss []string) map[string]struct{} {
	m := make(map[string]struct{}, len(ss))
	for _, s := range ss {
		m[s] = struct{}{}
	}
	return m
}

func sliceContains(ss []string, target string) bool {
	for _, s := range ss {
		if s == target {
			return true
		}
	}
	return false
}

func contains(s, sub string) bool {
	return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
		func() bool {
			for i := 0; i <= len(s)-len(sub); i++ {
				if s[i:i+len(sub)] == sub {
					return true
				}
			}
			return false
		}())
}
