package system

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

type systemCall struct {
	sudo bool
	name string
	args []string
}

type systemStep struct {
	call systemCall
	out  []byte
	err  error
}

type scriptedRunner struct {
	t     *testing.T
	steps []systemStep
	calls []systemCall
}

func (r *scriptedRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
	return r.next(systemCall{name: name, args: append([]string(nil), args...)})
}

func (r *scriptedRunner) RunSudo(ctx context.Context, args ...string) ([]byte, error) {
	return r.next(systemCall{sudo: true, args: append([]string(nil), args...)})
}

func (r *scriptedRunner) next(call systemCall) ([]byte, error) {
	r.t.Helper()
	r.calls = append(r.calls, call)
	if len(r.steps) == 0 {
		r.t.Fatalf("unexpected call: %+v", call)
	}
	step := r.steps[0]
	r.steps = r.steps[1:]
	if step.call.sudo != call.sudo || step.call.name != call.name || !reflect.DeepEqual(step.call.args, call.args) {
		r.t.Fatalf("call mismatch:\n got:  %+v\n want: %+v", call, step.call)
	}
	return step.out, step.err
}

func (r *scriptedRunner) assertExhausted() {
	r.t.Helper()
	if len(r.steps) != 0 {
		r.t.Fatalf("unconsumed steps: %+v", r.steps)
	}
}

type funcRunner struct {
	run     func(ctx context.Context, name string, args ...string) ([]byte, error)
	runSudo func(ctx context.Context, args ...string) ([]byte, error)
}

func (r funcRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
	if r.run == nil {
		return nil, errors.New("unexpected Run call")
	}
	return r.run(ctx, name, args...)
}

func (r funcRunner) RunSudo(ctx context.Context, args ...string) ([]byte, error) {
	if r.runSudo == nil {
		return nil, errors.New("unexpected RunSudo call")
	}
	return r.runSudo(ctx, args...)
}

func TestResizeExt4ImageStopsAtFirstFailure(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name      string
		setup     func(t *testing.T) string
		steps     func(path string) []systemStep
		wantErr   string
		wantCalls int
	}{
		{
			name: "truncate failure",
			setup: func(t *testing.T) string {
				return t.TempDir()
			},
			wantErr:   "",
			wantCalls: 0,
		},
		{
			name: "e2fsck failure",
			steps: func(path string) []systemStep {
				return []systemStep{
					{call: systemCall{name: "e2fsck", args: []string{"-p", "-f", path}}, err: errors.New("e2fsck failed")},
				}
			},
			wantErr:   "e2fsck failed",
			wantCalls: 1,
		},
		{
			name: "resize2fs failure",
			steps: func(path string) []systemStep {
				return []systemStep{
					{call: systemCall{name: "e2fsck", args: []string{"-p", "-f", path}}},
					{call: systemCall{name: "resize2fs", args: []string{path}}, err: errors.New("resize2fs failed")},
				}
			},
			wantErr:   "resize2fs failed",
			wantCalls: 2,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			path := "/tmp/root.ext4"
			if tt.setup != nil {
				path = tt.setup(t)
			} else {
				path = filepath.Join(t.TempDir(), "root.ext4")
				if err := os.WriteFile(path, []byte("seed"), 0o644); err != nil {
					t.Fatalf("WriteFile(%s): %v", path, err)
				}
			}
			var steps []systemStep
			if tt.steps != nil {
				steps = tt.steps(path)
			}
			runner := &scriptedRunner{t: t, steps: steps}
			err := ResizeExt4Image(context.Background(), runner, path, 4096)
			if err == nil {
				t.Fatal("ResizeExt4Image() succeeded, want error")
			}
			if tt.wantErr != "" && !strings.Contains(err.Error(), tt.wantErr) {
				t.Fatalf("ResizeExt4Image() error = %v, want %q", err, tt.wantErr)
			}
			if len(runner.calls) != tt.wantCalls {
				t.Fatalf("call count = %d, want %d", len(runner.calls), tt.wantCalls)
			}
			runner.assertExhausted()
		})
	}
}

