// This file implements sub-agent orchestration (US-027, #45) with an optional
// process-isolation mode (US-019, #135).
//
// A sub-agent is a full agent loop with its own AgentContext (independent system
// prompt, message history and tool set), launched by the parent through a normal
// tool call. The child runs to completion and its final assistant text is fed
// back to the parent as the tool result - so from the parent loop's perspective
// a sub-agent is just another tool.
//
// Two isolation modes are supported, selected by SubAgentSpec.Isolation:
//
//   - Goroutine (default): the child loop runs in-process in a goroutine sharing
//     the parent process, matching the original "single-process goroutine" decision.
//   - Process: the parent spawns a fresh pigo subprocess (pigo --subagent-rpc)
//     and delegates the run over stdio JSON-RPC (reusing internal/jsonrpc). The
//     child runs in a separate process, so a crash or resource leak in the child
//     cannot affect the parent loop; a crash is surfaced as a tool error. The
//     subprocess resolves its own provider from the model/provider passed in the
//     request and inherits the parent environment for credentials.
//
// Because each Execute call spins up an independent run, multiple sub-agents can
// run concurrently (the batch executor already runs parallel tool calls in
// separate goroutines/processes).
package runtime

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"os"

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

// SubAgentIsolation selects how a sub-agent runs relative to its parent.
type SubAgentIsolation int

const (
	// SubAgentIsolationGoroutine runs the child agent loop in-process in a
	// goroutine. This is the default and the original behavior; it changes
	// nothing about how sub-agents previously ran.
	SubAgentIsolationGoroutine SubAgentIsolation = iota
	// SubAgentIsolationProcess runs the child in a fresh pigo subprocess,
	// delegating the run over stdio JSON-RPC. A subprocess crash is surfaced to
	// the parent as a tool error and never affects the parent loop.
	SubAgentIsolationProcess
)

// SubAgentProcessConfig configures process-isolated sub-agent execution. It
// carries the serializable provider config the subprocess needs to reconstruct
// the run: the in-process Stream/GetAPIKey functions a goroutine-mode
// NewRunConfig returns cannot cross a process boundary, so the parent forwards
// the model (and optional base URL/protocol) and the subprocess resolves the
// provider itself, inheriting the parent environment for API keys.
type SubAgentProcessConfig struct {
	// Command is the executable to spawn. When empty, os.Executable() (the pigo
	// binary itself) is used so a pigo process spawns another pigo.
	Command string
	// Args are appended to the command after the subagent-rpc flag. Rarely
	// needed; reserved for test doubles or non-standard layouts.
	Args []string
	// Model is the model id the subprocess runs against. Required. A preset id
	// (e.g. "openrouter/free", "anthropic/claude-...") or ollama/nvidia-prefixed
	// id resolves its own provider; a custom gateway needs BaseURL/Protocol.
	Model string
	// BaseURL and Protocol override the provider endpoint and wire protocol for
	// custom gateways (Protocol "anthropic"/"openai" forces that wire format).
	// Empty falls back to the same resolution the CLI uses.
	BaseURL  string
	Protocol string
	// ToolNames restricts the subprocess's builtin tool set to the named tools
	// (e.g. a read-only researcher). Empty keeps all builtins. Non-builtin names
	// are ignored: custom/plugin tools cannot cross a process boundary, so a
	// process-isolated child runs with builtins only.
	ToolNames []string
	// Env is the child's environment (os/exec form). When nil the child inherits
	// the parent environment, which is how it picks up provider API keys.
	Env []string
	// Dir is the child's working directory; empty means the parent's.
	Dir string
	// Stderr optionally receives the child's stderr. When nil it is discarded.
	Stderr io.Writer
}

// SubAgentRunParams is the JSON-RPC request payload for a process-isolated
// sub-agent run (the "subagent/run" method). It is the wire contract between
// the parent (SubAgentTool in process mode) and the pigo subprocess
// (cmd/pigo --subagent-rpc).
type SubAgentRunParams struct {
	Prompt       string   `json:"prompt"`
	SystemPrompt string   `json:"systemPrompt,omitempty"`
	Model        string   `json:"model"`
	BaseURL      string   `json:"baseUrl,omitempty"`
	Protocol     string   `json:"protocol,omitempty"`
	Tools        []string `json:"tools,omitempty"`
}

