package cli

import (
	"archive/tar"
	"context"
	"errors"
	"fmt"
	"strings"
	"testing"

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

// runPush drives the push flow against a fake storage API for the given archive,
// using the default concurrency.
func runPush(source string, api storageAPI) error {
	return runPushN(source, api, 8)
}

func runPushN(source string, api storageAPI, concurrency uint) error {
	cmd := &pushCommand{concurrency: concurrency}
	return cmd.push(context.Background(), api, source)
}

func TestPushAppliesPrefix(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "index.html", body: "<html>"},
		{name: "assets/app.abc.js", body: "console.log(1)"},
		{name: "assets/skip.js", body: "same"},
	})
	fc := newFakeAPI()
	// The remote walk starts under the prefix and returns already-prefixed keys.
	fc.lists["foo"] = []bunny.Object{{Path: "foo/assets", IsDirectory: true}}
	fc.lists["foo/assets"] = []bunny.Object{
		{Path: "foo/assets/skip.js", Size: int(len("same"))},
	}
	out, _ := captureLogs(t)

	cmd := &pushCommand{concurrency: 8, prefix: "foo"}
	if err := cmd.push(context.Background(), fc, arc); err != nil {
		t.Fatalf("push: %v", err)
	}

	got := fc.paths()
	want := []string{"foo/assets/app.abc.js", "foo/index.html"}
	if strings.Join(got, ",") != strings.Join(want, ",") {
		t.Fatalf("paths = %v, want %v", got, want)
	}
	if !strings.Contains(out.String(), "skipping foo/assets/skip.js (exists)") {
		t.Fatalf("skip-check must compare prefixed key: %q", out.String())
	}
}

func TestPushSelectsRootAndStrips(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "README.md", body: "ignored"},
		{name: "app/index.html", body: "<html>"},
		{name: "app/assets/x.js", body: "x"},
		{name: "other/y.js", body: "ignored"},
	})
	fc := newFakeAPI()
	captureLogs(t)

	cmd := &pushCommand{concurrency: 8, root: "app"}
	if err := cmd.push(context.Background(), fc, arc); err != nil {
		t.Fatalf("push: %v", err)
	}

	got := fc.paths()
	want := []string{"assets/x.js", "index.html"}
	if strings.Join(got, ",") != strings.Join(want, ",") {
		t.Fatalf("paths = %v, want %v", got, want)
	}
}

func TestPushSelectsNestedRoot(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "dist/public/index.html", body: "<html>"},
		{name: "dist/public/assets/x.js", body: "x"},
		{name: "dist/private/secret", body: "ignored"},
	})
	fc := newFakeAPI()
	captureLogs(t)

	cmd := &pushCommand{concurrency: 8, root: "dist/public"}
	if err := cmd.push(context.Background(), fc, arc); err != nil {
		t.Fatalf("push: %v", err)
	}

	got := fc.paths()
	want := []string{"assets/x.js", "index.html"}
	if strings.Join(got, ",") != strings.Join(want, ",") {
		t.Fatalf("paths = %v, want %v", got, want)
	}
}

func TestPushRootWithPrefix(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "app/assets/x.js", body: "x"},
	})
	fc := newFakeAPI()
	captureLogs(t)

	cmd := &pushCommand{concurrency: 8, root: "app", prefix: "foo"}
	if err := cmd.push(context.Background(), fc, arc); err != nil {
		t.Fatalf("push: %v", err)
	}

	got := fc.paths()
	want := []string{"foo/assets/x.js"}
	if strings.Join(got, ",") != strings.Join(want, ",") {
		t.Fatalf("paths = %v, want %v", got, want)
	}
}

func TestPushRootMatchesNothing(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "app/index.html", body: "<html>"},
	})
	fc := newFakeAPI()
	out, _ := captureLogs(t)

	cmd := &pushCommand{concurrency: 8, root: "nope"}
	if err := cmd.push(context.Background(), fc, arc); err != nil {
		t.Fatalf("push: %v", err)
	}

	if got := fc.paths(); len(got) != 0 {
		t.Fatalf("paths = %v, want none", got)
	}
	if !strings.Contains(out.String(), "uploaded: 0") {
		t.Fatalf("summary missing: %q", out.String())
	}
}

