package agent

import (
	"context"
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"

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

const (
	openCodeMessageIDRandomLength = 14
	openCodeMessageIDPrefix       = "msg_"
	openCodeMessageIDBase62       = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
	openCodeMessageIDTimeMask     = uint64(0xffffffffffff)
	// Runner admission is normally observable almost immediately as busy,
	// retry, assistant, tool, or text activity. A bounded watchdog prevents a
	// prompt_async catchCause path (session.error without Runner/idle) from
	// leaving the driver permanently logically busy.
	openCodePromptRunnerAdmissionTimeout = 30 * time.Second
)

var errOpenCodePromptTransportAmbiguous = errors.New("opencode prompt transport outcome is ambiguous")

func (d *OpenCodeDriver) nextRuntimeInputMessageID() (string, error) {
	suffix, err := randomOpenCodeBase62(openCodeMessageIDRandomLength)
	if err != nil {
		return "", err
	}

	encoded := (uint64(time.Now().UnixMilli()) * 0x1000) & openCodeMessageIDTimeMask
	d.mu.Lock()
	if encoded <= d.messageIDLastEncoded {
		encoded = d.messageIDLastEncoded
	}
	if encoded == openCodeMessageIDTimeMask {
		d.mu.Unlock()
		return "", fmt.Errorf("opencode message id high-water exhausted")
	}
	encoded++
	d.messageIDLastEncoded = encoded
	d.mu.Unlock()

	return fmt.Sprintf("%s%012x%s", openCodeMessageIDPrefix, encoded, suffix), nil
}

func openCodeMessageIDFromParts(unixMilli int64, counter uint64, suffix string) string {
	encoded := (uint64(unixMilli)*0x1000 + counter) & openCodeMessageIDTimeMask
	return fmt.Sprintf("%s%012x%s", openCodeMessageIDPrefix, encoded, suffix)
}

func openCodeMessageIDEncoded(messageID string) (uint64, bool) {
	trimmedMessageID := strings.TrimSpace(messageID)
	const encodedLength = 12
	const messageIDLength = len(openCodeMessageIDPrefix) + encodedLength + openCodeMessageIDRandomLength
	if !strings.HasPrefix(trimmedMessageID, openCodeMessageIDPrefix) || len(trimmedMessageID) != messageIDLength {
		return 0, false
	}
	for _, ch := range trimmedMessageID[len(openCodeMessageIDPrefix)+encodedLength:] {
		if !strings.ContainsRune(openCodeMessageIDBase62, ch) {
			return 0, false
		}
	}
	encoded, err := strconv.ParseUint(
		trimmedMessageID[len(openCodeMessageIDPrefix):len(openCodeMessageIDPrefix)+encodedLength],
		16,
		64,
	)
	if err != nil {
		return 0, false
	}
	return encoded, true
}

func (d *OpenCodeDriver) observeOpenCodeMessageID(messageID string) {
	encoded, ok := openCodeMessageIDEncoded(messageID)
	if !ok {
		return
	}
	d.mu.Lock()
	if encoded > d.messageIDLastEncoded {
		d.messageIDLastEncoded = encoded
	}
	d.mu.Unlock()
}

func (d *OpenCodeDriver) recoveryMessageIDForRuntimeInputID(runtimeInputID string) (string, error) {
	trimmedRuntimeInputID := strings.TrimSpace(runtimeInputID)
	if trimmedRuntimeInputID == "" {
		return d.nextRuntimeInputMessageID()
	}

	d.mu.Lock()
	if d.recoveryMessageIDs != nil {
		if messageID := strings.TrimSpace(d.recoveryMessageIDs[trimmedRuntimeInputID]); messageID != "" {
			d.mu.Unlock()
			return messageID, nil
		}
	}
	d.mu.Unlock()

	messageID, err := d.nextRuntimeInputMessageID()
	if err != nil {
		return "", err
	}

	d.mu.Lock()
	if d.recoveryMessageIDs == nil {
		d.recoveryMessageIDs = make(map[string]string)
	}
	if existing := strings.TrimSpace(d.recoveryMessageIDs[trimmedRuntimeInputID]); existing != "" {
		d.mu.Unlock()
		return existing, nil
	}
	d.recoveryMessageIDs[trimmedRuntimeInputID] = messageID
	d.mu.Unlock()
	return messageID, nil
}

func randomOpenCodeBase62(length int) (string, error) {
	if length <= 0 {
		return "", nil
	}
	buf := make([]byte, length)
	if _, err := rand.Read(buf); err != nil {
		return "", err
	}
	var b strings.Builder
	b.Grow(length)
	for _, value := range buf {
		b.WriteByte(openCodeMessageIDBase62[int(value)%len(openCodeMessageIDBase62)])
	}
	return b.String(), nil
}

type openCodePendingRuntimeInput struct {
	RuntimeInputID string
	Persisted      bool
	SessionID      string
}

// openCodePromptRunnerReservation represents a prompt_async request whose
// background fiber may exist, but for which AgentTeam has not yet observed positive
// evidence that OpenCode entered its per-session Runner. A 204 response, user
// message persistence, session.error, /abort 200, or an idle status are not
// sufficient evidence in OpenCode v1.17.11.
type openCodePromptRunnerReservation struct {
	MessageID string
	SessionID string
	done      chan struct{}
}

// reservePromptAwaitingRunnerWithCompaction additionally binds a persisted
// compaction task when resume bootstrap re-enters runAgentTeam with an empty prompt.
// The binding is installed before transport because OpenCode may publish busy
// and summary SSE frames before prompt_async's 204 reaches the caller.
func (d *OpenCodeDriver) reservePromptAwaitingRunnerWithCompaction(
	sessionID string,
	messageID string,
	resumeCompactionAuto *bool,
) (*openCodePromptRunnerReservation, error) {
	trimmedSessionID := strings.TrimSpace(sessionID)
	trimmedMessageID := strings.TrimSpace(messageID)
	if trimmedSessionID == "" || trimmedMessageID == "" {
		return nil, errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf("opencode prompt runner reservation requires session and message ids"),
		)
	}

	d.mu.Lock()
	defer d.mu.Unlock()
	currentSessionID := strings.TrimSpace(d.sessionID)
	if currentSessionID == "" || currentSessionID != trimmedSessionID {
		return nil, errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf("opencode prompt runner reservation session is no longer current"),
		)
	}
	if d.sseDeliveryBlocked {
		return nil, errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf("opencode prompt runner reservation requires a connected SSE acknowledgement stream"),
		)
	}
	if d.summarizeInProgress {
		return nil, errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf("opencode prompt runner reservation conflicts with session compaction"),
		)
	}
	if d.contextTerminalDrain {
		return nil, errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf("opencode prompt runner reservation awaits native context recovery"),
		)
	}
	if d.turnActive || (strings.TrimSpace(d.sessionStatus) != "" && strings.TrimSpace(d.sessionStatus) != "idle") {
		return nil, errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf("opencode prompt runner reservation requires an inactive session"),
		)
	}
	if existing := d.promptAwaitingRunner; existing != nil {
		return nil, errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf(
				"opencode prompt %s still awaits Runner admission",
				strings.TrimSpace(existing.MessageID),
			),
		)
	}
	for pendingMessageID, pending := range d.pendingRuntimeInputs {
		pendingSessionID := strings.TrimSpace(pending.SessionID)
		if pendingMessageID != trimmedMessageID &&
			(pendingSessionID == "" || pendingSessionID == currentSessionID) {
			return nil, errors.Join(
				errRuntimeInputMutationNotAttempted,
				fmt.Errorf(
					"opencode prompt runner reservation awaits a prior assistant receipt for %s",
					strings.TrimSpace(pending.RuntimeInputID),
				),
			)
		}
	}
	reservation := &openCodePromptRunnerReservation{
		MessageID: trimmedMessageID,
		SessionID: trimmedSessionID,
		done:      make(chan struct{}),
	}
	d.resetOpenCodeRunnerMessageAdmissionLocked()
	d.admitOpenCodeRunnerUserMessageLocked(trimmedMessageID)
	d.autoCompactionUserMessageID = ""
	d.autoCompactionAssistantMessageID = ""
	if resumeCompactionAuto != nil {
		if d.runnerCompactionUserMessageIDs == nil {
			d.runnerCompactionUserMessageIDs = make(map[string]bool)
		}
		d.runnerCompactionUserMessageIDs[trimmedMessageID] = *resumeCompactionAuto
		if *resumeCompactionAuto {
			d.autoCompactionUserMessageID = trimmedMessageID
		}
	}
	d.promptAwaitingRunner = reservation
	d.activePromptMessageID = trimmedMessageID
	// A compaction request that the Process could not start (for example
	// recovery was disabled or its preflight failed) must not remain a permanent
	// mutation gate. A successfully reserved new prompt supersedes that stale
	// between-turn request; a later context error can queue compaction again.
	d.compactQueued = false
	d.turnActive = true
	d.sessionStatus = "busy"
	return reservation, nil
}

