package runtime

// Tests for process-isolated sub-agents (US-019, #135): the Isolation field,
// the process-mode Execute path (params shaping, crash-as-tool-error), the
// shared RunSubAgentOnce core, and the real defaultProcessCall transport
// (exec + stdio JSON-RPC + crash handling) driven through a tiny compiled
// helper binary - no provider or network, mirroring the plugin tests.

import (
	"context"
	"encoding/json"
	"errors"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	goruntime "runtime"

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

// errorTurn scripts a turn whose final message stops on StopReasonError, so a
// RunSubAgentOnce run surfaces as a failure (matching the goroutine-mode
// "failed run -> tool error" contract).
func errorTurn(msg string) fauxTurn {
	partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
	final := partial
	final.StopReason = agentcore.StopReasonError
	final.ErrorMessage = msg
	return fauxTurn{
		provider.StreamStartEvent{Partial: partial},
		provider.StreamDoneEvent{Message: final},
	}
}

// TestSubAgentGoroutineModeUnchanged verifies the default (zero-value) isolation
// still runs in-process: the existing parent->child->parent tests cover the
// full path, but this pins that Isolation==goroutine is the default and reaches
// NewRunConfig (not the process path) so the regression is caught locally too.
func TestSubAgentGoroutineModeUnchanged(t *testing.T) {
	child := &fauxProvider{
		name:   "faux-child",
		models: []provider.Model{{Provider: "faux-child", ID: "child"}},
		turns:  []fauxTurn{textTurn("child answer")},
	}
	sub := NewSubAgentTool(SubAgentSpec{
		Name:         "researcher",
		SystemPrompt: "you are a researcher",
		NewRunConfig: func() RunConfig { return newFauxRunCfg(child) },
	})
	if sub.spec.Isolation != SubAgentIsolationGoroutine {
		t.Errorf("default Isolation = %v, want goroutine", sub.spec.Isolation)
	}
	res, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"go"}`), nil)
	if err != nil {
		t.Fatalf("goroutine Execute err = %v", err)
	}
	if got := agentcore.ContentToText(res.Content); got != "child answer" {
		t.Errorf("goroutine result = %q, want 'child answer'", got)
	}
	if child.callCount() != 1 {
		t.Errorf("child provider calls = %d, want 1", child.callCount())
	}
}

// TestSubAgentGoroutineNilRunConfigErrors verifies goroutine mode reports a
// missing NewRunConfig, and - preserving the original precedence - reports it
// even when the prompt is empty (so a nil config is not masked by "empty
// prompt"). This pins the L3 regression: the NewRunConfig check stays before
// the empty-prompt check on the goroutine path.
func TestSubAgentGoroutineNilRunConfigErrors(t *testing.T) {
	sub := NewSubAgentTool(SubAgentSpec{Name: "x"}) // no NewRunConfig, default goroutine
	_, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"go"}`), nil)
	if err == nil || !strings.Contains(err.Error(), "no run configuration") {
		t.Errorf("err = %v, want 'no run configuration'", err)
	}
	// Precedence: nil NewRunConfig is reported even when the prompt is empty.
	_, err = sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":""}`), nil)
	if err == nil || !strings.Contains(err.Error(), "no run configuration") {
		t.Errorf("empty-prompt err = %v, want 'no run configuration' (nil config takes precedence)", err)
	}
}

