package retention

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"io/fs"
	"log/slog"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"testing"
	"time"
)

// fixedNow is the reference timestamp used by all table cases; individual
// file ages are expressed as durations relative to it.
var fixedNow = time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)

const day = 24 * time.Hour

type fakeFile struct {
	name      string
	ageOffset time.Duration
}

func writeFakeFile(t *testing.T, dir string, f fakeFile) {
	t.Helper()

	full := filepath.Join(dir, f.name)

	if err := os.WriteFile(full, []byte("backup-bytes"), 0o600); err != nil {
		t.Fatalf("write %s: %v", f.name, err)
	}

	mtime := fixedNow.Add(-f.ageOffset)
	if err := os.Chtimes(full, mtime, mtime); err != nil {
		t.Fatalf("chtimes %s: %v", f.name, err)
	}
}

func fileExists(t *testing.T, path string) bool {
	t.Helper()

	_, err := os.Stat(path)
	if err == nil {
		return true
	}
	if errors.Is(err, fs.ErrNotExist) {
		return false
	}
	t.Fatalf("stat %s: %v", path, err)

	return false
}

func captureLogger() (*slog.Logger, *bytes.Buffer) {
	buf := &bytes.Buffer{}
	handler := slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})

	return slog.New(handler), buf
}

func TestPruneByAge_TableDriven(t *testing.T) {
	oldMtimes := []time.Duration{
		365 * day, 200 * day, 181 * day, 250 * day, 400 * day,
	}
	newMtimes := []time.Duration{
		0, 30 * day, 90 * day, 179 * day, 180 * day,
	}

	var corpus []fakeFile
	for index, age := range oldMtimes {
		corpus = append(corpus, fakeFile{name: makeName(index), ageOffset: age})
	}
	for index, age := range newMtimes {
		corpus = append(corpus, fakeFile{
			name:      makeName(index + len(oldMtimes)),
			ageOffset: age,
		})
	}

	tests := []struct {
		name             string
		files            []fakeFile
		retentionDays    int
		wantPrunedNames  []string
		wantKeptNames    []string
		expectAnyRemoval bool
	}{
		{
			name:             "by_age_removes_older_keeps_younger",
			files:            corpus,
			retentionDays:    180,
			wantPrunedNames:  []string{makeName(0), makeName(1), makeName(2), makeName(3), makeName(4)},
			wantKeptNames:    []string{makeName(5), makeName(6), makeName(7), makeName(8), makeName(9)},
			expectAnyRemoval: true,
		},
		{
			name:             "retention_zero_is_opt_out",
			files:            corpus,
			retentionDays:    0,
			wantPrunedNames:  []string{},
			wantKeptNames:    allNames(len(corpus)),
			expectAnyRemoval: false,
		},
		{
			name: "non_matching_files_never_touched",
			files: append(
				allCorpus(corpus),
				fakeFile{name: "other-20200101-000000.dump.pqenc", ageOffset: 1000 * day},
				fakeFile{name: "synapse-foo.txt", ageOffset: 1000 * day},
				fakeFile{name: "README", ageOffset: 1000 * day},
			),
			retentionDays: 30,
			wantPrunedNames: []string{
				makeName(0), makeName(1), makeName(2), makeName(3), makeName(4),
				makeName(7), makeName(8), makeName(9),
			},
			wantKeptNames: []string{
				makeName(5), makeName(6),
				"other-20200101-000000.dump.pqenc",
				"synapse-foo.txt",
				"README",
			},
			expectAnyRemoval: true,
		},
		{
			name: "strict_glob_synapse_dump_pqenc",
			files: []fakeFile{
				{name: "synapse-20250101-000000.dump.pqenc", ageOffset: 1000 * day},
				{name: "synapse-20250102-000000.dump.pqenc", ageOffset: 1 * day},
				{name: "notsynapse-20250101-000000.dump.pqenc", ageOffset: 1000 * day},
				{name: "synapse-20250101-000000.dump.pqenc.bak", ageOffset: 1000 * day},
				{name: "synapse-20250101-000000.dump", ageOffset: 1000 * day},
				{name: "SYNAPSE-20250101-000000.dump.pqenc", ageOffset: 1000 * day},
				{name: "synapse-20250101-000000", ageOffset: 1000 * day},
			},
			retentionDays:   30,
			wantPrunedNames: []string{"synapse-20250101-000000.dump.pqenc"},
			wantKeptNames: []string{
				"synapse-20250102-000000.dump.pqenc",
				"notsynapse-20250101-000000.dump.pqenc",
				"synapse-20250101-000000.dump.pqenc.bak",
				"synapse-20250101-000000.dump",
				"SYNAPSE-20250101-000000.dump.pqenc",
				"synapse-20250101-000000",
			},
			expectAnyRemoval: true,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			dir := t.TempDir()
			for _, file := range tc.files {
				writeFakeFile(t, dir, file)
			}

			pruned, err := PruneByAge(context.Background(), dir, tc.retentionDays, fixedNow)
			if err != nil {
				t.Fatalf("PruneByAge: unexpected error: %v", err)
			}

			gotPruned := basenames(pruned)
			sort.Strings(gotPruned)
			wantPruned := append([]string(nil), tc.wantPrunedNames...)
			sort.Strings(wantPruned)

			if !equalStringSlices(gotPruned, wantPruned) {
				t.Errorf("pruned mismatch\n  got:  %v\n  want: %v", gotPruned, wantPruned)
			}

			for _, name := range pruned {
				if fileExists(t, filepath.Join(dir, name)) {
					t.Errorf("PruneByAge returned %q but file still exists on disk", name)
				}
			}

			for _, name := range tc.wantKeptNames {
				if !fileExists(t, filepath.Join(dir, name)) {
					t.Errorf("expected to keep %q, but it is missing", name)
				}
			}

			if tc.expectAnyRemoval && len(pruned) == 0 {
				t.Errorf("expected at least one file to be removed, got none")
			}
		})
	}
}

