package agent

import (
	"bufio"
	"fmt"
	"io"
	"log/slog"
	"strings"

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

const (
	codexAuthoritativeTurnStartMetadataKey = "codex_authoritative_turn_start"
	codexTerminalErrorTurnEndMetadataKey   = "codex_terminal_error_turn_end"
	codexThreadIDMetadataKey               = "codex_thread_id"
	codexTurnIDMetadataKey                 = "codex_turn_id"
	// runtimeErrorNonterminalMetadataKey marks a runtime diagnostic that must be
	// projected to observers but must not poison Process lifecycle or fail wake
	// obligations. OpenCode session.error is one such event: the runtime also
	// publishes it for recoverable preprocessing failures before continuing the
	// same prompt.
	runtimeErrorNonterminalMetadataKey = "runtime_error_nonterminal"
)

func (ap *Process) parseStdout(r io.Reader) {
	reader := bufio.NewReader(r)
	for {
		line, err := readLineWithLimit(reader, maxProcessLineBytes)
		if err != nil {
			if err != io.EOF {
				slog.Error("agent stdout read error", "agent_id", ap.AgentID, "error", err)
			}
			return
		}
		line = strings.TrimRight(line, "\r\n")
		if strings.TrimSpace(line) == "" || matchesIgnorePattern(line) {
			continue
		}
		for _, ev := range ap.driver.ParseLine(line) {
			ap.handleParsedEvent(ev)
		}
	}
}

// readLineWithLimit reads a complete line (terminated by '\n') from the reader,
// returning an error if the accumulated data exceeds maxBytes.
func readLineWithLimit(reader *bufio.Reader, maxBytes int) (string, error) {
	if reader == nil {
		return "", fmt.Errorf("line reader is required")
	}
	if maxBytes < 0 {
		return "", fmt.Errorf("message byte limit must be non-negative")
	}
	var line []byte
	for {
		// ReadSlice returns as soon as the reader buffer fills, unlike ReadBytes,
		// which accumulates the complete token internally before returning. The
		// latter would make a nominal line limit ineffective against an untrusted
		// unterminated stream: memory could grow without bound before we ever got a
		// chance to inspect len(chunk).
		chunk, err := reader.ReadSlice('\n')
		if len(chunk) > 0 {
			if len(line) > maxBytes || len(chunk) > maxBytes-len(line) {
				return "", fmt.Errorf("message exceeds %d bytes", maxBytes)
			}
			line = append(line, chunk...)
			if chunk[len(chunk)-1] == '\n' {
				return string(line), nil
			}
		}
		if err == bufio.ErrBufferFull {
			continue
		}
		if err != nil {
			if err == io.EOF && len(line) > 0 {
				return string(line), nil
			}
			return "", err
		}
	}
}

func (ap *Process) handleParsedEvent(ev drivercontract.ParsedEvent) {
	ap.mu.Lock()
	stopping := ap.stopping
	admittedFinalHop := false
	if stopping && (ev.Kind == consts.ParsedEventKindRuntimeInputAcked || ev.Kind == consts.ParsedEventKindRuntimeInputUncertain) {
		if pending, ok := ap.pendingRuntimeInputs[strings.TrimSpace(ev.RuntimeInputID)]; ok {
			admittedFinalHop = pending.finalHopLifecycleLease != nil
		}
	}
	ap.mu.Unlock()
	if stopping && !admittedFinalHop {
		return
	}

	switch ev.Kind {
	case consts.ParsedEventKindRuntimeInputAcked:
		ap.handleRuntimeInputAcked(ev)
		return
	case consts.ParsedEventKindRuntimeInputUncertain:
		ap.AcknowledgeRuntimeMessageToolDeliveriesForSession([]string{ev.RuntimeInputID}, types.MCPToolDeliveryUncertain, ev.SessionID)
		return
	case consts.ParsedEventKindSessionInit:
		boundaryEpoch := ap.currentRuntimeHandoffEpoch()
		ap.clearTransientRuntimeRetryAwaitingTurnEnd()
		ap.prepareTokenSourceSessionInit(ev.SessionID)
		var (
			nextMessage types.InboxMessage
			hasQueued   bool
			wasIdle     bool
			popErr      error
		)
		ap.mu.Lock()
		ap.SessionID = ev.SessionID
		if strings.TrimSpace(ev.SessionID) == "" {
			ev.SessionID = ap.SessionID
		}
		wasIdle = ap.isIdle
		if wasIdle {
			ap.isIdle = false
		}
		ap.mu.Unlock()
		ap.markResumeSafety(false)
		ap.emitParsedEvent(ev)

		// SessionInit must be visible before callback-triggered delivery, so do
		// not hold the non-reentrant gate while invoking onEvent. The epoch check
		// below makes any successful delivery in that callback/gap supersede this
		// boundary's older idle snapshot.
		if err := ap.acquireDeliveryGate(nil); err != nil {
			return
		}
		if ap.runtimeBoundarySuperseded(boundaryEpoch, ev) {
			ap.releaseDeliveryGate()
			return
		}

		ap.mu.Lock()
		if ap.stopping {
			ap.mu.Unlock()
			ap.releaseDeliveryGate()
			return
		}
		supportsStdinNotification := ap.driver.SupportsStdinNotification()
		_, hasAckedBusyInput := ap.ackedBusyRuntimeInputDriverLocked()
		canDrainConcreteInput := supportsStdinNotification &&
			strings.TrimSpace(ap.SessionID) != "" &&
			(wasIdle || hasAckedBusyInput)
		if canDrainConcreteInput {
			nextMessage, hasQueued, popErr = ap.popNextDeliverableInboxMessageLocked("already_observed_before_session_init_dequeue")
		}
		if hasQueued {
			// Preserve the process's actual runtime phase. A message queued before
			// Codex exposed its session can now be steered into an already-active
			// turn; marking that turn idle would incorrectly force turn/start.
			ap.isIdle = wasIdle
		} else if wasIdle {
			ap.isIdle = true
		}
		ap.mu.Unlock()
		if hasQueued {
			_ = ap.deliverMessageWithContextSerialized(nil, nextMessage, true)
		}
		if popErr != nil {
			ap.scheduleInboxRetry(false)
		}
		// A no-prompt launch has no future TurnEnd to establish safety. Promote
		// the initialized idle thread here only after callback delivery/drain has
		// been resolved and no durable handoff or pending input remains.
		ap.markResumeSafetyIfQuiescent()
		ap.releaseDeliveryGate()
		// A session becoming available is an authoritative liveness change. It
		// should wake the daemon retry path even when this boundary could not drain
		// concrete input itself (for example while the runtime is non-steerable).
		ap.scheduleInboxRetry(true)
		return
	case consts.ParsedEventKindSubagentActivity:
		// This is a bounded control-plane projection emitted by the primary Codex
		// thread. It belongs in activity/trajectory, but it must not change root
		// liveness, wake completion, retry state, inbox ownership, or steerability.
	case consts.ParsedEventKindUsage:
		ap.recordUsage(ev)
		if ev.InputTokens == 0 && ev.CachedInputTokens == 0 && ev.OutputTokens == 0 &&
			ev.ActiveContextTokens == 0 && ev.ContextWindowTokens == 0 {
			return
		}
	case consts.ParsedEventKindThinking, consts.ParsedEventKindText, consts.ParsedEventKindError:
		ap.clearTransientRuntimeRetryAwaitingTurnEnd()
		if ap.usesExplicitTurnLifecycle() && parsedBoolMetadata(ev.Metadata, codexAuthoritativeTurnStartMetadataKey) {
			// TurnStarted can originate outside AgentTeam delivery (for example a
			// runtime-native action), so it is independently authoritative that
			// the session is no longer restart-safe.
			ap.markResumeSafety(false)
		}
		ap.mu.Lock()
		if ap.eventChangesRootLiveness(ev) {
			ap.isIdle = false
		}
		ap.mu.Unlock()

		// Detect context window exceeded errors for drivers that implement
		// drivercontract.ContextWindowExceededDriver. Runtimes with native
		// compaction queue a same-session compact+continuation; others fall back
		// to dispatcher-managed fresh-session recovery after turn_end.
		if ev.Kind == consts.ParsedEventKindError {
			nonterminalRuntimeError := parsedBoolMetadata(ev.Metadata, runtimeErrorNonterminalMetadataKey)
			contextCompactionFailed := parsedBoolMetadata(ev.Metadata, contextCompactionFailedMetadataKey)
			contextCompactionQueued := false
			ap.mu.Lock()
			contextRecoveryDisabled := ap.disableRecovery || ap.stopping
			ap.mu.Unlock()
			// A runtime can publish a recoverable diagnostic before continuing
			// the same run (OpenCode does this before its own automatic
			// compaction/replay). Do not queue AgentTeam recovery from that provisional
			// event. If it remains terminal, the driver emits a second Error
			// without runtime_error_nonterminal at the authoritative boundary.
			if !nonterminalRuntimeError && !contextCompactionFailed {
				if cwDriver, ok := ap.driver.(drivercontract.ContextWindowExceededDriver); ok {
					if cwDriver.IsContextWindowExceeded(ev) {
						if !contextRecoveryDisabled {
							if compactionDriver, hasCompaction := ap.driver.(drivercontract.ContextCompactionDriver); hasCompaction && compactionDriver.QueueContextCompaction(ev) {
								contextCompactionQueued = true
							}
						}
						ap.mu.Lock()
						ap.contextWindowExceeded = true
						if wakes := ap.pendingDeliveryInfosLocked(); len(wakes) > 0 {
							ap.contextRecoveryWakes = wakes
						}
						ap.mu.Unlock()
					}
				}
			}
			ap.noteContextCompactionFailure(ev)
			if !contextCompactionQueued && !nonterminalRuntimeError {
				outputTruncated := false
				if truncationDriver, ok := ap.driver.(drivercontract.OutputTruncationDriver); ok &&
					truncationDriver.IsOutputTruncated(ev) {
					ap.mu.Lock()
					ap.outputTruncationDetected = true
					ap.mu.Unlock()
					outputTruncated = true
				}
				if !outputTruncated && !runtimeErrorWillRetry(ev) && !contextCompactionFailed {
					if classification, attempt, ok := ap.shouldRetryTransientRuntimeError(ev); ok {
						ap.retryTransientRuntimeError(ev, classification, attempt)
						break
					}
					overflowWakes := ap.terminalContextOverflowWakeSnapshot(ev)
					if detail, ok := ap.failRuntimeError(ev); ok {
						ev = withUserFacingRuntimeError(ev, detail, ap.runtimeNameSnapshot())
					}
					ap.logTerminalContextOverflowOutcome(ev, overflowWakes)
				}
			} else {
				if truncationDriver, ok := ap.driver.(drivercontract.OutputTruncationDriver); ok &&
					truncationDriver.IsOutputTruncated(ev) {
					ap.mu.Lock()
					ap.outputTruncationDetected = true
					ap.mu.Unlock()
				}
			}
		}
	case consts.ParsedEventKindToolOutput:
		ap.clearTransientRuntimeRetryAwaitingTurnEnd()
		ap.observeTokenSourceOutput(ev)
		ap.mu.Lock()
		if ap.eventChangesRootLiveness(ev) {
			ap.isIdle = false
		}
		ap.mu.Unlock()
	case consts.ParsedEventKindTurnEnd:
		ap.resetIncompleteTokenSource(ev.SessionID)
		maintenanceCompactionBoundary := parsedBoolMetadata(
			ev.Metadata,
			contextCompactionMaintenanceMetadataKey,
		)
		// Accepted-but-unacknowledged runtime inputs remain owned by the runtime
		// across normal boundaries. OpenCode and Claude may acknowledge the correlated
		// acknowledgement after TurnEnd/RuntimeIdle; reclaiming them here would
		// duplicate delivery. Only daemon-owned inbox entries are drained below.
		boundaryEpoch := ap.currentRuntimeHandoffEpoch()
		if err := ap.acquireDeliveryGate(nil); err != nil {
			return
		}
		if ap.runtimeBoundarySuperseded(boundaryEpoch, ev) {
			ap.releaseDeliveryGate()
			// Consumers treat TurnEnd as terminal (the execution runner stops its
			// process immediately), so a stale boundary must be suppressed rather
			// than merely skipped for Process-local idle bookkeeping.
			return
		}
		if maintenanceCompactionBoundary {
			// OpenCode emits this synthetic boundary only after retiring an
			// ambiguous proactive summarize generation. Keep protocol delivery
			// closed until waitProcess proves that generation stopped; accepted
			// input remains in the daemon FIFO for the replacement Process.
			ap.mu.Lock()
			ap.isIdle = false
			ap.mu.Unlock()
			ap.releaseDeliveryGate()
			return
		}
		ap.clearTransientRuntimeRetryAwaitingTurnEnd()
		if ap.usesExplicitTurnLifecycle() && parsedBoolMetadata(ev.Metadata, codexTerminalErrorTurnEndMetadataKey) {
			// Codex emits a non-retrying Error as turn-scoped failure detail and
			// then finalizes the turn. Keep the already-emitted wake/runtime error
			// visible, but do not poison the resumable session after the matching
			// authoritative terminal boundary (the TUI immediately accepts the
			// next input at this point).
			ap.mu.Lock()
			ap.runtimeErrorDetail = ""
			ap.mu.Unlock()
		}
		// On turn end, check if we have a pending wake with no confirmed reply.
		var recoveryWakes []WakeInfo
		ap.mu.Lock()
		hasPendingWakes := ap.pendingWake != nil
		runtimeErrorWakeFailed := ap.runtimeErrorWakeFailed
		transientRuntimeRetryPending := ap.transientRuntimeRetryPending
		runtimeErrorActive := ap.runtimeErrorDetail != "" || transientRuntimeRetryPending
		shouldFinalCheck := hasPendingWakes &&
			!runtimeErrorWakeFailed &&
			!transientRuntimeRetryPending &&
			ap.pendingWakeNeedsFinalCheckLocked()
		if hasPendingWakes {
			recoveryWakes = ap.pendingDeliveryInfosLocked()
		}
		ap.mu.Unlock()

		if ap.checkAndTriggerOutputTruncationContinuation(ev) {
			ap.releaseDeliveryGate()
			return
		}

		ap.noteContextCompactionBoundary(ev)

		if ap.injectContextCompactionContinuation() {
			ap.releaseDeliveryGate()
			return
		}

		ap.noteContextCompactionFailure(ev)
		ap.noteUnconfirmedContextCompactionTurnEnd(ev, recoveryWakes)

		if ap.checkAndTriggerContextCompaction(ev, recoveryWakes) {
			ap.releaseDeliveryGate()
			return
		}

		if hasPendingWakes && !runtimeErrorWakeFailed && !transientRuntimeRetryPending {
			if shouldFinalCheck && ap.injectFinalVisibleReplyCheck() {
				ap.releaseDeliveryGate()
				break
			}
			ap.mu.Lock()
			events := ap.terminalAllWakeStatusesLocked(consts.WakeStatusCompletedWithoutVisibleReply, "")
			ap.mu.Unlock()
			for _, event := range events {
				ap.emitWakeStatus(event)
			}
		}

		// Check for context window exceeded: either detected via error events
		// during the turn, or via the stop_reason on this turn_end event.
		if ap.checkAndTriggerContextRecovery(ev, recoveryWakes) {
			ap.releaseDeliveryGate()
			return
		}
		if transientRuntimeRetryPending {
			ap.releaseDeliveryGate()
			return
		}

		// Every continuation/recovery path above keeps the originating
		// provider turn. Reaching ordinary drain is the authoritative boundary
		// between provider turns, so advance the surface epoch before the next
		// inbox body can become model-visible.
		ap.advanceAckEpoch()

		// Stop-after-work idle model: on turn_end, drain inbox or go idle.
		ap.mu.Lock()
		canDrainInbox := ap.driver.SupportsStdinNotification() && strings.TrimSpace(ap.SessionID) != ""
		nextMessage, hasQueued := types.InboxMessage{}, false
		var popErr error
		if canDrainInbox {
			nextMessage, hasQueued, popErr = ap.popNextDeliverableInboxMessageLocked("already_observed_before_turn_end_dequeue")
		}
		if hasQueued {
			ap.isIdle = true
			ap.mu.Unlock()

			_ = ap.deliverMessageWithContextSerialized(nil, nextMessage, true)
			ap.releaseDeliveryGate()
		} else {
			ap.isIdle = !ap.hasLivePendingRuntimeInputLocked()
			ap.mu.Unlock()

			if popErr != nil {
				ap.scheduleInboxRetry(false)
			} else if !runtimeErrorActive {
				ap.markResumeSafetyIfQuiescent()
			}
			// Try idle compaction if the driver supports it. When compaction
			// starts, report that state instead of briefly reporting Idle.
			// Overflow-triggered compaction runs first; if none is queued, fall
			// back to proactive between-turn compaction from the runtime's own
			// context-usage signal (no continuation retry is injected).
			if ap.startIdleCompactionAfterTurnEnd() || ap.startProactiveIdleCompactionAfterTurnEnd() {
				ap.RecordCheckpoint("compact", promptcompiler.RecoveryCheckpoint("compact"))
				ap.emitParsedEvent(drivercontract.ParsedEvent{
					Kind:      consts.ParsedEventKindRuntimeCompacting,
					SessionID: ap.GetSessionID(),
				})
				ap.releaseDeliveryGate()
				ap.scheduleInboxRetry(true)
				return
			}

			ap.mu.Lock()
			ap.isIdle = true
			ap.mu.Unlock()
			ap.releaseDeliveryGate()
		}
		ap.scheduleInboxRetry(true)

	case consts.ParsedEventKindToolCall:
		ap.clearTransientRuntimeRetryAwaitingTurnEnd()
		ap.observeTokenSourceCall(ev)
		ap.mu.Lock()
		if ap.eventChangesRootLiveness(ev) {
			ap.isIdle = false
		}
		ap.transientRuntimeRetryToolCallObserved = true

		// message_send is settled only by the server response's resolved
		// target frontier. Tool-call observation does not mutate wake status.
		ap.mu.Unlock()

	case consts.ParsedEventKindRuntimeCompacting:
		ap.noteContextCompactionBoundary(ev)

	case consts.ParsedEventKindRuntimeIdle:
		boundaryEpoch := ap.currentRuntimeHandoffEpoch()
		if err := ap.acquireDeliveryGate(nil); err != nil {
			return
		}
		if ap.runtimeBoundarySuperseded(boundaryEpoch, ev) {
			ap.releaseDeliveryGate()
			// RuntimeIdle also drives external idle/flush state. Do not publish an
			// older turn's boundary after a newer runtime handoff has won the gate.
			return
		}
		// Update session ID if provided.
		if strings.TrimSpace(ev.SessionID) != "" {
			ap.mu.Lock()
			ap.SessionID = ev.SessionID
			ap.mu.Unlock()
		}

		// RuntimeIdle is the authoritative completion boundary for a runtime-owned
		// compaction that signals success through idle (e.g. OpenCode session
		// summarize). Promote an in-progress compaction to a confirmed continuation
		// injection before normal idle/inbox handling.
		ap.mu.Lock()
		compactionJustCompleted := ap.contextCompactionInProgress
		if compactionJustCompleted {
			ap.contextCompactionContinuation = true
			ap.contextCompactionInProgress = false
		}
		ap.mu.Unlock()
		if compactionJustCompleted {
			if ap.injectContextCompactionContinuation() {
				ap.releaseDeliveryGate()
				return
			}
			// Continuation injection failed after a successful compaction. The
			// interrupted work cannot be retried in this session, so fall back to
			// fresh-session recovery now (preserving the original recovery wakes)
			// instead of returning to ordinary idle/inbox handling. This mirrors
			// the turn_end path, which falls through to checkAndTriggerContextRecovery
			// when continuation injection fails.
			if ap.checkAndTriggerContextRecovery(ev, nil) {
				ap.releaseDeliveryGate()
				return
			}
		}

		// A successful compaction continuation returned above and retains the
		// interrupted turn's runtime session. Ordinary idle drain starts a new
		// provider turn and must advance the surface epoch.
		ap.advanceAckEpoch()

		// Flush inbox or go idle (same as turn_end inbox drain but without
		// wake-status check or idle compaction).
		ap.mu.Lock()
		canDrainInbox := ap.driver.SupportsStdinNotification() && strings.TrimSpace(ap.SessionID) != ""
		nextMessage, hasQueued := types.InboxMessage{}, false
		var popErr error
		if canDrainInbox {
			nextMessage, hasQueued, popErr = ap.popNextDeliverableInboxMessageLocked("already_observed_before_runtime_idle_dequeue")
		}
		if hasQueued {
			ap.isIdle = true
			ap.mu.Unlock()

			_ = ap.deliverMessageWithContextSerialized(nil, nextMessage, true)
			ap.releaseDeliveryGate()
		} else {
			ap.isIdle = true
			ap.mu.Unlock()
			// RuntimeIdle is the authoritative completion boundary for Codex
			// compaction. In particular, an ambiguous thread/compact/start keeps
			// durable resume safety false until this event proves that no mutation
			// remains active.
			ap.markResumeSafetyIfQuiescent()
			ap.releaseDeliveryGate()
		}
		if popErr != nil {
			ap.scheduleInboxRetry(false)
			return
		}
		ap.scheduleInboxRetry(true)
	}

	if strings.TrimSpace(ev.SessionID) == "" {
		ap.mu.Lock()
		ev.SessionID = ap.SessionID
		ap.mu.Unlock()
	}

	// Notify external listener (e.g. agentteam trajectory reporting).
	ap.emitParsedEvent(ev)
}

func (ap *Process) eventChangesRootLiveness(ev drivercontract.ParsedEvent) bool {
	if !ap.usesExplicitTurnLifecycle() {
		return true
	}
	return parsedBoolMetadata(ev.Metadata, codexAuthoritativeTurnStartMetadataKey)
}

func (ap *Process) usesExplicitTurnLifecycle() bool {
	driver, explicit := ap.driver.(drivercontract.ExplicitTurnLifecycleDriver)
	return explicit && driver.UsesExplicitTurnLifecycle()
}

func runtimeErrorWillRetry(ev drivercontract.ParsedEvent) bool {
	return parsedBoolMetadata(ev.Metadata, "will_retry")
}

func parsedBoolMetadata(metadata map[string]any, key string) bool {
	if len(metadata) == 0 {
		return false
	}
	value, ok := metadata[key]
	if !ok {
		return false
	}
	switch v := value.(type) {
	case bool:
		return v
	case string:
		return strings.EqualFold(strings.TrimSpace(v), "true")
	default:
		return false
	}
}

func (ap *Process) terminalContextOverflowWakeSnapshot(ev drivercontract.ParsedEvent) []pendingDelivery {
	if !parsedBoolMetadata(ev.Metadata, "context_window_exceeded") || runtimeErrorWillRetry(ev) {
		return nil
	}
	ap.mu.Lock()
	defer ap.mu.Unlock()
	return ap.pendingDeliveriesLocked()
}

func (ap *Process) logTerminalContextOverflowOutcome(
	ev drivercontract.ParsedEvent,
	wakes []pendingDelivery,
) {
	if !parsedBoolMetadata(ev.Metadata, "context_window_exceeded") || runtimeErrorWillRetry(ev) {
		return
	}
	logOutcome := func(messageID, wakeToken, target, messageType string) {
		slog.Info("codex terminal context overflow work outcome",
			"agent_id", strings.TrimSpace(ap.AgentID),
			"thread_id", strings.TrimSpace(parsedStringMetadata(ev.Metadata, codexThreadIDMetadataKey)),
			"turn_id", strings.TrimSpace(parsedStringMetadata(ev.Metadata, codexTurnIDMetadataKey)),
			"message_id", strings.TrimSpace(messageID),
			"wake_token", strings.TrimSpace(wakeToken),
			"target", strings.TrimSpace(target),
			"message_type", strings.TrimSpace(messageType),
			"work_outcome", "terminal_failure",
			"requires_new_human_input", true)
	}
	if len(wakes) == 0 {
		logOutcome("", "", "", "")
		return
	}
	for _, wake := range wakes {
		logOutcome(wake.Wake.MessageID, wake.Token, wake.Wake.Target, wake.Wake.MessageType)
	}
}

func (ap *Process) failRuntimeError(ev drivercontract.ParsedEvent) (string, bool) {
	detail, userFacing := ap.formatRuntimeErrorDetail(ev)
	ap.markResumeSafety(false)

	ap.mu.Lock()
	if ap.runtimeErrorDetail == "" {
		ap.runtimeErrorDetail = detail
	}
	wake := ap.pendingWake
	token := ap.pendingWakeToken
	emitWakeFailure := wake != nil && !ap.runtimeErrorWakeFailed
	if emitWakeFailure {
		ap.runtimeErrorWakeFailed = true
	}
	eventClass := consts.RuntimeEventClassRuntimeExitedUnexpectedly
	messageID := ""
	target := ""
	if wake != nil {
		eventClass = consts.RuntimeEventClassWakeFailed
		messageID = wake.Wake.MessageID
		target = wake.Wake.Target
	}
	emitRuntimeEvent := !ap.runtimeErrorEventEmitted
	if emitRuntimeEvent {
		ap.runtimeErrorEventEmitted = true
	}
	ap.mu.Unlock()

	if emitWakeFailure {
		ap.mu.Lock()
		events := ap.terminalAllWakeStatusesLocked(consts.WakeStatusFailed, detail)
		ap.mu.Unlock()
		for _, event := range events {
			ap.emitWakeStatus(event)
		}
	}
	if emitRuntimeEvent {
		ap.emitRuntimeEvent(RuntimeEvent{
			MessageID:  messageID,
			WakeToken:  token,
			EventClass: eventClass,
			Target:     target,
			Detail:     detail,
		})
	}
	return detail, userFacing
}

func (ap *Process) formatRuntimeErrorDetail(ev drivercontract.ParsedEvent) (string, bool) {
	detail := ap.runtimeErrorDetailWithStderr(ev)
	if userDetail, ok := classifyUserFacingRuntimeError(ap.runtimeNameSnapshot(), detail); ok {
		return userDetail, true
	}
	return detail, false
}

func (ap *Process) runtimeErrorDetailWithStderr(ev drivercontract.ParsedEvent) string {
	detail := strings.TrimSpace(firstNonEmptyString(ev.Message, ev.Text))
	if detail == "" {
		detail = "runtime reported an error"
	}
	return ap.detailWithStderr(detail)
}

func withUserFacingRuntimeError(ev drivercontract.ParsedEvent, detail string, runtimeName string) drivercontract.ParsedEvent {
	detail = strings.TrimSpace(detail)
	if detail == "" {
		return ev
	}
	rawDetail := strings.TrimSpace(firstNonEmptyString(ev.Message, ev.Text))
	if rawDetail != "" && rawDetail != detail {
		ev.Metadata = cloneParsedEventMetadata(ev.Metadata)
		rawBytes, rawFingerprint := runtimeErrorDiagnosticFingerprint(rawDetail)
		ev.Metadata["runtime_error_detail_bytes"] = rawBytes
		ev.Metadata["runtime_error_detail_fingerprint"] = rawFingerprint
		if code := classifyUserFacingRuntimeErrorCode(runtimeName, rawDetail); code != "" {
			ev.Metadata["runtime_error_code"] = code
		}
	}
	ev.Message = detail
	if strings.TrimSpace(ev.Text) != "" {
		ev.Text = detail
	}
	return ev
}

func cloneParsedEventMetadata(metadata map[string]any) map[string]any {
	out := make(map[string]any, len(metadata)+1)
	for key, value := range metadata {
		out[key] = value
	}
	return out
}

func (ap *Process) detailWithStderr(detail string) string {
	detail = strings.TrimSpace(detail)
	ap.mu.Lock()
	stderrTail := ap.stderrTail
	ap.mu.Unlock()
	if stderrTail == nil {
		return detail
	}
	stderr := strings.TrimSpace(stderrTail())
	if stderr == "" {
		return detail
	}
	return detail + "\nstderr:\n" + stderr
}

func (ap *Process) runtimeNameSnapshot() string {
	ap.mu.Lock()
	driver := ap.driver
	ap.mu.Unlock()
	if driver == nil {
		return ""
	}
	return driver.Name()
}

// SetOnEvent sets or replaces the event callback.
func (ap *Process) SetOnEvent(fn func(drivercontract.ParsedEvent)) {
	ap.mu.Lock()
	defer ap.mu.Unlock()
	ap.onEvent = fn
}

func (ap *Process) emitParsedEvent(ev drivercontract.ParsedEvent) {
	ap.mu.Lock()
	fn := ap.onEvent
	ap.mu.Unlock()
	if fn != nil {
		fn(ev)
	}
}
func matchesIgnorePattern(line string) bool {
	lower := strings.ToLower(line)
	return strings.Contains(lower, "reconnecting...") ||
		strings.Contains(lower, "falling back from websockets")
}