func TestReadNormalizedLines(t *testing.T) {
	t.Parallel()

	path := filepath.Join(t.TempDir(), "packages.apt")
	if err := os.WriteFile(path, []byte("\n# comment\n git \nless # trailing\n\r\ntmux\r\n"), 0o644); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}

	got, err := ReadNormalizedLines(path)
	if err != nil {
		t.Fatalf("ReadNormalizedLines: %v", err)
	}
	want := []string{"git", "less", "tmux"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("lines = %v, want %v", got, want)
	}
}

func TestBuildBootArgsOmitsKernelIPAutoconfig(t *testing.T) {
	t.Parallel()

	got := BuildBootArgs("devbox")
	want := "console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rootfstype=ext4 rw hostname=devbox systemd.mask=home.mount systemd.mask=var.mount"
	if got != want {
		t.Fatalf("BuildBootArgs() = %q, want %q", got, want)
	}
}

func TestBuildBootArgsWithKernelIPIncludesHostnameInIPField(t *testing.T) {
	t.Parallel()

	got := BuildBootArgsWithKernelIP("devbox", "172.16.0.2", "172.16.0.1", "1.1.1.1")
	want := "console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rootfstype=ext4 rw ip=172.16.0.2::172.16.0.1:255.255.255.0:devbox:eth0:off:1.1.1.1 hostname=devbox systemd.mask=home.mount systemd.mask=var.mount"
	if got != want {
		t.Fatalf("BuildBootArgsWithKernelIP() = %q, want %q", got, want)
	}
}

func TestWriteExt4FileRemovesTempFileAndReturnsCopyError(t *testing.T) {
	t.Parallel()

	copyErr := errors.New("e2cp failed")
	var tempPath string
	runner := funcRunner{
		runSudo: func(ctx context.Context, args ...string) ([]byte, error) {
			switch args[0] {
			case "e2rm":
				return nil, errors.New("ignore remove")
			case "e2cp":
				tempPath = args[1]
				if _, err := os.Stat(tempPath); err != nil {
					t.Fatalf("temp file missing during e2cp: %v", err)
				}
				return nil, copyErr
			default:
				t.Fatalf("unexpected sudo call: %v", args)
				return nil, nil
			}
		},
	}

	err := WriteExt4File(context.Background(), runner, "/tmp/root.ext4", "/etc/hostname", []byte("devbox\n"))
	if !errors.Is(err, copyErr) {
		t.Fatalf("WriteExt4File() error = %v, want %v", err, copyErr)
	}
	if tempPath == "" {
		t.Fatal("expected e2cp temp path to be recorded")
	}
	if _, err := os.Stat(tempPath); !errors.Is(err, os.ErrNotExist) {
		t.Fatalf("temp file still exists after WriteExt4File: %v", err)
	}
}

func TestWriteExt4FileModeUsesRequestedPermissions(t *testing.T) {
	t.Parallel()

	debugfsCalled := false
	runner := funcRunner{
		runSudo: func(ctx context.Context, args ...string) ([]byte, error) {
			switch args[0] {
			case "e2rm":
				return nil, nil
			case "e2cp":
				info, err := os.Stat(args[1])
				if err != nil {
					t.Fatalf("Stat(temp file): %v", err)
				}
				if got := info.Mode().Perm(); got != 0o755 {
					t.Fatalf("temp file mode = %o, want 755", got)
				}
				return nil, nil
			case "debugfs":
				debugfsCalled = true
				want := []string{"debugfs", "-w", "-R", "sif /usr/local/libexec/banger-network-bootstrap mode 0100755", "/tmp/root.ext4"}
				if !reflect.DeepEqual(args, want) {
					t.Fatalf("debugfs args = %v, want %v", args, want)
				}
				return nil, nil
			default:
				t.Fatalf("unexpected sudo call: %v", args)
				return nil, nil
			}
		},
	}

	if err := WriteExt4FileMode(context.Background(), runner, "/tmp/root.ext4", "/usr/local/libexec/banger-network-bootstrap", 0o755, []byte("#!/bin/sh\n")); err != nil {
		t.Fatalf("WriteExt4FileMode() error = %v", err)
	}
	if !debugfsCalled {
		t.Fatal("expected debugfs mode fixup to run")
	}
}

