package agent

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
	"sync"
	"time"

	"github.com/tidbcloud/AgentTeam/desktop/internal/agent/drivercontract"
	"github.com/tidbcloud/AgentTeam/desktop/internal/consts"
	"github.com/tidbcloud/AgentTeam/internal/core/rewindcaps"
)

const (
	// openCodeHealthTimeout is how long to wait for the serve process
	// to become healthy before giving up.
	openCodeHealthTimeout = 60 * time.Second

	// openCodeHealthPollInterval is the interval between health check polls.
	openCodeHealthPollInterval = 200 * time.Millisecond

	// openCodeHealthAttemptTimeout bounds each individual health request so the
	// overall startup deadline is not hidden by a single hung HTTP handler.
	openCodeHealthAttemptTimeout = 2 * time.Second

	// openCodeHealthLogInterval is the minimum interval between pending health
	// diagnostics while waiting for OpenCode to become healthy.
	openCodeHealthLogInterval = 5 * time.Second

	// openCodeSSEReadyTimeout bounds startup while waiting for the event stream
	// to be subscribed before the first prompt is delivered.
	openCodeSSEReadyTimeout = 10 * time.Second

	openCodeCapturedOutputMaxBytes = 32 * 1024

	// openCodeServerUsername is the default Basic Auth username.
	openCodeServerUsername = "opencode"

	openCodeDefaultAgent = "build"
)

