package agent

import (
	"encoding/json"
	"log/slog"
	"regexp"
	"sort"
	"strconv"
	"strings"

	"github.com/tidbcloud/AgentTeam/desktop/internal/consts"
	"github.com/tidbcloud/AgentTeam/desktop/internal/types"
)

var appServerTrailingReasoningPlaceholder = regexp.MustCompile(`(?:[[:space:]]*<!--[[:space:]]*-->[[:space:]]*)+$`)

// UsesExplicitTurnLifecycle keeps Process liveness aligned with Codex TUI:
// only TurnStarted/TurnCompleted own the running indicator; item activity is
// trajectory content, not a lifecycle transition.
func (d *CodexAppServerDriver) UsesExplicitTurnLifecycle() bool { return true }

func (d *CodexAppServerDriver) handleNotification(envelope appServerEnvelope) {
	d.handleNotificationWithLifecycleFence(envelope, false)
}

// handleNotificationWithLifecycleFence applies one already-decoded app-server
// notification. lifecycleFenceHeld is true only for the transport read path,
// which must acquire primaryTurnDispatchMu before transportGenerationMu's read
// fence. Keeping that lock order is important: a turn request holds the primary
// fence across its physical stdin write, while launch replacement needs the
// generation write fence before it can detach and close that stdin. Taking the
// locks in the opposite order would let a lifecycle notification pin generation
// replacement behind a blocked write whose only release is that replacement.
//
// Direct unit-level callers use handleNotification above and therefore retain
// the ordinary self-contained lifecycle fence.
func (d *CodexAppServerDriver) handleNotificationWithLifecycleFence(envelope appServerEnvelope, lifecycleFenceHeld bool) {
	method := strings.TrimSpace(envelope.Method)
	// A routed turn request performs its final cached-state validation while
	// holding this fence and releases it immediately after the request frame is
	// written. Serialize primary lifecycle mutations through the same boundary
	// so an idle->active notification cannot slip between target selection and
	// turn/start dispatch. Do not hold the fence while waiting for the request
	// response: Codex may emit turn/started before that response on stdout.
	if appServerNotificationMutatesPrimaryTurnLifecycle(method) && !lifecycleFenceHeld {
		d.primaryTurnDispatchMu.Lock()
		defer d.primaryTurnDispatchMu.Unlock()
	}
	// App-server notifications are ordered on the read agentteam, but tool callbacks
	// complete on independent goroutines. Serialize every root turn activity
	// notification with those callbacks so a terminal event is a real fence:
	// an activity is either published before TurnEnd, or observes the completed
	// turn and is discarded. This mirrors Codex TUI, whose running lifecycle is
	// driven by TurnStarted/TurnCompleted rather than late item output.
	if appServerNotificationCarriesTurnActivity(method) {
		d.turnEventMu.Lock()
		defer d.turnEventMu.Unlock()
	}
	switch method {
	case "thread/status/changed":
		var params appServerThreadStatusNotification
		if err := json.Unmarshal(envelope.Params, &params); err == nil {
			if !d.isPrimaryThreadEvent(params.ThreadID) {
				return
			}
			if d.updateThreadStatus(params.ThreadID, params.Status) {
				d.enqueueTranslatedEvent(translatedEvent{
					Kind:      consts.ParsedEventKindRuntimeIdle,
					SessionID: strings.TrimSpace(params.ThreadID),
				})
			}
		}
	case "thread/settings/updated":
		var params appServerThreadSettingsUpdatedNotification
		if err := json.Unmarshal(envelope.Params, &params); err == nil {
			// Settings are thread-scoped in Codex. In particular, multi-agent v2
			// exposes a child's effective model here even though its parent-side
			// subAgentActivity projection does not carry a model.
			d.updateThreadModelFromSettings(params.ThreadID, params.ThreadSettings.Model)
		}
	case "model/rerouted":
		var params appServerModelReroutedNotification
		if err := json.Unmarshal(envelope.Params, &params); err == nil {
			d.updateThreadTurnModel(params.ThreadID, params.TurnID, params.ToModel)
		}
	case "thread/tokenUsage/updated":
		var params appServerThreadTokenUsageNotification
		if err := json.Unmarshal(envelope.Params, &params); err == nil {
			last := params.TokenUsage.Last
			if last.InputTokens > 0 || last.CachedInputTokens > 0 || last.OutputTokens > 0 {
				if d.recordAndEnqueueThreadTokenUsage(params.ThreadID, params.TurnID, params.TokenUsage) {
					d.ensureThreadModelHydration(params.ThreadID)
				}
			} else {
				d.updateThreadTokenUsage(params.ThreadID, params.TurnID, params.TokenUsage)
			}
		}
	case "turn/started":
		var params appServerTurnNotification
		if err := json.Unmarshal(envelope.Params, &params); err != nil {
			return
		}
		if !d.isPrimaryThreadEvent(params.ThreadID) {
			return
		}
		if d.markTurnStarted(params.ThreadID, params.Turn) {
			return
		}
		d.enqueueTranslatedEvent(translatedEvent{
			Kind: consts.ParsedEventKindThinking,
			Metadata: map[string]any{
				codexAuthoritativeTurnStartMetadataKey: true,
			},
		})
	case "turn/completed":
		var params appServerTurnNotification
		if err := json.Unmarshal(envelope.Params, &params); err != nil {
			return
		}
		defer d.clearThreadTurnModel(params.ThreadID, params.Turn.ID)
		if !d.isPrimaryThreadEvent(params.ThreadID) {
			d.forgetToolActivitiesForTurn(params.ThreadID, params.Turn.ID)
			return
		}
		if strings.EqualFold(strings.TrimSpace(params.Turn.Status.Type), "failed") {
			generation, source, causeTurnID, first, correlated :=
				d.agentteamCompactionFailureCorrelation(params.ThreadID, params.Turn.ID)
			if correlated {
				slog.Warn("codex context compaction terminal failure",
					"driver", "codex-app-server",
					"launch_generation", generation,
					"source", source,
					"thread_id", strings.TrimSpace(params.ThreadID),
					"turn_id", strings.TrimSpace(params.Turn.ID),
					"cause_turn_id", causeTurnID,
					"outcome", "failed")
			}
			if first && source == codexCompactionSourceLoopProactive {
				d.enqueueTranslatedEvent(translatedEvent{
					Kind:      consts.ParsedEventKindError,
					SessionID: strings.TrimSpace(params.ThreadID),
					Message:   "codex proactive context compaction failed",
					Metadata: map[string]any{
						contextCompactionFailedMetadataKey:      true,
						contextCompactionMaintenanceMetadataKey: true,
					},
				})
			}
		}
		completion := d.markTurnCompleted(params.ThreadID, params.Turn)
		d.forgetToolActivitiesForTurn(params.ThreadID, params.Turn.ID)
		if completion.compactionCompleted {
			if strings.EqualFold(strings.TrimSpace(params.Turn.Status.Type), "completed") {
				d.enqueueTranslatedEvent(codexContextCompactionCompletedEvent(
					params.ThreadID,
					completion.compactionBoundary,
				))
			}
			d.enqueueTranslatedEvent(translatedEvent{
				Kind:      consts.ParsedEventKindRuntimeIdle,
				SessionID: strings.TrimSpace(params.ThreadID),
			})
		}
		if completion.isCompaction {
			return
		}
		if d.consumeTerminalErrorTurnEnd(params.ThreadID, params.Turn.ID) {
			return
		}
		if !completion.turnEnded {
			return
		}
		d.enqueueTranslatedEvent(translatedEvent{
			Kind:      consts.ParsedEventKindTurnEnd,
			SessionID: strings.TrimSpace(params.ThreadID),
			Metadata: map[string]any{
				runtimeCompletedTurnIDKey: strings.TrimSpace(params.Turn.ID),
			},
		})
	case "item/reasoning/summaryTextDelta":
		d.handleReasoningDelta(envelope.Params, true)
	case "item/reasoning/textDelta":
		if d.showRawReasoningEnabled() {
			d.handleReasoningDelta(envelope.Params, false)
		}
	case "item/agentMessage/delta":
		var params appServerItemDeltaNotification
		if err := json.Unmarshal(envelope.Params, &params); err == nil && strings.TrimSpace(params.Delta) != "" {
			if !d.isCurrentPrimaryTurnActivity(params.ThreadID, params.TurnID) {
				return
			}
			if d.isCompactionTurn(params.ThreadID, params.TurnID) {
				return
			}
			d.markItemStreamed(params.ItemID)
			d.enqueueTranslatedEvent(translatedEvent{
				Kind: consts.ParsedEventKindText,
				Text: params.Delta,
			})
		}
	case "PlanDeltaNotification", "item/plan/delta":
		var params appServerItemDeltaNotification
		if err := json.Unmarshal(envelope.Params, &params); err == nil && strings.TrimSpace(params.Delta) != "" {
			if !d.isCurrentPrimaryTurnActivity(params.ThreadID, params.TurnID) {
				return
			}
			if d.isCompactionTurn(params.ThreadID, params.TurnID) {
				return
			}
			d.markItemStreamed(params.ItemID)
			d.enqueueTranslatedEvent(translatedEvent{
				Kind: consts.ParsedEventKindThinking,
				Text: params.Delta,
			})
		}
	case "item/commandExecution/outputDelta":
		d.handleToolOutputDelta(envelope.Params, "shell")
	case "item/fileChange/outputDelta":
		d.handleToolOutputDelta(envelope.Params, "file_change")
	case "item/started":
		d.handleItemNotification(envelope.Params, false)
	case "item/completed":
		d.handleItemNotification(envelope.Params, true)
	case "serverRequest/resolved":
		var params appServerServerRequestResolvedNotification
		if err := json.Unmarshal(envelope.Params, &params); err != nil {
			return
		}
		requestKey := normalizedServerRequestID(params.RequestID)
		if requestKey == "" {
			return
		}
		if d.resolveServerRequest(requestKey) {
			d.forgetToolActivity(requestKey)
			slog.Debug("codex-app-server server request resolved",
				"driver", "codex-app-server",
				"request_id", requestKey,
				"thread_id", strings.TrimSpace(params.ThreadID))
		}
	case "thread/compacted",
		"thread/started",
		"turn/diff/updated",
		"item/reasoning/summaryPartAdded",
		"mcpServer/startupStatus/updated",
		"account/rateLimits/updated":
		return
	case consts.ParsedEventKindError:
		var params appServerErrorNotification
		if err := json.Unmarshal(envelope.Params, &params); err == nil {
			if !d.isPrimaryThreadEvent(params.ThreadID) {
				d.forgetToolActivitiesForTurn(params.ThreadID, params.TurnID)
				return
			}
			if !d.isCurrentPrimaryTurnActivity(params.ThreadID, params.TurnID) {
				// A duplicate or delayed error must not make a completed Process
				// busy again. Tool/activity cleanup still belongs to the turn.
				d.forgetToolActivitiesForTurn(params.ThreadID, params.TurnID)
				return
			}
			message, codexErrorInfo := codexAppServerErrorMessage(params.Error)
			contextWindowExceeded := isContextWindowExceededCodexError(codexErrorInfo)
			compactionSource := ""
			if !params.WillRetry {
				if generation, source, causeTurnID, _, ok :=
					d.agentteamCompactionFailureCorrelation(params.ThreadID, params.TurnID); ok {
					compactionSource = source
					slog.Warn("codex context compaction terminal failure",
						"driver", "codex-app-server",
						"launch_generation", generation,
						"source", source,
						"thread_id", strings.TrimSpace(params.ThreadID),
						"turn_id", strings.TrimSpace(params.TurnID),
						"cause_turn_id", causeTurnID,
						"outcome", "failed")
				}
			}
			if contextWindowExceeded &&
				!params.WillRetry &&
				compactionSource != codexCompactionSourceLoopProactive {
				d.queueCompaction(params.ThreadID, params.TurnID)
			}
			metadata := map[string]any{
				"context_window_exceeded": contextWindowExceeded,
				"will_retry":              params.WillRetry,
			}
			if compactionSource == codexCompactionSourceLoopProactive {
				metadata[contextCompactionFailedMetadataKey] = true
				metadata[contextCompactionMaintenanceMetadataKey] = true
			}
			if contextWindowExceeded && !params.WillRetry {
				metadata[codexThreadIDMetadataKey] = strings.TrimSpace(params.ThreadID)
				metadata[codexTurnIDMetadataKey] = strings.TrimSpace(params.TurnID)
			}
			d.enqueueTranslatedEvent(translatedEvent{
				Kind:     consts.ParsedEventKindError,
				Message:  message,
				Metadata: metadata,
			})
			if !params.WillRetry && d.rememberTerminalErrorTurnEnd(params.ThreadID, params.TurnID) {
				completion := d.markTurnCompleted(params.ThreadID, appServerTurn{
					ID:     params.TurnID,
					Status: appServerTurnStatus{Type: "failed"},
				})
				d.forgetToolActivitiesForTurn(params.ThreadID, params.TurnID)
				if completion.compactionCompleted {
					d.enqueueTranslatedEvent(translatedEvent{
						Kind:      consts.ParsedEventKindRuntimeIdle,
						SessionID: strings.TrimSpace(params.ThreadID),
					})
				}
				if !completion.isCompaction && completion.turnEnded {
					d.enqueueTranslatedEvent(translatedEvent{
						Kind:      consts.ParsedEventKindTurnEnd,
						SessionID: strings.TrimSpace(params.ThreadID),
						Metadata: map[string]any{
							runtimeCompletedTurnIDKey:            strings.TrimSpace(params.TurnID),
							codexTerminalErrorTurnEndMetadataKey: true,
						},
					})
				}
			}
		}
	default:
		slog.Debug("unhandled codex-app-server notification", "method", method)
	}
}

