package cli

import (
	"archive/tar"
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"banger/internal/imagecat"

	"github.com/klauspost/compress/zstd"
)

func TestInternalMakeBundleFlagsExist(t *testing.T) {
	root := NewBangerCommand()
	internal, _, err := root.Find([]string{"internal"})
	if err != nil {
		t.Fatalf("find internal: %v", err)
	}
	mk, _, err := internal.Find([]string{"make-bundle"})
	if err != nil {
		t.Fatalf("find make-bundle: %v", err)
	}
	for _, name := range []string{"rootfs-tar", "name", "distro", "arch", "kernel-ref", "description", "size", "out"} {
		if mk.Flags().Lookup(name) == nil {
			t.Errorf("missing flag %q", name)
		}
	}
}

func TestMakeBundleRequiresName(t *testing.T) {
	cmd := NewBangerCommand()
	cmd.SetArgs([]string{"internal", "make-bundle", "--rootfs-tar", "some.tar", "--out", "out.tar.zst"})
	cmd.SetOut(&bytes.Buffer{})
	cmd.SetErr(&bytes.Buffer{})
	err := cmd.Execute()
	if err == nil || !strings.Contains(err.Error(), "image name is required") {
		t.Fatalf("execute error = %v, want image-name-required", err)
	}
}

func TestMakeBundleRequiresRootfsTar(t *testing.T) {
	cmd := NewBangerCommand()
	cmd.SetArgs([]string{"internal", "make-bundle", "--name", "x", "--out", "out.tar.zst"})
	cmd.SetOut(&bytes.Buffer{})
	cmd.SetErr(&bytes.Buffer{})
	err := cmd.Execute()
	if err == nil || !strings.Contains(err.Error(), "--rootfs-tar is required") {
		t.Fatalf("execute error = %v, want --rootfs-tar required", err)
	}
}

func TestMakeBundleRequiresOut(t *testing.T) {
	cmd := NewBangerCommand()
	cmd.SetArgs([]string{"internal", "make-bundle", "--name", "x", "--rootfs-tar", "-"})
	cmd.SetOut(&bytes.Buffer{})
	cmd.SetErr(&bytes.Buffer{})
	err := cmd.Execute()
	if err == nil || !strings.Contains(err.Error(), "--out is required") {
		t.Fatalf("execute error = %v, want --out required", err)
	}
}

func TestWriteBundleTarZstRoundTrip(t *testing.T) {
	stage := t.TempDir()
	rootfsContent := []byte("fake-rootfs-bytes")
	rootfsPath := filepath.Join(stage, "rootfs.ext4")
	if err := os.WriteFile(rootfsPath, rootfsContent, 0o644); err != nil {
		t.Fatal(err)
	}
	manifest := imagecat.Manifest{Name: "debian-bookworm", Distro: "debian"}
	manifestJSON, _ := json.Marshal(manifest)
	manifestPath := filepath.Join(stage, "manifest.json")
	if err := os.WriteFile(manifestPath, manifestJSON, 0o644); err != nil {
		t.Fatal(err)
	}

	bundlePath := filepath.Join(stage, "bundle.tar.zst")
	if err := writeBundleTarZst(bundlePath, rootfsPath, manifestPath); err != nil {
		t.Fatalf("writeBundleTarZst: %v", err)
	}

	// Decode and verify.
	raw, err := os.Open(bundlePath)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { raw.Close() })
	zr, err := zstd.NewReader(raw)
	if err != nil {
		t.Fatal(err)
	}
	tr := tar.NewReader(zr)
	got := map[string][]byte{}
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			t.Fatal(err)
		}
		b, _ := io.ReadAll(tr)
		got[hdr.Name] = b
	}
	if !bytes.Equal(got[imagecat.RootfsFilename], rootfsContent) {
		t.Errorf("rootfs mismatch: got %q want %q", got[imagecat.RootfsFilename], rootfsContent)
	}
	if !bytes.Equal(got[imagecat.ManifestFilename], manifestJSON) {
		t.Errorf("manifest mismatch: got %q want %q", got[imagecat.ManifestFilename], manifestJSON)
	}
}

func TestSha256HexFile(t *testing.T) {
	dir := t.TempDir()
	content := []byte("hello world")
	p := filepath.Join(dir, "f")
	if err := os.WriteFile(p, content, 0o644); err != nil {
		t.Fatal(err)
	}
	got, err := sha256HexFile(p)
	if err != nil {
		t.Fatal(err)
	}
	expected := sha256.Sum256(content)
	if got != hex.EncodeToString(expected[:]) {
		t.Fatalf("sha256 = %q, want %q", got, hex.EncodeToString(expected[:]))
	}
}

func TestDirSize(t *testing.T) {
	dir := t.TempDir()
	_ = os.MkdirAll(filepath.Join(dir, "sub"), 0o755)
	_ = os.WriteFile(filepath.Join(dir, "a"), []byte("abc"), 0o644)          // 3
	_ = os.WriteFile(filepath.Join(dir, "sub", "b"), []byte("defgh"), 0o644) // 5
	// Symlink must not be counted.
	_ = os.Symlink(filepath.Join(dir, "a"), filepath.Join(dir, "link"))
	n, err := dirSize(dir)
	if err != nil {
		t.Fatal(err)
	}
	if n != 8 {
		t.Fatalf("dirSize = %d, want 8", n)
	}
}