func (d *OpenCodeDriver) clearPromptAwaitingRunner(sessionID string, messageID string) bool {
	trimmedSessionID := strings.TrimSpace(sessionID)
	trimmedMessageID := strings.TrimSpace(messageID)
	d.mu.Lock()
	defer d.mu.Unlock()
	reservation := d.promptAwaitingRunner
	if reservation == nil ||
		strings.TrimSpace(reservation.SessionID) != trimmedSessionID ||
		strings.TrimSpace(reservation.MessageID) != trimmedMessageID {
		return false
	}
	d.promptAwaitingRunner = nil
	if strings.TrimSpace(d.activePromptMessageID) == trimmedMessageID {
		d.activePromptMessageID = ""
	}
	close(reservation.done)
	return true
}

// clearPromptStateAfterGenerationRetired releases state that was scoped to a
// server generation only after that generation has been stopped. It must not
// be used for a live ambiguous request: while the old server can still run,
// clearing the reservation could authorize a duplicate prompt.
func (d *OpenCodeDriver) clearPromptStateAfterGenerationRetired(sessionID string) {
	trimmedSessionID := strings.TrimSpace(sessionID)
	d.mu.Lock()
	defer d.mu.Unlock()
	if trimmedSessionID == "" || strings.TrimSpace(d.sessionID) != trimmedSessionID {
		return
	}
	if reservation := d.promptAwaitingRunner; reservation != nil {
		d.promptAwaitingRunner = nil
		close(reservation.done)
	}
	d.pendingRuntimeInputs = nil
	d.pendingSessionError = nil
	d.activePromptMessageID = ""
	d.autoCompactionUserMessageID = ""
	d.autoCompactionAssistantMessageID = ""
	d.turnActive = false
	d.sessionStatus = ""
	d.lastRetryStatusKey = ""
	d.beginOpenCodeGenerationRetirementLocked()
}