func appServerNotificationMutatesPrimaryTurnLifecycle(method string) bool {
	switch strings.TrimSpace(method) {
	case "thread/status/changed", "turn/started", "turn/completed", consts.ParsedEventKindError:
		return true
	default:
		return false
	}
}

func appServerNotificationCarriesTurnActivity(method string) bool {
	switch strings.TrimSpace(method) {
	case "thread/status/changed",
		"turn/started",
		"turn/completed",
		"item/reasoning/summaryTextDelta",
		"item/reasoning/textDelta",
		"item/agentMessage/delta",
		"PlanDeltaNotification",
		"item/plan/delta",
		"item/commandExecution/outputDelta",
		"item/fileChange/outputDelta",
		"item/started",
		"item/completed",
		consts.ParsedEventKindError:
		return true
	default:
		return false
	}
}

func (d *CodexAppServerDriver) rememberTerminalErrorTurnEnd(threadID string, turnID string) bool {
	key := terminalErrorTurnEndKey(threadID, turnID)
	if key == "" {
		return false
	}

	d.mu.Lock()
	defer d.mu.Unlock()
	if _, completed := d.completedTurnIDs[strings.TrimSpace(turnID)]; completed {
		return false
	}
	if d.terminalErrorTurnEndIDs == nil {
		d.terminalErrorTurnEndIDs = make(map[string]struct{})
	}
	if _, exists := d.terminalErrorTurnEndIDs[key]; exists {
		return false
	}
	d.terminalErrorTurnEndIDs[key] = struct{}{}
	return true
}

