package tools

import (
	"context"
	"encoding/json"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
)

func TestCLIExecute_Name(t *testing.T) {
	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"echo"},
	})
	if got := tool.Name(); got != "cli_execute" {
		t.Errorf("Name() = %q, want %q", got, "cli_execute")
	}
}

func TestCLIExecute_Category(t *testing.T) {
	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"echo"},
	})
	if got := tool.Category(); got != "builtin" {
		t.Errorf("Category() = %q, want %q", got, "builtin")
	}
}

func TestCLIExecute_DynamicSchema(t *testing.T) {
	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"curl", "jq"},
	})
	schema := tool.InputSchema()

	var parsed map[string]any
	if err := json.Unmarshal(schema, &parsed); err != nil {
		t.Fatalf("InputSchema() returned invalid JSON: %v", err)
	}

	props, ok := parsed["properties"].(map[string]any)
	if !ok {
		t.Fatal("InputSchema() missing 'properties'")
	}
	binaryProp, ok := props["binary"].(map[string]any)
	if !ok {
		t.Fatal("InputSchema() missing 'binary' property")
	}
	enumVals, ok := binaryProp["enum"].([]any)
	if !ok {
		t.Fatal("InputSchema() missing 'enum' on binary property")
	}
	if len(enumVals) != 2 {
		t.Errorf("InputSchema() enum has %d items, want 2", len(enumVals))
	}

	found := map[string]bool{}
	for _, v := range enumVals {
		found[v.(string)] = true
	}
	if !found["curl"] || !found["jq"] {
		t.Errorf("InputSchema() enum = %v, want [curl, jq]", enumVals)
	}
}

func TestCLIExecute_Allowed(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("echo behavior differs on Windows")
	}

	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"echo"},
	})

	args, _ := json.Marshal(cliExecuteArgs{
		Binary: "echo",
		Args:   []string{"hello"},
	})

	result, err := tool.Execute(context.Background(), args)
	if err != nil {
		t.Fatalf("Execute() error = %v", err)
	}

	var res cliExecuteResult
	if err := json.Unmarshal([]byte(result), &res); err != nil {
		t.Fatalf("failed to unmarshal result: %v", err)
	}

	if got := strings.TrimSpace(res.Stdout); got != "hello" {
		t.Errorf("stdout = %q, want %q", got, "hello")
	}
	if res.ExitCode != 0 {
		t.Errorf("exit_code = %d, want 0", res.ExitCode)
	}
}

func TestCLIExecute_Disallowed(t *testing.T) {
	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"echo"},
	})

	args, _ := json.Marshal(cliExecuteArgs{
		Binary: "rm",
	})

	_, err := tool.Execute(context.Background(), args)
	if err == nil {
		t.Fatal("Execute() expected error for disallowed binary, got nil")
	}
	if !strings.Contains(err.Error(), "not in the allowed list") {
		t.Errorf("error = %q, want it to mention 'not in the allowed list'", err.Error())
	}
}

func TestCLIExecute_ShellInjection(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("echo behavior differs on Windows")
	}

	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"echo"},
	})

	tests := []struct {
		name string
		arg  string
	}{
		{"backtick", "hello `whoami`"},
		{"command_substitution", "hello $(whoami)"},
		{"newline", "hello\nworld"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			args, _ := json.Marshal(cliExecuteArgs{
				Binary: "echo",
				Args:   []string{tt.arg},
			})

			_, err := tool.Execute(context.Background(), args)
			if err == nil {
				t.Errorf("Execute() expected error for injection arg %q, got nil", tt.arg)
			}
		})
	}
}

func TestCLIExecute_FileProtocolBlocked(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("echo behavior differs on Windows")
	}

	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"echo"},
	})

	tests := []struct {
		name    string
		arg     string
		blocked bool
	}{
		{"file_lower", "file:///etc/passwd", true},
		{"file_upper", "FILE:///etc/shadow", true},
		{"file_mixed", "File:///etc/hosts", true},
		{"http_allowed", "http://example.com", false},
		{"https_allowed", "https://example.com", false},
		{"plain_arg", "get", false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			args, _ := json.Marshal(cliExecuteArgs{
				Binary: "echo",
				Args:   []string{tt.arg},
			})

			_, err := tool.Execute(context.Background(), args)
			if tt.blocked && err == nil {
				t.Errorf("Execute() expected error for %q, got nil", tt.arg)
			}
			if tt.blocked && err != nil && !strings.Contains(err.Error(), "file:// protocol") {
				t.Errorf("error = %q, want it to mention 'file:// protocol'", err.Error())
			}
			if !tt.blocked && err != nil {
				t.Errorf("Execute() unexpected error for %q: %v", tt.arg, err)
			}
		})
	}
}