func (d *OpenCodeDriver) startPromptRunnerAdmissionWatchdog(reservation *openCodePromptRunnerReservation) {
	if reservation == nil {
		return
	}
	d.mu.Lock()
	timeout := d.promptRunnerAdmissionTimeout
	d.mu.Unlock()
	if timeout <= 0 {
		timeout = openCodePromptRunnerAdmissionTimeout
	}
	go func() {
		timer := time.NewTimer(timeout)
		defer timer.Stop()
		select {
		case <-reservation.done:
			return
		case <-timer.C:
		}

		d.mu.Lock()
		if d.promptAwaitingRunner != reservation {
			d.mu.Unlock()
			return
		}
		// Do not release the logical reservation: the mutating request was
		// accepted and replay would be unsafe. Retire the whole runtime
		// generation so restored ownership can fail closed without duplication.
		serverCancel := d.beginOpenCodeGenerationRetirementLocked()
		d.mu.Unlock()
		d.emitRuntimeDebug("opencode prompt Runner admission timed out; retiring runtime generation", map[string]any{
			"session_id": reservation.SessionID,
			"message_id": reservation.MessageID,
			"timeout":    timeout.String(),
		})
		if serverCancel != nil {
			serverCancel()
		}
	}()
}

func (d *OpenCodeDriver) rememberPendingRuntimeInput(messageID string, runtimeInputID string, sessionID string) {
	trimmedMessageID := strings.TrimSpace(messageID)
	trimmedRuntimeInputID := strings.TrimSpace(runtimeInputID)
	if trimmedMessageID == "" || trimmedRuntimeInputID == "" {
		return
	}

	d.mu.Lock()
	if d.pendingRuntimeInputs == nil {
		d.pendingRuntimeInputs = make(map[string]openCodePendingRuntimeInput)
	}
	d.pendingRuntimeInputs[trimmedMessageID] = openCodePendingRuntimeInput{
		RuntimeInputID: trimmedRuntimeInputID,
		SessionID:      strings.TrimSpace(sessionID),
	}
	d.mu.Unlock()
	d.emitRuntimeDebug("opencode pending runtime input remembered", map[string]any{
		"message_id":       trimmedMessageID,
		"runtime_input_id": trimmedRuntimeInputID,
		"session_id":       strings.TrimSpace(sessionID),
	})
}