func (d *CodexAppServerDriver) consumeTerminalErrorTurnEnd(threadID string, turnID string) bool {
	key := terminalErrorTurnEndKey(threadID, turnID)
	if key == "" {
		return false
	}

	d.mu.Lock()
	defer d.mu.Unlock()
	if _, exists := d.terminalErrorTurnEndIDs[key]; !exists {
		return false
	}
	delete(d.terminalErrorTurnEndIDs, key)
	return true
}

func terminalErrorTurnEndKey(threadID string, turnID string) string {
	threadID = strings.TrimSpace(threadID)
	turnID = strings.TrimSpace(turnID)
	if threadID == "" || turnID == "" {
		return ""
	}
	return threadID + "\x00" + turnID
}

func (d *CodexAppServerDriver) handleReasoningDelta(raw json.RawMessage, summary bool) {
	var params appServerReasoningTextDeltaNotification
	if err := json.Unmarshal(raw, &params); err != nil {
		return
	}
	if !d.isPrimaryThreadEvent(params.ThreadID) {
		return
	}
	if !d.isCurrentPrimaryTurnActivity(params.ThreadID, params.TurnID) {
		return
	}
	delta := normalizeAppServerReasoningText(params.Delta)
	if strings.TrimSpace(delta) == "" {
		return
	}
	if d.isCompactionTurn(params.ThreadID, params.TurnID) {
		return
	}
	sectionID := appServerReasoningSectionID(params, summary)
	title := d.updateReasoningSection(sectionID, delta)
	d.markItemStreamed(params.ItemID)
	d.enqueueTranslatedEvent(translatedEvent{
		Kind:      consts.ParsedEventKindThinking,
		Text:      delta,
		Title:     title,
		SectionID: sectionID,
	})
}