func TestCLIExecute_Timeout(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("sleep not available on Windows")
	}

	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"sleep"},
		TimeoutSeconds:  1,
	})

	args, _ := json.Marshal(cliExecuteArgs{
		Binary: "sleep",
		Args:   []string{"10"},
	})

	_, err := tool.Execute(context.Background(), args)
	if err == nil {
		t.Fatal("Execute() expected timeout error, got nil")
	}
	if !strings.Contains(err.Error(), "timed out") {
		t.Errorf("error = %q, want it to mention 'timed out'", err.Error())
	}
}

func TestCLIExecute_OutputLimit(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("dd not available on Windows")
	}

	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"dd"},
		MaxOutputBytes:  100, // very small limit
	})

	// dd will produce more than 100 bytes of output
	args, _ := json.Marshal(cliExecuteArgs{
		Binary: "dd",
		Args:   []string{"if=/dev/zero", "bs=1024", "count=1"},
	})

	result, err := tool.Execute(context.Background(), args)
	if err != nil {
		t.Fatalf("Execute() error = %v", err)
	}

	var res cliExecuteResult
	if err := json.Unmarshal([]byte(result), &res); err != nil {
		t.Fatalf("failed to unmarshal result: %v", err)
	}

	if !res.Truncated {
		t.Error("expected truncated = true")
	}
}

func TestCLIExecute_MissingBinary(t *testing.T) {
	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"nonexistent_binary_xyz_12345"},
	})

	avail, missing := tool.Availability()
	if len(avail) != 0 {
		t.Errorf("Availability() available = %v, want empty", avail)
	}
	if len(missing) != 1 || missing[0] != "nonexistent_binary_xyz_12345" {
		t.Errorf("Availability() missing = %v, want [nonexistent_binary_xyz_12345]", missing)
	}

	args, _ := json.Marshal(cliExecuteArgs{
		Binary: "nonexistent_binary_xyz_12345",
	})

	_, err := tool.Execute(context.Background(), args)
	if err == nil {
		t.Fatal("Execute() expected error for missing binary, got nil")
	}
	if !strings.Contains(err.Error(), "not found on this system") {
		t.Errorf("error = %q, want it to mention 'not found on this system'", err.Error())
	}
}

func TestCLIExecute_EnvIsolation(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("env command differs on Windows")
	}

	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"env"},
		EnvPassthrough:  []string{"FORGE_TEST_VAR"},
	})

	// Set a test var and a var that should NOT pass through
	t.Setenv("FORGE_TEST_VAR", "test_value")
	t.Setenv("SECRET_VAR", "should_not_appear")

	args, _ := json.Marshal(cliExecuteArgs{
		Binary: "env",
	})

	result, err := tool.Execute(context.Background(), args)
	if err != nil {
		t.Fatalf("Execute() error = %v", err)
	}

	var res cliExecuteResult
	if err := json.Unmarshal([]byte(result), &res); err != nil {
		t.Fatalf("failed to unmarshal result: %v", err)
	}

	// Check passthrough var is present
	if !strings.Contains(res.Stdout, "FORGE_TEST_VAR=test_value") {
		t.Error("expected FORGE_TEST_VAR=test_value in env output")
	}

	// Check secret var is NOT present
	if strings.Contains(res.Stdout, "SECRET_VAR") {
		t.Error("SECRET_VAR should not appear in isolated env output")
	}

	// Check only expected vars are present
	lines := strings.Split(strings.TrimSpace(res.Stdout), "\n")
	for _, line := range lines {
		parts := strings.SplitN(line, "=", 2)
		if len(parts) < 2 {
			continue
		}
		key := parts[0]
		allowed := map[string]bool{
			"PATH": true, "HOME": true, "LANG": true, "FORGE_TEST_VAR": true,
		}
		if !allowed[key] {
			t.Errorf("unexpected env var in output: %s", key)
		}
	}
}

