package runtime

// This file is the end-to-end test for the headless / stdio run modes (US-020,
// #39). It drives RunHeadless over the real faux provider seam (no loop-internal
// mocking) and asserts the two output contracts — PrintMode's final text and
// StreamJSONMode's line-delimited JSON events — plus the success/failure signal
// that the CLI maps to a process exit code.

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"strings"
	"testing"

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

// TestRunHeadlessPrintMode runs a text→tool→text scenario through RunHeadless in
// PrintMode and asserts that only the final assistant text reaches the writer,
// terminated by a newline, and that the run reports success (nil error).
func TestRunHeadlessPrintMode(t *testing.T) {
	p := &fauxProvider{
		name:   "faux",
		models: []provider.Model{{Provider: "faux", ID: "faux"}},
		turns: []fauxTurn{
			toolCallTurn("call-1", "echo", `{"msg":"hi"}`), // turn 1: tool call
			textTurn("final answer"),                       // turn 2: final text
		},
	}
	cfg := newFauxRunCfg(p, echoTool("echo", agentcore.ToolExecutionParallel, false))
	var out bytes.Buffer
	agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("start")}}}}

	err := RunHeadless(context.Background(), agentCtx, HeadlessConfig{Run: cfg, Mode: PrintMode, Out: &out})
	if err != nil {
		t.Fatalf("RunHeadless print mode: unexpected error %v", err)
	}
	got := out.String()
	if got != "final answer\n" {
		t.Errorf("print mode output = %q, want %q", got, "final answer\n")
	}
}

// TestRunHeadlessStreamJSON runs the same scenario in StreamJSONMode and asserts
// every line is a valid JSON object carrying a "type" discriminant, that the run
// is bracketed by agent_start/agent_end, and that a tool execution is reported —
// the machine-readable protocol a parent process consumes.
func TestRunHeadlessStreamJSON(t *testing.T) {
	p := &fauxProvider{
		name:   "faux",
		models: []provider.Model{{Provider: "faux", ID: "faux"}},
		turns: []fauxTurn{
			toolCallTurn("call-1", "echo", `{"msg":"hi"}`),
			textTurn("done"),
		},
	}
	cfg := newFauxRunCfg(p, echoTool("echo", agentcore.ToolExecutionParallel, false))
	var out bytes.Buffer
	agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("start")}}}}

	if err := RunHeadless(context.Background(), agentCtx, HeadlessConfig{Run: cfg, Mode: StreamJSONMode, Out: &out}); err != nil {
		t.Fatalf("RunHeadless stream-json: unexpected error %v", err)
	}

	var types []string
	sc := bufio.NewScanner(&out)
	for sc.Scan() {
		line := sc.Bytes()
		if len(bytes.TrimSpace(line)) == 0 {
			continue
		}
		var env map[string]any
		if err := json.Unmarshal(line, &env); err != nil {
			t.Fatalf("stream-json line is not valid JSON: %q (%v)", line, err)
		}
		typ, ok := env["type"].(string)
		if !ok || typ == "" {
			t.Errorf("stream-json line missing type discriminant: %q", line)
		}
		types = append(types, typ)
	}
	if len(types) == 0 {
		t.Fatal("stream-json produced no event lines")
	}
	if types[0] != agentcore.EventAgentStart || types[len(types)-1] != agentcore.EventAgentEnd {
		t.Errorf("stream must be bracketed by agent_start/agent_end, got %v", types)
	}
	if !contains(types, agentcore.EventToolExecutionEnd) {
		t.Errorf("expected a tool_execution_end event, got %v", types)
	}
}

// TestRunHeadlessStreamJSONSessionID verifies that when RunConfig.SessionID is
// set, the first stream-json event (agent_start) carries it under "sessionId",
// so a consumer can associate the run's output with a session and resume it
// later (mirrors pi/Claude Code). When SessionID is empty the key is omitted.
func TestRunHeadlessStreamJSONSessionID(t *testing.T) {
	run := func(sessionID string) map[string]any {
		p := &fauxProvider{
			name:   "faux",
			models: []provider.Model{{Provider: "faux", ID: "faux"}},
			turns:  []fauxTurn{textTurn("done")},
		}
		cfg := newFauxRunCfg(p)
		cfg.SessionID = sessionID
		var out bytes.Buffer
		agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("start")}}}}
		if err := RunHeadless(context.Background(), agentCtx, HeadlessConfig{Run: cfg, Mode: StreamJSONMode, Out: &out}); err != nil {
			t.Fatalf("RunHeadless stream-json: unexpected error %v", err)
		}
		sc := bufio.NewScanner(&out)
		for sc.Scan() {
			line := sc.Bytes()
			if len(bytes.TrimSpace(line)) == 0 {
				continue
			}
			var env map[string]any
			if err := json.Unmarshal(line, &env); err != nil {
				t.Fatalf("stream-json line is not valid JSON: %q (%v)", line, err)
			}
			if env["type"] == agentcore.EventAgentStart {
				return env
			}
		}
		t.Fatal("no agent_start event found")
		return nil
	}

	first := run("sess-123")
	if got, ok := first["sessionId"].(string); !ok || got != "sess-123" {
		t.Errorf("agent_start sessionId = %v, want %q", first["sessionId"], "sess-123")
	}

	none := run("")
	if _, present := none["sessionId"]; present {
		t.Errorf("agent_start must omit sessionId when SessionID is empty, got %v", none["sessionId"])
	}
}