func (d *CodexAppServerDriver) handleItemNotification(raw json.RawMessage, completed bool) {
	var params appServerItemNotification
	if err := json.Unmarshal(raw, &params); err != nil {
		return
	}
	item := params.Item
	if !d.isPrimaryThreadEvent(params.ThreadID) {
		// Nested-agent internals stay out of the root trajectory, but lifecycle
		// state still governs whether that nested thread may be model-hydrated.
		switch item.Type {
		case "collabAgentToolCall":
			d.updateCollabAgentModelState(params, item, completed)
		case "subAgentActivity":
			if completed {
				d.updateSubagentModelState(item)
			}
		}
		return
	}

	// The client id echo is a delivery acknowledgement, not root activity. It
	// remains authoritative even if it races behind the turn's terminal event;
	// dropping it would strand an already accepted RuntimeInput in pending state.
	if item.Type == "userMessage" {
		if clientID := strings.TrimSpace(item.ClientID); clientID != "" {
			d.enqueueTranslatedEvent(translatedEvent{
				Kind:           consts.ParsedEventKindRuntimeInputAcked,
				RuntimeInputID: clientID,
				SessionID:      strings.TrimSpace(params.ThreadID),
			})
		}
		d.finishItemNotification(item, completed)
		return
	}

	// Authoritative subagent activity is explicitly liveness-neutral in Process.
	// Preserve it after a root turn boundary just like usage/settings metadata.
	if item.Type == "subAgentActivity" {
		if completed {
			d.emitSubagentActivity(params, item)
		}
		d.finishItemNotification(item, completed)
		return
	}

	if item.Type == "contextCompaction" {
		d.handleContextCompactionItem(params, completed)
		d.finishItemNotification(item, completed)
		return
	}

	if !d.isCurrentPrimaryTurnActivity(params.ThreadID, params.TurnID) {
		// Late collab completion can still carry child model/lifecycle state, but
		// its ToolOutput must not reopen the root trajectory.
		if item.Type == "collabAgentToolCall" {
			d.updateCollabAgentModelState(params, item, completed)
		}
		d.finishItemNotification(item, completed)
		return
	}
	if d.isCompactionTurn(params.ThreadID, params.TurnID) {
		d.finishItemNotification(item, completed)
		return
	}

	streamed := d.wasItemStreamed(item.ID)
	switch item.Type {
	case "plan":
		if strings.TrimSpace(item.Text) != "" && (!completed || !streamed) {
			d.enqueueTranslatedEvent(translatedEvent{
				Kind: consts.ParsedEventKindThinking,
				Text: item.Text,
			})
		}
	case "reasoning":
		if completed {
			for _, event := range d.completedReasoningItemEvents(item, streamed) {
				d.enqueueTranslatedEvent(event)
			}
		}
	case "agentMessage":
		if completed && strings.TrimSpace(item.Text) != "" && !streamed {
			d.enqueueTranslatedEvent(translatedEvent{
				Kind: consts.ParsedEventKindText,
				Text: item.Text,
			})
		}
	case "commandExecution":
		if !completed && strings.TrimSpace(item.Command) != "" {
			toolInput, _ := json.Marshal(map[string]any{"command": item.Command})
			d.enqueueTranslatedEvent(translatedEvent{
				Kind:      consts.ParsedEventKindToolCall,
				ToolName:  "shell",
				ToolInput: string(toolInput),
			})
		}
	case "fileChange":
		if completed {
			break
		}
		for _, change := range item.Changes {
			toolInput, _ := json.Marshal(map[string]any{
				"path": change.Path,
				"kind": change.Kind,
			})
			d.enqueueTranslatedEvent(translatedEvent{
				Kind:      consts.ParsedEventKindToolCall,
				ToolName:  "file_change",
				ToolInput: string(toolInput),
			})
		}
	case "mcpToolCall":
		toolCallID := appServerItemCallID(item)
		toolName := d.mcpToolEventName(item.Server, item.Tool)
		if completed {
			d.emitCompletedToolCallOutput(params.ThreadID, params.TurnID, toolCallID, toolName, item)
			break
		}
		d.registerToolActivity(toolActivityRef{
			threadID: strings.TrimSpace(params.ThreadID),
			turnID:   strings.TrimSpace(params.TurnID),
			callID:   toolCallID,
			toolName: toolName,
		}, toolCallID)
		d.enqueueTranslatedEvent(translatedEvent{
			Kind:       consts.ParsedEventKindToolCall,
			ToolName:   toolName,
			ToolCallID: toolCallID,
			ToolInput:  normalizeAppServerToolInput(item.Arguments),
			SessionID:  strings.TrimSpace(params.ThreadID),
		})
	case "dynamicToolCall":
		toolCallID := appServerItemCallID(item)
		toolName := appServerDynamicToolName(item.Namespace, item.Tool)
		if completed {
			d.emitCompletedToolCallOutput(params.ThreadID, params.TurnID, toolCallID, toolName, item)
			break
		}
		d.registerToolActivity(toolActivityRef{
			threadID: strings.TrimSpace(params.ThreadID),
			turnID:   strings.TrimSpace(params.TurnID),
			callID:   toolCallID,
			toolName: toolName,
		}, toolCallID)
		d.enqueueTranslatedEvent(translatedEvent{
			Kind:       consts.ParsedEventKindToolCall,
			ToolName:   toolName,
			ToolCallID: toolCallID,
			ToolInput:  normalizeAppServerToolInput(item.Arguments),
			SessionID:  strings.TrimSpace(params.ThreadID),
		})
	case "function_call_output", "functionCallOutput":
		d.emitCompletedToolCallOutput(params.ThreadID, params.TurnID, appServerItemCallID(item), "", item)
	case "collabAgentToolCall":
		d.emitCollabAgentToolCall(params, item, completed)
	case "webSearch":
		toolCallID := appServerItemCallID(item)
		query := appServerWebSearchQuery(item)
		if completed {
			text := completedToolCallOutputText(item)
			if strings.TrimSpace(text) == "" {
				if strings.TrimSpace(query) != "" {
					text = "Searched web for: " + query
				} else {
					text = "Web search completed."
				}
			}
			threadID := strings.TrimSpace(params.ThreadID)
			turnID := strings.TrimSpace(params.TurnID)
			if toolCallID != "" {
				if ref, ok := d.resolveToolActivity(toolCallID); ok {
					if threadID != "" && strings.TrimSpace(ref.threadID) != "" && threadID != strings.TrimSpace(ref.threadID) {
						break
					}
					if turnID != "" && strings.TrimSpace(ref.turnID) != "" && turnID != strings.TrimSpace(ref.turnID) {
						break
					}
					ref.threadID = firstNonEmptyString(ref.threadID, threadID)
					ref.turnID = firstNonEmptyString(ref.turnID, turnID)
					ref.callID = firstNonEmptyString(ref.callID, toolCallID)
					ref.toolName = firstNonEmptyString(ref.toolName, "web_search")
					if !ref.started {
						d.emitWebSearchToolCall(ref.threadID, ref.callID, query)
					}
					d.emitMCPToolOutputText(ref.threadID, ref.callID, ref.toolName, text)
					d.forgetToolActivity(ref.callID, toolCallID)
					break
				}
			}
			if toolCallID != "" {
				d.emitWebSearchToolCall(threadID, toolCallID, query)
			}
			d.emitMCPToolOutputText(threadID, toolCallID, "web_search", text)
			break
		}
		started := strings.TrimSpace(query) != ""
		if toolCallID != "" {
			d.registerToolActivity(toolActivityRef{
				threadID: strings.TrimSpace(params.ThreadID),
				turnID:   strings.TrimSpace(params.TurnID),
				callID:   toolCallID,
				toolName: "web_search",
				started:  started,
			}, toolCallID)
		}
		if started {
			d.emitWebSearchToolCall(strings.TrimSpace(params.ThreadID), toolCallID, query)
		}
	}
	d.finishItemNotification(item, completed)
}