// TestMakeBundleEndToEnd exercises the full pipeline against a tiny
// synthesized rootfs tar. Skips if any external tool (mkfs.ext4 /
// debugfs) or the companion banger-vsock-agent binary is unavailable.
func TestMakeBundleEndToEnd(t *testing.T) {
	if _, err := exec.LookPath("mkfs.ext4"); err != nil {
		t.Skip("mkfs.ext4 not installed")
	}
	if _, err := exec.LookPath("debugfs"); err != nil {
		t.Skip("debugfs not installed")
	}
	// Build companion binary if the build tree doesn't already have one.
	buildDir := findBuildBinDir(t)
	if buildDir == "" {
		t.Skip("build/bin not found; run `make build` to enable this test")
	}
	if _, err := os.Stat(filepath.Join(buildDir, "banger-vsock-agent")); err != nil {
		t.Skip("banger-vsock-agent not in build/bin; run `make build`")
	}
	// Ensure the banger binary also exists so CompanionBinaryPath
	// resolves (it looks alongside the banger binary).
	if _, err := os.Stat(filepath.Join(buildDir, "banger")); err != nil {
		t.Skip("banger not in build/bin; run `make build`")
	}

	// Build a minimal rootfs tar: just /etc/os-release and /tmp (a dir).
	dir := t.TempDir()
	tarPath := filepath.Join(dir, "rootfs.tar")
	if err := writeMinimalTar(tarPath); err != nil {
		t.Fatal(err)
	}
	outPath := filepath.Join(dir, "bundle.tar.zst")

	// Invoke via the cobra command to cover arg handling too.
	cmd := NewBangerCommand()
	cmd.SetArgs([]string{
		"internal", "make-bundle",
		"--rootfs-tar", tarPath,
		"--name", "test-bundle",
		"--distro", "debian",
		"--arch", "x86_64",
		"--kernel-ref", "generic-6.12",
		"--size", "64M",
		"--out", outPath,
	})
	var stderr bytes.Buffer
	cmd.SetOut(&bytes.Buffer{})
	cmd.SetErr(&stderr)
	// paths.CompanionBinaryPath looks alongside the banger binary, but
	// the test binary lives elsewhere. Use the env override instead.
	t.Setenv("BANGER_VSOCK_AGENT_BIN", filepath.Join(buildDir, "banger-vsock-agent"))
	cmd.SetContext(context.Background())
	if err := cmd.Execute(); err != nil {
		t.Fatalf("execute: %v\nstderr:\n%s", err, stderr.String())
	}

	if stat, err := os.Stat(outPath); err != nil {
		t.Fatalf("output not written: %v", err)
	} else if stat.Size() < 1024 {
		t.Fatalf("output suspiciously small: %d bytes", stat.Size())
	}

	// Verify we can fetch-reparse it (mirror of imagecat.Fetch logic,
	// but reading straight from disk instead of HTTP).
	extractDir := t.TempDir()
	verifyBundle(t, outPath, extractDir)
}

// findBuildBinDir returns the absolute path to the project's build/bin,
// or "" if it can't be located. Walks up from CWD to find go.mod.
func findBuildBinDir(t *testing.T) string {
	t.Helper()
	cwd, err := os.Getwd()
	if err != nil {
		return ""
	}
	for d := cwd; d != "/" && d != "."; d = filepath.Dir(d) {
		if _, err := os.Stat(filepath.Join(d, "go.mod")); err == nil {
			return filepath.Join(d, "build", "bin")
		}
	}
	return ""
}

func writeMinimalTar(path string) error {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer f.Close()
	tw := tar.NewWriter(f)
	defer tw.Close()

	// /etc dir
	if err := tw.WriteHeader(&tar.Header{
		Name: "etc/", Typeflag: tar.TypeDir, Mode: 0o755, Uid: 0, Gid: 0,
	}); err != nil {
		return err
	}
	// /etc/os-release
	body := []byte(`ID=debian` + "\n" + `PRETTY_NAME="banger test"` + "\n")
	if err := tw.WriteHeader(&tar.Header{
		Name: "etc/os-release", Typeflag: tar.TypeReg, Mode: 0o644,
		Size: int64(len(body)), Uid: 0, Gid: 0,
	}); err != nil {
		return err
	}
	if _, err := tw.Write(body); err != nil {
		return err
	}
	// /tmp dir
	return tw.WriteHeader(&tar.Header{
		Name: "tmp/", Typeflag: tar.TypeDir, Mode: 0o1777, Uid: 0, Gid: 0,
	})
}

func verifyBundle(t *testing.T, bundlePath, extractDir string) {
	t.Helper()
	f, err := os.Open(bundlePath)
	if err != nil {
		t.Fatal(err)
	}
	defer f.Close()
	zr, err := zstd.NewReader(f)
	if err != nil {
		t.Fatal(err)
	}
	defer zr.Close()
	tr := tar.NewReader(zr)
	seen := map[string]bool{}
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			t.Fatal(err)
		}
		dst := filepath.Join(extractDir, hdr.Name)
		if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
			t.Fatal(err)
		}
		out, err := os.Create(dst)
		if err != nil {
			t.Fatal(err)
		}
		if _, err := io.Copy(out, tr); err != nil {
			t.Fatal(err)
		}
		out.Close()
		seen[hdr.Name] = true
	}
	if !seen[imagecat.RootfsFilename] || !seen[imagecat.ManifestFilename] {
		t.Fatalf("bundle missing expected files: seen=%v", seen)
	}
	manifestData, err := os.ReadFile(filepath.Join(extractDir, imagecat.ManifestFilename))
	if err != nil {
		t.Fatal(err)
	}
	var m imagecat.Manifest
	if err := json.Unmarshal(manifestData, &m); err != nil {
		t.Fatal(err)
	}
	if m.Name != "test-bundle" || m.KernelRef != "generic-6.12" || m.Distro != "debian" {
		t.Fatalf("manifest = %+v", m)
	}
}
