package compose

import (
	"context"
	"encoding/json"
	"errors"
	"strings"
	"sync"
	"testing"

	"github.com/alexei/tinyforge/internal/stack"
	"github.com/alexei/tinyforge/internal/store"
	"github.com/alexei/tinyforge/internal/workload/plugin"
)

// fakeRunner stands in for *stack.Compose. Every method records its
// inputs and returns whatever the test set on the corresponding field.
// Defaults are happy-path: empty services from Ps, no error from Up /
// Down. Fields are slice-typed so a single fakeRunner can serve a
// sequence of calls (Deploy issues Up + Ps in order).
type fakeRunner struct {
	mu sync.Mutex

	upCalls    []runnerCall
	upOuts     []string
	upErrs     []error
	downCalls  []runnerCall
	downOuts   []string
	downErrs   []error
	psCalls    []runnerCall
	psResults  [][]stack.Service
	psErrs     []error
	upCallIdx  int
	psCallIdx  int
	downCallI  int
}

type runnerCall struct {
	ProjectName   string
	YAMLPath      string
	RemoveVolumes bool
}

func (f *fakeRunner) Up(_ context.Context, projectName, yamlPath string) (string, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.upCalls = append(f.upCalls, runnerCall{ProjectName: projectName, YAMLPath: yamlPath})
	out, err := pop(f.upOuts, f.upErrs, f.upCallIdx)
	f.upCallIdx++
	return out, err
}

func (f *fakeRunner) Down(_ context.Context, projectName string, removeVolumes bool) (string, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.downCalls = append(f.downCalls, runnerCall{ProjectName: projectName, RemoveVolumes: removeVolumes})
	out, err := pop(f.downOuts, f.downErrs, f.downCallI)
	f.downCallI++
	return out, err
}

func (f *fakeRunner) Ps(_ context.Context, projectName, yamlPath string) ([]stack.Service, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.psCalls = append(f.psCalls, runnerCall{ProjectName: projectName, YAMLPath: yamlPath})

	idx := f.psCallIdx
	f.psCallIdx++
	var svcs []stack.Service
	if idx < len(f.psResults) {
		svcs = f.psResults[idx]
	}
	var err error
	if idx < len(f.psErrs) {
		err = f.psErrs[idx]
	}
	return svcs, err
}

// pop returns the nth element of outs/errs or zero values when n is
// past the end. Lets a test set a single expected response without
// padding slices for every other call.
func pop(outs []string, errs []error, n int) (string, error) {
	var out string
	if n < len(outs) {
		out = outs[n]
	}
	var err error
	if n < len(errs) {
		err = errs[n]
	}
	return out, err
}

// withFakeRunner swaps newComposeRunner for the duration of one test
// and restores the original on cleanup. Tests that need to inspect the
// fake post-hoc keep the returned pointer.
func withFakeRunner(t *testing.T, f *fakeRunner) {
	t.Helper()
	orig := newComposeRunner
	newComposeRunner = func() composeRunner { return f }
	t.Cleanup(func() { newComposeRunner = orig })
}

func testStore(t *testing.T) *store.Store {
	t.Helper()
	st, err := store.New(":memory:")
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })
	return st
}

// seedWorkload creates the parent workload row that container rows FK
// onto. Returns the workload's ID so callers can reuse it.
func seedWorkload(t *testing.T, st *store.Store, name, yamlText string) string {
	t.Helper()
	cfg := Config{ComposeYAML: yamlText}
	body, err := json.Marshal(cfg)
	if err != nil {
		t.Fatalf("marshal config: %v", err)
	}
	w, err := st.CreateWorkload(store.Workload{
		Kind:         "plugin",
		Name:         name,
		SourceKind:   "compose",
		SourceConfig: string(body),
	})
	if err != nil {
		t.Fatalf("create workload: %v", err)
	}
	return w.ID
}