func TestCLIExecute_Stdin(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("cat behavior differs on Windows")
	}

	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"cat"},
	})

	args, _ := json.Marshal(cliExecuteArgs{
		Binary: "cat",
		Stdin:  "hello from stdin",
	})

	result, err := tool.Execute(context.Background(), args)
	if err != nil {
		t.Fatalf("Execute() error = %v", err)
	}

	var res cliExecuteResult
	if err := json.Unmarshal([]byte(result), &res); err != nil {
		t.Fatalf("failed to unmarshal result: %v", err)
	}

	if res.Stdout != "hello from stdin" {
		t.Errorf("stdout = %q, want %q", res.Stdout, "hello from stdin")
	}
}

func TestCLIExecute_ParseConfig(t *testing.T) {
	raw := map[string]any{
		"allowed_binaries": []any{"curl", "jq", "yq"},
		"env_passthrough":  []any{"GITHUB_TOKEN"},
		"timeout":          120,
		"max_output_bytes": 1048576,
	}

	cfg := ParseCLIExecuteConfig(raw)

	if len(cfg.AllowedBinaries) != 3 {
		t.Errorf("AllowedBinaries = %v, want 3 items", cfg.AllowedBinaries)
	}
	if cfg.AllowedBinaries[0] != "curl" || cfg.AllowedBinaries[1] != "jq" || cfg.AllowedBinaries[2] != "yq" {
		t.Errorf("AllowedBinaries = %v, want [curl jq yq]", cfg.AllowedBinaries)
	}

	if len(cfg.EnvPassthrough) != 1 || cfg.EnvPassthrough[0] != "GITHUB_TOKEN" {
		t.Errorf("EnvPassthrough = %v, want [GITHUB_TOKEN]", cfg.EnvPassthrough)
	}

	if cfg.TimeoutSeconds != 120 {
		t.Errorf("TimeoutSeconds = %d, want 120", cfg.TimeoutSeconds)
	}

	if cfg.MaxOutputBytes != 1048576 {
		t.Errorf("MaxOutputBytes = %d, want 1048576", cfg.MaxOutputBytes)
	}

	// Test with float64 (JSON round-trip)
	rawFloat := map[string]any{
		"allowed_binaries": []any{"echo"},
		"timeout":          float64(60),
		"max_output_bytes": float64(2097152),
	}

	cfgFloat := ParseCLIExecuteConfig(rawFloat)
	if cfgFloat.TimeoutSeconds != 60 {
		t.Errorf("TimeoutSeconds (float64) = %d, want 60", cfgFloat.TimeoutSeconds)
	}
	if cfgFloat.MaxOutputBytes != 2097152 {
		t.Errorf("MaxOutputBytes (float64) = %d, want 2097152", cfgFloat.MaxOutputBytes)
	}
}

func TestLooksLikePath(t *testing.T) {
	tests := []struct {
		arg  string
		want bool
	}{
		{"/etc/passwd", true},
		{"~/Library/Keychains/", true},
		{"./data.txt", true},
		{"../../../.ssh/id_rsa", true},
		{"~", true},
		{".", true},
		{"..", true},
		// Non-path arguments
		{"get", false},
		{"pods", false},
		{"--namespace=default", false},
		{"--kubeconfig=~/.kube/config", false},
		{"-o", false},
		{"json", false},
		{"", false},
	}

	for _, tt := range tests {
		t.Run(tt.arg, func(t *testing.T) {
			if got := looksLikePath(tt.arg); got != tt.want {
				t.Errorf("looksLikePath(%q) = %v, want %v", tt.arg, got, tt.want)
			}
		})
	}
}