// OpenCodeDriver drives an `opencode serve` headless server process,
// communicating via HTTP REST APIs and SSE for real-time event streaming.
//
// Architecture overview:
//  1. Spawn() starts `opencode serve --hostname 127.0.0.1 --port <random>` with
//     a random Basic Auth password, then polls GET /global/health until healthy.
//  2. A session is created via POST /session.
//  3. An SSE subscription (GET /event) streams real-time events (text deltas,
//     tool calls, session status) which are translated into drivercontract.ParsedEvent structs
//     via an internal translatedLines channel.
//  4. Messages are sent via POST /session/:id/prompt_async (fire-and-forget).
//  5. Recovery points are prepared as OpenCode-native session revert refs. LAN
//     E2E verifies the full runtime path before release.
type OpenCodeDriver struct {
	mu sync.Mutex
	// deliveryMu serializes AgentTeam-originated prompt mutations. OpenCode's
	// Runner.ensureRunning waits for an already-running agentteam and discards the
	// new work supplied by the second caller, so AgentTeam must never overlap prompt
	// submissions for one session.
	deliveryMu sync.Mutex

	// Server connection details.
	serverURL  string
	password   string
	workDir    string
	httpClient *http.Client

	// Session state.
	sessionID             string
	sessionResumeFallback *drivercontract.SessionResumeFallback
	translatedLines       chan string
	launchModel           string

	// textBuf accumulates text deltas (message.part.delta with field "text")
	// during a turn. Instead of emitting one drivercontract.ParsedEvent per SSE delta, the
	// buffered text is flushed as a single consolidated "text" event when the
	// turn ends (session.status → idle) or when a tool_call is observed.
	textBuf strings.Builder
	// countedStepFinish dedupes step-finish parts already counted toward token
	// usage. Each step-finish part is one model invocation carrying that step's
	// usage; accumulating them (instead of message.updated, which only holds the
	// last step's replacement value) yields the true per-message total.
	countedStepFinish map[string]struct{}
	contextCompacted  bool
	// pendingRuntimeInputs maps OpenCode user message ids to AgentTeam RuntimeInput
	// values waiting for an assistant whose parentID is that exact user
	// message. The driver submits at most one such input, and only while the
	// session is authoritatively idle.
	pendingRuntimeInputs map[string]openCodePendingRuntimeInput
	// promptAwaitingRunner closes the idle-delivery gate after prompt_async is
	// dispatched but before OpenCode has produced positive evidence that the
	// forked prompt fiber entered its Runner. In OpenCode v1.17.11 prompt_async
	// returns 204 before createUserMessage/runLoop, while /abort reports idle
	// when no Runner exists; that idle is not proof that the fork was canceled.
	promptAwaitingRunner *openCodePromptRunnerReservation
	// activePromptMessageID retains the exact user message id after Runner
	// admission. It anchors source-proven internal user transitions such as
	// automatic compaction; later continuation assistants may parent to one of
	// those admitted current-Runner users rather than directly to this root.
	activePromptMessageID string
	// runnerUserMessageIDs and runnerAssistantMessageIDs bind part/delta SSE
	// frames to messages created by the currently admitted Runner. OpenCode can
	// publish background compaction.prune updates for an older assistant after
	// Runner idle (and even after the next Runner is busy); same-session
	// activity alone therefore cannot prove that a part belongs to this turn.
	runnerUserMessageIDs      map[string]struct{}
	runnerAssistantMessageIDs map[string]struct{}
	// runnerCompactionUserMessageIDs records compaction user rows created by
	// the current Runner and whether each one is automatic. A resumed Runner
	// can continue a persisted compaction task without republishing its
	// compaction part, so this binding must also be seeded by resume bootstrap.
	runnerCompactionUserMessageIDs map[string]bool
	// runnerSummaryAssistantMessageIDs marks internal compaction assistants.
	// Their model text is persisted into OpenCode's context, but must never be
	// forwarded as a user-visible AgentTeam reply.
	runnerSummaryAssistantMessageIDs map[string]struct{}
	// sseDeliveryBlocked is true before the first SSE frame and after any
	// established stream loss. OpenCode's event stream has no replay cursor, so
	// AgentTeam must not create new mutations while the positive
	// acknowledgement/status channel is unavailable; post-ready loss retires
	// the whole runtime generation instead of reconnecting it. Direct unit
	// drivers leave the zero value unblocked; Spawn explicitly closes the gate
	// until ready.
	sseDeliveryBlocked bool
	// generationRetiring fences late SSE events after a terminal lifecycle
	// contradiction has made this server generation unsafe to reuse. In
	// particular, OpenCode can publish Runner idle before prompt_async's outer
	// catchCause publishes its final session.error.
	generationRetiring bool
	// messageIDLastEncoded is the greatest validated OpenCode ascending-id
	// prefix observed or allocated by this driver. Unlike OpenCode's generator,
	// this high-water does not move backwards when the wall clock does.
	messageIDLastEncoded uint64
	// promptRunnerAdmissionTimeout bounds the interval between prompt_async
	// acceptance and positive Runner/activity evidence. The zero value selects
	// openCodePromptRunnerAdmissionTimeout; tests may shorten it.
	promptRunnerAdmissionTimeout time.Duration
	// recoveryMessageIDs binds AgentTeam message ids to OpenCode message ids. Native
	// OpenCode revert restores to a user message id, so the id captured before
	// runtime delivery must be the exact prompt_async.messageID used later.
	recoveryMessageIDs map[string]string
	// sessionStatus and turnActive gate session.status snapshots so startup or
	// repeated idle events do not look like new turn completions.
	sessionStatus       string
	turnActive          bool
	pendingSessionError *translatedEvent
	// pendingSessionErrorFinalIdle is set when an assistant error update proves
	// processor cleanup has completed. A context error seen before that update
	// can be followed by an early processor idle and must wait for Runner's
	// later idle before AgentTeam starts native summarize.
	pendingSessionErrorFinalIdle bool
	// contextTerminalDrain suppresses assistant/part cleanup emitted after a
	// processor idle. OpenCode can publish session.error -> idle, then update
	// the failed assistant/tool parts before Runner.finishRun publishes a
	// second idle. Only that second idle is safe for a terminal boundary.
	contextTerminalDrain bool
	// These ids bind an upstream automatic compaction to the current run. A
	// generic provider error from that summary follows the same two-idle drain
	// as a manual context failure and must become context_compaction_failed.
	autoCompactionUserMessageID      string
	autoCompactionAssistantMessageID string
	// postBoundaryAwaitFreshBusy keeps the just-completed turn's terminal-error
	// ownership until the next authoritative busy/retry. prompt_async's outer
	// catchCause publishes after Runner.finishRun's idle; AgentTeam may dispatch the
	// next queued prompt before that old wrapping error reaches SSE.
	postBoundaryAwaitFreshBusy bool
	// interruptCleanupDrain suppresses an aborting processor's cleanup and
	// second idle after the first interrupt idle fence has completed. It stays
	// active across a live revert and is cleared only by a fresh busy/retry,
	// whose SSE ordering proves all prior abort frames were drained.
	interruptCleanupDrain bool
	interruptIdleFence    *openCodeInterruptIdleFence
	sessionGeneration     uint64
	// permissionReply is fixed from the launch permission mode.
	permissionReply string
	// lastRetryStatusKey suppresses duplicate OpenCode retry status updates
	// while the same backoff window remains active.
	lastRetryStatusKey string
	// compactQueued marks a context-window recovery compaction requested after
	// OpenCode reported input context exhaustion. summarizeInProgress keeps
	// duplicate requests from racing while the native summarize endpoint runs.
	compactQueued       bool
	summarizeInProgress bool
	summarizeGeneration uint64
	summarizeFence      *openCodeSummarizeBoundaryFence
	// summarizeIdleTimeout bounds the post-HTTP wait for the corresponding SSE
	// idle boundary. The zero value selects openCodeSummarizeIdleTimeout; tests
	// may shorten it.
	summarizeIdleTimeout time.Duration
	// proactiveCompactionThreshold is the absolute input-context token count at
	// which AgentTeam proactively triggers OpenCode's native summarize at an idle
	// boundary, before any overflow. 0 disables proactive compaction. It is
	// configured per launch from AGENTTEAM_PROACTIVE_COMPACTION_TOKENS.
	proactiveCompactionThreshold uint64
	// lastInputContextTokens is the most recent total input-context size
	// (input + cached read tokens) observed via message.updated, used as the
	// proactive-compaction signal.
	lastInputContextTokens uint64
	// lastSummarizeCompletedAt bounds proactive re-trigger frequency so a fresh
	// summarize does not race opencode's own native compaction or thrash.
	lastSummarizeCompletedAt time.Time

	// serveLogPath is the stable per-workdir file where AgentTeam tees raw
	// `opencode serve` stdout/stderr for diagnosis when process logs are needed
	// alongside the structured runtime diagnostics.
	serveLogPath string
	// runtimeDebugLog receives bounded, scrubbed JSONL diagnostics when runtime
	// diagnostics mode is enabled. These details stay out of the user-facing
	// activity trajectory so high-frequency protocol events do not obscure
	// actual agent work.
	runtimeDebugLog      *openCodeDiagnosticLogFile
	runtimeDebugAgentID  string
	runtimeDebugLaunchID string

	// Provider ID injected via OPENCODE_CONFIG_CONTENT. Used as the
	// providerID field in prompt requests so opencode routes to the
	// correct provider.
	providerID string

	// Process lifecycle.
	serverCancel        context.CancelFunc
	runtimeDebugEnabled bool
}