func TestDeploy_HappyPath(t *testing.T) {
	withTempDir(t) // isolates the YAML scratch dir under t.TempDir()

	deps := plugin.Deps{Store: testStore(t)}
	yamlText := "services:\n  web:\n    image: nginx:alpine\n"
	wid := seedWorkload(t, deps.Store, "myapp", yamlText)
	w := plugin.Workload{
		ID:           wid,
		Name:         "myapp",
		SourceKind:   "compose",
		SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}),
	}

	fake := &fakeRunner{
		psResults: [][]stack.Service{{
			{Service: "web", State: "running", Status: "Up 5 seconds"},
		}},
	}
	withFakeRunner(t, fake)

	src := &source{}
	if err := src.Deploy(context.Background(), deps, w, plugin.DeploymentIntent{}); err != nil {
		t.Fatalf("Deploy: %v", err)
	}

	// Up called exactly once with the workload-derived project name.
	if len(fake.upCalls) != 1 {
		t.Fatalf("Up called %d times, want 1", len(fake.upCalls))
	}
	if !strings.HasPrefix(fake.upCalls[0].ProjectName, "tf-myapp-") {
		t.Errorf("Up projectName = %q, want prefix tf-myapp-", fake.upCalls[0].ProjectName)
	}
	if !strings.HasSuffix(fake.upCalls[0].YAMLPath, "compose.yml") {
		t.Errorf("Up yamlPath = %q, want suffix compose.yml", fake.upCalls[0].YAMLPath)
	}

	// Ps follows Up to enumerate the resulting containers.
	if len(fake.psCalls) != 1 {
		t.Fatalf("Ps called %d times, want 1", len(fake.psCalls))
	}

	// Service row written.
	row, err := deps.Store.GetContainerByID(wid + ":web")
	if err != nil {
		t.Fatalf("get container row: %v", err)
	}
	if row.WorkloadID != wid {
		t.Errorf("row.WorkloadID = %q, want %q", row.WorkloadID, wid)
	}
	if row.Role != "web" {
		t.Errorf("row.Role = %q, want %q", row.Role, "web")
	}
	if row.State != "running" {
		t.Errorf("row.State = %q, want %q", row.State, "running")
	}
}

func TestDeploy_EmptyYAMLConfig_RejectsBeforeExec(t *testing.T) {
	deps := plugin.Deps{Store: testStore(t)}
	wid := seedWorkload(t, deps.Store, "empty", "services:\n  web:\n    image: x\n")
	w := plugin.Workload{
		ID:           wid,
		Name:         "empty",
		SourceKind:   "compose",
		SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: ""}),
	}

	fake := &fakeRunner{}
	withFakeRunner(t, fake)

	src := &source{}
	err := src.Deploy(context.Background(), deps, w, plugin.DeploymentIntent{})
	if err == nil {
		t.Fatal("Deploy accepted empty compose_yaml")
	}
	if !strings.Contains(err.Error(), "empty compose_yaml") {
		t.Errorf("error = %v, want substring \"empty compose_yaml\"", err)
	}
	if len(fake.upCalls) != 0 {
		t.Errorf("Up should not have been called; got %d calls", len(fake.upCalls))
	}
}

func TestDeploy_UpFailure_PropagatesAndIncludesTruncatedOutput(t *testing.T) {
	withTempDir(t)

	deps := plugin.Deps{Store: testStore(t)}
	yamlText := "services:\n  web:\n    image: bad-image\n"
	wid := seedWorkload(t, deps.Store, "fail", yamlText)
	w := plugin.Workload{
		ID:           wid,
		Name:         "fail",
		SourceKind:   "compose",
		SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}),
	}

	bigOut := strings.Repeat("docker compose log noise ", 200) // > 1024 bytes
	fake := &fakeRunner{
		upOuts: []string{bigOut},
		upErrs: []error{errors.New("exit status 1")},
	}
	withFakeRunner(t, fake)

	src := &source{}
	err := src.Deploy(context.Background(), deps, w, plugin.DeploymentIntent{})
	if err == nil {
		t.Fatal("Deploy accepted Up failure")
	}
	if !strings.Contains(err.Error(), "docker compose up") {
		t.Errorf("error = %v, want substring \"docker compose up\"", err)
	}
	if !strings.Contains(err.Error(), "exit status 1") {
		t.Errorf("error = %v, want wrapped Up err", err)
	}
	if !strings.Contains(err.Error(), "(truncated)") {
		t.Errorf("error = %v, want truncated-output marker", err)
	}
	// Ps must not be called when Up failed.
	if len(fake.psCalls) != 0 {
		t.Errorf("Ps called %d times after Up failure; want 0", len(fake.psCalls))
	}
}

