package agenttool

import (
	"context"
	"encoding/json"
	"fmt"
	"runtime"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/smallnest/pigo/internal/agentcore"
)

func runBash(t *testing.T, tool *BashTool, args map[string]any, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
	t.Helper()
	raw, err := json.Marshal(args)
	if err != nil {
		t.Fatalf("marshal args: %v", err)
	}
	return tool.Execute(context.Background(), "call-1", raw, onUpdate)
}

func TestBashToolSuccess(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("bash not available on windows")
	}
	tool := &BashTool{}
	res, gerr := runBash(t, tool, map[string]any{"command": "echo hello"}, nil)
	if gerr != nil {
		t.Fatalf("unexpected go error: %v", gerr)
	}
	if !strings.Contains(resultText(res), "hello") {
		t.Errorf("output = %q, want to contain hello", resultText(res))
	}
	details, ok := res.Details.(map[string]any)
	if !ok || details["exitCode"] != 0 {
		t.Errorf("expected exitCode 0, details = %+v", res.Details)
	}
}

func TestBashToolNonZeroExitIsError(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("bash not available on windows")
	}
	tool := &BashTool{}
	res, gerr := runBash(t, tool, map[string]any{"command": "echo oops >&2; exit 3"}, nil)
	// A non-zero exit must surface as a Go error so the executor flags isError.
	if gerr == nil {
		t.Fatalf("expected go error for non-zero exit, got nil")
	}
	if !strings.Contains(gerr.Error(), "code 3") {
		t.Errorf("error = %q, want to mention code 3", gerr.Error())
	}
	// The captured output must ride along.
	if !strings.Contains(gerr.Error(), "oops") {
		t.Errorf("error = %q, want to carry output", gerr.Error())
	}
	details, ok := res.Details.(map[string]any)
	if !ok || details["exitCode"] != 3 {
		t.Errorf("expected exitCode 3, details = %+v", res.Details)
	}
}

func TestBashToolStreaming(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("bash not available on windows")
	}
	tool := &BashTool{}
	var mu sync.Mutex
	var updates []string
	onUpdate := func(r agentcore.AgentToolResult) {
		mu.Lock()
		updates = append(updates, resultText(r))
		mu.Unlock()
	}
	_, gerr := runBash(t, tool, map[string]any{"command": "printf 'a'; printf 'b'"}, onUpdate)
	if gerr != nil {
		t.Fatalf("unexpected go error: %v", gerr)
	}
	mu.Lock()
	defer mu.Unlock()
	if len(updates) == 0 {
		t.Fatalf("expected streaming updates, got none")
	}
	// The final partial should be the full accumulated output.
	if last := updates[len(updates)-1]; !strings.Contains(last, "ab") {
		t.Errorf("final update = %q, want to contain ab", last)
	}
}

func TestBashToolTimeout(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("bash not available on windows")
	}
	tool := &BashTool{}
	start := time.Now()
	res, gerr := runBash(t, tool, map[string]any{"command": "sleep 5", "timeout_ms": 100}, nil)
	if gerr == nil {
		t.Fatalf("expected timeout error, got nil")
	}
	if !strings.Contains(gerr.Error(), "timed out") {
		t.Errorf("error = %q, want to mention timed out", gerr.Error())
	}
	if elapsed := time.Since(start); elapsed > 3*time.Second {
		t.Errorf("timeout took too long: %s (process not killed?)", elapsed)
	}
	_ = res
}

func TestBashToolCancel(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("bash not available on windows")
	}
	tool := &BashTool{}
	ctx, cancel := context.WithCancel(context.Background())
	raw, _ := json.Marshal(map[string]any{"command": "sleep 5"})
	go func() {
		time.Sleep(100 * time.Millisecond)
		cancel()
	}()
	start := time.Now()
	_, gerr := tool.Execute(ctx, "call-1", raw, nil)
	if gerr == nil {
		t.Fatalf("expected cancellation error, got nil")
	}
	if elapsed := time.Since(start); elapsed > 3*time.Second {
		t.Errorf("cancel took too long: %s (process not killed?)", elapsed)
	}
}

func TestBashToolMissingCommand(t *testing.T) {
	tool := &BashTool{}
	res, gerr := runBash(t, tool, map[string]any{"command": ""}, nil)
	if gerr != nil {
		t.Fatalf("unexpected go error: %v", gerr)
	}
	if !strings.Contains(resultText(res), "command is required") {
		t.Errorf("expected command-required error, got %q", resultText(res))
	}
}

func TestBashToolMode(t *testing.T) {
	tool := &BashTool{}
	if tool.Name() != "bash" {
		t.Errorf("name = %q", tool.Name())
	}
	if tool.ExecutionMode() != agentcore.ToolExecutionSequential {
		t.Error("bash should be sequential")
	}
	var schema map[string]any
	if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
		t.Errorf("schema not valid JSON: %v", err)
	}
}