// Name returns the runtime name.
func (d *OpenCodeDriver) Name() string { return "opencode" }

// SupportsStdinNotification reports protocol delivery support. Active-turn
// input remains in AgentTeam's durable FIFO and is submitted after OpenCode reports
// authoritative idle; it is not written into a running OpenCode prompt agentteam.
func (d *OpenCodeDriver) SupportsStdinNotification() bool { return true }

// MCPToolPrefix returns the runtime-specific MCP tool prefix.
func (d *OpenCodeDriver) MCPToolPrefix() string { return "mcp_agentteam_" }

// SupportsAckedBusyRuntimeInputDelivery is deliberately false. In OpenCode
// v1.17.11 createUserMessage writes the user row and parts separately, while
// Runner.ensureRunning discards a second runAgentTeam closure when a run is active.
// A prompt_async 204 therefore is not an ACK-safe active-turn enqueue.
func (d *OpenCodeDriver) SupportsAckedBusyRuntimeInputDelivery() bool { return false }

// SupportsCorrelatedRuntimeInputDelivery keeps AgentTeam ownership pending after the
// idle prompt_async request until an assistant selects the exact supplied user
// message as its parent. A user message echo alone is only persistence evidence.
func (d *OpenCodeDriver) SupportsCorrelatedRuntimeInputDelivery() bool { return true }