func TestDeploy_UpSucceedsButPsFails_SurfacesError(t *testing.T) {
	// `up` succeeded but enumerate failed — Deploy must surface so the UI
	// doesn't show an empty containers index for a running stack.
	withTempDir(t)

	deps := plugin.Deps{Store: testStore(t)}
	yamlText := "services:\n  web:\n    image: nginx\n"
	wid := seedWorkload(t, deps.Store, "psfail", yamlText)
	w := plugin.Workload{
		ID:           wid,
		Name:         "psfail",
		SourceKind:   "compose",
		SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}),
	}

	fake := &fakeRunner{
		psErrs: []error{errors.New("compose ps boom")},
	}
	withFakeRunner(t, fake)

	src := &source{}
	err := src.Deploy(context.Background(), deps, w, plugin.DeploymentIntent{})
	if err == nil {
		t.Fatal("Deploy ignored Ps failure")
	}
	if !strings.Contains(err.Error(), "sync container rows") {
		t.Errorf("error = %v, want substring \"sync container rows\"", err)
	}
}

func TestTeardown_DropsContainerRows_EvenWhenDownFails(t *testing.T) {
	// docker compose down failing must not orphan rows in the DB.
	withTempDir(t)
	deps := plugin.Deps{Store: testStore(t)}
	wid := seedWorkload(t, deps.Store, "tdown", "services:\n  web:\n    image: nginx\n")

	// Seed two service rows the way Deploy would.
	for _, role := range []string{"web", "db"} {
		if err := deps.Store.UpsertContainer(store.Container{
			ID:           wid + ":" + role,
			WorkloadID:   wid,
			WorkloadKind: "compose",
			Role:         role,
			Host:         "local",
			State:        "running",
		}); err != nil {
			t.Fatalf("seed container: %v", err)
		}
	}

	fake := &fakeRunner{downErrs: []error{errors.New("compose project unknown")}}
	withFakeRunner(t, fake)

	src := &source{}
	w := plugin.Workload{
		ID:           wid,
		Name:         "tdown",
		SourceKind:   "compose",
		SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: "services:\n  web:\n    image: nginx\n"}),
	}
	if err := src.Teardown(context.Background(), deps, w); err != nil {
		t.Fatalf("Teardown: %v", err)
	}

	// Down requested removeVolumes=true (matches the docstring claim).
	if len(fake.downCalls) != 1 {
		t.Fatalf("Down calls = %d, want 1", len(fake.downCalls))
	}
	if !fake.downCalls[0].RemoveVolumes {
		t.Errorf("Down removeVolumes = false, want true (workload teardown is destructive)")
	}

	// Rows gone despite the Down error.
	for _, role := range []string{"web", "db"} {
		if _, err := deps.Store.GetContainerByID(wid + ":" + role); !errors.Is(err, store.ErrNotFound) {
			t.Errorf("container row %q survived teardown: err=%v", role, err)
		}
	}
}

func TestTeardown_HappyPath(t *testing.T) {
	withTempDir(t)
	deps := plugin.Deps{Store: testStore(t)}
	wid := seedWorkload(t, deps.Store, "tdown2", "services:\n  web:\n    image: nginx\n")

	if err := deps.Store.UpsertContainer(store.Container{
		ID:           wid + ":web",
		WorkloadID:   wid,
		WorkloadKind: "compose",
		Role:         "web",
		Host:         "local",
		State:        "running",
	}); err != nil {
		t.Fatalf("seed: %v", err)
	}

	fake := &fakeRunner{}
	withFakeRunner(t, fake)

	src := &source{}
	w := plugin.Workload{
		ID:           wid,
		Name:         "tdown2",
		SourceKind:   "compose",
		SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: "services:\n  web:\n    image: nginx\n"}),
	}
	if err := src.Teardown(context.Background(), deps, w); err != nil {
		t.Fatalf("Teardown: %v", err)
	}
	if len(fake.downCalls) != 1 {
		t.Errorf("Down calls = %d, want 1", len(fake.downCalls))
	}
	if _, err := deps.Store.GetContainerByID(wid + ":web"); !errors.Is(err, store.ErrNotFound) {
		t.Errorf("container row survived teardown: err=%v", err)
	}
}

