package update

import (
	"context"
	"errors"
	"path/filepath"
	"strings"
	"testing"
)

// The deployment the README documents builds the image from the working tree:
// source_dir and install_dir are the same path, the Dockerfile copies cmd/ and
// internal/ and runs the build inside the builder stage, and .dockerignore
// keeps the host binaries out. Restoring binaries there restores bytes nothing
// reads. These tests pin the two acceptable outcomes: the source goes back, or
// the config is refused.

// dockerBox — a fakeBox wired the way the README wires the docker deployment.
func dockerBox(t *testing.T, sourceRollback string) (*fakeBox, Config) {
	t.Helper()
	b := newFakeBox(t)
	// The source IS the deployment. The old commit's source is what the running
	// image was built from.
	b.byCommit["old-commit"] = "GOOD-SOURCE"
	b.commit = "old-commit"
	write(t, filepath.Join(b.root, "src", "source.go"), "BAD-SOURCE")

	cfg := b.cfg()
	cfg.InstallDir = cfg.SourceDir
	cfg.ConfigFiles = nil
	cfg.SourceRollback = sourceRollback
	// The artifacts the docker shape snapshots live in the tree.
	write(t, filepath.Join(b.root, "src", "mavend"), "OLD-BUILD")
	return b, cfg
}

func TestValidate_RefusesABuildFromSourceDeploymentThatCannotRollBack(t *testing.T) {
	_, cfg := dockerBox(t, "")
	err := cfg.Validate()
	if !errors.Is(err, ErrSourceRollback) {
		t.Fatalf("Validate = %v; want ErrSourceRollback — a binary snapshot rolls back nothing when the restart rebuilds from the tree", err)
	}
	if _, err := New(cfg); !errors.Is(err, ErrSourceRollback) {
		t.Fatalf("New = %v; want the same refusal", err)
	}
}

func TestApply_RollbackPutsTheSourceBackBeforeTheRestart(t *testing.T) {
	b, cfg := dockerBox(t, "git")
	u, err := New(cfg, WithRunner(b.run), WithHealth(b.health))
	if err != nil {
		t.Fatal(err)
	}
	// The new build compiles and passes, and then does not come up — the case
	// this whole package exists for.
	first := true
	b.healthFn = func() error {
		if first {
			first = false
			return nil // preflight
		}
		if len(b.builtFromSource) > 0 && b.builtFromSource[len(b.builtFromSource)-1] == "GOOD-SOURCE" {
			return nil // she answers again once the good source is deployed
		}
		return errors.New("she does not answer")
	}

	res, err := u.Apply(context.Background())
	if !errors.Is(err, ErrRolledBack) {
		t.Fatalf("Apply = %v; want ErrRolledBack", err)
	}
	if !res.RollbackHealthy {
		t.Fatalf("result = %+v; want a healthy rollback", res)
	}
	if len(b.builtFromSource) != 2 {
		t.Fatalf("restarts = %v; want the bad one and the rolled-back one", b.builtFromSource)
	}
	if b.builtFromSource[1] != "GOOD-SOURCE" {
		t.Errorf("the rollback restarted on %q; want the previous commit's source — restoring binaries alone redeploys the bad commit", b.builtFromSource[1])
	}
	// And the checkout came before the restart, not after it.
	var checkoutAt, restartAt = -1, -1
	for i, c := range b.ran {
		if strings.HasPrefix(c, "git checkout") {
			checkoutAt = i
		}
		if c == "restart-the-thing" {
			restartAt = i
		}
	}
	if checkoutAt < 0 || restartAt < checkoutAt {
		t.Errorf("commands ran = %v; want the checkout before the last restart", b.ran)
	}
}