// beginOpenCodeGenerationRetirementLocked atomically fences a server
// generation and invalidates all asynchronous work captured from it. The
// caller must hold d.mu and invoke the returned cancel function only after
// releasing that mutex.
func (d *OpenCodeDriver) beginOpenCodeGenerationRetirementLocked() context.CancelFunc {
	d.sseDeliveryBlocked = true
	d.resetOpenCodeRunnerMessageAdmissionLocked()
	if !d.generationRetiring {
		d.generationRetiring = true
		d.sessionGeneration++
		d.summarizeGeneration++
		if fence := d.summarizeFence; fence != nil && !fence.retired {
			fence.retired = true
			close(fence.retiredCh)
		}
	}
	return d.serverCancel
}

func (d *OpenCodeDriver) beginOpenCodeGenerationRetirement() context.CancelFunc {
	d.mu.Lock()
	serverCancel := d.beginOpenCodeGenerationRetirementLocked()
	d.mu.Unlock()
	return serverCancel
}

// DeliverAckedBusyRuntimeInput rejects before any OpenCode mutation. Process
// normally observes the false capability above and leaves the input in its
// durable FIFO until the current turn ends.
func (d *OpenCodeDriver) DeliverAckedBusyRuntimeInput(context.Context, drivercontract.RuntimeInput, string) error {
	return errors.Join(
		errRuntimeInputMutationNotAttempted,
		fmt.Errorf("opencode active-turn delivery is not ACK-safe"),
	)
}

// DeliverBusyStdinInput adapts compiler-produced input to the same idle-only gate.
func (d *OpenCodeDriver) DeliverBusyStdinInput(ctx context.Context, input drivercontract.RuntimeInput, sessionID string) error {
	return d.DeliverRuntimeInput(ctx, input, sessionID)
}

