package create

import (
	"bytes"
	"context"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/google/shlex"
	"github.com/moby/moby/api/types/container"
	moby "github.com/moby/moby/client"

	"github.com/schmitthub/clawker/internal/auth"
	"github.com/schmitthub/clawker/internal/cmd/container/shared"
	"github.com/schmitthub/clawker/internal/cmdutil"
	"github.com/schmitthub/clawker/internal/config"
	configmocks "github.com/schmitthub/clawker/internal/config/mocks"
	"github.com/schmitthub/clawker/internal/docker"
	"github.com/schmitthub/clawker/internal/docker/mocks"
	"github.com/schmitthub/clawker/internal/hostproxy"
	"github.com/schmitthub/clawker/internal/hostproxy/hostproxytest"
	"github.com/schmitthub/clawker/internal/iostreams"
	"github.com/schmitthub/clawker/internal/logger"
	"github.com/schmitthub/clawker/internal/prompter"
	"github.com/schmitthub/clawker/internal/testenv"
	"github.com/schmitthub/clawker/internal/tui"
	"github.com/stretchr/testify/require"
)

// wantOpts holds expected values for test comparisons against captured CreateOptions.
type wantOpts struct {
	Agent      string
	Name       string
	Mode       string
	Env        []string
	Volumes    []string
	User       string
	Entrypoint string
	TTY        bool
	Stdin      bool
	Network    string
	Labels     []string
	AutoRemove bool
	Image      string
	Command    []string
}