func TestPushUploadsRegularFiles(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "index.html", body: "<html>"},
		{name: "assets/app.abc.js", body: "console.log(1)"},
		{name: "dir/", typeflag: tar.TypeDir},
	})
	fc := newFakeAPI()
	out, _ := captureLogs(t)

	if err := runPush(arc, fc); err != nil {
		t.Fatalf("push: %v", err)
	}

	got := fc.paths()
	want := []string{"assets/app.abc.js", "index.html"}
	if strings.Join(got, ",") != strings.Join(want, ",") {
		t.Fatalf("paths = %v, want %v", got, want)
	}
	if string(fc.puts["index.html"]) != "<html>" {
		t.Fatalf("bad body for index.html: %q", fc.puts["index.html"])
	}
	if string(fc.puts["assets/app.abc.js"]) != "console.log(1)" {
		t.Fatalf("bad body for app.abc.js: %q", fc.puts["assets/app.abc.js"])
	}
	if !strings.Contains(out.String(), "uploaded: 2") {
		t.Fatalf("summary missing: %q", out.String())
	}
}

func TestPushUploadsHTMLAfterAllAssets(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "index.html", body: "<html>"},
		{name: "a.js", body: "x"},
		{name: "nested/page.HTML", body: "<p>"}, // case-insensitive .html
		{name: "b.css", body: "y"},
	})
	fc := newFakeAPI()
	captureLogs(t)

	if err := runPush(arc, fc); err != nil {
		t.Fatalf("push: %v", err)
	}

	// Every HTML Put must happen after the last asset Put.
	lastAsset := -1
	firstHTML := len(fc.order)
	for i, p := range fc.order {
		if strings.HasSuffix(strings.ToLower(p), ".html") {
			if i < firstHTML {
				firstHTML = i
			}
		} else if i > lastAsset {
			lastAsset = i
		}
	}
	if firstHTML <= lastAsset {
		t.Fatalf("HTML uploaded before an asset; order = %v", fc.order)
	}
}

func TestPushDoesNotUploadHTMLWhenAssetFails(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "index.html", body: "<html>"},
		{name: "a.js", body: "x"},
	})
	fc := newFakeAPI()
	fc.failOn = "a.js"
	captureLogs(t)

	if err := runPush(arc, fc); err == nil {
		t.Fatal("expected error")
	}
	for _, p := range fc.order {
		if strings.HasSuffix(strings.ToLower(p), ".html") {
			t.Fatalf("HTML must not be uploaded when an asset fails; order = %v", fc.order)
		}
	}
}

func TestPushSkipsSymlinkWithWarning(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "a.js", body: "x"},
		{name: "link", typeflag: tar.TypeSymlink, linkname: "a.js"},
	})
	fc := newFakeAPI()
	_, errb := captureLogs(t)

	if err := runPush(arc, fc); err != nil {
		t.Fatalf("push: %v", err)
	}
	if got := fc.paths(); len(got) != 1 || got[0] != "a.js" {
		t.Fatalf("paths = %v", got)
	}
	if !strings.Contains(errb.String(), "Warning") {
		t.Fatalf("expected warning on stderr, got %q", errb.String())
	}
}

func TestPushFailsOnUploadError(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "a.js", body: "x"},
		{name: "b.js", body: "y"},
	})
	fc := newFakeAPI()
	fc.failOn = "a.js"
	captureLogs(t)

	if err := runPush(arc, fc); err == nil {
		t.Fatal("expected error")
	}
}

func TestPushSkipsUnchanged(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "index.html", body: "<html>"},
		{name: "assets/app.abc.js", body: "console.log(1)"},
	})
	fc := newFakeAPI()
	fc.lists[""] = []bunny.Object{
		{Path: "index.html", Size: int(len("<html>"))},
		{Path: "assets", IsDirectory: true},
	}
	fc.lists["assets"] = []bunny.Object{
		{Path: "assets/app.abc.js", Size: int(len("console.log(1)"))},
	}
	out, _ := captureLogs(t)

	if err := runPush(arc, fc); err != nil {
		t.Fatalf("push: %v", err)
	}
	// The asset is skipped (matching size); the *.html is the commit point and is
	// always uploaded even when an identically-sized copy exists remotely.
	if got := fc.paths(); len(got) != 1 || got[0] != "index.html" {
		t.Fatalf("expected only index.html uploaded, got %v", got)
	}
	s := out.String()
	if !strings.Contains(s, "skipping assets/app.abc.js (exists)") {
		t.Fatalf("missing skip line: %q", s)
	}
	if strings.Contains(s, "skipping index.html") {
		t.Fatalf("index.html must never be skipped: %q", s)
	}
	if !strings.Contains(s, "uploaded: 1") || !strings.Contains(s, "skipped:  1") {
		t.Fatalf("summary missing: %q", s)
	}
}