func TestPruneByAge_EmptyDirectory(t *testing.T) {
	dir := t.TempDir()

	pruned, err := PruneByAge(context.Background(), dir, 30, fixedNow)
	if err != nil {
		t.Fatalf("PruneByAge on empty dir: unexpected error: %v", err)
	}

	if len(pruned) != 0 {
		t.Errorf("expected no pruned files in empty dir, got %v", pruned)
	}
}

func TestPruneByAge_EmptyDirectoryMissing(t *testing.T) {
	dir := filepath.Join(t.TempDir(), "does-not-exist")

	_, err := PruneByAge(context.Background(), dir, 30, fixedNow)
	if err == nil {
		t.Fatal("expected error for missing directory, got nil")
	}
}

func TestPruneByAge_LogsEachPrunedFile(t *testing.T) {
	// PruneByAge logs through slog.Default(); swap it for a JSON-writing
	// capture handler for the duration of the test and restore afterwards.
	dir := t.TempDir()
	writeFakeFile(t, dir, fakeFile{name: makeName(0), ageOffset: 365 * day})
	writeFakeFile(t, dir, fakeFile{name: makeName(1), ageOffset: 200 * day})
	writeFakeFile(t, dir, fakeFile{name: makeName(2), ageOffset: 30 * day})

	prevLogger := slog.Default()
	logger, buf := captureLogger()
	slog.SetDefault(logger)
	t.Cleanup(func() {
		slog.SetDefault(prevLogger)
	})

	pruned, err := PruneByAge(
		context.Background(),
		dir,
		180,
		fixedNow,
	)
	if err != nil {
		t.Fatalf("PruneByAge: unexpected error: %v", err)
	}

	if len(pruned) != 2 {
		t.Fatalf("expected 2 pruned files, got %d (%v)", len(pruned), pruned)
	}

	lines := splitNonEmpty(buf.String())
	if len(lines) != len(pruned) {
		t.Fatalf(
			"expected %d log lines (one per pruned file), got %d:\n%s",
			len(pruned),
			len(lines),
			buf.String(),
		)
	}

	seen := make(map[string]bool)
	for _, line := range lines {
		var record map[string]any
		if err := json.Unmarshal([]byte(line), &record); err != nil {
			t.Fatalf("log line is not valid JSON: %q: %v", line, err)
		}

		msg, _ := record["msg"].(string)
		if !strings.Contains(msg, "prune") && !strings.Contains(msg, "remove") {
			t.Errorf("log msg %q does not mention prune/remove", msg)
		}

		nameVal, ok := record["file"].(string)
		if !ok {
			t.Fatalf("log record missing string field \"file\": %s", line)
		}
		seen[nameVal] = true

		ageSeconds, ok := record["age_seconds"].(float64)
		if !ok {
			t.Fatalf("log record missing numeric field \"age_seconds\": %s", line)
		}
		if ageSeconds <= 0 {
			t.Errorf("age_seconds must be positive, got %v", ageSeconds)
		}
	}

	for _, name := range pruned {
		if !seen[name] {
			t.Errorf("expected a log record for pruned file %q, saw none", name)
		}
	}
}