// SubAgentRunResult is the JSON-RPC response payload carrying the child's final
// assistant text.
type SubAgentRunResult struct {
	Text string `json:"text"`
}

// SubAgentRPCMethod is the JSON-RPC method name the parent calls on the
// subprocess: "subagent/run".
const SubAgentRPCMethod = "subagent/run"

// SubAgentRPCFlag is the command-line flag the parent launches the pigo
// subprocess with so it enters the sub-agent RPC server mode: "--subagent-rpc".
const SubAgentRPCFlag = "--subagent-rpc"

// SubAgentSpec declares a spawnable sub-agent: its identity (surfaced to the
// model as a tool), the system prompt and tools its child context runs with,
// and a factory for the child's run configuration (provider stream, batch
// registry, hooks). The factory is called once per spawn so each child gets an
// independent RunConfig; NewRunConfig must wire a ToolRegistry consistent with
// Tools. It is used by goroutine mode; process mode uses Process instead (the
// subprocess builds its own RunConfig from the serializable provider config).
type SubAgentSpec struct {
	// Name is the tool name the parent invokes to spawn this sub-agent.
	Name string
	// Description is injected into the parent's tool list / capability list so
	// the model knows when to delegate.
	Description string
	// SystemPrompt seeds the child context's system prompt. When empty the child
	// runs with no system prompt.
	SystemPrompt string
	// Tools is the child's independent tool set. It may differ from the parent's
	// (e.g. a read-only researcher sub-agent) and may be empty. In goroutine
	// mode these exact tools run in-process; in process mode only the tools'
	// NAMES are forwarded (the subprocess rebuilds builtins by name).
	Tools []agentcore.AgentTool
	// NewRunConfig builds the loop configuration for one child run in goroutine
	// mode. It is called per spawn; the returned config's Batch registry should
	// contain Tools. Ignored in process mode (the subprocess resolves its own).
	NewRunConfig func() RunConfig
	// Isolation selects goroutine (default) vs process execution. Zero value is
	// goroutine, preserving the original behavior.
	Isolation SubAgentIsolation
	// Process configures process-isolated execution. Required when Isolation is
	// SubAgentIsolationProcess; ignored otherwise.
	Process SubAgentProcessConfig
	// Schema, when non-empty, overrides the default single-prompt argument schema
	// advertised to the model. The generic task tool uses this to also accept an
	// optional description; a nil/empty Schema keeps the original prompt-only
	// schema so existing specs are unaffected.
	Schema json.RawMessage
	// Sem, when non-nil, is a shared buffered channel used as a concurrency
	// semaphore for goroutine-mode runs: executeGoroutine acquires a slot before
	// spawning the child and releases it when the child settles. A full channel
	// blocks (queues) the acquire rather than erroring. nil disables limiting, so
	// existing sub-agent specs run unbounded exactly as before.
	Sem chan struct{}
}

// subAgentArgs is the JSON argument shape for a sub-agent tool call: a
// free-form prompt describing the delegated task, plus an optional short
// description used for status display (accepted by the generic task tool;
// ignored by prompt-only specs).
type subAgentArgs struct {
	Prompt      string `json:"prompt"`
	Description string `json:"description,omitempty"`
}

// subAgentSchema is the JSON Schema validating a sub-agent invocation.
var subAgentSchema = json.RawMessage(`{
  "type": "object",
  "properties": {
    "prompt": {
      "type": "string",
      "description": "The task for the sub-agent to perform, described in full since the sub-agent runs with a fresh context."
    }
  },
  "required": ["prompt"],
  "additionalProperties": false
}`)

