package cli

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"strings"
	"sync/atomic"
	"testing"

	"iop/apps/node/internal/runtime"
	"iop/packages/go/config"
)

// fakeAppServer is a minimal fake of codex app-server --stdio.
// It responds to requests by dispatching to a handler func.
type fakeAppServer struct {
	dec     *json.Decoder
	enc     *json.Encoder
	handler func(method string, id int64, params map[string]any) (map[string]any, error)
}

func newFakeAppServer(r io.Reader, w io.Writer) *fakeAppServer {
	return &fakeAppServer{
		dec: json.NewDecoder(r),
		enc: json.NewEncoder(w),
	}
}

func (f *fakeAppServer) serveOne() error {
	var req struct {
		ID     int64          `json:"id"`
		Method string         `json:"method"`
		Params map[string]any `json:"params"`
	}
	if err := f.dec.Decode(&req); err != nil {
		return err
	}
	// Notifications (id == 0) get no response.
	if req.ID == 0 {
		return nil
	}
	result, rpcErr := f.handler(req.Method, req.ID, req.Params)
	resp := map[string]any{"id": req.ID}
	if rpcErr != nil {
		resp["error"] = map[string]any{"code": -32000, "message": rpcErr.Error()}
	} else {
		resp["result"] = result
	}
	return f.enc.Encode(resp)
}

// makeStdioPipe creates an in-process pipe pair that looks like a subprocess stdio.
// Returns (stdinReader, stdinWriter, stdoutReader, stdoutWriter).
func makeStdioPipes() (io.Reader, io.WriteCloser, io.ReadCloser, io.WriteCloser) {
	stdinR, stdinW := io.Pipe()
	stdoutR, stdoutW := io.Pipe()
	return stdinR, stdinW, stdoutR, stdoutW
}

// TestCodexAppServerRPC_InitializeShape verifies the initialize request:
//   - has "id", "method", and "params" fields
//   - does NOT have a "jsonrpc" field
//   - method == "initialize"
func TestCodexAppServerRPC_InitializeShape(t *testing.T) {
	stdinR, stdinW := io.Pipe()
	stdoutR, stdoutW := io.Pipe()

	var capturedReq map[string]json.RawMessage
	serverDone := make(chan error, 1)
	go func() {
		defer close(serverDone)
		dec := json.NewDecoder(stdinR)

		// 1. initialize request
		if err := dec.Decode(&capturedReq); err != nil {
			serverDone <- fmt.Errorf("decode initialize: %w", err)
			return
		}
		var id int64
		_ = json.Unmarshal(capturedReq["id"], &id)
		resp := map[string]any{"id": id, "result": map[string]any{
			"protocolVersion": "2024-11-05",
			"capabilities":    map[string]any{},
		}}
		_ = json.NewEncoder(stdoutW).Encode(resp)

		// 2. initialized notification (no response)
		var notif map[string]json.RawMessage
		_ = dec.Decode(&notif)

		// 3. thread/start
		var tsReq map[string]json.RawMessage
		if err := dec.Decode(&tsReq); err != nil {
			serverDone <- fmt.Errorf("decode thread/start: %w", err)
			return
		}
		var tsID int64
		_ = json.Unmarshal(tsReq["id"], &tsID)
		_ = json.NewEncoder(stdoutW).Encode(map[string]any{
			"id":     tsID,
			"result": map[string]any{"threadId": "thread-abc"},
		})
	}()

	// Build a proc directly (bypass exec.Command) using our pipes.
	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 8),
		done:    make(chan struct{}),
	}
	proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
	go proc.readLoop()

	threadID, err := codexAppServerInit(context.Background(), proc)
	if err != nil {
		t.Fatalf("codexAppServerInit: %v", err)
	}
	// Close stdin so the fake server goroutine can finish.
	_ = stdinW.Close()
	_ = stdoutW.Close()

	if threadID != "thread-abc" {
		t.Errorf("threadID = %q, want %q", threadID, "thread-abc")
	}

	// Verify initialize request shape.
	if _, hasJSONRPC := capturedReq["jsonrpc"]; hasJSONRPC {
		t.Error("initialize request must NOT contain 'jsonrpc' field")
	}
	var method string
	_ = json.Unmarshal(capturedReq["method"], &method)
	if method != "initialize" {
		t.Errorf("method = %q, want %q", method, "initialize")
	}
	if _, hasID := capturedReq["id"]; !hasID {
		t.Error("initialize request must have 'id' field")
	}
	if _, hasParams := capturedReq["params"]; !hasParams {
		t.Error("initialize request must have 'params' field")
	}
}