// TestRunHeadlessReportsFailure verifies that a run whose final assistant message
// carries stopReason=error surfaces as an ErrRunFailed, so the CLI maps it to a
// non-zero exit code.
func TestRunHeadlessReportsFailure(t *testing.T) {
	errPartial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
	errFinal := errPartial
	errFinal.StopReason = agentcore.StopReasonError
	errFinal.ErrorMessage = "boom"
	p := &fauxProvider{
		name: "faux",
		turns: []fauxTurn{
			{
				provider.StreamStartEvent{Partial: errPartial},
				provider.StreamDoneEvent{Message: errFinal},
			},
		},
	}
	cfg := newFauxRunCfg(p)
	var out bytes.Buffer
	agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}

	err := RunHeadless(context.Background(), agentCtx, HeadlessConfig{Run: cfg, Mode: PrintMode, Out: &out})
	if err == nil {
		t.Fatal("run ending in stopReason=error must return a non-nil error")
	}
	var failed *ErrRunFailed
	if !as(err, &failed) {
		t.Fatalf("error = %T (%v), want *ErrRunFailed", err, err)
	}
	if !strings.Contains(failed.Error(), "boom") {
		t.Errorf("error message = %q, want it to mention the failure reason", failed.Error())
	}
}

// TestRunHeadlessNilWriter guards the misconfiguration path.
func TestRunHeadlessNilWriter(t *testing.T) {
	p := &fauxProvider{turns: []fauxTurn{textTurn("x")}}
	cfg := newFauxRunCfg(p)
	agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}}
	if err := RunHeadless(context.Background(), agentCtx, HeadlessConfig{Run: cfg, Out: nil}); err == nil {
		t.Fatal("nil output writer must be rejected")
	}
}

// emitTool returns a tool that surfaces ev on the run stream via the run-level
// progress emitter the loop injects into ctx (WithProgressEmitter), then returns
// a trivial text result. This mirrors how a dispatched sub-agent surfaces a
// SubAgentProgressEvent up the parent stream.
func emitTool(name string, ev agentcore.AgentEvent) execTool {
	return execTool{
		name: name,
		mode: agentcore.ToolExecutionParallel,
		run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
			if emit := agentcore.ProgressEmitterFromContext(ctx); emit != nil {
				_ = emit(ctx, ev)
			}
			return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil
		},
	}
}

// TestRunHeadlessSubAgentProgressToStderr verifies the D-9 contract: a
// SubAgentProgressEvent emitted during the run is rendered as a human-readable
// line to the progress writer (stderr) and is NEVER serialised onto stdout —
// neither the final result text nor the stream-json envelope stream may contain
// it. The event is injected via a faux tool whose execution fires it on the
// run's event stream (the same seam the loop uses).
func TestRunHeadlessSubAgentProgressToStderr(t *testing.T) {
	const desc = "investigate the parser"
	const activity = "Editing"

	run := func(mode HeadlessMode) (stdout, stderr string) {
		p := &fauxProvider{
			name:   "faux",
			models: []provider.Model{{Provider: "faux", ID: "faux"}},
			turns: []fauxTurn{
				toolCallTurn("call-1", "task", `{"description":"investigate the parser"}`),
				textTurn("done"),
			},
		}
		// The tool emits a SubAgentProgressEvent onto the run stream, mimicking a
		// dispatched sub-agent surfacing progress up the parent stream.
		tool := emitTool("task", agentcore.SubAgentProgressEvent{
			ToolCallID:  "call-1",
			Description: desc,
			Activity:    activity,
		})
		cfg := newFauxRunCfg(p, tool)
		var out, prog bytes.Buffer
		agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("start")}}}}
		if err := RunHeadless(context.Background(), agentCtx, HeadlessConfig{Run: cfg, Mode: mode, Out: &out, Progress: &prog}); err != nil {
			t.Fatalf("RunHeadless: unexpected error %v", err)
		}
		return out.String(), prog.String()
	}

	for _, mode := range []struct {
		name string
		mode HeadlessMode
	}{{"print", PrintMode}, {"stream-json", StreamJSONMode}} {
		t.Run(mode.name, func(t *testing.T) {
			stdout, stderr := run(mode.mode)
			// (a) stderr carries the progress line with description + activity.
			if !strings.Contains(stderr, desc) || !strings.Contains(stderr, activity) {
				t.Errorf("stderr = %q, want it to contain description %q and activity %q", stderr, desc, activity)
			}
			// (b) stdout must not contain the progress event in any form.
			if strings.Contains(stdout, "subagent_progress") {
				t.Errorf("stdout must not contain the subagent_progress envelope, got %q", stdout)
			}
			if strings.Contains(stdout, desc) {
				t.Errorf("stdout must not leak the progress description, got %q", stdout)
			}
		})
	}
}

// TestWriteProgressLineEmptyDescription verifies the line degrades gracefully to
// the activity alone when the task supplied no description.
func TestWriteProgressLineEmptyDescription(t *testing.T) {
	var buf bytes.Buffer
	writeProgressLine(&buf, agentcore.SubAgentProgressEvent{Activity: "Thinking"})
	got := buf.String()
	if !strings.Contains(got, "Thinking") {
		t.Errorf("line = %q, want it to contain the activity", got)
	}
	if strings.Contains(got, "·") {
		t.Errorf("line = %q, want no separator when description is empty", got)
	}
}

// contains reports whether s contains v.
func contains(s []string, v string) bool {
	for _, x := range s {
		if x == v {
			return true
		}
	}
	return false
}

// as is a tiny errors.As shim kept local to avoid an extra import in a test that
// only ever unwraps one level.
func as(err error, target **ErrRunFailed) bool {
	if e, ok := err.(*ErrRunFailed); ok {
		*target = e
		return true
	}
	return false
}