func (d *CodexAppServerDriver) finishItemNotification(item appServerItem, completed bool) {
	if !completed {
		return
	}
	d.clearItemStreamed(item.ID)
	d.clearReasoningSectionsForItem(item.ID)
	d.forgetToolActivity(appServerItemCallID(item), item.ID)
}

func (d *CodexAppServerDriver) emitWebSearchToolCall(threadID string, toolCallID string, query string) {
	toolInput, _ := json.Marshal(map[string]any{"query": query})
	d.enqueueTranslatedEvent(translatedEvent{
		Kind:       consts.ParsedEventKindToolCall,
		ToolName:   "web_search",
		ToolCallID: strings.TrimSpace(toolCallID),
		ToolInput:  string(toolInput),
		SessionID:  strings.TrimSpace(threadID),
	})
}

const (
	maxCodexCollabPromptRunes       = 1000
	maxCodexCollabStateMessageRunes = 240
	maxCodexSubagentIdentityRunes   = 240
	maxCodexCollabReceivers         = 64
)

func (d *CodexAppServerDriver) emitCollabAgentToolCall(params appServerItemNotification, item appServerItem, completed bool) {
	d.updateCollabAgentModelState(params, item, completed)
	toolName := codexCollabToolEventName(item.Tool)
	metadata := codexCollabToolMetadata(params, item)
	if completed {
		d.enqueueTranslatedEvent(translatedEvent{
			Kind:       consts.ParsedEventKindToolOutput,
			ToolName:   toolName,
			ToolCallID: strings.TrimSpace(item.ID),
			Text:       codexCollabToolOutput(item),
			SessionID:  strings.TrimSpace(params.ThreadID),
			Metadata:   metadata,
		})
		return
	}

	d.enqueueTranslatedEvent(translatedEvent{
		Kind:       consts.ParsedEventKindToolCall,
		ToolName:   toolName,
		ToolCallID: strings.TrimSpace(item.ID),
		ToolInput:  codexCollabToolInput(item),
		SessionID:  strings.TrimSpace(params.ThreadID),
		Metadata:   metadata,
	})
}

func (d *CodexAppServerDriver) updateCollabAgentModelState(params appServerItemNotification, item appServerItem, completed bool) {
	tool := strings.TrimSpace(item.Tool)
	failed := strings.EqualFold(strings.TrimSpace(item.Status), "failed")
	switch tool {
	case "closeAgent":
		// Fence retries as soon as close starts and keep the fence for every
		// completion status. Codex derives CloseEnd.status from the child's
		// *previous* status rather than close operation success, so "failed" can
		// still mean a missing/dead child was successfully persisted as closed.
		// Only an explicit later resume/send/activity event may reopen hydration.
		d.setThreadModelHydrationBlocked(item.ReceiverThreadIDs, true)
	case "sendInput":
		// v1 emits sendInput only after ensure_v2_agent_loaded succeeds.
		d.setThreadModelHydrationBlocked(item.ReceiverThreadIDs, false)
	case "spawnAgent", "resumeAgent":
		if completed && !failed {
			d.setThreadModelHydrationBlocked(item.ReceiverThreadIDs, false)
		}
	}
	if completed && tool == "spawnAgent" {
		model := strings.TrimSpace(item.Model)
		if model == "" {
			model = d.threadModel(firstNonEmptyString(item.SenderThreadID, params.ThreadID))
		}
		for _, receiverThreadID := range item.ReceiverThreadIDs {
			d.updateThreadModel(receiverThreadID, model)
		}
	}
}

func codexCollabToolEventName(tool string) string {
	switch strings.TrimSpace(tool) {
	case "spawnAgent":
		return "collaboration.spawn_agent"
	case "sendInput":
		return "collaboration.send_input"
	case "resumeAgent":
		return "collaboration.resume_agent"
	case "wait":
		return "collaboration.wait"
	case "closeAgent":
		return "collaboration.close_agent"
	default:
		return "collaboration.agent_tool"
	}
}