func TestMountTempDirUsesLoopForRegularFilesAndCleanupUsesBackgroundContext(t *testing.T) {
	t.Parallel()

	source := filepath.Join(t.TempDir(), "root.ext4")
	if err := os.WriteFile(source, []byte("rootfs"), 0o644); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	var mountDir string
	calls := 0
	runner := funcRunner{
		runSudo: func(callCtx context.Context, args ...string) ([]byte, error) {
			calls++
			switch calls {
			case 1:
				mountDir = args[len(args)-1]
				want := []string{"mount", "-o", "ro,loop", source, mountDir}
				if !reflect.DeepEqual(args, want) {
					t.Fatalf("mount args = %v, want %v", args, want)
				}
			case 2:
				if callCtx.Err() != nil {
					t.Fatalf("cleanup context should not be canceled: %v", callCtx.Err())
				}
				want := []string{"umount", mountDir}
				if !reflect.DeepEqual(args, want) {
					t.Fatalf("cleanup args = %v, want %v", args, want)
				}
			default:
				t.Fatalf("unexpected RunSudo call %d: %v", calls, args)
			}
			return nil, nil
		},
	}

	gotMountDir, cleanup, err := MountTempDir(ctx, runner, source, true)
	if err != nil {
		t.Fatalf("MountTempDir: %v", err)
	}
	if gotMountDir != mountDir {
		t.Fatalf("mount dir = %q, want %q", gotMountDir, mountDir)
	}
	cancel()
	if err := cleanup(); err != nil {
		t.Fatalf("cleanup: %v", err)
	}
	if _, err := os.Stat(mountDir); !errors.Is(err, os.ErrNotExist) {
		t.Fatalf("mount dir still exists after cleanup: %v", err)
	}
}

func TestParseProcHelpers(t *testing.T) {
	t.Parallel()

	stat, err := parseProcStat("1234 (firecracker) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22")
	if err != nil {
		t.Fatalf("parseProcStat: %v", err)
	}
	if stat.userTicks != 11 || stat.systemTicks != 12 || stat.startTicks != 19 {
		t.Fatalf("proc stat = %+v", stat)
	}

	statm, err := parseProcStatm("200 50 0 0 0 0 0")
	if err != nil {
		t.Fatalf("parseProcStatm: %v", err)
	}
	if statm.sizePages != 200 || statm.residentPages != 50 {
		t.Fatalf("proc statm = %+v", statm)
	}

	uptime, err := parseProcUptime("321.50 42.10")
	if err != nil {
		t.Fatalf("parseProcUptime: %v", err)
	}
	if uptime != 321.50 {
		t.Fatalf("uptime = %v, want 321.50", uptime)
	}
}

func TestParseMemTotal(t *testing.T) {
	t.Parallel()

	total, err := parseMemTotal("MemTotal:       131900020 kB\nMemFree:         1024 kB\n")
	if err != nil {
		t.Fatalf("parseMemTotal: %v", err)
	}
	if total != 131900020*1024 {
		t.Fatalf("total = %d, want %d", total, int64(131900020*1024))
	}
}

func TestParseMemTotalErrorsWhenMissing(t *testing.T) {
	t.Parallel()

	if _, err := parseMemTotal("MemFree: 123 kB\n"); err == nil {
		t.Fatal("parseMemTotal() error = nil, want missing MemTotal")
	}
}

func TestReadFilesystemUsage(t *testing.T) {
	t.Parallel()

	usage, err := ReadFilesystemUsage(t.TempDir())
	if err != nil {
		t.Fatalf("ReadFilesystemUsage: %v", err)
	}
	if usage.TotalBytes <= 0 {
		t.Fatalf("usage.TotalBytes = %d, want positive", usage.TotalBytes)
	}
	if usage.FreeBytes < 0 {
		t.Fatalf("usage.FreeBytes = %d, want non-negative", usage.FreeBytes)
	}
}

func TestMountTempDirRemovesTempDirWhenMountFails(t *testing.T) {
	t.Parallel()

	source := t.TempDir()
	var mountDir string
	runner := funcRunner{
		runSudo: func(ctx context.Context, args ...string) ([]byte, error) {
			mountDir = args[len(args)-1]
			return nil, errors.New("mount failed")
		},
	}

	_, _, err := MountTempDir(context.Background(), runner, source, false)
	if err == nil || !strings.Contains(err.Error(), "mount failed") {
		t.Fatalf("MountTempDir() error = %v, want mount failure", err)
	}
	if mountDir == "" {
		t.Fatal("expected mount path to be recorded")
	}
	if _, statErr := os.Stat(mountDir); !errors.Is(statErr, os.ErrNotExist) {
		t.Fatalf("mount dir still exists after failed mount: %v", statErr)
	}
}

