package appexport

import (
	"context"
	"fmt"
	"io"
	"log"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"testing"
)

// v0.125.0 — the containerized-.fab volume-strand fix (IA finding 1, HIGH). These tests pin:
// (B) export can no longer lie — a stranded/empty tar aborts the export, no bundle;
// (C) import validates BEFORE it destroys — a hollow bundle is refused with the app untouched;
// the docker-cp command construction (image, mount shape, cp direction) and the always-remove
// helper-container rule. The real docker legs are covered by the §3 live probe + §13 round-trip.

// dockerCall records one dockerExec invocation.
type dockerCall struct {
	args  []string
	stdin bool
}

// swapDockerExec installs a scripted fake for the package seam and restores it on cleanup.
func swapDockerExec(t *testing.T, fn func(call dockerCall, stdin io.Reader, stdout io.Writer) (string, error)) *[]dockerCall {
	t.Helper()
	var mu sync.Mutex
	calls := &[]dockerCall{}
	orig := dockerExec
	dockerExec = func(ctx context.Context, stdin io.Reader, stdout io.Writer, args ...string) (string, error) {
		mu.Lock()
		c := dockerCall{args: append([]string{}, args...), stdin: stdin != nil}
		*calls = append(*calls, c)
		mu.Unlock()
		return fn(c, stdin, stdout)
	}
	t.Cleanup(func() { dockerExec = orig })
	return calls
}

func volTestExporter(t *testing.T, volumes []string) (*Exporter, *rtProvider, string) {
	t.Helper()
	srcStack := t.TempDir()
	os.WriteFile(filepath.Join(srcStack, "docker-compose.yml"), []byte("services:\n  vol-app:\n    image: alpine\n"), 0644)
	prov := &rtProvider{stackDir: srcStack, stacksDir: t.TempDir(), deployed: true, volumes: volumes}
	drive := t.TempDir()
	return NewExporter(prov, log.New(io.Discard, "", 0), "test"), prov, drive
}

// Scenario B: a volume tar that fails to materialize (cp "succeeds" but writes nothing — the
// strand's signature) must FAIL the export with the volume named, and NO bundle may exist.
// Red-proof: remove the assertBundleDataComplete call → this test fails (hollow success).
func TestExport_HollowVolumeTarAbortsExport(t *testing.T) {
	swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
		switch c.args[0] {
		case "create":
			fmt.Fprint(stdout, "cid-123\n")
			return "", nil
		case "cp":
			return "", nil // writes NOTHING to stdout — the stranded-tar signature
		case "rm":
			return "", nil
		}
		return "", fmt.Errorf("unexpected docker call: %v", c.args)
	})

	e, _, drive := volTestExporter(t, []string{"vol1"})
	if err := e.StartExport(ExportRequest{StackName: "vol-app", DestDrive: drive}); err != nil {
		t.Fatalf("StartExport: %v", err)
	}
	job := waitJob(t, e)
	msg := jobErr(job)
	if msg == "" {
		t.Fatal("a hollow volume tar must FAIL the export — got success")
	}
	if !strings.Contains(msg, "vol1") {
		t.Errorf("the error must NAME the missing volume, got %q", msg)
	}
	entries, _ := os.ReadDir(ExportDir(drive))
	for _, en := range entries {
		if strings.HasSuffix(en.Name(), ".fab") {
			t.Fatalf("a bundle was produced despite the hollow tar: %s", en.Name())
		}
	}
}

// Scenario C: a bundle whose manifest claims a volume without its tar is refused BEFORE any
// destructive step — the app is not stopped, no volume is removed or recreated, zero docker
// calls happen. Red-proof: disable the pre-flight (pre-fix order: wipe first, discover later)
// → the zero-destruction assertions fail.
func TestImport_HollowBundleRefusedBeforeDestroy(t *testing.T) {
	calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
		return "", nil
	})

	// Handcraft a hollow bundle: manifest CLAIMS volume data, data/volumes is empty.
	tree := t.TempDir()
	os.MkdirAll(filepath.Join(tree, "config"), 0755)
	os.MkdirAll(filepath.Join(tree, "data", "volumes"), 0755)
	os.WriteFile(filepath.Join(tree, "config", "docker-compose.yml"), []byte("services: {}\n"), 0644)
	man := &Manifest{
		Version: ManifestVersion, AppName: "vol-app", DisplayName: "Vol App",
		HasVolumeData: true, VolumeNames: []string{"vol1"},
		ConfigFiles: []string{"docker-compose.yml"},
	}
	data, err := man.Marshal()
	if err != nil {
		t.Fatal(err)
	}
	os.WriteFile(filepath.Join(tree, "manifest.json"), data, 0644)
	fab := filepath.Join(t.TempDir(), "hollow.fab")
	if err := createTarGz(fab, tree); err != nil {
		t.Fatalf("createTarGz: %v", err)
	}

	prov := &rtProvider{stackDir: t.TempDir(), stacksDir: t.TempDir(), deployed: true, running: true}
	e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
	if err := e.StartImport(ImportRequest{FABPath: fab}); err != nil {
		t.Fatalf("StartImport: %v", err)
	}
	job := waitJob(t, e)
	msg := jobErr(job)
	if msg == "" {
		t.Fatal("a hollow bundle must be REFUSED — got success")
	}
	if !strings.Contains(msg, "érintetlen") {
		t.Errorf("refusal copy must state the app is untouched, got %q", msg)
	}
	// THE exact non-effects: nothing was stopped, wiped, recreated or started.
	if prov.stopped != 0 || prov.removed != 0 {
		t.Fatalf("refusal happened AFTER destruction: stopped=%d removedVolumes=%d", prov.stopped, prov.removed)
	}
	if prov.started {
		t.Fatal("a refused import must not start the app")
	}
	if len(*calls) != 0 {
		t.Fatalf("a refused import must make ZERO docker calls, got %v", *calls)
	}
}