func (d *OpenCodeDriver) forgetPendingRuntimeInput(messageID string) {
	trimmedMessageID := strings.TrimSpace(messageID)
	if trimmedMessageID == "" {
		return
	}

	d.mu.Lock()
	delete(d.pendingRuntimeInputs, trimmedMessageID)
	if len(d.pendingRuntimeInputs) == 0 {
		d.pendingRuntimeInputs = nil
	}
	d.mu.Unlock()
	d.emitRuntimeDebug("opencode pending runtime input forgotten", map[string]any{"message_id": trimmedMessageID})
}

func (d *OpenCodeDriver) markPendingRuntimeInputPersisted(messageID string, sessionID string) {
	trimmedMessageID := strings.TrimSpace(messageID)
	if trimmedMessageID == "" {
		return
	}

	d.mu.Lock()
	if d.generationRetiring {
		d.mu.Unlock()
		return
	}
	pending, ok := d.pendingRuntimeInputs[trimmedMessageID]
	if !ok || strings.TrimSpace(pending.RuntimeInputID) == "" {
		d.mu.Unlock()
		return
	}
	pending.Persisted = true
	if trimmedSessionID := strings.TrimSpace(sessionID); trimmedSessionID != "" {
		pending.SessionID = trimmedSessionID
	}
	d.pendingRuntimeInputs[trimmedMessageID] = pending
	d.mu.Unlock()
	d.emitRuntimeDebug("opencode pending runtime input persisted", map[string]any{
		"message_id":       trimmedMessageID,
		"runtime_input_id": pending.RuntimeInputID,
		"session_id":       pending.SessionID,
	})
}

// rekeyPendingRuntimeInputLocked carries an unresolved RuntimeInput receipt
// along a source-proven internal user-message transition. OpenCode may
// auto-compact at the start of a newly admitted user turn, before creating an
// assistant parented by that original user. The compaction summary assistant
// is instead parented by the generated compaction user; moving the correlation
// to that user preserves the exact-parent acknowledgement rule.
//
// The caller must hold d.mu. A conflicting destination is left untouched and
// returns false so ownership remains fail-closed.
func (d *OpenCodeDriver) rekeyPendingRuntimeInputLocked(
	fromMessageID string,
	toMessageID string,
	sessionID string,
) bool {
	trimmedFromMessageID := strings.TrimSpace(fromMessageID)
	trimmedToMessageID := strings.TrimSpace(toMessageID)
	trimmedSessionID := strings.TrimSpace(sessionID)
	if trimmedFromMessageID == "" || trimmedToMessageID == "" {
		return false
	}
	if trimmedFromMessageID == trimmedToMessageID {
		return true
	}
	pending, ok := d.pendingRuntimeInputs[trimmedFromMessageID]
	if !ok {
		return true
	}
	pendingSessionID := strings.TrimSpace(pending.SessionID)
	if pendingSessionID == "" {
		pendingSessionID = trimmedSessionID
		pending.SessionID = pendingSessionID
	}
	if pendingSessionID != trimmedSessionID {
		return false
	}
	if existing, exists := d.pendingRuntimeInputs[trimmedToMessageID]; exists {
		if existing.RuntimeInputID != pending.RuntimeInputID ||
			strings.TrimSpace(existing.SessionID) != pendingSessionID {
			return false
		}
		delete(d.pendingRuntimeInputs, trimmedFromMessageID)
		return true
	}
	delete(d.pendingRuntimeInputs, trimmedFromMessageID)
	d.pendingRuntimeInputs[trimmedToMessageID] = pending
	return true
}