// TestCodexAppServerRPC_NoJSONRPCField confirms that send() never emits "jsonrpc".
func TestCodexAppServerRPC_NoJSONRPCField(t *testing.T) {
	stdinR, stdinW := io.Pipe()
	stdoutR, stdoutW := io.Pipe()

	go func() {
		dec := json.NewDecoder(stdinR)
		var raw map[string]json.RawMessage
		if err := dec.Decode(&raw); err != nil {
			return
		}
		var id int64
		_ = json.Unmarshal(raw["id"], &id)
		_ = json.NewEncoder(stdoutW).Encode(map[string]any{
			"id":     id,
			"result": map[string]any{},
		})
		_ = stdinR.Close()
		_ = stdoutW.Close()
	}()

	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 8),
		done:    make(chan struct{}),
	}
	proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
	go proc.readLoop()

	// Capture raw bytes written to stdin.
	var written strings.Builder
	origStdin := proc.stdin
	proc.stdin = &captureWriter{w: origStdin, buf: &written}

	_, _ = proc.send(context.Background(), "ping", nil)
	_ = stdinW.Close()

	line := strings.TrimSpace(written.String())
	var decoded map[string]json.RawMessage
	if err := json.Unmarshal([]byte(line), &decoded); err != nil {
		t.Fatalf("parse written line: %v", err)
	}
	if _, ok := decoded["jsonrpc"]; ok {
		t.Error("send() must NOT include 'jsonrpc' field in request")
	}
}

type captureWriter struct {
	w   io.WriteCloser
	buf *strings.Builder
}

func (c *captureWriter) Write(p []byte) (int, error) {
	c.buf.Write(p)
	return c.w.Write(p)
}

func (c *captureWriter) Close() error { return c.w.Close() }

// TestCodexAppServerProcessLifecycle_CloseSafe verifies that proc.close() is safe
// to call multiple times (no panic on duplicate close of done channel).
func TestCodexAppServerProcessLifecycle_CloseSafe(t *testing.T) {
	stdinR, stdinW := io.Pipe()
	stdoutR, _ := io.Pipe()
	_ = stdinR

	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 8),
		done:    make(chan struct{}),
	}

	// close() twice must not panic.
	proc.close()
	proc.close()
}

// TestCodexAppServerProcessLifecycle_ExitSurfaces verifies that send() returns an
// error when the process stdout closes (simulating process exit).
func TestCodexAppServerProcessLifecycle_ExitSurfaces(t *testing.T) {
	// Use a devnull reader for stdin so the write goroutine doesn't block.
	stdinR, stdinW := io.Pipe()
	go io.Copy(io.Discard, stdinR) // drain stdin so write doesn't block
	stdoutR, stdoutW := io.Pipe()

	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 8),
		done:    make(chan struct{}),
	}
	proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
	go proc.readLoop()

	// Close stdout to signal process exit after a brief moment.
	go func() {
		_ = stdoutW.Close()
	}()

	_, err := proc.send(context.Background(), "initialize", nil)
	if err == nil {
		t.Fatal("expected error when process exits, got nil")
	}
}

// TestCodexAppServerSessionResolve verifies that two Execute calls with the same
// target/sessionID reuse the same codexAppServerSession (without starting a real process).
func TestCodexAppServerSessionResolve(t *testing.T) {
	e := &codexAppServerExecutor{
		cli:      &CLI{},
		sessions: make(map[sessionKey]*codexAppServerSession),
	}

	// Pre-seed a session so resolveCodexAppServerSession returns it without spawning.
	key := sessionKey{target: "codex-as", sessionID: runtime.DefaultSessionID}
	existing := &codexAppServerSession{key: key, threadID: "tid-1"}
	e.sessions[key] = existing

	spec := runtime.ExecutionSpec{
		Target:    "codex-as",
		SessionID: "",
	}
	profile := config.CLIProfileConf{Mode: modeCodexAppServer, Command: "false"}

	got, err := e.resolveCodexAppServerSession(context.Background(), spec, profile)
	if err != nil {
		t.Fatalf("resolveCodexAppServerSession: %v", err)
	}
	if got != existing {
		t.Error("expected same session pointer to be returned")
	}
}