// DeliverRuntimeInput submits one fresh prompt only at an authoritative idle
// boundary. OpenCode's prompt() awaits the complete createUserMessage call
// before entering runAgentTeam, so an exact assistant parent is a positive receipt
// for the complete body without relying on the broken active-run path.
func (d *OpenCodeDriver) DeliverRuntimeInput(ctx context.Context, input drivercontract.RuntimeInput, sessionID string) error {
	if input.Empty() {
		return nil
	}

	d.deliveryMu.Lock()
	defer d.deliveryMu.Unlock()
	if err := d.openCodeIdleDeliveryMutationReady(sessionID); err != nil {
		return err
	}

	runtimeInputID := strings.TrimSpace(input.ID)
	if runtimeInputID == "" {
		return d.sendPromptAsync(ctx, input, "", "")
	}
	messageID, err := d.recoveryMessageIDForRuntimeInputID(runtimeInputID)
	if err != nil {
		return fmt.Errorf("opencode correlated delivery message id: %w", err)
	}
	d.rememberPendingRuntimeInput(messageID, runtimeInputID, strings.TrimSpace(sessionID))
	if err := d.sendPromptAsync(ctx, input, "", messageID); err != nil {
		// A transport error after client.Do may have reached prompt_async. Keep
		// the correlation in that case so a late assistant parent can settle
		// ownership; only definitive pre-dispatch/status failures are replayable.
		if !errors.Is(err, errOpenCodePromptTransportAmbiguous) {
			d.forgetPendingRuntimeInput(messageID)
		}
		return err
	}
	return nil
}
func (d *OpenCodeDriver) ParseLine(line string) []drivercontract.ParsedEvent {
	trimmed := strings.TrimSpace(line)
	if !strings.HasPrefix(trimmed, "{") {
		return nil
	}

	var ev translatedEvent
	if err := json.Unmarshal([]byte(trimmed), &ev); err != nil {
		return nil
	}

	var events []drivercontract.ParsedEvent
	switch ev.Kind {
	case consts.ParsedEventKindSessionInit:
		if strings.TrimSpace(ev.SessionID) != "" {
			events = append(events, drivercontract.ParsedEvent{
				Kind:      consts.ParsedEventKindSessionInit,
				SessionID: ev.SessionID,
			})
		}
	case consts.ParsedEventKindThinking:
		if strings.TrimSpace(ev.Text) != "" {
			events = append(events, drivercontract.ParsedEvent{
				Kind: consts.ParsedEventKindThinking,
				Text: ev.Text,
			})
		}
	case consts.ParsedEventKindText:
		if strings.TrimSpace(ev.Text) != "" {
			events = append(events, drivercontract.ParsedEvent{
				Kind: consts.ParsedEventKindText,
				Text: ev.Text,
			})
		}
	case consts.ParsedEventKindToolCall:
		inputStr := "{}"
		if ev.ToolInput != nil {
			if s, ok := ev.ToolInput.(string); ok {
				inputStr = s
			} else {
				b, _ := json.Marshal(ev.ToolInput)
				inputStr = string(b)
			}
		}
		events = append(events, drivercontract.ParsedEvent{
			Kind:       consts.ParsedEventKindToolCall,
			ToolName:   ev.ToolName,
			ToolCallID: ev.ToolCallID,
			ToolInput:  inputStr,
		})
	case consts.ParsedEventKindToolOutput:
		events = append(events, drivercontract.ParsedEvent{
			Kind:       consts.ParsedEventKindToolOutput,
			Text:       ev.Text,
			ToolName:   ev.ToolName,
			ToolCallID: ev.ToolCallID,
			Metadata:   ev.Metadata,
		})
	case consts.ParsedEventKindTurnEnd:
		events = append(events, drivercontract.ParsedEvent{
			Kind:      consts.ParsedEventKindTurnEnd,
			SessionID: ev.SessionID,
			Metadata:  ev.Metadata,
		})
	case consts.ParsedEventKindRuntimeIdle:
		events = append(events, drivercontract.ParsedEvent{
			Kind:      consts.ParsedEventKindRuntimeIdle,
			SessionID: ev.SessionID,
		})
	case consts.ParsedEventKindRuntimeCompacted:
		events = append(events, drivercontract.ParsedEvent{
			Kind:      consts.ParsedEventKindRuntimeCompacted,
			SessionID: ev.SessionID,
			Message:   ev.Message,
			Metadata:  ev.Metadata,
		})
	case consts.ParsedEventKindSubagentActivity:
		if strings.TrimSpace(ev.Message) != "" || strings.TrimSpace(ev.Text) != "" {
			events = append(events, drivercontract.ParsedEvent{
				Kind:      consts.ParsedEventKindSubagentActivity,
				Message:   ev.Message,
				Text:      ev.Text,
				SessionID: strings.TrimSpace(ev.SessionID),
				Runtime:   strings.TrimSpace(ev.Runtime),
				EventType: strings.TrimSpace(ev.EventType),
				Metadata:  ev.Metadata,
			})
		}
	case consts.ParsedEventKindRuntimeInputAcked:
		if strings.TrimSpace(ev.RuntimeInputID) != "" {
			events = append(events, drivercontract.ParsedEvent{
				Kind:           consts.ParsedEventKindRuntimeInputAcked,
				RuntimeInputID: strings.TrimSpace(ev.RuntimeInputID),
				SessionID:      strings.TrimSpace(ev.SessionID),
			})
		}
	case consts.ParsedEventKindUsage:
		events = append(events, drivercontract.ParsedEvent{
			Kind:                    consts.ParsedEventKindUsage,
			SessionID:               ev.SessionID,
			Model:                   ev.Model,
			ModelInvocationID:       ev.ModelInvocationID,
			ContextExposureKnown:    ev.ContextExposureKnown,
			ContextExposureExcluded: ev.ContextExposureExcluded,
			ContextCompacted:        ev.ContextCompacted,
			InputTokens:             ev.InputTokens,
			CachedInputTokens:       ev.CachedInputTokens,
			OutputTokens:            ev.OutputTokens,
		})
	case consts.ParsedEventKindError:
		if strings.TrimSpace(ev.Message) != "" {
			events = append(events, drivercontract.ParsedEvent{
				Kind:     consts.ParsedEventKindError,
				Message:  ev.Message,
				Metadata: ev.Metadata,
			})
		}
	}
	return events
}