func codexCollabToolMetadata(params appServerItemNotification, item appServerItem) map[string]any {
	return map[string]any{
		"runtimeItemType":   "collabAgentToolCall",
		"itemId":            strings.TrimSpace(item.ID),
		"sourceThreadId":    strings.TrimSpace(params.ThreadID),
		"sourceTurnId":      strings.TrimSpace(params.TurnID),
		"collabTool":        strings.TrimSpace(item.Tool),
		"collabStatus":      strings.TrimSpace(item.Status),
		"senderThreadId":    boundedCodexActivityText(item.SenderThreadID, maxCodexSubagentIdentityRunes),
		"receiverThreadIds": boundedCodexReceiverThreadIDs(item.ReceiverThreadIDs),
	}
}

func codexCollabToolInput(item appServerItem) string {
	payload := map[string]any{
		"action":            strings.TrimSpace(item.Tool),
		"senderThreadId":    boundedCodexActivityText(item.SenderThreadID, maxCodexSubagentIdentityRunes),
		"receiverThreadIds": boundedCodexReceiverThreadIDs(item.ReceiverThreadIDs),
	}
	if prompt := boundedCodexActivityText(item.Prompt, maxCodexCollabPromptRunes); prompt != "" {
		payload["prompt"] = prompt
	}
	if model := strings.TrimSpace(item.Model); model != "" {
		payload["model"] = model
	}
	if effort := strings.TrimSpace(item.ReasoningEffort); effort != "" {
		payload["reasoningEffort"] = effort
	}
	encoded, _ := json.Marshal(payload)
	return string(encoded)
}

func codexCollabToolOutput(item appServerItem) string {
	stateThreadIDs := make([]string, 0, len(item.AgentsStates))
	for threadID := range item.AgentsStates {
		stateThreadIDs = append(stateThreadIDs, threadID)
	}
	sort.Strings(stateThreadIDs)
	if len(stateThreadIDs) > maxCodexCollabReceivers {
		stateThreadIDs = stateThreadIDs[:maxCodexCollabReceivers]
	}
	states := make(map[string]appServerCollabAgentState, len(stateThreadIDs))
	for _, threadID := range stateThreadIDs {
		state := item.AgentsStates[threadID]
		threadID = boundedCodexActivityText(threadID, maxCodexSubagentIdentityRunes)
		if threadID == "" {
			continue
		}
		state.Status = strings.TrimSpace(state.Status)
		state.Message = boundedCodexActivityText(state.Message, maxCodexCollabStateMessageRunes)
		states[threadID] = state
	}
	payload := map[string]any{
		"status":            strings.TrimSpace(item.Status),
		"receiverThreadIds": boundedCodexReceiverThreadIDs(item.ReceiverThreadIDs),
		"agentsStates":      states,
	}
	encoded, _ := json.Marshal(payload)
	return string(encoded)
}

func boundedCodexReceiverThreadIDs(values []string) []string {
	if len(values) > maxCodexCollabReceivers {
		values = values[:maxCodexCollabReceivers]
	}
	out := make([]string, 0, len(values))
	for _, value := range values {
		if value = boundedCodexActivityText(value, maxCodexSubagentIdentityRunes); value != "" {
			out = append(out, value)
		}
	}
	return out
}

func (d *CodexAppServerDriver) emitSubagentActivity(params appServerItemNotification, item appServerItem) {
	threadID := boundedCodexActivityText(item.AgentThreadID, maxCodexSubagentIdentityRunes)
	path := boundedCodexActivityText(item.AgentPath, maxCodexSubagentIdentityRunes)
	detail := codexSubagentActivityDetail(item.Kind, path)
	d.updateSubagentModelState(item)
	if threadID == "" || path == "" || detail == "" {
		return
	}
	d.enqueueTranslatedEvent(translatedEvent{
		Kind:      consts.ParsedEventKindSubagentActivity,
		Message:   detail,
		SessionID: strings.TrimSpace(params.ThreadID),
		Metadata: map[string]any{
			"runtimeItemType": "subAgentActivity",
			"itemId":          strings.TrimSpace(item.ID),
			"sourceThreadId":  strings.TrimSpace(params.ThreadID),
			"sourceTurnId":    strings.TrimSpace(params.TurnID),
			"activityKind":    strings.TrimSpace(item.Kind),
			"agentThreadId":   threadID,
			"agentPath":       path,
		},
	})
}

func (d *CodexAppServerDriver) updateSubagentModelState(item appServerItem) {
	threadID := strings.TrimSpace(item.AgentThreadID)
	if threadID == "" {
		return
	}
	switch strings.TrimSpace(item.Kind) {
	case "started", "interacted":
		// v2 emits Interacted only after ensure_v2_agent_loaded and the
		// inter-agent submission both succeed.
		d.setThreadModelHydrationBlocked([]string{threadID}, false)
	case "interrupted":
		d.setThreadModelHydrationBlocked([]string{threadID}, true)
	}
}

func codexSubagentActivityDetail(kind string, path string) string {
	switch strings.TrimSpace(kind) {
	case "started":
		return "Started " + path
	case "interacted":
		return "Interacted with " + path
	case "interrupted":
		return "Interrupted " + path
	default:
		return ""
	}
}

func boundedCodexActivityText(value string, maxRunes int) string {
	value = strings.TrimSpace(value)
	if value == "" || maxRunes <= 0 {
		return value
	}
	runes := []rune(value)
	if len(runes) <= maxRunes {
		return value
	}
	return string(runes[:maxRunes-1]) + "…"
}

func appServerWebSearchQuery(item appServerItem) string {
	if query := strings.TrimSpace(item.Query); query != "" {
		return query
	}
	if len(item.Action) == 0 || strings.TrimSpace(string(item.Action)) == "null" {
		return ""
	}
	var action struct {
		Query   string   `json:"query"`
		URL     string   `json:"url"`
		Pattern string   `json:"pattern"`
		Queries []string `json:"queries"`
	}
	if err := json.Unmarshal(item.Action, &action); err != nil {
		return ""
	}
	if query := strings.TrimSpace(action.Query); query != "" {
		return query
	}
	url := strings.TrimSpace(action.URL)
	pattern := strings.TrimSpace(action.Pattern)
	if pattern != "" && url != "" {
		return "'" + pattern + "' in " + url
	}
	if url != "" {
		return url
	}
	for _, query := range action.Queries {
		if query := strings.TrimSpace(query); query != "" {
			return query
		}
	}
	return ""
}