// TestCodexAppServerRequireExisting verifies that SessionModeRequireExisting returns
// an error when no session exists.
func TestCodexAppServerRequireExisting(t *testing.T) {
	e := &codexAppServerExecutor{
		cli:      &CLI{},
		sessions: make(map[sessionKey]*codexAppServerSession),
	}

	spec := runtime.ExecutionSpec{
		Target:      "codex-as",
		SessionID:   "missing",
		SessionMode: runtime.SessionModeRequireExisting,
	}
	profile := config.CLIProfileConf{Mode: modeCodexAppServer}

	_, err := e.resolveCodexAppServerSession(context.Background(), spec, profile)
	if err == nil {
		t.Fatal("expected error for SessionModeRequireExisting with no session, got nil")
	}
}

// TestCodexAppServerSend_RequestIDMonotone verifies that successive send() calls
// use strictly increasing id values.
func TestCodexAppServerSend_RequestIDMonotone(t *testing.T) {
	var counter atomic.Int64
	var ids []int64

	stdinR, stdinW := io.Pipe()
	stdoutR, stdoutW := io.Pipe()

	go func() {
		dec := json.NewDecoder(stdinR)
		enc := json.NewEncoder(stdoutW)
		for i := 0; i < 3; i++ {
			var raw map[string]json.RawMessage
			if err := dec.Decode(&raw); err != nil {
				return
			}
			var id int64
			_ = json.Unmarshal(raw["id"], &id)
			counter.Add(1)
			_ = enc.Encode(map[string]any{"id": id, "result": map[string]any{}})
		}
		_ = stdoutW.Close()
	}()

	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 8),
		done:    make(chan struct{}),
	}
	proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
	go proc.readLoop()

	for i := 0; i < 3; i++ {
		id := proc.nextID.Load() + 1
		ids = append(ids, id)
		if _, err := proc.send(context.Background(), fmt.Sprintf("method-%d", i), nil); err != nil {
			t.Fatalf("send %d: %v", i, err)
		}
	}
	_ = stdinW.Close()

	for i := 1; i < len(ids); i++ {
		if ids[i] <= ids[i-1] {
			t.Errorf("ids not monotone: %v", ids)
		}
	}
}

// TestCodexAppServerThreadMissingID verifies that codexAppServerInit returns an error
// when thread/start responds without a thread id field.
func TestCodexAppServerThreadMissingID(t *testing.T) {
	stdinR, stdinW := io.Pipe()
	stdoutR, stdoutW := io.Pipe()

	go func() {
		dec := json.NewDecoder(stdinR)
		enc := json.NewEncoder(stdoutW)

		// initialize request → respond with capabilities
		var req map[string]json.RawMessage
		_ = dec.Decode(&req)
		var id int64
		_ = json.Unmarshal(req["id"], &id)
		_ = enc.Encode(map[string]any{
			"id":     id,
			"result": map[string]any{"protocolVersion": "2024-11-05", "capabilities": map[string]any{}},
		})

		// initialized notification (no response needed)
		_ = dec.Decode(&req)

		// thread/start → respond with empty result (no threadId, no id)
		_ = dec.Decode(&req)
		var tsID int64
		_ = json.Unmarshal(req["id"], &tsID)
		_ = enc.Encode(map[string]any{
			"id":     tsID,
			"result": map[string]any{},
		})
		_ = stdoutW.Close()
	}()

	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 8),
		done:    make(chan struct{}),
	}
	proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
	go proc.readLoop()

	_, err := codexAppServerInit(context.Background(), proc)
	_ = stdinW.Close()
	if err == nil {
		t.Fatal("expected error when thread/start returns no thread id, got nil")
	}
}