func TestPushReuploadsOnSizeMismatch(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "a.js", body: "hello"}, // 5 bytes
		{name: "b.js", body: "world"}, // present, matching size -> skip
	})
	fc := newFakeAPI()
	fc.lists[""] = []bunny.Object{
		{Path: "a.js", Size: 1}, // size mismatch -> upload
		{Path: "b.js", Size: int(len("world"))},
	}
	captureLogs(t)

	if err := runPush(arc, fc); err != nil {
		t.Fatalf("push: %v", err)
	}
	if got := fc.paths(); len(got) != 1 || got[0] != "a.js" {
		t.Fatalf("paths = %v, want [a.js]", got)
	}
}

func TestPushErrorsOnDirectoryFileCollision(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "data", body: ""}, // 0-byte asset colliding with a remote dir
	})
	fc := newFakeAPI()
	// Remote has a directory named "data"; a file at the same path is a hard
	// conflict (and a 0-size dir must never silently shadow a 0-byte file).
	fc.lists[""] = []bunny.Object{{Path: "data", IsDirectory: true}}
	captureLogs(t)

	if err := runPush(arc, fc); err == nil {
		t.Fatal("expected error on directory/file collision")
	}
	if got := fc.paths(); len(got) != 0 {
		t.Fatalf("expected no uploads, got %v", got)
	}
}

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

	if err := runPush(arc, fc); err == nil {
		t.Fatal("expected error")
	}
	if got := fc.paths(); len(got) != 0 {
		t.Fatalf("expected no uploads before abort, got %v", got)
	}
}

func TestPushRunRejectsBadSourceArgs(t *testing.T) {
	captureLogs(t)
	cmd := newPushCommand(&mainCommand{})

	var ue usageError
	if err := cmd.Run(context.Background(), nil); !errors.As(err, &ue) {
		t.Fatalf("missing source: expected usageError, got %v", err)
	}
	if err := cmd.Run(context.Background(), []string{"a.tgz", "b.tgz"}); !errors.As(err, &ue) {
		t.Fatalf("multiple sources: expected usageError, got %v", err)
	}
}

func TestPushRejectsNegativeConcurrencyAtParse(t *testing.T) {
	captureLogs(t)
	// A negative value is not a valid uint, so pflag rejects it at parse time.
	cmd := newPushCommand(&mainCommand{})
	if err := cmd.flags.Parse([]string{"--concurrency", "-1", "a.tgz"}); err == nil {
		t.Fatal("--concurrency -1: expected a parse error, got nil")
	}
}

func TestPushRejectsEscapingPrefix(t *testing.T) {
	captureLogs(t)
	cmd := newPushCommand(&mainCommand{})
	var ue usageError
	if err := cmd.flags.Parse([]string{"--prefix", "../x", "a.tgz"}); !errors.As(err, &ue) {
		t.Fatalf("--prefix ../x: expected usageError, got %v", err)
	}
}

func TestPushBoundsInFlightUploads(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "a.js", body: "1"},
		{name: "b.js", body: "2"},
		{name: "c.js", body: "3"},
		{name: "d.js", body: "4"},
		{name: "e.js", body: "5"},
	})
	const limit = 2
	gate := &gatingAPI{fakeAPI: newFakeAPI(), release: make(chan struct{})}
	captureLogs(t)

	done := make(chan error, 1)
	go func() { done <- runPushN(arc, gate, limit) }()

	// The reader blocks on the semaphore once the pool is saturated, so no more
	// uploads can begin until one drains; release them so the run finishes.
	for i := 0; i < 5; i++ {
		gate.release <- struct{}{}
	}
	if err := <-done; err != nil {
		t.Fatalf("push: %v", err)
	}
	if peak := gate.peak(); peak > limit {
		t.Fatalf("peak in-flight = %d, want <= %d", peak, limit)
	}
}