// SubAgentTool adapts a SubAgentSpec into an AgentTool. Executing it spawns a
// child agent run (in a goroutine or a subprocess, per Isolation) and returns
// the child's final text.
type SubAgentTool struct {
	spec SubAgentSpec
	// processCall, when non-nil, overrides the default subprocess transport for
	// process-isolated mode. Tests inject a fake to exercise the process-mode
	// logic (params shaping, crash-as-error, result forwarding) without building
	// a real binary; production leaves it nil so Execute uses defaultProcessCall.
	processCall func(ctx context.Context, cfg SubAgentProcessConfig, params SubAgentRunParams) (string, error)
}

// NewSubAgentTool builds a sub-agent tool from a spec. In goroutine mode
// NewRunConfig is required (it supplies the provider stream that drives the
// child); in process mode Process.Model is required instead.
func NewSubAgentTool(spec SubAgentSpec) *SubAgentTool {
	return &SubAgentTool{spec: spec}
}

func (t *SubAgentTool) Name() string { return t.spec.Name }

func (t *SubAgentTool) Description() string { return t.spec.Description }

func (t *SubAgentTool) Schema() json.RawMessage {
	if len(t.spec.Schema) > 0 {
		return t.spec.Schema
	}
	return subAgentSchema
}

// ExecutionMode is parallel: independent sub-agents may run concurrently, since
// each spawns its own context and run (goroutine or process) with no shared
// mutable state.
func (t *SubAgentTool) ExecutionMode() agentcore.ToolExecutionMode {
	return agentcore.ToolExecutionParallel
}

// Execute spawns the child agent run and blocks until it settles, then returns
// the child's final assistant text as the tool result. The parent's ctx governs
// the child, so cancelling the parent run cancels in-flight sub-agents (in
// goroutine mode via ctx; in process mode via ctx cancelling the JSON-RPC call
// and Close killing the child).
func (t *SubAgentTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
	// Goroutine mode requires NewRunConfig (it supplies the in-process provider
	// stream). Process mode does not - the subprocess resolves its own provider
	// from Process.Model - so the check is guarded to goroutine mode. This
	// preserves the original precedence (nil NewRunConfig reported before an
	// empty prompt) for the unchanged goroutine path.
	if t.spec.Isolation != SubAgentIsolationProcess && t.spec.NewRunConfig == nil {
		return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: no run configuration", t.spec.Name)
	}
	var a subAgentArgs
	if len(args) > 0 {
		if err := json.Unmarshal(args, &a); err != nil {
			return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: decode args: %w", t.spec.Name, err)
		}
	}
	if a.Prompt == "" {
		return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: empty prompt", t.spec.Name)
	}

	if t.spec.Isolation == SubAgentIsolationProcess {
		// Process mode returns only the child's final text (the JSON-RPC protocol
		// does not stream partial updates), so onUpdate is intentionally not
		// forwarded here; a caller supplying a sink gets no deltas in this mode.
		return t.executeProcess(ctx, a.Prompt)
	}
	return t.executeGoroutine(ctx, id, a.Prompt, a.Description, onUpdate)
}