func TestValidatePathArg_BlocksHomeTraversal(t *testing.T) {
	home := os.Getenv("HOME")
	if home == "" {
		t.Skip("HOME not set")
	}

	workDir := filepath.Join(home, "projects", "myagent")

	tool := &CLIExecuteTool{
		workDir: workDir,
		homeDir: home,
	}

	tests := []struct {
		name    string
		arg     string
		wantErr bool
	}{
		// Allowed: within workDir
		{"workdir_file", "./data.txt", false},
		{"workdir_subdir", "./subdir/file.yaml", false},
		// Allowed: system paths (outside $HOME)
		{"system_tmp", "/tmp/data.json", false},
		{"system_etc", "/etc/hosts", false},
		// Allowed: non-path arguments
		{"plain_arg", "get", false},
		{"flag_arg", "--namespace=default", false},
		{"flag_with_path", "--kubeconfig=~/.kube/config", false},
		// Blocked: home traversal
		{"home_library", "~/Library/Keychains/", true},
		{"home_downloads", "~/Downloads/", true},
		{"home_ssh", "~/.ssh/id_rsa", true},
		{"home_root", "~/", true},
		// Blocked: relative escape (stays inside $HOME but outside workDir)
		{"dotdot_escape", "../../.ssh/id_rsa", true},
		{"dotdot_one_level", "../other_project/secret.key", true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := tool.validatePathArg(tt.arg)
			if tt.wantErr && err == nil {
				t.Errorf("validatePathArg(%q) = nil, want error", tt.arg)
			}
			if !tt.wantErr && err != nil {
				t.Errorf("validatePathArg(%q) = %v, want nil", tt.arg, err)
			}
			if tt.wantErr && err != nil && !strings.Contains(err.Error(), "outside the agent working directory") {
				t.Errorf("error = %q, want it to contain 'outside the agent working directory'", err.Error())
			}
		})
	}
}

func TestCLIExecute_PathTraversalBlocked(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("test uses Unix paths")
	}

	home := os.Getenv("HOME")
	if home == "" {
		t.Skip("HOME not set")
	}

	workDir := filepath.Join(home, "projects", "myagent")

	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"ls"},
		WorkDir:         workDir,
	})

	args, _ := json.Marshal(cliExecuteArgs{
		Binary: "ls",
		Args:   []string{home},
	})

	_, err := tool.Execute(context.Background(), args)
	if err == nil {
		t.Fatal("Execute() expected error for home directory traversal, got nil")
	}
	if !strings.Contains(err.Error(), "outside the agent working directory") {
		t.Errorf("error = %q, want it to mention 'outside the agent working directory'", err.Error())
	}
}

func TestCLIExecute_ShellInterpreterBlocked(t *testing.T) {
	shells := []string{"bash", "sh", "zsh", "dash", "ksh", "csh", "tcsh", "fish"}
	for _, shell := range shells {
		t.Run(shell, func(t *testing.T) {
			tool := NewCLIExecuteTool(CLIExecuteConfig{
				AllowedBinaries: []string{shell},
			})

			args, _ := json.Marshal(cliExecuteArgs{
				Binary: shell,
				Args:   []string{"-c", "echo hello"},
			})

			_, err := tool.Execute(context.Background(), args)
			if err == nil {
				t.Fatalf("Execute(%s) expected error, got nil", shell)
			}
			if !strings.Contains(err.Error(), "shell interpreter") {
				t.Errorf("error = %q, want it to mention 'shell interpreter'", err.Error())
			}
		})
	}
}

func TestCLIExecute_HomeOverriddenToWorkDir(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("env command differs on Windows")
	}

	tmpDir := t.TempDir()

	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"env"},
		WorkDir:         tmpDir,
	})

	args, _ := json.Marshal(cliExecuteArgs{
		Binary: "env",
	})

	result, err := tool.Execute(context.Background(), args)
	if err != nil {
		t.Fatalf("Execute() error = %v", err)
	}

	var res cliExecuteResult
	if err := json.Unmarshal([]byte(result), &res); err != nil {
		t.Fatalf("failed to unmarshal result: %v", err)
	}

	// HOME should be overridden to workDir, not the real home
	expected := "HOME=" + tmpDir
	if !strings.Contains(res.Stdout, expected) {
		t.Errorf("expected %q in env output, got:\n%s", expected, res.Stdout)
	}
}