// TestCodexAppServerSessionThreadMapping verifies that same IOP session reuses
// the same thread id and different sessions get different thread ids.
func TestCodexAppServerSessionThreadMapping(t *testing.T) {
	e := &codexAppServerExecutor{
		cli:      &CLI{},
		sessions: make(map[sessionKey]*codexAppServerSession),
	}

	key1 := sessionKey{target: "codex-as", sessionID: "sess-A"}
	key2 := sessionKey{target: "codex-as", sessionID: "sess-B"}
	e.sessions[key1] = &codexAppServerSession{key: key1, threadID: "thread-1"}
	e.sessions[key2] = &codexAppServerSession{key: key2, threadID: "thread-2"}

	// Same session must return same pointer (same thread id).
	spec := runtime.ExecutionSpec{Target: "codex-as", SessionID: "sess-A"}
	profile := config.CLIProfileConf{Mode: modeCodexAppServer, Command: "false"}
	got, err := e.resolveCodexAppServerSession(context.Background(), spec, profile)
	if err != nil {
		t.Fatalf("resolveCodexAppServerSession: %v", err)
	}
	if got.threadID != "thread-1" {
		t.Errorf("expected thread-1, got %q", got.threadID)
	}

	// Different session must return different thread id.
	spec2 := runtime.ExecutionSpec{Target: "codex-as", SessionID: "sess-B"}
	got2, err := e.resolveCodexAppServerSession(context.Background(), spec2, profile)
	if err != nil {
		t.Fatalf("resolveCodexAppServerSession sess-B: %v", err)
	}
	if got2.threadID != "thread-2" {
		t.Errorf("expected thread-2, got %q", got2.threadID)
	}
	if got.threadID == got2.threadID {
		t.Error("different sessions must have different thread ids")
	}
}

// TestCodexAppServerTurnStartRequestShape verifies that codexAppServerTurnStart sends
// a turn/start request with the required TurnStartParams shape:
// { threadId, input: [{ type, text, text_elements }] }
func TestCodexAppServerTurnStartRequestShape(t *testing.T) {
	stdinR, stdinW := io.Pipe()
	stdoutR, stdoutW := io.Pipe()

	var capturedParams map[string]json.RawMessage
	serverDone := make(chan struct{})
	go func() {
		defer close(serverDone)
		dec := json.NewDecoder(stdinR)
		enc := json.NewEncoder(stdoutW)

		var req map[string]json.RawMessage
		if err := dec.Decode(&req); err != nil {
			return
		}
		capturedParams = req
		var id int64
		_ = json.Unmarshal(req["id"], &id)
		_ = enc.Encode(map[string]any{
			"id":     id,
			"result": map[string]any{"turn": map[string]any{"id": "turn-shape-test"}},
		})
		_ = stdoutW.Close()
	}()

	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 8),
		done:    make(chan struct{}),
	}
	proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
	go proc.readLoop()

	turnID, err := codexAppServerTurnStart(context.Background(), proc, "thread-shape-1", "hello codex")
	_ = stdinW.Close()
	<-serverDone

	if err != nil {
		t.Fatalf("codexAppServerTurnStart: %v", err)
	}
	if turnID != "turn-shape-test" {
		t.Errorf("turnID = %q, want %q", turnID, "turn-shape-test")
	}

	// Verify threadId field.
	var threadID string
	_ = json.Unmarshal(capturedParams["params"], &map[string]json.RawMessage{})
	var paramsMap map[string]json.RawMessage
	_ = json.Unmarshal(capturedParams["params"], &paramsMap)

	_ = json.Unmarshal(paramsMap["threadId"], &threadID)
	if threadID != "thread-shape-1" {
		t.Errorf("threadId = %q, want %q", threadID, "thread-shape-1")
	}

	// Verify input array shape.
	var inputArr []map[string]json.RawMessage
	if err := json.Unmarshal(paramsMap["input"], &inputArr); err != nil {
		t.Fatalf("parse input: %v", err)
	}
	if len(inputArr) != 1 {
		t.Fatalf("input length = %d, want 1", len(inputArr))
	}
	item := inputArr[0]

	var typ string
	_ = json.Unmarshal(item["type"], &typ)
	if typ != "text" {
		t.Errorf("input[0].type = %q, want %q", typ, "text")
	}

	var text string
	_ = json.Unmarshal(item["text"], &text)
	if text != "hello codex" {
		t.Errorf("input[0].text = %q, want %q", text, "hello codex")
	}

	// text_elements must be present (required by UserInput contract), value must be an array.
	rawTE, ok := item["text_elements"]
	if !ok {
		t.Fatal("input[0].text_elements field missing")
	}
	var textElements []any
	if err := json.Unmarshal(rawTE, &textElements); err != nil {
		t.Fatalf("input[0].text_elements is not an array: %v", err)
	}
}