func appServerItemCallID(item appServerItem) string {
	return firstNonEmptyString(item.ID, item.CallID, item.CallIDSnake)
}

func (d *CodexAppServerDriver) emitCompletedToolCallOutput(threadID string, turnID string, callID string, toolName string, item appServerItem) {
	callID = strings.TrimSpace(callID)
	if callID == "" {
		return
	}
	text := completedToolCallOutputText(item)
	if strings.TrimSpace(text) == "" {
		return
	}
	ref, ok := d.resolveToolActivity(callID)
	if !ok {
		return
	}
	threadID = strings.TrimSpace(threadID)
	turnID = strings.TrimSpace(turnID)
	if threadID != "" && strings.TrimSpace(ref.threadID) != "" && threadID != strings.TrimSpace(ref.threadID) {
		return
	}
	if turnID != "" && strings.TrimSpace(ref.turnID) != "" && turnID != strings.TrimSpace(ref.turnID) {
		return
	}
	ref.threadID = firstNonEmptyString(ref.threadID, threadID)
	ref.turnID = firstNonEmptyString(ref.turnID, turnID)
	ref.callID = firstNonEmptyString(ref.callID, callID)
	ref.toolName = firstNonEmptyString(ref.toolName, toolName)
	d.emitMCPToolOutputText(ref.threadID, ref.callID, ref.toolName, text)
	d.forgetToolActivity(ref.callID, callID)
}

func completedToolCallOutputText(item appServerItem) string {
	for _, raw := range []json.RawMessage{item.Output, item.Result, item.Content} {
		if text := appServerOutputText(raw); strings.TrimSpace(text) != "" {
			return text
		}
	}
	if len(item.ContentItems) > 0 {
		parts := make([]string, 0, len(item.ContentItems))
		for _, content := range item.ContentItems {
			if text := strings.TrimSpace(content.Text); text != "" {
				parts = append(parts, text)
			}
		}
		return strings.TrimSpace(strings.Join(parts, "\n"))
	}
	return ""
}

func appServerOutputText(raw json.RawMessage) string {
	if len(raw) == 0 || string(raw) == "null" {
		return ""
	}
	var asString string
	if err := json.Unmarshal(raw, &asString); err == nil {
		return strings.TrimSpace(asString)
	}
	var content []types.MCPToolContent
	if err := json.Unmarshal(raw, &content); err == nil {
		return mcpToolResultMessage(&types.MCPToolResult{Content: content})
	}
	var contentItems []appServerDynamicToolCallOutputContentItem
	if err := json.Unmarshal(raw, &contentItems); err == nil {
		parts := make([]string, 0, len(contentItems))
		for _, content := range contentItems {
			if text := strings.TrimSpace(content.Text); text != "" {
				parts = append(parts, text)
			}
		}
		return strings.TrimSpace(strings.Join(parts, "\n"))
	}
	var record map[string]json.RawMessage
	if err := json.Unmarshal(raw, &record); err == nil {
		for _, key := range []string{"output", "text", "message", "content", "contentItems", "result"} {
			if value, ok := record[key]; ok {
				if text := appServerOutputText(value); strings.TrimSpace(text) != "" {
					return text
				}
			}
		}
	}
	return strings.TrimSpace(string(raw))
}

func appServerStringList(raw json.RawMessage) []string {
	if len(raw) == 0 || string(raw) == "null" {
		return nil
	}
	var list []string
	if err := json.Unmarshal(raw, &list); err == nil {
		return list
	}
	var single string
	if err := json.Unmarshal(raw, &single); err == nil {
		return []string{single}
	}
	return nil
}

func normalizeAppServerToolInput(raw json.RawMessage) string {
	trimmed := strings.TrimSpace(string(raw))
	switch trimmed {
	case "", "null", "{}":
		return ""
	default:
		return trimmed
	}
}

func (d *CodexAppServerDriver) handleToolOutputDelta(raw json.RawMessage, toolName string) {
	var params appServerItemDeltaNotification
	if err := json.Unmarshal(raw, &params); err != nil {
		return
	}
	if !d.isCurrentPrimaryTurnActivity(params.ThreadID, params.TurnID) {
		return
	}
	if strings.TrimSpace(params.Delta) == "" {
		return
	}
	if d.isCompactionTurn(params.ThreadID, params.TurnID) {
		return
	}

	d.enqueueTranslatedEvent(translatedEvent{
		Kind:     consts.ParsedEventKindToolOutput,
		Text:     params.Delta,
		ToolName: strings.TrimSpace(toolName),
	})
}

// ---------------------------------------------------------------------------
// Translated line helpers
// ---------------------------------------------------------------------------

func codexAppServerErrorMessage(raw json.RawMessage) (string, json.RawMessage) {
	var fields struct {
		Message           string          `json:"message"`
		AdditionalDetails string          `json:"additionalDetails"`
		CodexErrorInfo    json.RawMessage `json:"codexErrorInfo,omitempty"`
	}
	if err := json.Unmarshal(raw, &fields); err != nil {
		return runtimeErrorDetailFromRaw(raw, nil, 0), nil
	}
	return runtimeErrorMessage("", fields.Message, fields.AdditionalDetails, raw), fields.CodexErrorInfo
}

func isContextWindowExceededCodexError(raw json.RawMessage) bool {
	if len(raw) == 0 {
		return false
	}

	var code string
	if err := json.Unmarshal(raw, &code); err == nil {
		return strings.TrimSpace(code) == "contextWindowExceeded"
	}
	return false
}