// EncodeStdinMessage preserves the legacy stdin encoder contract for
// non-delivery call sites. Routed message delivery uses prompt_async through
// DeliverBusyStdinInput so the process layer can observe transport failures;
// OpenCode has no separate stdin delivery path.
func (d *OpenCodeDriver) EncodeStdinMessage(text, sessionID string) []byte {
	slog.Warn("opencode: EncodeStdinMessage called for routed message; delivery result cannot be observed",
		"session_id", sessionID)
	return []byte("{}\n")
}

// ---------------------------------------------------------------------------
// drivercontract.RecoveryDriver interface
// ---------------------------------------------------------------------------

// PrepareRecoveryPoint requires a AgentTeam message id so that the OpenCode user
// message id captured for recovery is identical to the prompt_async.messageID
// used for the runtime input. The process layer calls
// PrepareRecoveryPointForMessage when it has that message-bound context.
func (d *OpenCodeDriver) PrepareRecoveryPoint() (strategy string, recoveryRef string, supported bool, err error) {
	return "", "", false, nil
}

// PrepareRecoveryPointForMessage prepares an OpenCode native revert recovery
// point for one AgentTeam message before it enters runtime context.
func (d *OpenCodeDriver) PrepareRecoveryPointForMessage(messageID string) (strategy string, recoveryRef string, supported bool, err error) {
	if !rewindcaps.ForRuntime(d.Name()).Supported {
		return "", "", false, nil
	}
	trimmedMessageID := strings.TrimSpace(messageID)
	if trimmedMessageID == "" {
		return "", "", false, nil
	}
	d.mu.Lock()
	sessionID := strings.TrimSpace(d.sessionID)
	d.mu.Unlock()
	if sessionID == "" {
		return "", "", true, fmt.Errorf("opencode session is not initialized")
	}
	openCodeMessageID, err := d.recoveryMessageIDForRuntimeInputID(trimmedMessageID)
	if err != nil {
		return "", "", true, err
	}
	ref, err := rewindcaps.EncodeOpenCodeSessionRevertRecoveryRef(sessionID, openCodeMessageID)
	if err != nil {
		return "", "", true, err
	}
	return rewindcaps.StrategyOpenCodeSessionRevert, ref, true, nil
}