func TestBashToolSmallOutputNotTruncated(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("bash not available on windows")
	}
	tool := &BashTool{}
	res, gerr := runBash(t, tool, map[string]any{"command": "echo hello world"}, nil)
	if gerr != nil {
		t.Fatalf("unexpected go error: %v", gerr)
	}
	out := resultText(res)
	if strings.Contains(out, "truncated") {
		t.Errorf("small output should not be truncated, got %q", out)
	}
	if strings.TrimSpace(out) != "hello world" {
		t.Errorf("output = %q, want %q", out, "hello world")
	}
}

func TestBashToolLargeOutputTruncatedHeadTail(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("bash not available on windows")
	}
	tool := &BashTool{}
	// Emit a marker at the very start and very end, with a large filler between,
	// so we can prove both the head and the tail survive truncation.
	total := bashMaxOutputBytes * 3
	filler := bashMaxOutputBytes // bytes of 'x' between the two markers
	cmd := fmt.Sprintf("printf 'HEADMARK'; head -c %d /dev/zero | tr '\\0' 'x'; printf 'TAILMARK'", filler)
	_ = total
	res, gerr := runBash(t, tool, map[string]any{"command": cmd}, nil)
	if gerr != nil {
		t.Fatalf("unexpected go error: %v", gerr)
	}
	out := resultText(res)
	if len(out) > bashMaxOutputBytes+128 {
		t.Errorf("truncated output too long: %d bytes (cap %d)", len(out), bashMaxOutputBytes)
	}
	if !strings.HasPrefix(out, "HEADMARK") {
		t.Errorf("head not preserved; output starts with %q", out[:min(16, len(out))])
	}
	if !strings.HasSuffix(out, "TAILMARK") {
		t.Errorf("tail not preserved; output ends with %q", out[max(0, len(out)-16):])
	}
	if !strings.Contains(out, "[truncated ") || !strings.Contains(out, " bytes]") {
		t.Errorf("missing truncation marker in %q", out)
	}
}

func TestTruncateBashOutputByteCount(t *testing.T) {
	// A pure-ASCII input of a known size: the marker's N must equal the exact
	// number of middle bytes dropped, i.e. total - head - tail.
	total := bashMaxOutputBytes * 2
	in := strings.Repeat("z", total)
	out := truncateBashOutput(in)

	half := bashMaxOutputBytes / 2
	// For all-ASCII input no rune-boundary trimming happens, so head/tail are
	// each exactly half and N = total - 2*half.
	wantRemoved := total - 2*half
	wantMarker := fmt.Sprintf("[truncated %d bytes]", wantRemoved)
	if !strings.Contains(out, wantMarker) {
		t.Errorf("marker = ...%q..., want to contain %q", out, wantMarker)
	}
	if got := strings.Count(out, "z"); got != 2*half {
		t.Errorf("preserved %d content bytes, want %d (head+tail)", got, 2*half)
	}

	// Input at or below the cap is returned verbatim.
	small := strings.Repeat("b", bashMaxOutputBytes)
	if got := truncateBashOutput(small); got != small {
		t.Errorf("input at cap should be unchanged")
	}
}

// TestResolveShell covers the platform-aware interpreter selection (issue #518).
// It injects goos + a lookPath stub so every branch runs regardless of the host.
func TestResolveShell(t *testing.T) {
	found := func(name string) func(string) (string, error) {
		return func(s string) (string, error) {
			if s == name {
				return `C:\bin\` + s, nil
			}
			return "", fmt.Errorf("not found")
		}
	}
	none := func(string) (string, error) { return "", fmt.Errorf("not found") }

	tests := []struct {
		name           string
		explicit, goos string
		lookPath       func(string) (string, error)
		wantFlag       string
		wantShellHas   string // substring the resolved shell must contain
	}{
		{"explicit honored on windows", "zsh", "windows", none, "-c", "zsh"},
		{"explicit honored on linux", "fish", "linux", none, "-c", "fish"},
		{"non-windows always bash", "", "linux", none, "-c", "bash"},
		{"darwin always bash", "", "darwin", none, "-c", "bash"},
		{"windows with bash", "", "windows", found("bash"), "-c", "bash"},
		{"windows falls back to powershell", "", "windows", found("powershell"), "-Command", "powershell"},
		{"windows falls back to cmd", "", "windows", none, "/C", "cmd"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			shell, flag := resolveShell(tc.explicit, tc.goos, tc.lookPath)
			if flag != tc.wantFlag {
				t.Errorf("flag = %q, want %q", flag, tc.wantFlag)
			}
			if !strings.Contains(shell, tc.wantShellHas) {
				t.Errorf("shell = %q, want to contain %q", shell, tc.wantShellHas)
			}
		})
	}
}