func TestApply_RefusesADirtyTreeWhenTheSourceIsTheRollbackTarget(t *testing.T) {
	b, cfg := dockerBox(t, "git")
	b.dirty = true
	u, err := New(cfg, WithRunner(b.run), WithHealth(b.health))
	if err != nil {
		t.Fatal(err)
	}
	if _, err := u.Apply(context.Background()); !errors.Is(err, ErrDirtyTree) {
		t.Fatalf("Apply on a dirty tree = %v; want ErrDirtyTree", err)
	}
	for _, c := range b.ran {
		if strings.HasPrefix(c, "make") || c == "restart-the-thing" {
			t.Errorf("a refused update still ran %q", c)
		}
	}
}

func TestApply_VerifyFailureDoesNotClaimARollback(t *testing.T) {
	b := newFakeBox(t)
	b.testErr = errors.New("exit status 1")
	res, err := b.updater(t).Apply(context.Background())
	if !errors.Is(err, ErrVerifyFailed) {
		t.Fatalf("Apply = %v; want ErrVerifyFailed", err)
	}
	if res.RolledBack {
		t.Error("a compile error reported rolled_back=true; nothing was installed and nothing was restarted")
	}
}

func TestRollback_LeavesHisConfigAlone(t *testing.T) {
	b := newFakeBox(t)
	if _, err := b.updater(t).Apply(context.Background()); err != nil {
		t.Fatalf("setup Apply: %v", err)
	}
	// He edits the config after the update — a phraser.model_path change, say.
	cfgPath := filepath.Join(b.root, "install", "mavend.json")
	write(t, cfgPath, `{"tick_interval":"90s"}`)

	if _, err := b.updater(t).Rollback(context.Background(), ""); err != nil && !errors.Is(err, ErrRolledBack) {
		t.Fatalf("Rollback: %v", err)
	}
	if got := read(t, cfgPath); !strings.Contains(got, "90s") {
		t.Errorf("config after rollback = %q; a rollback must not revert his config", got)
	}
	if got := b.deployed(); got != "OLD-BUILD" {
		t.Errorf("deployed binary = %q; want the binaries rolled back", got)
	}
}

func TestVerify_RefusesToBuildHisTreeAsRoot(t *testing.T) {
	b := newFakeBox(t)
	u := b.updater(t, WithIDs(func(string) (int, uint32, error) { return 0, 1000, nil }))
	if _, err := u.Verify(context.Background()); !errors.Is(err, ErrRootOnHisTree) {
		t.Fatalf("Verify as root over a uid-1000 tree = %v; want ErrRootOnHisTree", err)
	}
	if len(b.ran) != 0 {
		t.Errorf("the refusal still ran %v — root-owned artifacts would break his next make", b.ran)
	}
	// Root's own tree is fine, and so is a normal user.
	for _, ids := range []func(string) (int, uint32, error){
		func(string) (int, uint32, error) { return 0, 0, nil },
		func(string) (int, uint32, error) { return 1000, 1000, nil },
	} {
		if _, err := b.updater(t, WithIDs(ids)).Verify(context.Background()); err != nil {
			t.Errorf("Verify refused a legitimate build: %v", err)
		}
	}
}

func TestValidate_RefusesSnapshotsInsideTheSourceTree(t *testing.T) {
	cfg := (&fakeBox{root: t.TempDir()}).cfg()
	cfg.SnapshotDir = filepath.Join(cfg.SourceDir, "snaps")
	if err := cfg.Validate(); err == nil {
		t.Error("snapshot_dir inside source_dir was accepted; it lands in the docker build context")
	}
	cfg = (&fakeBox{root: t.TempDir()}).cfg()
	cfg.SourceRollback = "svn"
	if err := cfg.Validate(); err == nil {
		t.Error("an unknown source_rollback was accepted")
	}
}

func TestTail_CutsOnARuneBoundary(t *testing.T) {
	s := strings.Repeat("x", 20) + "--- FAIL: TestПривет"
	got := tail(s, 10)
	if !utf8ValidString(got) {
		t.Errorf("tail(%q) = %q; a cut mid-rune shows as a replacement character", s, got)
	}
}

func utf8ValidString(s string) bool {
	for _, r := range s {
		if r == 0xFFFD {
			return false
		}
	}
	return true
}