// TestCodexAppServerTurnStartRequestShape_EmptyPrompt verifies that an empty prompt
// still sends input with a text item (explicit design: always send input array).
func TestCodexAppServerTurnStartRequestShape_EmptyPrompt(t *testing.T) {
	stdinR, stdinW := io.Pipe()
	stdoutR, stdoutW := io.Pipe()

	var capturedParams map[string]json.RawMessage
	serverDone := make(chan struct{})
	go func() {
		defer close(serverDone)
		dec := json.NewDecoder(stdinR)
		enc := json.NewEncoder(stdoutW)
		var req map[string]json.RawMessage
		if err := dec.Decode(&req); err != nil {
			return
		}
		capturedParams = req
		var id int64
		_ = json.Unmarshal(req["id"], &id)
		_ = enc.Encode(map[string]any{
			"id":     id,
			"result": map[string]any{"turn": map[string]any{"id": "turn-empty"}},
		})
		_ = stdoutW.Close()
	}()

	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 8),
		done:    make(chan struct{}),
	}
	proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
	go proc.readLoop()

	_, err := codexAppServerTurnStart(context.Background(), proc, "thread-empty", "")
	_ = stdinW.Close()
	<-serverDone

	if err != nil {
		t.Fatalf("codexAppServerTurnStart: %v", err)
	}

	var paramsMap map[string]json.RawMessage
	_ = json.Unmarshal(capturedParams["params"], &paramsMap)

	// input must be present even for empty prompt.
	if _, ok := paramsMap["input"]; !ok {
		t.Fatal("input field missing for empty prompt")
	}

	var inputArr []map[string]json.RawMessage
	if err := json.Unmarshal(paramsMap["input"], &inputArr); err != nil {
		t.Fatalf("parse input: %v", err)
	}
	if len(inputArr) != 1 {
		t.Fatalf("input length = %d, want 1", len(inputArr))
	}
	if _, ok := inputArr[0]["text_elements"]; !ok {
		t.Fatal("input[0].text_elements missing for empty prompt")
	}
}

// TestCodexAppServerThreadStartResponseShape verifies that codexAppServerInit parses
// the actual ThreadStartResponse shape: result.thread.id.
func TestCodexAppServerThreadStartResponseShape(t *testing.T) {
	stdinR, stdinW := io.Pipe()
	stdoutR, stdoutW := io.Pipe()

	go func() {
		dec := json.NewDecoder(stdinR)
		enc := json.NewEncoder(stdoutW)
		// initialize
		var req map[string]json.RawMessage
		_ = dec.Decode(&req)
		var id int64
		_ = json.Unmarshal(req["id"], &id)
		_ = enc.Encode(map[string]any{"id": id, "result": map[string]any{
			"protocolVersion": "2024-11-05", "capabilities": map[string]any{},
		}})
		// initialized notification
		_ = dec.Decode(&req)
		// thread/start → actual shape: result.thread.id
		_ = dec.Decode(&req)
		var tsID int64
		_ = json.Unmarshal(req["id"], &tsID)
		_ = enc.Encode(map[string]any{
			"id":     tsID,
			"result": map[string]any{"thread": map[string]any{"id": "thread-nested-id"}},
		})
		_ = stdoutW.Close()
	}()

	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 8),
		done:    make(chan struct{}),
	}
	proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
	go proc.readLoop()

	threadID, err := codexAppServerInit(context.Background(), proc)
	_ = stdinW.Close()
	if err != nil {
		t.Fatalf("codexAppServerInit: %v", err)
	}
	if threadID != "thread-nested-id" {
		t.Errorf("threadID = %q, want %q", threadID, "thread-nested-id")
	}
}

// TestCodexAppServerTurnStartResponseShape verifies that codexAppServerTurnStart parses
// the actual TurnStartResponse shape: result.turn.id.
func TestCodexAppServerTurnStartResponseShape(t *testing.T) {
	stdinR, stdinW := io.Pipe()
	stdoutR, stdoutW := io.Pipe()

	go func() {
		dec := json.NewDecoder(stdinR)
		enc := json.NewEncoder(stdoutW)
		var req map[string]json.RawMessage
		_ = dec.Decode(&req)
		var id int64
		_ = json.Unmarshal(req["id"], &id)
		_ = enc.Encode(map[string]any{
			"id":     id,
			"result": map[string]any{"turn": map[string]any{"id": "turn-nested-id"}},
		})
		_ = stdoutW.Close()
	}()

	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 8),
		done:    make(chan struct{}),
	}
	proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
	go proc.readLoop()

	turnID, err := codexAppServerTurnStart(context.Background(), proc, "thread-1", "hello")
	_ = stdinW.Close()
	if err != nil {
		t.Fatalf("codexAppServerTurnStart: %v", err)
	}
	if turnID != "turn-nested-id" {
		t.Errorf("turnID = %q, want %q", turnID, "turn-nested-id")
	}
}