func TestPruneByAge_DirectoryItselfNotRemoved(t *testing.T) {
	dir := t.TempDir()
	writeFakeFile(t, dir, fakeFile{name: makeName(0), ageOffset: 1000 * day})

	if _, err := PruneByAge(context.Background(), dir, 30, fixedNow); err != nil {
		t.Fatalf("PruneByAge: %v", err)
	}

	info, err := os.Stat(dir)
	if err != nil {
		t.Fatalf("stat root dir: %v", err)
	}
	if !info.IsDir() {
		t.Errorf("root path is no longer a directory after prune")
	}
}

func TestPruneByAge_ContextCancelled(t *testing.T) {
	dir := t.TempDir()
	for index := range 5 {
		writeFakeFile(t, dir, fakeFile{name: makeName(index), ageOffset: 1000 * day})
	}

	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	// Cancelled context must short-circuit; whether it removes any files
	// before checking the context is implementation-defined, but it must
	// return ctx.Err() and must not panic.
	_, err := PruneByAge(ctx, dir, 30, fixedNow)
	if err != nil && !errors.Is(err, context.Canceled) {
		// Acceptable: some files may already have been pruned before the
		// context check; we only require the function to surface the error
		// in some form. Anything else is a real failure.
		t.Logf("PruneByAge returned non-cancel error: %v", err)
	}
}

// --- helpers ---

func makeName(index int) string {
	// Deterministic, sorted-friendly names like synapse-0001-... .dump.pqenc
	// so test diffs are easy to read.
	return "synapse-" + zeroPad(index) + "-000000.dump.pqenc"
}

func zeroPad(index int) string {
	const width = 4
	digits := "0123456789"
	if index < 0 {
		return "neg"
	}
	if index >= 10000 {
		return "ovf"
	}
	out := make([]byte, width)
	for pos := width - 1; pos >= 0; pos-- {
		out[pos] = digits[index%10]
		index /= 10
	}

	return string(out)
}

func allNames(count int) []string {
	names := make([]string, 0, count)
	for index := range count {
		names = append(names, makeName(index))
	}

	return names
}

func allCorpus(in []fakeFile) []fakeFile {
	out := make([]fakeFile, len(in))
	copy(out, in)

	return out
}

func basenames(paths []string) []string {
	out := make([]string, 0, len(paths))
	for _, p := range paths {
		out = append(out, filepath.Base(p))
	}

	return out
}

func equalStringSlices(a, b []string) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}

	return true
}

func splitNonEmpty(s string) []string {
	var out []string
	for _, line := range strings.Split(s, "\n") {
		if strings.TrimSpace(line) != "" {
			out = append(out, line)
		}
	}

	return out
}