func TestNewCmdCreate(t *testing.T) {
	tests := []struct {
		name       string
		input      string
		args       []string
		want       wantOpts
		wantErr    bool
		wantErrMsg string
	}{
		{
			name:  "basic image only",
			input: "",
			args:  []string{"alpine"},
			want:  wantOpts{Image: "alpine"},
		},
		{
			name:  "image with command",
			input: "",
			args:  []string{"alpine", "echo", "hello"},
			want:  wantOpts{Image: "alpine", Command: []string{"echo", "hello"}},
		},
		{
			name:  "with agent flag",
			input: "--agent myagent",
			args:  []string{"alpine"},
			want:  wantOpts{Agent: "myagent", Image: "alpine"},
		},
		{
			name:  "with name flag",
			input: "--name mycontainer",
			args:  []string{"alpine"},
			want:  wantOpts{Name: "mycontainer", Image: "alpine"},
		},
		{
			name:  "with environment variable",
			input: "-e FOO=bar",
			args:  []string{"alpine"},
			want:  wantOpts{Env: []string{"FOO=bar"}, Image: "alpine"},
		},
		{
			name:  "with multiple env vars",
			input: "-e FOO=bar -e BAZ=qux",
			args:  []string{"alpine"},
			want:  wantOpts{Env: []string{"FOO=bar", "BAZ=qux"}, Image: "alpine"},
		},
		{
			name:  "with volume",
			input: "-v /host:/container",
			args:  []string{"alpine"},
			want:  wantOpts{Volumes: []string{"/host:/container"}, Image: "alpine"},
		},
		{
			name:  "with user",
			input: "-u nobody",
			args:  []string{"alpine"},
			want:  wantOpts{User: "nobody", Image: "alpine"},
		},
		{
			name:  "with entrypoint",
			input: "--entrypoint /bin/sh",
			args:  []string{"alpine"},
			want:  wantOpts{Entrypoint: "/bin/sh", Image: "alpine"},
		},
		{
			name:  "with tty",
			input: "-t",
			args:  []string{"alpine"},
			want:  wantOpts{TTY: true, Image: "alpine"},
		},
		{
			name:  "with interactive",
			input: "-i",
			args:  []string{"alpine"},
			want:  wantOpts{Stdin: true, Image: "alpine"},
		},
		{
			name:  "with tty and interactive",
			input: "-it",
			args:  []string{"alpine"},
			want:  wantOpts{TTY: true, Stdin: true, Image: "alpine"},
		},
		{
			name:  "with network",
			input: "--network mynet",
			args:  []string{"alpine"},
			want:  wantOpts{Network: "mynet", Image: "alpine"},
		},
		{
			name:  "with label",
			input: "-l foo=bar",
			args:  []string{"alpine"},
			want:  wantOpts{Labels: []string{"foo=bar"}, Image: "alpine"},
		},
		{
			name:  "with auto-remove",
			input: "--rm",
			args:  []string{"alpine"},
			want:  wantOpts{AutoRemove: true, Image: "alpine"},
		},
		{
			name:  "with mode bind",
			input: "--agent dev --mode=bind",
			args:  []string{"alpine"},
			want:  wantOpts{Agent: "dev", Mode: "bind", Image: "alpine"},
		},
		{
			name:  "with mode snapshot",
			input: "--agent dev --mode=snapshot",
			args:  []string{"alpine"},
			want:  wantOpts{Agent: "dev", Mode: "snapshot", Image: "alpine"},
		},
		{
			name:  "with mode and other flags",
			input: "-it --agent sandbox --mode=snapshot --rm",
			args:  []string{"alpine", "sh"},
			want:  wantOpts{TTY: true, Stdin: true, Agent: "sandbox", Mode: "snapshot", AutoRemove: true, Image: "alpine", Command: []string{"sh"}},
		},
		{
			name:  "flags after image passed as command",
			input: "-it --rm",
			args:  []string{"alpine", "--version"},
			want:  wantOpts{TTY: true, Stdin: true, AutoRemove: true, Image: "alpine", Command: []string{"--version"}},
		},
		{
			name:  "mixed clawker and container flags",
			input: "-it --rm -e FOO=bar",
			args:  []string{"alpine", "-p", "prompt"},
			want:  wantOpts{TTY: true, Stdin: true, AutoRemove: true, Env: []string{"FOO=bar"}, Image: "alpine", Command: []string{"-p", "prompt"}},
		},
		{
			name:  "claude flags passthrough",
			input: "-it --rm",
			args:  []string{"clawker-image:latest", "--allow-dangerously-skip-permissions", "-p", "Fix bugs"},
			want:  wantOpts{TTY: true, Stdin: true, AutoRemove: true, Image: "clawker-image:latest", Command: []string{"--allow-dangerously-skip-permissions", "-p", "Fix bugs"}},
		},
		{
			name:  "flags only as command with -- separator",
			input: "-it --rm --agent dev --",
			args:  []string{"--allow-dangerously-skip-permissions", "-p", "Fix bugs"},
			want:  wantOpts{TTY: true, Stdin: true, AutoRemove: true, Agent: "dev", Image: "--allow-dangerously-skip-permissions", Command: []string{"-p", "Fix bugs"}},
		},
		{
			name:  "arg starting with dash treated as image after -- separator",
			input: "-it --rm --",
			args:  []string{"-unusual-image:v1"},
			want:  wantOpts{TTY: true, Stdin: true, AutoRemove: true, Image: "-unusual-image:v1"},
		},
		{
			name:  "multiple flag-value pairs after image",
			input: "-it --rm",
			args:  []string{"alpine", "--flag1", "value1", "--flag2", "value2"},
			want:  wantOpts{TTY: true, Stdin: true, AutoRemove: true, Image: "alpine", Command: []string{"--flag1", "value1", "--flag2", "value2"}},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			f := &cmdutil.Factory{}

			var gotOpts *CreateOptions
			cmd := NewCmdCreate(f, func(_ context.Context, opts *CreateOptions) error {
				gotOpts = opts
				return nil
			})

			// Cobra hack-around for help flag
			cmd.Flags().BoolP("help", "x", false, "")

			// Parse arguments
			argv := tt.args
			if tt.input != "" {
				parsed, err := shlex.Split(tt.input)
				require.NoError(t, err)
				argv = append(parsed, tt.args...)
			}

			cmd.SetArgs(argv)
			cmd.SetIn(&bytes.Buffer{})
			cmd.SetOut(&bytes.Buffer{})
			cmd.SetErr(&bytes.Buffer{})

			_, err := cmd.ExecuteC()
			if tt.wantErr {
				require.Error(t, err)
				require.Contains(t, err.Error(), tt.wantErrMsg)
				return
			}

			require.NoError(t, err)
			require.NotNil(t, gotOpts)
			require.Equal(t, tt.want.Agent, gotOpts.Agent)
			require.Equal(t, tt.want.Name, gotOpts.Name)
			require.Equal(t, tt.want.Mode, gotOpts.Mode)
			require.Equal(t, tt.want.Image, gotOpts.Image)
			require.Equal(t, tt.want.Command, gotOpts.Command)
			requireSliceEqual(t, tt.want.Env, gotOpts.Env)
			requireSliceEqual(t, tt.want.Volumes, gotOpts.Volumes)
			require.Equal(t, tt.want.User, gotOpts.User)
			require.Equal(t, tt.want.Entrypoint, gotOpts.Entrypoint)
			require.Equal(t, tt.want.TTY, gotOpts.TTY)
			require.Equal(t, tt.want.Stdin, gotOpts.Stdin)
			require.Equal(t, tt.want.Network, gotOpts.NetMode.NetworkMode())
			requireSliceEqual(t, tt.want.Labels, gotOpts.Labels)
			require.Equal(t, tt.want.AutoRemove, gotOpts.AutoRemove)
		})
	}
}