// TestCodexAppServerTurnStartMissingID verifies that a missing turn id returns an error.
func TestCodexAppServerTurnStartMissingID(t *testing.T) {
	stdinR, stdinW := io.Pipe()
	stdoutR, stdoutW := io.Pipe()

	go func() {
		dec := json.NewDecoder(stdinR)
		enc := json.NewEncoder(stdoutW)
		var req map[string]json.RawMessage
		_ = dec.Decode(&req)
		var id int64
		_ = json.Unmarshal(req["id"], &id)
		_ = enc.Encode(map[string]any{"id": id, "result": map[string]any{}})
		_ = stdoutW.Close()
	}()

	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 8),
		done:    make(chan struct{}),
	}
	proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
	go proc.readLoop()

	_, err := codexAppServerTurnStart(context.Background(), proc, "thread-1", "")
	_ = stdinW.Close()
	if err == nil {
		t.Fatal("expected error when turn id is missing, got nil")
	}
}

// TestCodexAppServerEventMap_Delta verifies AgentMessageDeltaNotification shape:
// { threadId, turnId, delta } → EventTypeDelta with source/thread_id/turn_id metadata.
func TestCodexAppServerEventMap_Delta(t *testing.T) {
	notif := appServerNotification{
		Method: "item/agentMessage/delta",
		Params: map[string]any{
			"threadId": "th-1",
			"turnId":   "tn-1",
			"delta":    "hello world",
		},
	}
	events, done, err := decodeAppServerNotification(notif, "run-1", "th-ctx", "tn-ctx")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if done {
		t.Fatal("delta should not signal done")
	}
	if len(events) != 1 {
		t.Fatalf("expected 1 event, got %d", len(events))
	}
	ev := events[0]
	if ev.Type != runtime.EventTypeDelta {
		t.Errorf("Type = %q, want %q", ev.Type, runtime.EventTypeDelta)
	}
	if ev.Delta != "hello world" {
		t.Errorf("Delta = %q, want %q", ev.Delta, "hello world")
	}
	if ev.Metadata["source"] != "codex-app-server" {
		t.Errorf("source metadata = %q", ev.Metadata["source"])
	}
	// params threadId/turnId should override execution-context values
	if ev.Metadata["thread_id"] != "th-1" {
		t.Errorf("thread_id metadata = %q, want %q", ev.Metadata["thread_id"], "th-1")
	}
	if ev.Metadata["turn_id"] != "tn-1" {
		t.Errorf("turn_id metadata = %q, want %q", ev.Metadata["turn_id"], "tn-1")
	}
}

// TestCodexAppServerEventMap_DeltaMetadata verifies fallback to execution-context ids
// when notification params do not carry threadId/turnId.
func TestCodexAppServerEventMap_DeltaMetadata(t *testing.T) {
	notif := appServerNotification{
		Method: "item/agentMessage/delta",
		Params: map[string]any{"delta": "chunk"},
	}
	events, _, err := decodeAppServerNotification(notif, "run-x", "th-fallback", "tn-fallback")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(events) != 1 {
		t.Fatalf("expected 1 event, got %d", len(events))
	}
	if events[0].Metadata["thread_id"] != "th-fallback" {
		t.Errorf("thread_id = %q, want %q", events[0].Metadata["thread_id"], "th-fallback")
	}
	if events[0].Metadata["turn_id"] != "tn-fallback" {
		t.Errorf("turn_id = %q, want %q", events[0].Metadata["turn_id"], "tn-fallback")
	}
}

// TestCodexAppServerEventMap_TurnCompleted verifies that turn/completed with status
// "completed" maps to EventTypeComplete and signals done.
func TestCodexAppServerEventMap_TurnCompleted(t *testing.T) {
	notif := appServerNotification{
		Method: "turn/completed",
		Params: map[string]any{
			"threadId": "th-2",
			"turn":     map[string]any{"id": "tn-2", "status": "completed"},
		},
	}
	events, done, err := decodeAppServerNotification(notif, "run-2", "", "")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !done {
		t.Fatal("turn/completed should signal done")
	}
	if len(events) != 1 || events[0].Type != runtime.EventTypeComplete {
		t.Fatalf("expected 1 complete event, got %+v", events)
	}
	if events[0].Metadata["thread_id"] != "th-2" {
		t.Errorf("thread_id = %q, want %q", events[0].Metadata["thread_id"], "th-2")
	}
}