// TestSubAgentProcessModeFake drives process mode through an injectable
// processCall (the test seam) to verify params shaping and crash-as-tool-error
// without a real subprocess. The real transport is covered separately by
// TestSubAgentProcessDefaultCall.
func TestSubAgentProcessModeFake(t *testing.T) {
	t.Run("happy", func(t *testing.T) {
		sub := NewSubAgentTool(SubAgentSpec{
			Name:         "proc",
			Isolation:    SubAgentIsolationProcess,
			Process:      SubAgentProcessConfig{Model: "faux"},
			SystemPrompt: "you are a subprocess child",
		})
		var got SubAgentRunParams
		sub.processCall = func(ctx context.Context, cfg SubAgentProcessConfig, params SubAgentRunParams) (string, error) {
			got = params
			return "process result: 99", nil
		}
		res, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"find it"}`), nil)
		if err != nil {
			t.Fatalf("Execute err = %v", err)
		}
		if got := agentcore.ContentToText(res.Content); got != "process result: 99" {
			t.Errorf("result = %q, want 'process result: 99'", got)
		}
		// The prompt, system prompt, and model are forwarded to the subprocess;
		// NewRunConfig is NOT called (the subprocess resolves its own provider).
		if got.Prompt != "find it" || got.Model != "faux" || got.SystemPrompt != "you are a subprocess child" {
			t.Errorf("forwarded params = %+v", got)
		}
	})

	t.Run("crash is tool error", func(t *testing.T) {
		sub := NewSubAgentTool(SubAgentSpec{
			Name:      "proc",
			Isolation: SubAgentIsolationProcess,
			Process:   SubAgentProcessConfig{Model: "faux"},
		})
		sub.processCall = func(context.Context, SubAgentProcessConfig, SubAgentRunParams) (string, error) {
			return "", errors.New("subprocess exited: signal: killed")
		}
		_, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"x"}`), nil)
		if err == nil {
			t.Fatal("expected tool error on subprocess crash, got nil")
		}
	})

	t.Run("missing model errors", func(t *testing.T) {
		sub := NewSubAgentTool(SubAgentSpec{
			Name:      "proc",
			Isolation: SubAgentIsolationProcess,
			// Process.Model intentionally empty.
		})
		_, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"x"}`), nil)
		if err == nil {
			t.Fatal("expected error when Process.Model is missing")
		}
	})

	t.Run("forwards tool names", func(t *testing.T) {
		// spec.Tools is in-process; process mode forwards only their names so
		// the subprocess can rebuild builtins by name.
		sub := NewSubAgentTool(SubAgentSpec{
			Name:      "proc",
			Isolation: SubAgentIsolationProcess,
			Process:   SubAgentProcessConfig{Model: "faux"},
			Tools:     []agentcore.AgentTool{nameOnlyTool("read"), nameOnlyTool("grep")},
		})
		var got SubAgentRunParams
		sub.processCall = func(_ context.Context, _ SubAgentProcessConfig, params SubAgentRunParams) (string, error) {
			got = params
			return "ok", nil
		}
		if _, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"x"}`), nil); err != nil {
			t.Fatalf("Execute err = %v", err)
		}
		if len(got.Tools) != 2 || got.Tools[0] != "read" || got.Tools[1] != "grep" {
			t.Errorf("forwarded tool names = %v, want [read grep]", got.Tools)
		}
	})
}

// nameOnlyTool is a minimal AgentTool whose only meaningful attribute is its
// Name, used to verify process mode forwards tool names without needing real
// tool implementations.
func nameOnlyTool(name string) agentcore.AgentTool { return nameOnly{name: name} }

type nameOnly struct{ name string }

func (t nameOnly) Name() string            { return t.name }
func (t nameOnly) Description() string     { return "" }
func (t nameOnly) Schema() json.RawMessage { return json.RawMessage(`{}`) }
func (t nameOnly) ExecutionMode() agentcore.ToolExecutionMode {
	return agentcore.ToolExecutionParallel
}
func (t nameOnly) Execute(context.Context, string, json.RawMessage, agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
	return agentcore.AgentToolResult{}, nil
}

// TestRunSubAgentOnce verifies the shared subprocess-side agent core: a normal
// run returns the child's final text, and a run whose final turn stopped on
// error is reported as an error (so the subprocess surfaces it as an RPC error
// and the parent marks the tool result IsError).
func TestRunSubAgentOnce(t *testing.T) {
	t.Run("success", func(t *testing.T) {
		p := &fauxProvider{
			name:   "faux",
			models: []provider.Model{{Provider: "faux", ID: "faux"}},
			turns:  []fauxTurn{textTurn("hello from child")},
		}
		text, err := RunSubAgentOnce(context.Background(), "sys", "do it", nil, newFauxRunCfg(p))
		if err != nil {
			t.Fatalf("err = %v", err)
		}
		if text != "hello from child" {
			t.Errorf("text = %q, want 'hello from child'", text)
		}
		if p.callCount() != 1 {
			t.Errorf("provider calls = %d, want 1", p.callCount())
		}
	})

	t.Run("failed run errors", func(t *testing.T) {
		p := &fauxProvider{
			name:   "faux",
			models: []provider.Model{{Provider: "faux", ID: "faux"}},
			turns:  []fauxTurn{errorTurn("boom")},
		}
		_, err := RunSubAgentOnce(context.Background(), "sys", "do it", nil, newFauxRunCfg(p))
		if err == nil {
			t.Fatal("expected error for failed child run, got nil")
		}
		// The diagnostic is in ErrorMessage (errorTurn sets no Content); the
		// subprocess must surface it rather than a bare "error" stop reason.
		if !strings.Contains(err.Error(), "boom") {
			t.Errorf("error %q does not contain the 'boom' diagnostic", err.Error())
		}
	})
}