func TestCmdCreate_MutuallyExclusiveFlags(t *testing.T) {
	f := &cmdutil.Factory{}
	cmd := NewCmdCreate(f, func(_ context.Context, _ *CreateOptions) error {
		return nil
	})

	// Cobra hack-around for help flag
	cmd.Flags().BoolP("help", "x", false, "")

	// Test that --agent and --name are mutually exclusive
	cmd.SetArgs([]string{"--agent", "myagent", "--name", "myname", "alpine"})
	cmd.SetIn(&bytes.Buffer{})
	cmd.SetOut(&bytes.Buffer{})
	cmd.SetErr(&bytes.Buffer{})

	_, err := cmd.ExecuteC()
	require.Error(t, err)
	require.Contains(t, err.Error(), "agent")
	require.Contains(t, err.Error(), "name")
}

func TestBuildConfigs(t *testing.T) {
	tests := []struct {
		name    string
		opts    *shared.ContainerCreateOptions
		wantErr bool
	}{
		{
			name: "basic config",
			opts: &shared.ContainerCreateOptions{
				Image:   "alpine",
				Publish: shared.NewPortOpts(),
			},
		},
		{
			name: "with tty and stdin",
			opts: &shared.ContainerCreateOptions{
				Image:   "alpine",
				TTY:     true,
				Stdin:   true,
				Publish: shared.NewPortOpts(),
			},
		},
		{
			name: "with command",
			opts: &shared.ContainerCreateOptions{
				Image:   "alpine",
				Command: []string{"echo", "hello"},
				Publish: shared.NewPortOpts(),
			},
		},
		{
			name: "with env vars",
			opts: &shared.ContainerCreateOptions{
				Image:   "alpine",
				Env:     []string{"FOO=bar", "BAZ=qux"},
				Publish: shared.NewPortOpts(),
			},
		},
		{
			name: "with network",
			opts: func() *shared.ContainerCreateOptions {
				o := shared.NewContainerOptions()
				o.Image = "alpine"
				o.NetMode.Set("mynet")
				return o
			}(),
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			cfg, hostCfg, _, err := tt.opts.BuildConfigs(nil, nil, &config.Project{})

			if tt.wantErr {
				require.Error(t, err)
				return
			}

			require.NoError(t, err)
			require.NotNil(t, cfg)
			require.NotNil(t, hostCfg)

			// Verify basic fields
			require.Equal(t, tt.opts.Image, cfg.Image)
			require.Equal(t, tt.opts.TTY, cfg.Tty)
			require.Equal(t, tt.opts.Stdin, cfg.OpenStdin)

			// Verify command
			if len(tt.opts.Command) > 0 {
				require.Equal(t, tt.opts.Command, []string(cfg.Cmd))
			}

			// Verify env
			if len(tt.opts.Env) > 0 {
				require.Equal(t, tt.opts.Env, cfg.Env)
			}

			// Verify network mode is set in host config
			if tt.opts.NetMode.NetworkMode() != "" {
				require.Equal(t, container.NetworkMode(tt.opts.NetMode.NetworkMode()), hostCfg.NetworkMode)
			}
		})
	}
}