// TestCodexAppServerEventMap_TurnCompletedFailed verifies that turn/completed with
// status "failed" maps to EventTypeError (not complete).
func TestCodexAppServerEventMap_TurnCompletedFailed(t *testing.T) {
	notif := appServerNotification{
		Method: "turn/completed",
		Params: map[string]any{
			"threadId": "th-3",
			"turn": map[string]any{
				"id":     "tn-3",
				"status": "failed",
				"error":  map[string]any{"message": "context window exceeded"},
			},
		},
	}
	events, done, err := decodeAppServerNotification(notif, "run-3", "", "")
	if err == nil {
		t.Fatal("expected non-nil error for failed turn")
	}
	if done {
		t.Fatal("failed turn should not signal done as complete")
	}
	if len(events) != 1 || events[0].Type != runtime.EventTypeError {
		t.Fatalf("expected 1 error event, got %+v", events)
	}
	if events[0].Error != "context window exceeded" {
		t.Errorf("Error = %q, want %q", events[0].Error, "context window exceeded")
	}
}

// TestCodexAppServerEventMap_Error verifies ErrorNotification shape:
// { error: { message }, threadId, turnId } → EventTypeError with full metadata.
func TestCodexAppServerEventMap_Error(t *testing.T) {
	notif := appServerNotification{
		Method: "error",
		Params: map[string]any{
			"error":    map[string]any{"message": "quota exceeded"},
			"threadId": "th-4",
			"turnId":   "tn-4",
		},
	}
	events, _, err := decodeAppServerNotification(notif, "run-4", "", "")
	if err == nil {
		t.Fatal("expected non-nil error for error notification")
	}
	if len(events) != 1 || events[0].Type != runtime.EventTypeError {
		t.Fatalf("expected 1 error event, got %+v", events)
	}
	if events[0].Error != "quota exceeded" {
		t.Errorf("Error = %q, want %q", events[0].Error, "quota exceeded")
	}
	if events[0].Metadata["thread_id"] != "th-4" {
		t.Errorf("thread_id = %q, want %q", events[0].Metadata["thread_id"], "th-4")
	}
	if events[0].Metadata["turn_id"] != "tn-4" {
		t.Errorf("turn_id = %q, want %q", events[0].Metadata["turn_id"], "tn-4")
	}
}

// TestCodexAppServerEventMap_TurnFailed verifies that turn/failed (legacy) maps to EventTypeError.
func TestCodexAppServerEventMap_TurnFailed(t *testing.T) {
	notif := appServerNotification{
		Method: "turn/failed",
		Params: map[string]any{"error": map[string]any{"message": "rate limit"}},
	}
	events, _, err := decodeAppServerNotification(notif, "run-5", "", "")
	if err == nil {
		t.Fatal("expected non-nil error for turn/failed")
	}
	if len(events) != 1 || events[0].Type != runtime.EventTypeError {
		t.Fatalf("expected 1 error event, got %+v", events)
	}
	if events[0].Error != "rate limit" {
		t.Errorf("Error = %q, want %q", events[0].Error, "rate limit")
	}
}

// TestCodexAppServerDrainNotifications_DoesNotDropBurst verifies that a burst of
// more than notifCh capacity (32) deltas followed by a terminal turn/completed are
// all delivered without dropping the terminal event.
func TestCodexAppServerDrainNotifications_DoesNotDropBurst(t *testing.T) {
	const burstSize = 40 // > default channel capacity of 32

	stdinR, stdinW := io.Pipe()
	stdoutR, stdoutW := io.Pipe()
	_ = stdinR

	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 32),
		done:    make(chan struct{}),
	}
	proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
	go proc.readLoop()

	// Write burstSize delta notifications then a turn/completed.
	enc := json.NewEncoder(stdoutW)
	go func() {
		for i := 0; i < burstSize; i++ {
			_ = enc.Encode(map[string]any{
				"method": "item/agentMessage/delta",
				"params": map[string]any{"delta": fmt.Sprintf("chunk-%d", i)},
			})
		}
		_ = enc.Encode(map[string]any{
			"method": "turn/completed",
			"params": map[string]any{"turn": map[string]any{"status": "completed"}},
		})
		_ = stdoutW.Close()
	}()

	sink := &testSink{}
	err := codexAppServerDrainNotifications(context.Background(), proc, "run-burst", "th-burst", "tn-burst", sink)
	_ = stdinW.Close()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	var deltas, completes int
	for _, ev := range sink.events {
		switch ev.Type {
		case runtime.EventTypeDelta:
			deltas++
		case runtime.EventTypeComplete:
			completes++
		}
	}
	if deltas != burstSize {
		t.Errorf("delta count = %d, want %d", deltas, burstSize)
	}
	if completes != 1 {
		t.Errorf("complete count = %d, want 1", completes)
	}
}