func TestPushAssetFailureReportsOneFileAndSkipsHTML(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "index.html", body: "<html>"},
		{name: "a.js", body: "1"},
		{name: "b.js", body: "2"},
	})
	fc := newFakeAPI()
	// Every asset fails: fail-fast cancels the pool after the first genuine
	// failure. All genuine failures that landed before cancellation are
	// aggregated (§8.3); cancellation collateral (context.Canceled) is filtered
	// out (see TestPushFailFastFiltersCancellationCollateral). HTML is never
	// written.
	fc.failAll = true
	captureLogs(t)

	err := runPushN(arc, fc, 8)
	var ee exitError
	if !errors.As(err, &ee) {
		t.Fatalf("expected exitError, got %T", err)
	}
	for _, p := range fc.order {
		if strings.HasSuffix(strings.ToLower(p), ".html") {
			t.Fatalf("HTML must not be written when an asset fails; order = %v", fc.order)
		}
	}
}

// TestPushFailFastFiltersCancellationCollateral verifies that the fail-fast
// report names ONLY the genuine upload failure, not the in-flight uploads that
// were abandoned by the cancellation. A context.Canceled is a consequence of the
// stop decision, not an independent file failure, and is swallowed uniformly so
// push (fail-fast) and a user SIGINT behave identically (§8.3). The collateral
// errors WRAP context.Canceled.
func TestPushFailFastFiltersCancellationCollateral(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "a.js", body: "1"},
		{name: "b.js", body: "2"},
		{name: "c.js", body: "3"},
		{name: "index.html", body: "<html>"},
	})
	api := &ctxCancelAPI{
		fakeAPI:     newFakeAPI(),
		triggerPath: "a.js",
		triggerErr:  fileError{path: "a.js", err: errors.New("boom")},
	}
	_, errb := captureLogs(t)

	// Concurrency covers all three assets so b.js/c.js are genuinely in-flight
	// (blocked) when a.js triggers the cancellation.
	err := runPushN(arc, api, 3)
	var ee exitError
	if !errors.As(err, &ee) {
		t.Fatalf("expected exitError, got %T (%v)", err, err)
	}
	report := errb.String()
	if !strings.Contains(report, "a.js") || !strings.Contains(report, "boom") {
		t.Fatalf("report must name the genuine failure a.js: %q", report)
	}
	if strings.Contains(report, "b.js") || strings.Contains(report, "c.js") {
		t.Fatalf("report must NOT name cancellation collateral b.js/c.js: %q", report)
	}
	for _, p := range api.order {
		if strings.HasSuffix(strings.ToLower(p), ".html") {
			t.Fatalf("HTML must not be written when an asset fails; order = %v", api.order)
		}
	}
}

func TestPushHTMLFailuresAreAllReported(t *testing.T) {
	arc := makeArchive(t, []tarEntry{
		{name: "index.html", body: "<html>"},
		{name: "about.html", body: "<html>"},
		{name: "a.js", body: "1"},
	})
	fc := newFakeAPI()
	// Assets succeed; both HTML commit uploads fail. The drain phase reports
	// every failure under the friendly headline.
	fc.failErr = errors.New("boom")
	fc.failWhen = func(path string) bool { return strings.HasSuffix(path, ".html") }
	out, errb := captureLogs(t)

	err := runPushN(arc, fc, 8)
	var exit exitError
	if !errors.As(err, &exit) {
		t.Fatalf("expected exitError, got %v", err)
	}
	// The drain report lists every HTML failure on stderr...
	if !strings.Contains(errb.String(), "index.html") || !strings.Contains(errb.String(), "about.html") {
		t.Fatalf("drain report must list every HTML failure: %q", errb.String())
	}
	// ...and the summary is still printed (last) on stdout.
	if !strings.Contains(out.String(), "failed:") {
		t.Fatalf("summary must be printed after a commit-phase failure: %q", out.String())
	}
}

func TestShouldHintPrune(t *testing.T) {
	cases := []struct {
		name        string
		sourceFiles int
		remoteFiles int
		want        bool
	}{
		{"below floor", 1, 999, false},
		{"at floor within ratio", 1000, 6000, false},
		{"at exact multiple", 100, 600, false},
		{"one over multiple but below floor", 10, 61, false},
		{"one over multiple and at floor", 200, 1201, true},
		{"empty source", 0, 100000, false},
	}
	for _, tc := range cases {
		if got := shouldHintPrune(tc.sourceFiles, tc.remoteFiles); got != tc.want {
			t.Errorf("%s: shouldHintPrune(%d, %d) = %v, want %v",
				tc.name, tc.sourceFiles, tc.remoteFiles, got, tc.want)
		}
	}
}

