package cli

import (
	"context"
	"errors"
	"sort"
	"strings"
	"testing"
	"time"

	"tsne.dev/hopper/internal/bunny"
)

func runPrune(source string, api storageAPI, older duration, now time.Time) error {
	cmd := &pruneCommand{concurrency: 8, olderThan: older}
	return cmd.prune(context.Background(), api, source, now)
}

func days(n int) duration { return duration{days: n} }

func sortedDeletes(f *fakeAPI) []string {
	f.mu.Lock()
	defer f.mu.Unlock()
	out := append([]string(nil), f.deletes...)
	sort.Strings(out)
	return out
}

// TestPruneSelectsCandidates covers the core §10 correctness properties: an
// orphan older than the cutoff is deleted; a file still in the release is kept
// even with an old LastChanged; an orphan newer than the cutoff is kept.
func TestPruneSelectsCandidates(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "index.html", body: "<html>"},
		{name: "assets/live.js", body: "x"},
	})
	now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC)
	old := now.AddDate(0, 0, -100)
	recent := now.AddDate(0, 0, -5)

	fc := newFakeAPI()
	fc.lists[""] = []bunny.Object{
		{Path: "index.html", LastChanged: old}, // live, old -> kept
		{Path: "assets", IsDirectory: true},
		{Path: "orphan-old.js", Size: 3, LastChanged: old}, // orphan, old -> deleted
		{Path: "orphan-new.js", LastChanged: recent},       // orphan, new -> kept
	}
	fc.lists["assets"] = []bunny.Object{
		{Path: "assets/live.js", LastChanged: old},          // live asset, old -> kept
		{Path: "assets/gone.js", Size: 4, LastChanged: old}, // orphan, old -> deleted
	}
	captureLogs(t)

	if err := runPrune(arc, fc, days(30), now); err != nil {
		t.Fatalf("prune: %v", err)
	}

	got := sortedDeletes(fc)
	want := []string{"assets/gone.js", "orphan-old.js"}
	if strings.Join(got, ",") != strings.Join(want, ",") {
		t.Fatalf("deletes = %v, want %v", got, want)
	}
}

func TestPruneAppliesPrefix(t *testing.T) {
	arc := makeArchive(t, []tarEntry{{name: "app.js", body: "x"}})
	now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC)
	old := now.AddDate(0, 0, -100)

	fc := newFakeAPI()
	fc.lists["foo"] = []bunny.Object{
		{Path: "foo/app.js", LastChanged: old},   // live (prefixed) -> kept
		{Path: "foo/stale.js", LastChanged: old}, // orphan -> deleted
	}
	captureLogs(t)

	cmd := &pruneCommand{concurrency: 8, prefix: "foo", olderThan: days(30)}
	if err := cmd.prune(context.Background(), fc, arc, now); err != nil {
		t.Fatalf("prune: %v", err)
	}
	if got := sortedDeletes(fc); len(got) != 1 || got[0] != "foo/stale.js" {
		t.Fatalf("deletes = %v, want [foo/stale.js]", got)
	}
}

func TestPruneEmptyLiveSetIsFatal(t *testing.T) {
	arc := makeArchive(t, nil)
	fc := newFakeAPI()
	// A List failure would be reported if reached; the empty-guard must fire
	// first, so no Bunny call happens.
	fc.listErr = errors.New("should not be called")
	captureLogs(t)

	err := runPrune(arc, fc, days(30), time.Now())
	if err == nil || !strings.Contains(err.Error(), "empty source") {
		t.Fatalf("expected empty-live-set error, got %v", err)
	}
	if len(fc.deletes) != 0 {
		t.Fatalf("expected no deletes, got %v", fc.deletes)
	}
}

func TestPruneListFailureIsFatal(t *testing.T) {
	arc := makeArchive(t, []tarEntry{{name: "a.js", body: "x"}})
	fc := newFakeAPI()
	fc.listErr = errors.New("boom")
	captureLogs(t)

	if err := runPrune(arc, fc, days(30), time.Now()); err == nil {
		t.Fatal("expected error")
	}
	if len(fc.deletes) != 0 {
		t.Fatalf("expected no deletes, got %v", fc.deletes)
	}
}

