package control

import (
	"context"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"reasonix/internal/event"
	"reasonix/internal/i18n"
	"reasonix/internal/sandbox"
)

// collectSink returns a Sink that collects events and a channel that receives
// the TurnDone event when the turn finishes. The channel lets tests wait for
// the runGuarded goroutine to complete.
func collectSink() (event.Sink, chan event.Event, *[]event.Event) {
	var events []event.Event
	var mu sync.Mutex
	done := make(chan event.Event, 1)
	sink := event.FuncSink(func(e event.Event) {
		mu.Lock()
		defer mu.Unlock()
		events = append(events, e)
		if e.Kind == event.TurnDone {
			done <- e
		}
	})
	return sink, done, &events
}

func waitForDone(t *testing.T, done chan event.Event) event.Event {
	t.Helper()
	return waitForDoneWithin(t, done, 5*time.Second)
}

func waitForDoneWithin(t *testing.T, done chan event.Event, d time.Duration) event.Event {
	t.Helper()
	select {
	case e := <-done:
		return e
	case <-time.After(d):
		t.Fatal("timed out waiting for TurnDone")
		return event.Event{}
	}
}

func TestRunShell_EmitsEvents(t *testing.T) {
	sink, done, events := collectSink()
	ctrl := &Controller{sink: sink}

	ctrl.RunShell("echo hello")
	waitForDone(t, done)

	if len(*events) < 3 {
		t.Fatalf("expected at least 3 events, got %d: %v", len(*events), *events)
	}

	// First event: ToolDispatch
	if (*events)[0].Kind != event.ToolDispatch {
		t.Errorf("first event: want ToolDispatch, got %v", (*events)[0].Kind)
	}
	if (*events)[0].Tool.Name != "bash" {
		t.Errorf("tool name: want bash, got %s", (*events)[0].Tool.Name)
	}

	// Last event: TurnDone
	td := (*events)[len(*events)-1]
	if td.Kind != event.TurnDone {
		t.Errorf("last event: want TurnDone, got %v", td.Kind)
	}
	if td.CheckpointTurn != nil {
		t.Errorf("shell TurnDone checkpoint = %d, want nil", *td.CheckpointTurn)
	}

	// Penultimate event: ToolResult
	last := (*events)[len(*events)-2]
	if last.Kind != event.ToolResult {
		t.Errorf("penultimate event: want ToolResult, got %v", last.Kind)
	}
	if last.Tool.Err != "" {
		t.Errorf("unexpected error: %s", last.Tool.Err)
	}
	if !strings.Contains(last.Tool.Output, "hello") {
		t.Errorf("output should contain 'hello', got: %s", last.Tool.Output)
	}
}

func TestSubmit_BangPrefix(t *testing.T) {
	sink, done, events := collectSink()
	ctrl := &Controller{sink: sink}

	ctrl.Submit("!echo test")
	waitForDone(t, done)

	if len(*events) == 0 {
		t.Fatal("expected events from !echo, got none")
	}
	if (*events)[0].Kind != event.ToolDispatch {
		t.Errorf("first event: want ToolDispatch, got %v", (*events)[0].Kind)
	}
}

func TestSubmit_BangEmpty(t *testing.T) {
	var notices []string
	sink := event.FuncSink(func(e event.Event) {
		if e.Kind == event.Notice {
			notices = append(notices, e.Text)
		}
	})

	ctrl := &Controller{sink: sink}
	ctrl.Submit("!")

	if len(notices) == 0 {
		t.Fatal("expected a notice for bare !")
	}
	if !strings.Contains(notices[0], "!") {
		t.Errorf("notice should mention usage, got: %s", notices[0])
	}
}

func TestSubmit_BangNotFirstChar(t *testing.T) {
	// "! " not at position 0 should NOT trigger shell. Submit routes to
	// runRefTurn for normal text, which needs a runner — so we test the
	// prefix-check condition directly.
	input := "tell me about !important"
	trimmed := strings.TrimSpace(input)
	if strings.HasPrefix(trimmed, "!") {
		t.Error("trimmed input should not start with !")
	}
}

func TestRunShell_FailingCommand(t *testing.T) {
	sink, done, events := collectSink()
	ctrl := &Controller{sink: sink}

	ctrl.RunShell("false") // exits 1
	waitForDone(t, done)

	// Find the ToolResult
	var result *event.Event
	for i := range *events {
		if (*events)[i].Kind == event.ToolResult {
			result = &(*events)[i]
			break
		}
	}
	if result == nil {
		t.Fatal("expected a ToolResult event")
	} else if result.Tool.Err == "" {
		t.Error("failing command should produce an error string")
	}
}