// stalePushSetup builds a one-file source (source_files == 1) and a fake API
// whose remote listing holds remoteFiles file objects and remoteDirs directory
// objects, so tests can drive the hint decision at chosen boundaries.
func stalePushSetup(t *testing.T, remoteFiles, remoteDirs int) (string, *fakeAPI) {
	t.Helper()
	arc := makeArchive(t, []tarEntry{
		{name: "index.html", body: "<html>"},
	})
	fc := newFakeAPI()
	objs := make([]bunny.Object, 0, remoteFiles+remoteDirs)
	for i := 0; i < remoteFiles; i++ {
		objs = append(objs, bunny.Object{Path: fmt.Sprintf("stale/%d.js", i), Size: 1})
	}
	for i := 0; i < remoteDirs; i++ {
		objs = append(objs, bunny.Object{Path: fmt.Sprintf("dir%d", i), IsDirectory: true})
	}
	fc.lists[""] = objs
	return arc, fc
}

func TestPushHintFiresWhenStale(t *testing.T) {
	arc, fc := stalePushSetup(t, 1001, 0)
	out, _ := captureLogs(t)

	if err := runPush(arc, fc); err != nil {
		t.Fatalf("push: %v", err)
	}
	s := out.String()
	if !strings.Contains(s, "remote:   1001") {
		t.Fatalf("summary missing remote count: %q", s)
	}
	if !strings.Contains(s, "\n\nHint:") {
		t.Fatalf("expected prune hint: %q", s)
	}
	// The hint rides on stdout with the summary, not the stderr warning path.
	if strings.Contains(s, "\n\nHint:") && strings.Index(s, "remote:") > strings.Index(s, "\n\nHint:") {
		t.Fatalf("hint must follow the summary: %q", s)
	}
}

func TestPushNoHintWithinRetentionWindow(t *testing.T) {
	arc, fc := stalePushSetup(t, 6, 0)
	out, _ := captureLogs(t)

	if err := runPush(arc, fc); err != nil {
		t.Fatalf("push: %v", err)
	}
	if strings.Contains(out.String(), "hint:") {
		t.Fatalf("unexpected hint: %q", out.String())
	}
}

func TestPushNoHintBelowFloor(t *testing.T) {
	arc, fc := stalePushSetup(t, 999, 0)
	out, _ := captureLogs(t)

	if err := runPush(arc, fc); err != nil {
		t.Fatalf("push: %v", err)
	}
	if strings.Contains(out.String(), "hint:") {
		t.Fatalf("unexpected hint below floor: %q", out.String())
	}
}

func TestPushHintIgnoresRemoteDirectories(t *testing.T) {
	// Many directory nodes, few files: directories must not inflate remote_files.
	arc, fc := stalePushSetup(t, 5, 2000)
	out, _ := captureLogs(t)

	if err := runPush(arc, fc); err != nil {
		t.Fatalf("push: %v", err)
	}
	s := out.String()
	if !strings.Contains(s, "remote:   5") {
		t.Fatalf("directories must not count: %q", s)
	}
	if strings.Contains(s, "hint:") {
		t.Fatalf("unexpected hint from directory nodes: %q", s)
	}
}

func TestPushHintFiresUnderQuiet(t *testing.T) {
	arc, fc := stalePushSetup(t, 1001, 0)
	out, _ := captureLogs(t)
	log.SetQuiet(true)

	if err := runPush(arc, fc); err != nil {
		t.Fatalf("push: %v", err)
	}
	if !strings.Contains(out.String(), "\n\nHint:") {
		t.Fatalf("hint must appear under --quiet: %q", out.String())
	}
}

func TestPushHintFiresUnderDryRun(t *testing.T) {
	arc, fc := stalePushSetup(t, 1001, 0)
	out, _ := captureLogs(t)
	dryRun = true

	if err := runPush(arc, dryRunStorageAPI{fc}); err != nil {
		t.Fatalf("push: %v", err)
	}
	if !strings.Contains(out.String(), "\n\nHint:") {
		t.Fatalf("hint must appear under dry-run: %q", out.String())
	}
}

func TestHumanSize(t *testing.T) {
	cases := map[int]string{
		0:           "0 B",
		512:         "512 B",
		1024:        "1.0 KiB",
		1536:        "1.5 KiB",
		1024 * 1024: "1.0 MiB",
	}
	for in, want := range cases {
		if got := humanSize(in); got != want {
			t.Errorf("humanSize(%d) = %q, want %q", in, got, want)
		}
	}
}