// TestCodexAppServerSend_ResponsePriorityOverDone verifies that send() returns
// the response (not a process-exited error) when the fake server sends a
// response and then closes stdout simultaneously.
func TestCodexAppServerSend_ResponsePriorityOverDone(t *testing.T) {
	stdinR, stdinW := io.Pipe()
	stdoutR, stdoutW := io.Pipe()

	// Fake server: read one request, respond, then close stdout (simulate exit).
	// stdinR is owned exclusively by this goroutine — no concurrent reader.
	go func() {
		defer stdinR.Close()
		dec := json.NewDecoder(stdinR)
		enc := json.NewEncoder(stdoutW)
		var req map[string]json.RawMessage
		if err := dec.Decode(&req); err != nil {
			_ = stdoutW.Close()
			return
		}
		var id int64
		_ = json.Unmarshal(req["id"], &id)
		_ = enc.Encode(map[string]any{"id": id, "result": map[string]any{"ok": true}})
		_ = stdoutW.Close()
	}()

	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 8),
		done:    make(chan struct{}),
	}
	proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
	go proc.readLoop()

	resp, err := proc.send(context.Background(), "ping", nil)
	_ = stdinW.Close()
	if err != nil {
		t.Fatalf("send() returned error instead of response: %v", err)
	}
	if resp.Result == nil {
		t.Fatal("send() returned nil result")
	}
}

// TestCodexAppServerInit_ThreadStartHasParams verifies that codexAppServerInit sends
// thread/start with a "params" field (not null/absent). Real Codex app-server returns
// -32600 "missing field `params`" if the field is omitted.
func TestCodexAppServerInit_ThreadStartHasParams(t *testing.T) {
	stdinR, stdinW := io.Pipe()
	stdoutR, stdoutW := io.Pipe()

	var threadStartReq map[string]json.RawMessage
	serverDone := make(chan struct{})
	go func() {
		defer close(serverDone)
		dec := json.NewDecoder(stdinR)
		enc := json.NewEncoder(stdoutW)

		// initialize
		var req map[string]json.RawMessage
		_ = dec.Decode(&req)
		var id int64
		_ = json.Unmarshal(req["id"], &id)
		_ = enc.Encode(map[string]any{"id": id, "result": map[string]any{
			"protocolVersion": "2024-11-05", "capabilities": map[string]any{},
		}})

		// initialized notification (no response)
		_ = dec.Decode(&req)

		// thread/start — capture full request
		_ = dec.Decode(&threadStartReq)
		var tsID int64
		_ = json.Unmarshal(threadStartReq["id"], &tsID)
		_ = enc.Encode(map[string]any{
			"id":     tsID,
			"result": map[string]any{"thread": map[string]any{"id": "thread-params-check"}},
		})
		_ = stdoutW.Close()
	}()

	proc := &codexAppServerProc{
		stdin:   stdinW,
		stdout:  bufio.NewScanner(stdoutR),
		notifCh: make(chan appServerNotification, 8),
		done:    make(chan struct{}),
	}
	proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024)
	go proc.readLoop()

	threadID, err := codexAppServerInit(context.Background(), proc)
	_ = stdinW.Close()
	<-serverDone

	if err != nil {
		t.Fatalf("codexAppServerInit: %v", err)
	}
	if threadID != "thread-params-check" {
		t.Errorf("threadID = %q, want %q", threadID, "thread-params-check")
	}

	// "params" must be present in thread/start request — real server rejects null/absent.
	rawParams, ok := threadStartReq["params"]
	if !ok {
		t.Fatal("thread/start request must include 'params' field")
	}
	// params must be a JSON object (not null).
	var paramsObj map[string]json.RawMessage
	if err := json.Unmarshal(rawParams, &paramsObj); err != nil {
		t.Fatalf("thread/start 'params' must be a JSON object, got: %s", string(rawParams))
	}
}