func TestReconcile_PsSuccess_UpsertsRows(t *testing.T) {
	withTempDir(t)
	deps := plugin.Deps{Store: testStore(t)}
	yamlText := "services:\n  web:\n    image: nginx\n  db:\n    image: postgres\n"
	wid := seedWorkload(t, deps.Store, "rec", yamlText)

	fake := &fakeRunner{
		psResults: [][]stack.Service{{
			{Service: "web", State: "running"},
			{Service: "db", State: "running"},
		}},
	}
	withFakeRunner(t, fake)

	src := &source{}
	w := plugin.Workload{
		ID:           wid,
		Name:         "rec",
		SourceKind:   "compose",
		SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}),
	}
	if err := src.Reconcile(context.Background(), deps, w); err != nil {
		t.Fatalf("Reconcile: %v", err)
	}

	for _, role := range []string{"web", "db"} {
		row, err := deps.Store.GetContainerByID(wid + ":" + role)
		if err != nil {
			t.Errorf("row %q missing after reconcile: %v", role, err)
			continue
		}
		if row.State != "running" {
			t.Errorf("row %q state = %q, want \"running\"", role, row.State)
		}
	}
}

func TestReconcile_PsFailure_MarksExistingRowsMissing(t *testing.T) {
	// When compose ps fails (project unknown to Docker), the reconciler
	// flips existing rows to "missing" rather than deleting them — the UI
	// surfaces the desync to the operator.
	withTempDir(t)
	deps := plugin.Deps{Store: testStore(t)}
	yamlText := "services:\n  web:\n    image: nginx\n"
	wid := seedWorkload(t, deps.Store, "missing", yamlText)

	if err := deps.Store.UpsertContainer(store.Container{
		ID:           wid + ":web",
		WorkloadID:   wid,
		WorkloadKind: "compose",
		Role:         "web",
		Host:         "local",
		State:        "running",
	}); err != nil {
		t.Fatalf("seed: %v", err)
	}

	fake := &fakeRunner{psErrs: []error{errors.New("no such project")}}
	withFakeRunner(t, fake)

	src := &source{}
	w := plugin.Workload{
		ID:           wid,
		Name:         "missing",
		SourceKind:   "compose",
		SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}),
	}
	if err := src.Reconcile(context.Background(), deps, w); err != nil {
		t.Fatalf("Reconcile returned %v; should be nil even on Ps failure", err)
	}

	row, err := deps.Store.GetContainerByID(wid + ":web")
	if err != nil {
		t.Fatalf("row missing entirely (should be marked, not deleted): %v", err)
	}
	if row.State != "missing" {
		t.Errorf("row.State = %q, want \"missing\"", row.State)
	}
}

func TestReconcile_FallsBackToStatusWhenStateEmpty(t *testing.T) {
	// Some compose versions populate Status (human string) but not State
	// (enum) for non-running services. upsertServiceRow falls back to
	// Status; verify that here.
	withTempDir(t)
	deps := plugin.Deps{Store: testStore(t)}
	yamlText := "services:\n  worker:\n    image: alpine\n"
	wid := seedWorkload(t, deps.Store, "fallback", yamlText)

	fake := &fakeRunner{
		psResults: [][]stack.Service{{
			{Service: "worker", State: "", Status: "Exit 0"},
		}},
	}
	withFakeRunner(t, fake)

	src := &source{}
	w := plugin.Workload{
		ID:           wid,
		Name:         "fallback",
		SourceKind:   "compose",
		SourceConfig: mustMarshalConfig(t, Config{ComposeYAML: yamlText}),
	}
	if err := src.Reconcile(context.Background(), deps, w); err != nil {
		t.Fatalf("Reconcile: %v", err)
	}

	row, err := deps.Store.GetContainerByID(wid + ":worker")
	if err != nil {
		t.Fatalf("get row: %v", err)
	}
	if row.State != "Exit 0" {
		t.Errorf("row.State = %q, want \"Exit 0\" (Status fallback)", row.State)
	}
}

// mustMarshalConfig is a small helper that converts a Config to the
// raw-JSON shape SourceConfig expects. Tests use it instead of
// hand-rolling the string so a Config field rename can't drift the test
// fixture from the production decoder.
func mustMarshalConfig(t *testing.T, cfg Config) json.RawMessage {
	t.Helper()
	b, err := json.Marshal(cfg)
	if err != nil {
		t.Fatalf("marshal config: %v", err)
	}
	return json.RawMessage(b)
}

// Compile-time guards: *stack.Compose must continue to satisfy
// composeRunner so the production path keeps building, and the fake
// must continue to satisfy it too so a drift in the interface shape
// fails the build here rather than at runtime.
var (
	_ composeRunner = (*stack.Compose)(nil)
	_ composeRunner = (*fakeRunner)(nil)
)