// TestSubAgentProcessDefaultCall exercises the real defaultProcessCall transport
// (exec + stdio JSON-RPC) against a tiny compiled helper binary. It verifies the
// happy round-trip (prompt forwarded, result decoded) and that a crashing
// subprocess is surfaced as a Go error (the AC: "a subprocess crash is caught by the parent as a tool error").
func TestSubAgentProcessDefaultCall(t *testing.T) {
	bin := buildSubAgentHelper(t)
	cfg := SubAgentProcessConfig{Command: bin}

	t.Run("happy round-trip", func(t *testing.T) {
		text, err := defaultProcessCall(context.Background(), cfg, SubAgentRunParams{
			Prompt: "hello", Model: "faux", SystemPrompt: "sys",
		})
		if err != nil {
			t.Fatalf("defaultProcessCall err = %v", err)
		}
		if text != "echo: hello" {
			t.Errorf("text = %q, want 'echo: hello'", text)
		}
	})

	t.Run("crash is error", func(t *testing.T) {
		_, err := defaultProcessCall(context.Background(), cfg, SubAgentRunParams{
			Prompt: "CRASH", Model: "faux",
		})
		if err == nil {
			t.Fatal("expected error for crashing subprocess, got nil")
		}
	})
}

// buildSubAgentHelper compiles the sub-agent helper binary into a temp dir and
// returns its path. The helper speaks the same JSON-RPC "subagent/run" wire
// format as pigo --subagent-rpc (decoded with plain encoding/json, no internal
// imports, so it stays a standalone main package).
func buildSubAgentHelper(t *testing.T) string {
	t.Helper()
	dir := t.TempDir()
	srcPath := filepath.Join(dir, "subagent_helper.go")
	if err := os.WriteFile(srcPath, []byte(subAgentHelperSrc), 0o644); err != nil {
		t.Fatalf("write helper source: %v", err)
	}
	bin := filepath.Join(dir, "subagent_helper")
	if goruntime.GOOS == "windows" {
		bin += ".exe"
	}
	cmd := exec.Command("go", "build", "-o", bin, srcPath)
	cmd.Env = os.Environ()
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("build sub-agent helper: %v\n%s", err, out)
	}
	return bin
}

// subAgentHelperSrc is a tiny JSON-RPC server mirroring pigo --subagent-rpc:
// read a "subagent/run" request per line, and either respond with
// {text:"echo: <prompt>"} or, when the prompt is "CRASH", exit without
// responding to simulate a subprocess crash.
const subAgentHelperSrc = `package main

import (
	"bufio"
	"encoding/json"
	"os"
)

type params struct {
	Prompt string ` + "`json:\"prompt\"`" + `
}

type request struct {
	ID     *json.RawMessage ` + "`json:\"id\"`" + `
	Method string           ` + "`json:\"method\"`" + `
	Params params           ` + "`json:\"params\"`" + `
}

func main() {
	sc := bufio.NewScanner(os.Stdin)
	sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
	enc := json.NewEncoder(os.Stdout)
	for sc.Scan() {
		line := sc.Bytes()
		if len(line) == 0 {
			continue
		}
		var r request
		if err := json.Unmarshal(line, &r); err != nil {
			continue
		}
		if r.Params.Prompt == "CRASH" {
			// Simulate a subprocess crash: exit without writing a response so
			// the parent's JSON-RPC reader sees EOF and fails the call.
			os.Exit(1)
		}
		result, _ := json.Marshal(map[string]string{"text": "echo: " + r.Params.Prompt})
		resp := map[string]any{
			"jsonrpc": "2.0",
			"id":      r.ID,
			"result":  json.RawMessage(result),
		}
		_ = enc.Encode(resp)
	}
}
`