func TestRunShell_CancelStopsCommand(t *testing.T) {
	sink, done, events := collectSink()
	ctrl := &Controller{sink: sink}

	command := "sleep 30"
	if sandbox.ResolveShell("", "", nil).Kind == sandbox.ShellPowerShell {
		command = "Start-Sleep -Seconds 30"
	}
	ctrl.RunShell(command)
	time.Sleep(100 * time.Millisecond)
	ctrl.Cancel()

	// Cancel kills the shell via the run context, but cmd.Wait honours
	// shellWaitDelay (and on Windows cmd.Cancel spawns taskkill /F /T), so
	// TurnDone can arrive almost a full shellWaitDelay after Cancel. Wait
	// comfortably longer than that grace — a flat 5s budget equalled
	// shellWaitDelay and lost the race on a loaded windows runner.
	e := waitForDoneWithin(t, done, shellWaitDelay+10*time.Second)
	if e.Kind != event.TurnDone {
		t.Fatalf("done event kind = %v, want TurnDone", e.Kind)
	}
	if e.Err != nil {
		t.Fatalf("cancelled shell TurnDone err = %v, want nil", e.Err)
	}
	var result *event.Event
	for i := range *events {
		if (*events)[i].Kind == event.ToolResult {
			result = &(*events)[i]
			break
		}
	}
	if result == nil {
		t.Fatal("expected ToolResult for cancelled shell")
	}
	if result.Tool.Err != i18n.M.TurnCancelled {
		t.Fatalf("cancelled shell result err = %q, want %q", result.Tool.Err, i18n.M.TurnCancelled)
	}
}

func TestRunShell_HeredocCancelReleasesTurn(t *testing.T) {
	sh := requireRunShellHereDocBash(t)
	sink, done, events := collectSink()
	root := t.TempDir()
	target := filepath.Join(root, "test_redact.go")
	ctrl := &Controller{sink: sink, shell: sh, workspaceRoot: root}

	command := strings.Join([]string{
		"cat > " + controlShellQuote(filepath.ToSlash(target)) + " <<'EOF'",
		"package main",
		"",
		"import (",
		"\t\"encoding/json\"",
		"\t\"fmt\"",
		")",
		"",
		"func main() {",
		"\tdata := []byte(`{\"accounts\":[{\"id\":\"a1\",\"username\":\"alice\",\"token\":\"TOKEN_EXAMPLE\"}]}`)",
		"\tvar v any",
		"\tjson.Unmarshal(data, &v)",
		"\tfmt.Printf(\"before: %v\\n\", v)",
		"}",
		"EOF",
		"sleep 30",
	}, "\n")

	ctrl.RunShell(command)
	if !waitForFileContainingWithin(target, "TOKEN_EXAMPLE", 2*time.Second) {
		ctrl.Cancel()
		waitForDoneWithin(t, done, shellWaitDelay+10*time.Second)
		t.Fatalf("heredoc target body was not written before cancel: %s", target)
	}
	ctrl.Cancel()

	e := waitForDoneWithin(t, done, shellWaitDelay+10*time.Second)
	if e.Kind != event.TurnDone {
		t.Fatalf("done event kind = %v, want TurnDone", e.Kind)
	}
	if e.Err != nil {
		t.Fatalf("cancelled heredoc shell TurnDone err = %v, want nil", e.Err)
	}
	var result *event.Event
	for i := range *events {
		if (*events)[i].Kind == event.ToolResult {
			result = &(*events)[i]
			break
		}
	}
	if result == nil {
		t.Fatal("expected ToolResult for cancelled heredoc shell")
	}
	if result.Tool.Err != i18n.M.TurnCancelled {
		t.Fatalf("cancelled heredoc shell result err = %q, want %q", result.Tool.Err, i18n.M.TurnCancelled)
	}
	data, err := os.ReadFile(target)
	if err != nil {
		t.Fatalf("read heredoc target: %v", err)
	}
	if !strings.Contains(string(data), "TOKEN_EXAMPLE") {
		t.Fatalf("heredoc target missing expected body:\n%s", data)
	}
}

func requireRunShellHereDocBash(t *testing.T) sandbox.Shell {
	t.Helper()
	sh := sandbox.ResolveShell("bash", "", nil)
	if sh.Kind != sandbox.ShellBash {
		t.Skipf("bash heredoc regression requires bash, got %s", sh.Kind.String())
	}
	path := sh.Path
	if path == "" {
		path = "bash"
	}
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	if err := exec.CommandContext(ctx, path, "-c", "true").Run(); err != nil {
		t.Skipf("bash heredoc regression requires a runnable bash: %v", err)
	}
	sh.Path = path
	return sh
}

func waitForFileContainingWithin(path, want string, d time.Duration) bool {
	deadline := time.Now().Add(d)
	for time.Now().Before(deadline) {
		if data, err := os.ReadFile(path); err == nil && strings.Contains(string(data), want) {
			return true
		}
		time.Sleep(20 * time.Millisecond)
	}
	return false
}

func controlShellQuote(s string) string {
	return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}