func TestParseMetricsFileHandlesWholeAndLineDelimitedJSON(t *testing.T) {
	t.Parallel()

	full := filepath.Join(t.TempDir(), "metrics-full.json")
	if err := os.WriteFile(full, []byte(`{"uptime":1,"signals":{"sigterm":0}}`), 0o644); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}
	got := ParseMetricsFile(full)
	if got["uptime"] != float64(1) {
		t.Fatalf("ParseMetricsFile(full) = %v", got)
	}

	lines := filepath.Join(t.TempDir(), "metrics-lines.json")
	if err := os.WriteFile(lines, []byte("junk\n{\"uptime\":2}\n"), 0o644); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}
	got = ParseMetricsFile(lines)
	if got["uptime"] != float64(2) {
		t.Fatalf("ParseMetricsFile(lines) = %v", got)
	}
}

func TestUpdateFSTabStripsLegacyMountsAndAddsDefaultsOnce(t *testing.T) {
	t.Parallel()

	input := strings.Join([]string{
		"/dev/vda / ext4 defaults 0 1",
		"/dev/vdb /home ext4 defaults 0 2",
		"/dev/vdc /var ext4 defaults 0 2",
		"/dev/vdb /root ext4 defaults 0 2",
		"tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0",
		"",
	}, "\n")

	got := UpdateFSTab(input)
	if strings.Contains(got, "/home") || strings.Contains(got, "/var") {
		t.Fatalf("UpdateFSTab() kept legacy mounts: %q", got)
	}
	if strings.Count(got, "/dev/vdb /root ext4 defaults 0 2") != 1 {
		t.Fatalf("UpdateFSTab() duplicated /root mount: %q", got)
	}
	if strings.Count(got, "tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0") != 1 {
		t.Fatalf("UpdateFSTab() duplicated /run mount: %q", got)
	}
	if !strings.Contains(got, "tmpfs /tmp tmpfs defaults,nodev,nosuid,mode=1777 0 0") {
		t.Fatalf("UpdateFSTab() missing /tmp mount: %q", got)
	}
}

func TestUseLoopMount(t *testing.T) {
	t.Parallel()

	file := filepath.Join(t.TempDir(), "root.ext4")
	if err := os.WriteFile(file, []byte("rootfs"), 0o644); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}
	dir := t.TempDir()

	if !useLoopMount(file) {
		t.Fatalf("useLoopMount(%s) = false, want true", file)
	}
	if useLoopMount(dir) {
		t.Fatalf("useLoopMount(%s) = true, want false", dir)
	}
	if useLoopMount(filepath.Join(dir, "missing")) {
		t.Fatalf("useLoopMount(missing) = true, want false")
	}
}

func TestEstimateWorkSeedSizeFallsBackToSudoDuWhenUnreadable(t *testing.T) {
	t.Parallel()

	rootHome := filepath.Join(t.TempDir(), "root")
	if err := os.Mkdir(rootHome, 0o700); err != nil {
		t.Fatalf("Mkdir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(rootHome, "visible.txt"), []byte("seed"), 0o600); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}
	if err := os.Chmod(rootHome, 0o000); err != nil {
		t.Fatalf("Chmod: %v", err)
	}
	defer os.Chmod(rootHome, 0o700)

	var sudoCalled bool
	runner := funcRunner{
		runSudo: func(ctx context.Context, args ...string) ([]byte, error) {
			sudoCalled = true
			want := []string{"du", "-sb", rootHome}
			if !reflect.DeepEqual(args, want) {
				t.Fatalf("RunSudo args = %v, want %v", args, want)
			}
			return []byte("4096\t" + rootHome + "\n"), nil
		},
	}

	sizeBytes, err := estimateWorkSeedSize(context.Background(), runner, rootHome)
	if err != nil {
		t.Fatalf("estimateWorkSeedSize: %v", err)
	}
	if !sudoCalled {
		t.Fatal("estimateWorkSeedSize did not fall back to sudo du")
	}
	if sizeBytes != minWorkSeedBytes {
		t.Fatalf("sizeBytes = %d, want %d", sizeBytes, minWorkSeedBytes)
	}
}
