package tools

import (
	"context"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
	"unicode/utf8"

	"github.com/ollama/ollama/agent"
)

func TestBashReportsFinalWorkingDir(t *testing.T) {
	root := t.TempDir()
	subdir := filepath.Join(root, "sub")
	if err := os.Mkdir(subdir, 0o755); err != nil {
		t.Fatal(err)
	}

	result, err := (&Bash{}).Execute(context.Background(), agent.ToolContext{WorkingDir: root}, map[string]any{
		"command": shellTestCommand("cd sub && pwd", "Set-Location sub; Get-Location"),
	})
	if err != nil {
		t.Fatal(err)
	}
	wantDir, err := filepath.EvalSymlinks(subdir)
	if err != nil {
		t.Fatal(err)
	}
	if result.WorkingDir != wantDir {
		t.Fatalf("working dir = %q, want %q", result.WorkingDir, wantDir)
	}
	if !strings.Contains(result.Content, "sub") {
		t.Fatalf("content = %q, want pwd output", result.Content)
	}
}

func TestBashBoundsOutputWhileRunning(t *testing.T) {
	result, err := (&Bash{}).Execute(context.Background(), agent.ToolContext{WorkingDir: t.TempDir()}, map[string]any{
		"command": shellTestCommand("yes x | head -c 70000", "[Console]::Out.Write(('x' * 70000))"),
	})
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(result.Content, "[stdout truncated: showing first ~") || !strings.Contains(result.Content, "omitted ~") || !strings.Contains(result.Content, " tokens.]") {
		t.Fatalf("content = %q, want stdout truncation marker", result.Content)
	}
	if count, want := strings.Count(result.Content, "x"), shellTestCapturedXCount(); count != want {
		t.Fatalf("captured x count = %d, want %d", count, want)
	}
	if len(result.Content) > maxBashOutputBytes+200 {
		t.Fatalf("content length = %d, want bounded output", len(result.Content))
	}
}

func TestBoundedOutputTruncatesAtUTF8Boundary(t *testing.T) {
	var out boundedOutput
	out.Limit = len([]byte("abc")) + 1

	if _, err := out.Write([]byte("abcédef")); err != nil {
		t.Fatal(err)
	}
	content := out.String("stdout")
	if !utf8.ValidString(content) {
		t.Fatalf("content is not valid UTF-8: %q", content)
	}
	if strings.ContainsRune(content, utf8.RuneError) {
		t.Fatalf("content contains replacement rune: %q", content)
	}
	if !strings.HasPrefix(content, "abc\n\n[stdout truncated:") {
		t.Fatalf("content = %q, want complete ASCII prefix and truncation marker", content)
	}
}

func TestBoundedOutputKeepsCompleteUTF8AtBoundary(t *testing.T) {
	var out boundedOutput
	out.Limit = len([]byte("abcé"))

	if _, err := out.Write([]byte("abcédef")); err != nil {
		t.Fatal(err)
	}
	if content := out.String("stdout"); !strings.HasPrefix(content, "abcé\n\n[stdout truncated:") {
		t.Fatalf("content = %q, want complete UTF-8 prefix", content)
	}
}

func TestBoundedOutputTrimsTrailingPartialUTF8(t *testing.T) {
	var out boundedOutput
	out.Limit = 4

	if _, err := out.Write([]byte{'a', 'b', 'c', 0xc3}); err != nil {
		t.Fatal(err)
	}
	if _, err := out.Write([]byte{0xa9}); err != nil {
		t.Fatal(err)
	}
	if content := out.String("stdout"); !utf8.ValidString(content) || !strings.HasPrefix(content, "abc\n\n[stdout truncated:") {
		t.Fatalf("content = %q, want valid UTF-8 with partial suffix trimmed", content)
	}
}

func TestUTF8SafePrefixRejectsMalformedLeadByte(t *testing.T) {
	input := []byte{'a', 0xc0, 0x80, 'b'}
	if got := utf8SafePrefixLen(input); got != 1 {
		t.Fatalf("safe prefix length = %d, want 1", got)
	}
}

func TestBoundedOutputDropsMalformedUTF8(t *testing.T) {
	var out boundedOutput
	out.Limit = 4

	if _, err := out.Write([]byte{'a', 0xc0, 0x80, 'b'}); err != nil {
		t.Fatal(err)
	}
	content := out.String("stdout")
	if !utf8.ValidString(content) {
		t.Fatalf("content is not valid UTF-8: %q", content)
	}
	if strings.ContainsRune(content, utf8.RuneError) {
		t.Fatalf("content contains replacement rune: %q", content)
	}
	if !strings.HasPrefix(content, "a\n\n[stdout truncated:") {
		t.Fatalf("content = %q, want valid prefix and truncation marker", content)
	}
}

func TestBashReportsCanceledCommand(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	result, err := (&Bash{}).Execute(ctx, agent.ToolContext{WorkingDir: t.TempDir()}, map[string]any{
		"command": shellTestCommand("sleep 10", "Start-Sleep -Seconds 10"),
	})
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(result.Content, "Error: command was canceled") {
		t.Fatalf("content = %q, want canceled message", result.Content)
	}
	if strings.Contains(result.Content, "Exit code: -1") {
		t.Fatalf("content = %q, should not mask cancellation as exit code", result.Content)
	}
}