// executeGoroutine runs the child agent loop in-process and returns its final
// text. This is the default mode and the original sub-agent behavior.
//
// id is the parent tool call's id and description is the (optional) task
// description; both are threaded onto any SubAgentProgressEvent emitted for this
// run so a consumer can key status by the parent task call. When the parent loop
// injected a run-level progress emitter into ctx (WithProgressEmitter), the
// child's tool-execution / turn boundaries are translated into
// SubAgentProgressEvent and surfaced up the parent stream; when no emitter is
// present (e.g. the tool is called directly in a unit test) progress reporting is
// silently skipped.
func (t *SubAgentTool) executeGoroutine(ctx context.Context, id, prompt, description string, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
	// Concurrency guard: when a shared semaphore is configured, acquire a slot
	// before spawning the child and release it via defer so a panic or error
	// still frees the slot. A full channel blocks (queues) the acquire; a
	// cancelled parent ctx abandons the wait instead of blocking forever.
	if t.spec.Sem != nil {
		select {
		case t.spec.Sem <- struct{}{}:
			defer func() { <-t.spec.Sem }()
		case <-ctx.Done():
			return agentcore.AgentToolResult{}, ctx.Err()
		}
	}
	runCfg := t.spec.NewRunConfig()
	// Advertise the child's tools to the model. A spec may pin an explicit set
	// (spec.Tools); otherwise fall back to the run config's registry — the tools
	// the executor can actually run — so a factory that wires only the registry
	// (like the generic task tool) still tells the child what it can call.
	// Without this the model is handed an empty tool list, can only reply with
	// text, and a delegated task that needs tools comes back empty.
	tools := t.spec.Tools
	if len(tools) == 0 && runCfg.Batch.ToolExecutorConfig.Registry != nil {
		tools = runCfg.Batch.ToolExecutorConfig.Registry.List()
	}
	childCtx := &agentcore.AgentContext{
		SystemPrompt: t.spec.SystemPrompt,
		Messages: agentcore.MessageList{
			agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent(prompt)}},
		},
		Tools: tools,
	}

	stream := StartRun(ctx, childCtx, runCfg)
	// Drain events (DrainStream never returns early, so the producer goroutine is
	// never blocked on back-pressure); forward streamed child text as
	// tool-execution updates when a sink is set.
	var h StreamHandler
	if onUpdate != nil {
		h.OnText = func(delta string) {
			onUpdate(agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(delta)}})
		}
	}
	// Progress reporting: when the parent loop injected a run-level emitter into
	// ctx, translate the child's tool-execution / turn boundaries into
	// SubAgentProgressEvent and emit them up the parent stream. Reporting is at
	// activity granularity (per child tool start / turn boundary), NOT per text
	// delta, so event volume stays proportional to the child's tool calls. When
	// no emitter is present the OnEvent hook is left nil and progress is skipped.
	if parentEmit := agentcore.ProgressEmitterFromContext(ctx); parentEmit != nil {
		// chars accumulates the child's streamed text length so a coarse output
		// token estimate can ride along on each progress event (0 = unknown).
		chars := 0
		if prev := h.OnText; prev != nil {
			h.OnText = func(delta string) {
				chars += len(delta)
				prev(delta)
			}
		} else {
			h.OnText = func(delta string) { chars += len(delta) }
		}
		h.OnEvent = func(ev agentcore.AgentEvent) {
			act := activityOf(ev)
			if act == "" {
				return
			}
			_ = parentEmit(ctx, agentcore.SubAgentProgressEvent{
				ToolCallID:  id,
				Description: description,
				Activity:    act,
				Tokens:      estimateTokens(chars),
			})
		}
	}
	final, err := DrainStream(ctx, stream, h)
	if err != nil {
		return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: %w", t.spec.Name, err)
	}
	text := ""
	if final != nil {
		text = agentcore.ContentToText(final.Content)
	}
	if text == "" {
		text = fmt.Sprintf("(sub-agent %q produced no text output)", t.spec.Name)
	}
	// Surface a failed child run as a tool error so the parent model gets a
	// signal the delegation failed (the tool executor marks the result
	// IsError). A child whose final turn stopped on error/aborted otherwise
	// looks like a successful delegation carrying error text.
	if final != nil && (final.StopReason == agentcore.StopReasonError || final.StopReason == agentcore.StopReasonAborted) {
		return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q failed (%s): %s", t.spec.Name, final.StopReason, text)
	}
	return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(text)}}, nil
}