func TestPruneDrainAndReport(t *testing.T) {
	arc := makeArchive(t, []tarEntry{{name: "keep.js", body: "x"}})
	now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC)
	old := now.AddDate(0, 0, -100)

	fc := newFakeAPI()
	fc.lists[""] = []bunny.Object{
		{Path: "keep.js", LastChanged: old},
		{Path: "a.js", LastChanged: old},
		{Path: "b.js", LastChanged: old},
		{Path: "c.js", LastChanged: old},
	}
	fc.failErr = errors.New("boom")
	fc.failWhen = func(path string) bool { return path == "b.js" }
	_, errb := captureLogs(t)

	err := runPrune(arc, fc, days(30), now)
	var exit exitError
	if !errors.As(err, &exit) {
		t.Fatalf("expected exitError (non-zero exit) on delete failure, got %v", err)
	}
	// One failed delete must not stop the others: all three orphans are attempted.
	if got := sortedDeletes(fc); strings.Join(got, ",") != "a.js,b.js,c.js" {
		t.Fatalf("all candidates must be attempted, got %v", got)
	}
	if !strings.Contains(errb.String(), "b.js") {
		t.Fatalf("failure report must name b.js, got %v", errb.String())
	}
}

// TestPruneRemovesEmptyDirsBottomUp covers §10 step 5: a directory whose only
// file is deleted is removed, and its now-empty parent is removed too.
func TestPruneRemovesEmptyDirsBottomUp(t *testing.T) {
	arc := makeArchive(t, []tarEntry{{name: "index.html", body: "<html>"}})
	now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC)
	old := now.AddDate(0, 0, -100)

	fc := newFakeAPI()
	fc.lists[""] = []bunny.Object{
		{Path: "index.html", LastChanged: old},
		{Path: "a", IsDirectory: true},
	}
	fc.lists["a"] = []bunny.Object{{Path: "a/b", IsDirectory: true}}
	fc.lists["a/b"] = []bunny.Object{{Path: "a/b/gone.js", Size: 3, LastChanged: old}}
	captureLogs(t)

	if err := runPrune(arc, fc, days(30), now); err != nil {
		t.Fatalf("prune: %v", err)
	}

	got := sortedDeletes(fc)
	want := []string{"a", "a/b/gone.js"}
	if strings.Join(got, ",") != strings.Join(want, ",") {
		t.Fatalf("deletes = %v, want %v", got, want)
	}
}

// TestPruneKeepsDirWithFailedChild covers §10 step 5: a directory whose file
// delete FAILED must not be removed, because the node survives.
func TestPruneKeepsDirWithFailedChild(t *testing.T) {
	arc := makeArchive(t, []tarEntry{{name: "index.html", body: "<html>"}})
	now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC)
	old := now.AddDate(0, 0, -100)

	fc := newFakeAPI()
	fc.lists[""] = []bunny.Object{
		{Path: "index.html", LastChanged: old},
		{Path: "a", IsDirectory: true},
	}
	fc.lists["a"] = []bunny.Object{{Path: "a/gone.js", Size: 3, LastChanged: old}}
	fc.failWhen = func(path string) bool { return path == "a/gone.js" }
	fc.failErr = errors.New("boom")
	captureLogs(t)

	if err := runPrune(arc, fc, days(30), now); err == nil {
		t.Fatal("expected non-zero exit on file-delete failure")
	}
	for _, d := range fc.deletes {
		if d == "a" {
			t.Fatalf("dir with failed child must not be removed, got %v", fc.deletes)
		}
	}
}

// TestPruneEmptyDirFailureNonFatal covers §10 step 6: an empty-dir removal
// failure is warned and counted but never flips the exit code.
func TestPruneEmptyDirFailureNonFatal(t *testing.T) {
	arc := makeArchive(t, []tarEntry{{name: "index.html", body: "<html>"}})
	now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC)
	old := now.AddDate(0, 0, -100)

	fc := newFakeAPI()
	fc.lists[""] = []bunny.Object{
		{Path: "index.html", LastChanged: old},
		{Path: "a", IsDirectory: true},
	}
	fc.lists["a"] = []bunny.Object{{Path: "a/gone.js", Size: 3, LastChanged: old}}
	fc.failWhen = func(path string) bool { return path == "a" }
	fc.failErr = errors.New("boom")
	_, errb := captureLogs(t)

	if err := runPrune(arc, fc, days(30), now); err != nil {
		t.Fatalf("empty-dir failure must be non-fatal, got %v", err)
	}
	if !strings.Contains(errb.String(), "Error:") {
		t.Fatalf("expected error on empty-dir failure, got %q", errb.String())
	}
}

func TestPruneDryRunDeletesNothing(t *testing.T) {
	arc := makeArchive(t, []tarEntry{{name: "keep.js", body: "x"}})
	now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC)
	old := now.AddDate(0, 0, -100)

	fc := newFakeAPI()
	fc.lists[""] = []bunny.Object{
		{Path: "keep.js", LastChanged: old},
		{Path: "stale.js", LastChanged: old},
	}
	dryRun = true

	if err := runPrune(arc, dryRunStorageAPI{fc}, days(30), now); err != nil {
		t.Fatalf("prune: %v", err)
	}
	if len(fc.deletes) != 0 {
		t.Fatalf("dry-run must delete nothing, got %v", fc.deletes)
	}
}