// RestoreRecoveryPoint restores an OpenCode session by invoking OpenCode's
// native session revert endpoint. Revert is intentionally used instead of fork:
// the runtime keeps the same session identity and drops the target user message
// and later messages before the next prompt.
func (d *OpenCodeDriver) RestoreRecoveryPoint(ctx context.Context, strategy string, recoveryRef string) error {
	ref, err := decodeOpenCodeRecoveryRefForRestore(strategy, recoveryRef)
	if err != nil {
		return err
	}
	d.deliveryMu.Lock()
	defer d.deliveryMu.Unlock()
	d.mu.Lock()
	currentSessionID := strings.TrimSpace(d.sessionID)
	summarizeInProgress := d.summarizeInProgress
	d.mu.Unlock()
	if currentSessionID != "" && currentSessionID != ref.SessionID {
		return fmt.Errorf("opencode recovery ref session id %q does not match active session %q", ref.SessionID, currentSessionID)
	}
	if summarizeInProgress {
		return fmt.Errorf("%w: opencode cannot restore while session summarize is in progress", ErrAgentBusy)
	}
	if err := d.revertOpenCodeSession(ctx, ref.SessionID, ref.MessageID); err != nil {
		return err
	}
	d.mu.Lock()
	if d.promptAwaitingRunner != nil {
		close(d.promptAwaitingRunner.done)
	}
	d.sessionID = ref.SessionID
	d.sessionGeneration++
	d.pendingRuntimeInputs = nil
	d.promptAwaitingRunner = nil
	d.activePromptMessageID = ""
	d.pendingSessionError = nil
	d.pendingSessionErrorFinalIdle = false
	d.contextTerminalDrain = false
	d.autoCompactionUserMessageID = ""
	d.autoCompactionAssistantMessageID = ""
	d.interruptIdleFence = nil
	d.textBuf.Reset()
	d.resetOpenCodeRunnerMessageAdmissionLocked()
	d.countedStepFinish = make(map[string]struct{})
	d.contextCompacted = false
	d.lastInputContextTokens = 0
	d.turnActive = false
	d.sessionStatus = "idle"
	d.compactQueued = false
	d.mu.Unlock()
	return nil
}

// ---------------------------------------------------------------------------
// drivercontract.DetachedRecoveryDriver interface
// ---------------------------------------------------------------------------