func (d *OpenCodeDriver) popPendingRuntimeInputsForAssistant(parentMessageID string, sessionID string) []openCodeRuntimeInputAckedEvent {
	trimmedParentMessageID := strings.TrimSpace(parentMessageID)
	if trimmedParentMessageID == "" {
		return nil
	}
	trimmedSessionID := strings.TrimSpace(sessionID)

	d.mu.Lock()
	if d.generationRetiring {
		d.mu.Unlock()
		return nil
	}
	parentPending, ok := d.pendingRuntimeInputs[trimmedParentMessageID]
	if !ok || strings.TrimSpace(parentPending.RuntimeInputID) == "" {
		d.mu.Unlock()
		return nil
	}
	parentSessionID := strings.TrimSpace(parentPending.SessionID)
	if parentSessionID == "" {
		parentSessionID = trimmedSessionID
	}
	if parentSessionID != trimmedSessionID {
		d.mu.Unlock()
		return nil
	}
	delete(d.pendingRuntimeInputs, trimmedParentMessageID)
	if len(d.pendingRuntimeInputs) == 0 {
		d.pendingRuntimeInputs = nil
	}
	d.mu.Unlock()
	d.emitRuntimeDebug("opencode pending runtime input selected by assistant", map[string]any{
		"parent_message_id": trimmedParentMessageID,
		"runtime_input_id":  parentPending.RuntimeInputID,
		"session_id":        parentSessionID,
	})
	return []openCodeRuntimeInputAckedEvent{{
		RuntimeInputID: parentPending.RuntimeInputID,
		SessionID:      parentSessionID,
	}}
}

// hasPendingRuntimeInputForSessionLocked reports whether an exact-parent
// acknowledgement is still required for this session. The caller must hold
// d.mu.
func (d *OpenCodeDriver) hasPendingRuntimeInputForSessionLocked(sessionID string) bool {
	trimmedSessionID := strings.TrimSpace(sessionID)
	for _, pending := range d.pendingRuntimeInputs {
		pendingSessionID := strings.TrimSpace(pending.SessionID)
		if pendingSessionID == "" || pendingSessionID == trimmedSessionID {
			return true
		}
	}
	return false
}

// openCodeIdleDeliveryMutationReady verifies the source-derived exclusivity
// precondition for prompt_async: no OpenCode runner and no unresolved prior
// correlated request may exist when the new request is dispatched.
func (d *OpenCodeDriver) openCodeIdleDeliveryMutationReady(sessionID string) error {
	d.mu.Lock()
	defer d.mu.Unlock()
	currentSessionID := strings.TrimSpace(d.sessionID)
	if currentSessionID == "" {
		return errors.Join(errRuntimeInputMutationNotAttempted, fmt.Errorf("opencode delivery requires active session"))
	}
	if requested := strings.TrimSpace(sessionID); requested != "" && requested != currentSessionID {
		return errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf("opencode delivery session mismatch: current %s, requested %s", currentSessionID, requested),
		)
	}
	if d.sseDeliveryBlocked {
		return errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf("opencode delivery requires a connected SSE acknowledgement stream"),
		)
	}
	if d.turnActive || strings.TrimSpace(d.sessionStatus) != "idle" {
		return errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf("opencode delivery requires authoritative idle"),
		)
	}
	if d.contextTerminalDrain {
		return errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf("opencode delivery awaits native context recovery"),
		)
	}
	if reservation := d.promptAwaitingRunner; reservation != nil {
		return errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf(
				"opencode delivery awaits Runner admission for prompt %s",
				strings.TrimSpace(reservation.MessageID),
			),
		)
	}
	for _, pending := range d.pendingRuntimeInputs {
		pendingSessionID := strings.TrimSpace(pending.SessionID)
		if pendingSessionID == "" || pendingSessionID == currentSessionID {
			return errors.Join(
				errRuntimeInputMutationNotAttempted,
				fmt.Errorf("opencode delivery awaits an assistant receipt for prior runtime input %s", pending.RuntimeInputID),
			)
		}
	}
	return nil
}