func TestBuildEnv_GHConfigDirScopedToGh(t *testing.T) {
	tmpDir := t.TempDir()
	t.Setenv("HOME", "/Users/testuser")

	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"env", "gh", "curl"},
		WorkDir:         tmpDir,
	})

	// gh binary should get GH_CONFIG_DIR
	ghEnv := tool.buildEnv("gh")
	found := false
	for _, e := range ghEnv {
		if strings.HasPrefix(e, "GH_CONFIG_DIR=") {
			found = true
			break
		}
	}
	if !found {
		t.Error("expected GH_CONFIG_DIR for gh binary")
	}

	// curl binary should NOT get GH_CONFIG_DIR
	curlEnv := tool.buildEnv("curl")
	for _, e := range curlEnv {
		if strings.HasPrefix(e, "GH_CONFIG_DIR=") {
			t.Error("GH_CONFIG_DIR should not be set for curl binary")
		}
	}
}

func TestBuildEnv_KubeconfigScopedToKubectl(t *testing.T) {
	tmpDir := t.TempDir()
	realHome := t.TempDir()
	t.Setenv("HOME", realHome)

	// Create a fake kubeconfig
	kubeDir := filepath.Join(realHome, ".kube")
	if err := os.MkdirAll(kubeDir, 0o700); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(kubeDir, "config"), []byte("apiVersion: v1\nclusters:\n- cluster:\n    server: https://192.168.1.100:6443\n"), 0o600); err != nil {
		t.Fatal(err)
	}

	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"env", "kubectl", "curl"},
		WorkDir:         tmpDir,
	})

	// kubectl should get KUBECONFIG
	kubectlEnv := tool.buildEnv("kubectl")
	found := false
	for _, e := range kubectlEnv {
		if strings.HasPrefix(e, "KUBECONFIG=") {
			found = true
			if !strings.Contains(e, filepath.Join(realHome, ".kube", "config")) {
				t.Errorf("expected KUBECONFIG to point to real home, got: %s", e)
			}
			break
		}
	}
	if !found {
		t.Error("expected KUBECONFIG for kubectl binary")
	}

	// curl should NOT get KUBECONFIG
	curlEnv := tool.buildEnv("curl")
	for _, e := range curlEnv {
		if strings.HasPrefix(e, "KUBECONFIG=") {
			t.Error("KUBECONFIG should not be set for curl binary")
		}
	}
}

func TestBuildEnv_KubectlNoProxy(t *testing.T) {
	tmpDir := t.TempDir()
	realHome := t.TempDir()
	t.Setenv("HOME", realHome)

	// Create kubeconfig with a server address
	kubeDir := filepath.Join(realHome, ".kube")
	if err := os.MkdirAll(kubeDir, 0o700); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(kubeDir, "config"), []byte("apiVersion: v1\nclusters:\n- cluster:\n    server: https://my-k8s.example.com:6443\n"), 0o600); err != nil {
		t.Fatal(err)
	}

	tool := NewCLIExecuteTool(CLIExecuteConfig{
		AllowedBinaries: []string{"kubectl", "curl"},
		WorkDir:         tmpDir,
	})
	tool.proxyURL = "http://127.0.0.1:54321"

	// kubectl should get NO_PROXY with the K8s API server host
	kubectlEnv := tool.buildEnv("kubectl")
	var noProxy string
	for _, e := range kubectlEnv {
		if strings.HasPrefix(e, "NO_PROXY=") {
			noProxy = strings.TrimPrefix(e, "NO_PROXY=")
			break
		}
	}
	if noProxy == "" {
		t.Fatal("expected NO_PROXY for kubectl binary")
	}
	if !strings.Contains(noProxy, "my-k8s.example.com") {
		t.Errorf("expected NO_PROXY to contain K8s API host, got: %s", noProxy)
	}
	if !strings.Contains(noProxy, "localhost") {
		t.Errorf("expected NO_PROXY to contain localhost, got: %s", noProxy)
	}

	// curl should NOT get NO_PROXY
	curlEnv := tool.buildEnv("curl")
	for _, e := range curlEnv {
		if strings.HasPrefix(e, "NO_PROXY=") {
			t.Error("NO_PROXY should not be set for curl binary")
		}
	}
}