// Command construction + helper hygiene: the export leg uses create/cp/rm with the exact arg
// shapes the §3 probe validated, and the helper container is force-removed EVEN when cp fails.
func TestExportVolumeTar_CommandShapesAndHelperCleanup(t *testing.T) {
	t.Run("happy path shapes", func(t *testing.T) {
		calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
			switch c.args[0] {
			case "create":
				fmt.Fprint(stdout, "cid-abc\n")
			case "cp":
				fmt.Fprint(stdout, "TARBYTES")
			}
			return "", nil
		})
		e, _, _ := volTestExporter(t, nil)
		tarPath := filepath.Join(t.TempDir(), "v.tar")
		if err := e.exportVolumeTar("vol1", tarPath); err != nil {
			t.Fatalf("exportVolumeTar: %v", err)
		}
		got, _ := os.ReadFile(tarPath)
		if string(got) != "TARBYTES" {
			t.Fatalf("tar content = %q", got)
		}
		want := [][]string{
			{"create", "-v", "vol1:/vol", "alpine", "true"},
			{"cp", "cid-abc:/vol/.", "-"},
			{"rm", "-f", "cid-abc"},
		}
		assertCalls(t, *calls, want)
	})

	t.Run("helper removed on cp failure", func(t *testing.T) {
		calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
			switch c.args[0] {
			case "create":
				fmt.Fprint(stdout, "cid-err\n")
				return "", nil
			case "cp":
				return "boom", fmt.Errorf("cp failed")
			}
			return "", nil
		})
		e, _, _ := volTestExporter(t, nil)
		tarPath := filepath.Join(t.TempDir(), "v.tar")
		if err := e.exportVolumeTar("vol1", tarPath); err == nil {
			t.Fatal("cp failure must surface")
		}
		if _, err := os.Stat(tarPath); !os.IsNotExist(err) {
			t.Error("a failed export must not leave a partial tar")
		}
		last := (*calls)[len(*calls)-1]
		if strings.Join(last.args, " ") != "rm -f cid-err" {
			t.Fatalf("helper container must be force-removed on failure, last call: %v", last.args)
		}
	})

	t.Run("import leg shapes", func(t *testing.T) {
		calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) {
			if c.args[0] == "create" {
				fmt.Fprint(stdout, "cid-imp\n")
			}
			if c.args[0] == "cp" && !c.stdin {
				t.Error("import cp must stream the tar on stdin")
			}
			return "", nil
		})
		e, _, _ := volTestExporter(t, nil)
		tarPath := filepath.Join(t.TempDir(), "v.tar")
		os.WriteFile(tarPath, []byte("TAR"), 0644)
		if err := e.importVolumeTar("vol1", tarPath); err != nil {
			t.Fatalf("importVolumeTar: %v", err)
		}
		want := [][]string{
			{"create", "-v", "vol1:/vol", "alpine", "true"},
			{"cp", "-", "cid-imp:/vol"},
			{"rm", "-f", "cid-imp"},
		}
		assertCalls(t, *calls, want)
	})
}

func assertCalls(t *testing.T, got []dockerCall, want [][]string) {
	t.Helper()
	if len(got) != len(want) {
		t.Fatalf("docker calls = %d, want %d (%v)", len(got), len(want), got)
	}
	for i := range want {
		if strings.Join(got[i].args, " ") != strings.Join(want[i], " ") {
			t.Errorf("call %d = %v, want %v", i, got[i].args, want[i])
		}
	}
}