// requireSliceEqual compares two slices, treating nil and empty slice as equal.
func requireSliceEqual(t *testing.T, expected, actual []string) {
	t.Helper()
	if len(expected) == 0 && len(actual) == 0 {
		return // Both are empty (nil or [])
	}
	require.Equal(t, expected, actual)
}

// ---------------------------------------------------------------------------
// Tier 2 — Cobra+Factory integration tests
// ---------------------------------------------------------------------------

// testFactory builds a *cmdutil.Factory backed by a FakeClient for Tier 2 create tests.
func testFactory(t *testing.T, fake *mocks.FakeClient) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) {
	t.Helper()
	// Isolate XDG dirs + mint per-test auth material. shared.CreateContainer
	// reads the CA cert + signing key + agentregistry DB path through
	// internal/consts; without testenv + EnsureAuthMaterial, the new
	// InstallAgentBootstrap call inside CreateContainer fails with a
	// missing-CA error before reaching any of the assertions in these
	// Tier 2 tests.
	testenv.New(t)
	require.NoError(t, auth.EnsureAuthMaterial())
	// Ensure CWD is inside $HOME so IsOutsideHome returns false (matters in containers).
	cwd, _ := os.Getwd()
	t.Setenv("HOME", filepath.Dir(cwd))
	tio, in, out, errOut := iostreams.Test()
	return &cmdutil.Factory{
		IOStreams: tio,
		Logger:    func() (*logger.Logger, error) { return logger.Nop(), nil },
		TUI:       tui.NewTUI(tio),
		Client: func(_ context.Context) (*docker.Client, error) {
			return fake.Client, nil
		},
		Config: func() (config.Config, error) {
			mock := configmocks.NewFromString(`
version: "1"
workspace: { default_mode: "bind" }
security: { enable_host_proxy: false }
agent:
  claude_code:
    mount_projects: false
`, "")
			mock.GetProjectIgnoreFileFunc = func() (string, error) {
				return filepath.Join(os.TempDir(), mock.ClawkerIgnoreName()), nil
			}
			mock.GetProjectRootFunc = func() (string, error) {
				return os.TempDir(), nil
			}
			return mock, nil
		},
		HostProxy: func() hostproxy.HostProxyService {
			return hostproxytest.NewMockManager()
		},
		Prompter: func() *prompter.Prompter { return prompter.NewPrompter(tio) },
	}, in, out, errOut
}