// sendPromptAsync sends a message via the async prompt endpoint. The request
// body follows SessionPrompt.PromptInput (minus sessionID):
//
//	{ "messageID"?: "msg...", "parts": [{ "type": "file", "url": "file://...", "mime": "..." }, { "type": "text", "text": "..." }], "model"?: { "providerID", "modelID" } }
func (d *OpenCodeDriver) sendPromptAsync(ctx context.Context, input drivercontract.RuntimeInput, model string, messageID string) error {
	if strings.TrimSpace(messageID) == "" && strings.TrimSpace(input.ID) != "" {
		var err error
		messageID, err = d.recoveryMessageIDForRuntimeInputID(input.ID)
		if err != nil {
			return fmt.Errorf("opencode recovery message id: %w", err)
		}
	}
	parts := openCodePromptParts(input)
	if len(parts) == 0 {
		return errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf("opencode prompt contains no supported parts"),
		)
	}
	return d.sendPromptAsyncPartsWithRunnerBootstrap(ctx, parts, model, messageID, false, nil)
}

func (d *OpenCodeDriver) sendPromptAsyncPartsWithRunnerBootstrap(
	ctx context.Context,
	parts []map[string]interface{},
	model string,
	messageID string,
	allowEmpty bool,
	resumeCompactionAuto *bool,
) error {
	if ctx == nil {
		ctx = context.Background()
	}
	startedAt := time.Now()
	d.mu.Lock()
	sessionID := strings.TrimSpace(d.sessionID)
	providerID := d.providerID
	client := d.httpClient
	sseDeliveryBlocked := d.sseDeliveryBlocked
	d.mu.Unlock()
	if sessionID == "" {
		d.emitRuntimeDebug("opencode prompt_async rejected without active session", map[string]any{
			"message_id":  strings.TrimSpace(messageID),
			"parts_count": len(parts),
			"allow_empty": allowEmpty,
		})
		return errors.Join(errRuntimeInputMutationNotAttempted, fmt.Errorf("no active session"))
	}
	if client == nil {
		return errors.Join(errRuntimeInputMutationNotAttempted, fmt.Errorf("opencode prompt_async requires HTTP client"))
	}
	// Check again at the shared mutation primitive so Spawn's initial prompt
	// and resume bootstrap cannot bypass the ordinary idle-delivery SSE gate.
	// A disconnect racing after this snapshot is still covered by the
	// pre-transport Runner reservation and therefore fails closed.
	if sseDeliveryBlocked {
		return errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf("opencode prompt_async requires a connected SSE acknowledgement stream"),
		)
	}

	if len(parts) == 0 && !allowEmpty {
		d.emitRuntimeDebug("opencode prompt_async skipped empty prompt", map[string]any{
			"session_id":  sessionID,
			"message_id":  strings.TrimSpace(messageID),
			"allow_empty": allowEmpty,
		})
		return nil
	}
	if len(parts) == 0 {
		parts = []map[string]interface{}{}
	}
	trimmedMessageID := strings.TrimSpace(messageID)
	if trimmedMessageID == "" {
		var err error
		trimmedMessageID, err = d.nextRuntimeInputMessageID()
		if err != nil {
			return errors.Join(errRuntimeInputMutationNotAttempted, fmt.Errorf("allocate opencode prompt message id: %w", err))
		}
	}

	body := map[string]interface{}{
		"agent": openCodeDefaultAgent,
		"parts": parts,
	}
	body["messageID"] = trimmedMessageID
	if trimmedModel := normalizeOpenCodeModelForProvider(model, providerID); trimmedModel != "" {
		if m := parseOpenCodeModel(trimmedModel, providerID); m != nil {
			body["model"] = m
		}
	}
	modelRef := ""
	if modelSpec, ok := body["model"].(map[string]string); ok {
		modelRef = strings.TrimSpace(modelSpec["providerID"]) + "/" + strings.TrimSpace(modelSpec["modelID"])
	}

	bodyJSON, _ := json.Marshal(body)

	endpoint := fmt.Sprintf("/session/%s/prompt_async", sessionID)
	d.emitRuntimeDebug("opencode prompt_async begin", map[string]any{
		"session_id":        sessionID,
		"endpoint":          endpoint,
		"message_id":        trimmedMessageID,
		"provider_id":       providerID,
		"model":             modelRef,
		"parts_count":       len(parts),
		"non_empty_prompt":  len(parts) > 0,
		"allow_empty":       allowEmpty,
		"request_body_size": len(bodyJSON),
	})
	req, err := d.newRequest(ctx, http.MethodPost, endpoint, strings.NewReader(string(bodyJSON)))
	if err != nil {
		d.emitRuntimeDebug("opencode prompt_async request build failed", map[string]any{
			"session_id": sessionID,
			"endpoint":   endpoint,
			"message_id": trimmedMessageID,
			"error":      err,
		})
		return errors.Join(errRuntimeInputMutationNotAttempted, err)
	}
	req.Header.Set("Content-Type", "application/json")
	d.addDirectoryHeader(req, d.getWorkDir())
	if err := ctx.Err(); err != nil {
		return errors.Join(errRuntimeInputMutationNotAttempted, err)
	}

	// Install the logical busy/Runner-admission reservation before transport.
	// OpenCode can emit SSE events before the 204 response reaches this
	// goroutine, and /abort can emit a misleading idle before the forked prompt
	// fiber has entered Runner. Only positive Runner/activity evidence clears
	// this reservation.
	reservation, err := d.reservePromptAwaitingRunnerWithCompaction(
		sessionID,
		trimmedMessageID,
		resumeCompactionAuto,
	)
	if err != nil {
		return err
	}
	d.startPromptRunnerAdmissionWatchdog(reservation)
	resp, err := client.Do(req)
	if err != nil {
		d.emitRuntimeDebug("opencode prompt_async transport failed", map[string]any{
			"session_id": sessionID,
			"endpoint":   endpoint,
			"message_id": trimmedMessageID,
			"duration":   time.Since(startedAt).Round(time.Millisecond).String(),
			"error":      err,
		})
		return errors.Join(errOpenCodePromptTransportAmbiguous, fmt.Errorf("async prompt: %w", err))
	}
	defer resp.Body.Close()

	// prompt_async returns 204 No Content on success; also accept 200.
	if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
		respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		d.emitRuntimeDebug("opencode prompt_async status failed", map[string]any{
			"session_id":          sessionID,
			"endpoint":            endpoint,
			"message_id":          trimmedMessageID,
			"status":              resp.StatusCode,
			"duration":            time.Since(startedAt).Round(time.Millisecond).String(),
			"response_body_bytes": len(respBody),
			"response_body":       "<omitted>",
		})
		// In OpenCode v1.17.11 the prompt_async handler returns 204 immediately
		// after forking prompt(); every non-success status is produced before
		// that fork and is therefore a definitive non-mutation.
		cleared := d.clearPromptAwaitingRunner(sessionID, trimmedMessageID)
		d.mu.Lock()
		if cleared && strings.TrimSpace(d.sessionID) == sessionID {
			d.turnActive = false
			d.sessionStatus = "idle"
		}
		d.mu.Unlock()
		return errors.Join(
			errRuntimeInputMutationNotAttempted,
			fmt.Errorf("async prompt: status %d: %s", resp.StatusCode, string(respBody)),
		)
	}

	d.emitRuntimeDebug("opencode prompt_async accepted", map[string]any{
		"session_id":       sessionID,
		"endpoint":         endpoint,
		"message_id":       trimmedMessageID,
		"status":           resp.StatusCode,
		"duration":         time.Since(startedAt).Round(time.Millisecond).String(),
		"marked_turn_busy": true,
	})
	return nil
}

func openCodePromptParts(input drivercontract.RuntimeInput) []map[string]interface{} {
	// Keep AgentTeam-prepared attachments on the manifest-only path for OpenCode.
	// OpenCode's local file parts have produced non-terminal runtime failures
	// for both PDFs and screenshots; the process layer already appends local
	// paths to Text before reaching the driver.
	parts := make([]map[string]interface{}, 0, 1)
	if strings.TrimSpace(input.PromptText()) != "" {
		textPart := map[string]interface{}{
			"type": "text",
			"text": input.PromptText(),
		}
		if runtimeInputID := strings.TrimSpace(input.ID); runtimeInputID != "" {
			textPart["metadata"] = map[string]interface{}{
				"agentteam": map[string]interface{}{
					"kind":             "runtime-input",
					"runtime_input_id": runtimeInputID,
				},
			}
		}
		parts = append(parts, textPart)
	}
	return parts
}