func (d *CodexAppServerDriver) completedReasoningItemEvents(item appServerItem, streamed bool) []translatedEvent {
	if streamed {
		return nil
	}

	parts := nonEmptyAppServerStringList(item.Summary)
	if len(parts) == 0 && d.showRawReasoningEnabled() {
		parts = nonEmptyAppServerStringList(item.Content)
	}
	if len(parts) == 0 {
		return nil
	}

	events := make([]translatedEvent, 0, len(parts))
	for idx, part := range parts {
		sectionID := appServerCompletedReasoningSectionID(item.ID, idx)
		events = append(events, translatedEvent{
			Kind:      consts.ParsedEventKindThinking,
			Text:      part,
			Title:     extractFirstMarkdownBold(part),
			SectionID: sectionID,
		})
	}
	return events
}

func nonEmptyAppServerStringList(raw json.RawMessage) []string {
	values := appServerStringList(raw)
	parts := make([]string, 0, len(values))
	for _, part := range values {
		trimmed := strings.TrimSpace(normalizeAppServerReasoningText(part))
		if trimmed != "" {
			parts = append(parts, trimmed)
		}
	}
	return parts
}

func normalizeAppServerReasoningText(text string) string {
	trimmed := strings.TrimSpace(text)
	if trimmed == "" || (strings.HasPrefix(trimmed, "<!--") && strings.HasSuffix(trimmed, "-->")) {
		return ""
	}
	// Codex app-server can append an empty HTML comment to reasoning summaries,
	// either by itself or after a meaningful heading. Remove only trailing empty
	// comments so placeholders stay out of the activity stream while real text
	// and incremental-delta whitespace remain intact.
	return appServerTrailingReasoningPlaceholder.ReplaceAllString(text, "")
}

func appServerReasoningSectionID(params appServerReasoningTextDeltaNotification, summary bool) string {
	itemID := strings.TrimSpace(params.ItemID)
	if itemID == "" {
		return ""
	}
	if summary && params.SummaryIndex != nil {
		return itemID + ":summary:" + strconv.Itoa(*params.SummaryIndex)
	}
	if !summary && params.ContentIndex != nil {
		return itemID + ":raw:" + strconv.Itoa(*params.ContentIndex)
	}
	return itemID
}

func appServerCompletedReasoningSectionID(itemID string, idx int) string {
	trimmedItemID := strings.TrimSpace(itemID)
	if trimmedItemID == "" {
		return ""
	}
	return trimmedItemID + ":summary:" + strconv.Itoa(idx)
}

func (d *CodexAppServerDriver) updateReasoningSection(sectionID string, delta string) string {
	if strings.TrimSpace(sectionID) == "" {
		return extractFirstMarkdownBold(delta)
	}

	d.mu.Lock()
	defer d.mu.Unlock()
	if d.reasoningSections == nil {
		d.reasoningSections = make(map[string]reasoningSectionState)
	}
	state := d.reasoningSections[sectionID]
	state.text += delta
	if strings.TrimSpace(state.title) == "" {
		state.title = extractFirstMarkdownBold(state.text)
	}
	d.reasoningSections[sectionID] = state
	return state.title
}

func (d *CodexAppServerDriver) clearReasoningSectionsForItem(itemID string) {
	trimmedItemID := strings.TrimSpace(itemID)
	if trimmedItemID == "" {
		return
	}

	d.mu.Lock()
	defer d.mu.Unlock()
	for sectionID := range d.reasoningSections {
		if sectionID == trimmedItemID || strings.HasPrefix(sectionID, trimmedItemID+":") {
			delete(d.reasoningSections, sectionID)
		}
	}
}

func extractFirstMarkdownBold(text string) string {
	searchFrom := 0
	for {
		start := strings.Index(text[searchFrom:], "**")
		if start < 0 {
			return ""
		}
		start += searchFrom
		contentStart := start + len("**")
		end := strings.Index(text[contentStart:], "**")
		if end < 0 {
			return ""
		}
		end += contentStart
		title := strings.Join(strings.Fields(text[contentStart:end]), " ")
		if title != "" {
			return title
		}
		searchFrom = end + len("**")
		if searchFrom >= len(text) {
			return ""
		}
	}
}

func (d *CodexAppServerDriver) showRawReasoningEnabled() bool {
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.showRawReasoning
}

func parseJSONRPCID(raw json.RawMessage) (int, bool) {
	if len(raw) == 0 {
		return 0, false
	}

	var requestID int
	if err := json.Unmarshal(raw, &requestID); err == nil {
		return requestID, true
	}

	var requestIDFloat float64
	if err := json.Unmarshal(raw, &requestIDFloat); err == nil {
		requestID = int(requestIDFloat)
		if float64(requestID) == requestIDFloat {
			return requestID, true
		}
	}

	var requestIDString string
	if err := json.Unmarshal(raw, &requestIDString); err == nil {
		requestID, err := strconv.Atoi(strings.TrimSpace(requestIDString))
		if err == nil {
			return requestID, true
		}
	}
	return 0, false
}

type serverRequestLogContext struct {
	ThreadID   string `json:"threadId"`
	CallID     string `json:"callId"`
	TurnID     string `json:"turnId"`
	Server     string `json:"server"`
	ServerName string `json:"serverName"`
	Tool       string `json:"tool"`
}

func parseServerRequestLogContext(raw json.RawMessage) serverRequestLogContext {
	if len(raw) == 0 {
		return serverRequestLogContext{}
	}

	var params serverRequestLogContext
	if err := json.Unmarshal(raw, &params); err != nil {
		return serverRequestLogContext{}
	}
	params.ThreadID = strings.TrimSpace(params.ThreadID)
	params.CallID = strings.TrimSpace(params.CallID)
	params.TurnID = strings.TrimSpace(params.TurnID)
	params.Server = strings.TrimSpace(params.Server)
	params.ServerName = strings.TrimSpace(params.ServerName)
	if params.Server == "" {
		params.Server = params.ServerName
	}
	params.Tool = strings.TrimSpace(params.Tool)
	return params
}