func TestCreateRun(t *testing.T) {
	t.Run("basic create prints container ID", func(t *testing.T) {
		fake := mocks.NewFakeClient(configmocks.NewBlankConfig())
		fake.SetupContainerCreate()
		fake.SetupCopyToContainer()

		f, _, out, errOut := testFactory(t, fake)
		cmd := NewCmdCreate(f, nil)

		cmd.SetArgs([]string{"alpine"})
		cmd.SetIn(&bytes.Buffer{})
		cmd.SetOut(out)
		cmd.SetErr(errOut)

		err := cmd.Execute()
		require.NoError(t, err)

		// Create prints 12-char container ID to stdout
		outStr := out.String()
		require.Contains(t, outStr, "abcdef123456")
		require.Len(t, strings.TrimSpace(outStr), 12)

		fake.AssertCalled(t, "ContainerCreate")
	})

	t.Run("config init runs when config volume freshly created", func(t *testing.T) {
		// Make all volumes report as not existing → EnsureVolume creates them → ConfigCreated=true
		fake := mocks.NewFakeClient(configmocks.NewBlankConfig())
		fake.SetupVolumeExists("", false)
		fake.FakeAPI.VolumeCreateFn = func(_ context.Context, _ moby.VolumeCreateOptions) (moby.VolumeCreateResult, error) {
			return moby.VolumeCreateResult{}, nil
		}
		fake.SetupContainerCreate()
		fake.SetupCopyToContainer()

		// Point CLAUDE_CONFIG_DIR to a non-existent path so InitContainerConfig fails
		// (proving it WAS called when ConfigCreated=true).
		t.Setenv("CLAUDE_CONFIG_DIR", "/tmp/nonexistent-clawker-test-dir")

		f, _, out, errOut := testFactory(t, fake)
		cmd := NewCmdCreate(f, nil)

		cmd.SetArgs([]string{"alpine"})
		cmd.SetIn(&bytes.Buffer{})
		cmd.SetOut(out)
		cmd.SetErr(errOut)

		err := cmd.Execute()
		require.Error(t, err)
		require.Contains(t, err.Error(), "container init")
		require.Contains(t, err.Error(), "CLAUDE_CONFIG_DIR")

		// Container should NOT have been created since init failed before ContainerCreate... wait no.
		// Actually, init runs AFTER workspace setup (which includes ContainerCreate path? No.)
		// Looking at create.go: SetupMounts → init → ContainerCreate. Init is before create.
		fake.AssertNotCalled(t, "ContainerCreate")
	})

	t.Run("config init skipped when config volume exists", func(t *testing.T) {
		// Default fake: volumes exist → ConfigCreated=false → no init
		fake := mocks.NewFakeClient(configmocks.NewBlankConfig())
		fake.SetupContainerCreate()
		fake.SetupCopyToContainer()

		f, _, out, errOut := testFactory(t, fake)
		cmd := NewCmdCreate(f, nil)

		cmd.SetArgs([]string{"alpine"})
		cmd.SetIn(&bytes.Buffer{})
		cmd.SetOut(out)
		cmd.SetErr(errOut)

		err := cmd.Execute()
		require.NoError(t, err)

		// Container created successfully — no init errors
		outStr := out.String()
		require.Len(t, strings.TrimSpace(outStr), 12)
		fake.AssertCalled(t, "ContainerCreate")
	})

	// NOTE: Onboarding bypass tests removed — onboarding is now handled at image level
	// (entrypoint seeds ~/.claude/.config.json from ~/.claude-init/.config.json).
	// CopyToContainer is no longer called for onboarding injection.

	t.Run("only one CopyToContainer when use_host_auth disabled and no post_init", func(t *testing.T) {
		// use_host_auth disabled + no post_init: post-init injection
		// must NOT fire, but the bootstrap material copy from
		// InstallAgentBootstrap always does → exactly one
		// CopyToContainer call.
		testenv.New(t)
		require.NoError(t, auth.EnsureAuthMaterial())

		useHostAuthCfg := configmocks.NewFromString(`
version: "1"
workspace: { default_mode: "bind" }
security: { enable_host_proxy: false }
agent: { claude_code: { use_host_auth: false, mount_projects: false, config: { strategy: "fresh" } } }
`, "")
		useHostAuthCfg.GetProjectIgnoreFileFunc = func() (string, error) {
			return filepath.Join(os.TempDir(), useHostAuthCfg.ClawkerIgnoreName()), nil
		}
		useHostAuthCfg.GetProjectRootFunc = func() (string, error) {
			return os.TempDir(), nil
		}
		fake := mocks.NewFakeClient(useHostAuthCfg)
		fake.SetupContainerCreate()
		fake.SetupCopyToContainer()

		// Ensure CWD is inside $HOME so IsOutsideHome returns false (matters in containers).
		cwd, _ := os.Getwd()
		t.Setenv("HOME", filepath.Dir(cwd))

		tio, _, out, errOut := iostreams.Test()
		f := &cmdutil.Factory{
			IOStreams: tio,
			Logger:    func() (*logger.Logger, error) { return logger.Nop(), nil },
			TUI:       tui.NewTUI(tio),
			Client: func(_ context.Context) (*docker.Client, error) {
				return fake.Client, nil
			},
			Config: func() (config.Config, error) {
				return useHostAuthCfg, nil
			},
			HostProxy: func() hostproxy.HostProxyService {
				return hostproxytest.NewMockManager()
			},
			Prompter: func() *prompter.Prompter { return prompter.NewPrompter(tio) },
		}

		cmd := NewCmdCreate(f, nil)
		cmd.SetArgs([]string{"alpine"})
		cmd.SetIn(&bytes.Buffer{})
		cmd.SetOut(out)
		cmd.SetErr(errOut)

		err := cmd.Execute()
		require.NoError(t, err)

		// Container created. Bootstrap material was copied (1 call);
		// post-init injection did NOT run.
		outStr := out.String()
		require.Len(t, strings.TrimSpace(outStr), 12)
		fake.AssertCalled(t, "ContainerCreate")
		fake.AssertCalledN(t, "CopyToContainer", 1)
	})
}