// executeProcess runs the child agent loop in a fresh pigo subprocess over stdio
// JSON-RPC and returns its final text. A subprocess crash, transport error, or
// failed child run is surfaced as a tool error; the parent loop is unaffected.
// Streamed child text is not forwarded (the process protocol returns only the
// final result); the parent sees the complete result when the child settles.
func (t *SubAgentTool) executeProcess(ctx context.Context, prompt string) (agentcore.AgentToolResult, error) {
	cfg := t.spec.Process
	if cfg.Model == "" {
		return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: process mode requires Process.Model", t.spec.Name)
	}
	// Forward the child's tool names so the subprocess can rebuild a matching
	// builtin set; an explicit ToolNames list wins over deriving from Tools.
	toolNames := cfg.ToolNames
	if len(toolNames) == 0 {
		for _, tl := range t.spec.Tools {
			toolNames = append(toolNames, tl.Name())
		}
	}
	params := SubAgentRunParams{
		Prompt:       prompt,
		SystemPrompt: t.spec.SystemPrompt,
		Model:        cfg.Model,
		BaseURL:      cfg.BaseURL,
		Protocol:     cfg.Protocol,
		Tools:        toolNames,
	}
	call := t.processCall
	if call == nil {
		call = defaultProcessCall
	}
	text, err := call(ctx, cfg, params)
	if err != nil {
		return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q (process): %w", t.spec.Name, err)
	}
	if text == "" {
		text = fmt.Sprintf("(sub-agent %q produced no text output)", t.spec.Name)
	}
	return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(text)}}, nil
}

// defaultProcessCall is the production subprocess transport: it launches the
// pigo binary (or cfg.Command) with the subagent-rpc flag, sends a single
// "subagent/run" JSON-RPC request over the child's stdin, and returns the
// child's final text from the response. The child is closed (killed if it does
// not exit on its own) before returning. A crash, transport error, or RPC error
// is returned as a Go error so executeProcess surfaces it as a tool error.
func defaultProcessCall(ctx context.Context, cfg SubAgentProcessConfig, params SubAgentRunParams) (string, error) {
	command := cfg.Command
	if command == "" {
		exe, err := os.Executable()
		if err != nil {
			return "", fmt.Errorf("resolve pigo executable: %w", err)
		}
		command = exe
	}
	args := append([]string{SubAgentRPCFlag}, cfg.Args...)
	client, err := jsonrpc.NewClient(jsonrpc.Config{
		Command: command,
		Args:    args,
		Env:     cfg.Env,
		Dir:     cfg.Dir,
		Stderr:  cfg.Stderr,
	})
	if err != nil {
		return "", err
	}
	defer client.Close()
	raw, err := client.Call(ctx, SubAgentRPCMethod, params)
	if err != nil {
		return "", err
	}
	var res SubAgentRunResult
	if err := json.Unmarshal(raw, &res); err != nil {
		return "", fmt.Errorf("decode sub-agent result: %w", err)
	}
	return res.Text, nil
}

// RunSubAgentOnce runs one sub-agent loop to completion and returns the child's
// final assistant text. It is the execution core shared by the process-isolated
// subprocess (cmd/pigo --subagent-rpc): given a resolved RunConfig (provider
// stream, tool registry) and the prompt/system prompt, it builds a fresh child
// context and drains the run. A run whose final turn stopped on error/aborted
// is reported as an error so the subprocess surfaces failure (as an RPC error)
// rather than returning empty text. It does not stream partial updates: the
// process protocol returns only the final result.
func RunSubAgentOnce(ctx context.Context, systemPrompt, prompt string, tools []agentcore.AgentTool, runCfg RunConfig) (string, error) {
	childCtx := &agentcore.AgentContext{
		SystemPrompt: systemPrompt,
		Messages: agentcore.MessageList{
			agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent(prompt)}},
		},
		Tools: tools,
	}
	stream := StartRun(ctx, childCtx, runCfg)
	final, err := DrainStream(ctx, stream, StreamHandler{})
	if err != nil {
		return "", err
	}
	text := ""
	if final != nil {
		text = agentcore.ContentToText(final.Content)
	}
	if final != nil && (final.StopReason == agentcore.StopReasonError || final.StopReason == agentcore.StopReasonAborted) {
		// When the loop synthesizes an error turn (e.g. a provider connection
		// failure) the diagnostic lands in ErrorMessage, not Content; fall back
		// to it so the subprocess surfaces the real cause rather than a bare
		// "error" stop reason.
		if text == "" && final.ErrorMessage != "" {
			text = final.ErrorMessage
		}
		if text == "" {
			text = string(final.StopReason)
		}
		return text, fmt.Errorf("sub-agent failed (%s): %s", final.StopReason, text)
	}
	return text, nil
}