func TestRejectUnsafeShellCommand(t *testing.T) {
	tests := []struct {
		name    string
		command string
		wantErr bool
	}{
		{name: "rm root", command: "rm -rf /", wantErr: true},
		{name: "sudo rm root", command: "sudo rm -rf -- /", wantErr: true},
		{name: "rm home", command: "rm -fr $HOME", wantErr: true},
		{name: "rm root wildcard", command: "rm -rf /*", wantErr: true},
		{name: "rm system subdir", command: "rm -rf /etc/ssh", wantErr: true},
		{name: "rm cwd", command: "rm -rf .", wantErr: true},
		{name: "powershell remove root", command: `Remove-Item -Recurse -Force C:\`, wantErr: true},
		{name: "powershell remove system subdir", command: `Remove-Item -Recurse -Force C:\Windows\Temp`, wantErr: true},
		{name: "ssh private key", command: "cat ~/.ssh/id_rsa", wantErr: true},
		{name: "aws credentials", command: "Get-Content $HOME/.aws/credentials", wantErr: true},
		{name: "shadow", command: "head /etc/shadow", wantErr: true},
		{name: "netrc", command: "cat ~/.netrc", wantErr: true},
		{name: "docker config", command: "cat ~/.docker/config.json", wantErr: true},
		{name: "gnupg dir", command: "cat ~/.gnupg/private-keys-v1.d/key", wantErr: true},
		{name: "gh hosts", command: "cat ~/.config/gh/hosts.yml", wantErr: true},
		{name: "ssh config", command: "cat ~/.ssh/config", wantErr: true},
		{name: "printenv dump", command: "printenv", wantErr: false},
		{name: "delete build dir", command: "rm -rf build", wantErr: false},
		{name: "read project file", command: "cat README.md", wantErr: false},
		{name: "mention key text", command: "rg id_rsa docs", wantErr: false},
		{name: "env example", command: "cat .env.example", wantErr: false},
		{name: "rm build then unrelated tilde path", command: "rm -rf build && echo ~/.ssh/config", wantErr: false},
		{name: "rm build then unrelated slash path", command: "rm -rf build; cat /etc/passwd", wantErr: false},
		{name: "rm build then unrelated star glob", command: "rm -rf build && ls *.go", wantErr: false},
		{name: "rm multiple targets one unsafe", command: "rm -rf build /etc", wantErr: true},
		{name: "rm unsafe then safe piped", command: "rm -rf / | tee log", wantErr: true},
		{name: "rm unsafe via command substitution", command: "rm -rf $(echo /)", wantErr: true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := rejectUnsafeShellCommand(tt.command)
			if tt.wantErr && err == nil {
				t.Fatal("expected unsafe command to be rejected")
			}
			if !tt.wantErr && err != nil {
				t.Fatalf("command rejected: %v", err)
			}
		})
	}
}

func TestBashRejectsUnsafeCommandBeforeExecution(t *testing.T) {
	_, err := (&Bash{}).Execute(context.Background(), agent.ToolContext{WorkingDir: t.TempDir()}, map[string]any{
		"command": "rm -rf /",
	})
	if err == nil || !strings.Contains(err.Error(), "refusing to run unsafe command") {
		t.Fatalf("err = %v, want unsafe command rejection", err)
	}
}

func shellTestCommand(unix, windows string) string {
	if runtime.GOOS == "windows" {
		return windows
	}
	return unix
}

func shellTestCapturedXCount() int {
	if runtime.GOOS == "windows" {
		return maxBashOutputBytes
	}
	return maxBashOutputBytes / 2
}

func TestReadFinalWorkingDirRejectsInvalidPaths(t *testing.T) {
	dir := t.TempDir()
	cwdFile := filepath.Join(dir, "cwd")
	notDir := filepath.Join(dir, "file.txt")
	if err := os.WriteFile(notDir, []byte("not a dir"), 0o644); err != nil {
		t.Fatal(err)
	}

	if err := os.WriteFile(cwdFile, []byte(notDir+"\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if got := readFinalWorkingDir(cwdFile); got != "" {
		t.Fatalf("regular file cwd = %q, want empty", got)
	}

	if err := os.WriteFile(cwdFile, []byte(filepath.Join(dir, "missing")+"\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if got := readFinalWorkingDir(cwdFile); got != "" {
		t.Fatalf("missing cwd = %q, want empty", got)
	}

	if err := os.WriteFile(cwdFile, []byte(dir+"\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if got := readFinalWorkingDir(cwdFile); got != dir {
		t.Fatalf("directory cwd = %q, want %q", got, dir)
	}
}

func TestNormalizeBashWorkingDirWindowsDriveLetter(t *testing.T) {
	if runtime.GOOS != "windows" {
		t.Skip("windows path normalization")
	}
	got := normalizeBashWorkingDir("/c/Users/jdoe/project")
	want := filepath.Clean(`C:\Users\jdoe\project`)
	if got != want {
		t.Fatalf("working dir = %q, want %q", got, want)
	}
}