// RestoreRecoveryPointDetached restores an OpenCode recovery point by starting a
// temporary OpenCode server in the same workdir and calling the native revert
// endpoint for the existing session.
func (d *OpenCodeDriver) RestoreRecoveryPointDetached(ctx context.Context, cfg drivercontract.LaunchConfig, strategy string, recoveryRef string) error {
	ref, err := decodeOpenCodeRecoveryRefForRestore(strategy, recoveryRef)
	if err != nil {
		return err
	}
	if strings.TrimSpace(cfg.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != ref.SessionID {
		return fmt.Errorf("opencode recovery ref session id %q does not match launch session id %q", ref.SessionID, strings.TrimSpace(cfg.SessionID))
	}
	cleanup, err := d.startTemporaryServer(ctx, cfg)
	if err != nil {
		return err
	}
	defer cleanup()
	d.mu.Lock()
	d.sessionID = ref.SessionID
	d.mu.Unlock()
	return d.revertOpenCodeSession(ctx, ref.SessionID, ref.MessageID)
}

// restoreRecoveryPointsDetached reuses one temporary OpenCode server for a GC
// batch. Starting one server per artifact creates avoidable concurrent access
// to OpenCode's local session store and can amplify one GC command into dozens
// of processes.
func (d *OpenCodeDriver) restoreRecoveryPointsDetached(ctx context.Context, cfg drivercontract.LaunchConfig, strategy string, recoveryRefs []string) error {
	type restoreTarget struct {
		sessionID string
		messageID string
	}
	targets := make([]restoreTarget, 0, len(recoveryRefs))
	seen := make(map[string]struct{}, len(recoveryRefs))
	var errs []error
	for index, recoveryRef := range recoveryRefs {
		recoveryRef = strings.TrimSpace(recoveryRef)
		if recoveryRef == "" {
			continue
		}
		if _, ok := seen[recoveryRef]; ok {
			continue
		}
		seen[recoveryRef] = struct{}{}
		ref, err := decodeOpenCodeRecoveryRefForRestore(strategy, recoveryRef)
		if err != nil {
			errs = append(errs, fmt.Errorf("decode recovery artifact %d: %w", index, err))
			continue
		}
		if launchSessionID := strings.TrimSpace(cfg.SessionID); launchSessionID != "" && launchSessionID != ref.SessionID {
			errs = append(errs, fmt.Errorf("recovery artifact %d session id %q does not match launch session id %q", index, ref.SessionID, launchSessionID))
			continue
		}
		targets = append(targets, restoreTarget{sessionID: ref.SessionID, messageID: ref.MessageID})
	}
	if len(targets) == 0 {
		return errors.Join(errs...)
	}

	cleanup, err := d.startTemporaryServer(ctx, cfg)
	if err != nil {
		errs = append(errs, err)
		return errors.Join(errs...)
	}
	defer cleanup()
	d.emitRuntimeDebug("opencode detached recovery cleanup batch started", map[string]any{
		"artifact_count": len(targets),
	})
	for index, target := range targets {
		if err := ctx.Err(); err != nil {
			errs = append(errs, err)
			break
		}
		d.mu.Lock()
		d.sessionID = target.sessionID
		d.mu.Unlock()
		if err := d.revertOpenCodeSession(ctx, target.sessionID, target.messageID); err != nil {
			errs = append(errs, fmt.Errorf("restore recovery artifact %d: %w", index, err))
		}
	}
	d.emitRuntimeDebug("opencode detached recovery cleanup batch completed", map[string]any{
		"artifact_count": len(targets),
		"error_count":    len(errs),
	})
	return errors.Join(errs...)
}

func decodeOpenCodeRecoveryRefForRestore(strategy string, recoveryRef string) (rewindcaps.OpenCodeSessionRevertRecoveryRef, error) {
	if strings.TrimSpace(strategy) != rewindcaps.StrategyOpenCodeSessionRevert {
		return rewindcaps.OpenCodeSessionRevertRecoveryRef{}, fmt.Errorf("unsupported recovery strategy %q", strategy)
	}
	ref, err := rewindcaps.DecodeOpenCodeSessionRevertRecoveryRef(recoveryRef)
	if err != nil {
		return rewindcaps.OpenCodeSessionRevertRecoveryRef{}, fmt.Errorf("decode opencode recovery ref: %w", err)
	}
	if ref.SessionID == "" {
		return rewindcaps.OpenCodeSessionRevertRecoveryRef{}, fmt.Errorf("opencode recovery ref is missing session id")
	}
	if ref.MessageID == "" {
		return rewindcaps.OpenCodeSessionRevertRecoveryRef{}, fmt.Errorf("opencode recovery ref is missing message id")
	}
	if !strings.HasPrefix(ref.MessageID, "msg") {
		return rewindcaps.OpenCodeSessionRevertRecoveryRef{}, fmt.Errorf("opencode recovery ref message id %q does not start with msg", ref.MessageID)
	}
	return ref, nil
}

func (d *OpenCodeDriver) emitRuntimeDebug(message string, metadata map[string]any) {
	message = strings.TrimSpace(message)
	if message == "" {
		return
	}
	d.mu.Lock()
	if !d.runtimeDebugEnabled {
		d.mu.Unlock()
		return
	}
	logFile := d.runtimeDebugLog
	agentID := strings.TrimSpace(d.runtimeDebugAgentID)
	launchID := strings.TrimSpace(d.runtimeDebugLaunchID)
	sessionID := strings.TrimSpace(d.sessionID)
	d.mu.Unlock()
	if logFile == nil {
		return
	}
	entry := openCodeRuntimeDebugEntry{
		SchemaVersion: 1,
		Timestamp:     time.Now().UTC(),
		Runtime:       d.Name(),
		Event:         message,
		AgentID:       agentID,
		LaunchID:      launchID,
		SessionID:     sessionID,
		Metadata:      scrubOpenCodeDebugMetadata(metadata),
	}
	if err := logFile.WriteJSONLine(entry); err != nil {
		logFile.logWriteErrorOnce(message, err)
	}
}
