import { feature } from 'bun:bundle'
import type {
  ContentBlockParam,
  TextBlockParam,
} from '@anthropic-ai/sdk/resources'
import { randomUUID } from 'crypto'
import { setPromptId } from 'src/bootstrap/state.js'
import {
  builtInCommandNames,
  type Command,
  type CommandBase,
  findCommand,
  getCommand,
  getCommandName,
  hasCommand,
  type PromptCommand,
} from 'src/commands.js'
import { NO_CONTENT_MESSAGE } from 'src/constants/messages.js'
import type { SetToolJSXFn, ToolUseContext } from 'src/Tool.js'
import type {
  AssistantMessage,
  AttachmentMessage,
  Message,
  NormalizedUserMessage,
  ProgressMessage,
  UserMessage,
} from 'src/types/message.js'
import { addInvokedSkill, getSessionId } from '../../bootstrap/state.js'
import { COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG } from '../../constants/xml.js'
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
import {
  type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
  logEvent,
} from '../../services/analytics/index.js'
import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'
import { buildPostCompactMessages } from '../../services/compact/compact.js'
import { resetMicrocompactState } from '../../services/compact/microCompact.js'
import type { Progress as AgentProgress } from '@claude-code-best/builtin-tools/tools/AgentTool/AgentTool.js'
import { runAgent } from '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js'
import { renderToolUseProgressMessage } from '@claude-code-best/builtin-tools/tools/AgentTool/UI.js'
import type { CommandResultDisplay } from '../../types/command.js'
import { createAbortController } from '../abortController.js'
import { getAgentContext } from '../agentContext.js'
import {
  createAttachmentMessage,
  getAttachmentMessages,
} from '../attachments.js'
import { logForDebugging } from '../debug.js'
import { isEnvTruthy } from '../envUtils.js'
import { AbortError, MalformedCommandError } from '../errors.js'
import { getDisplayPath } from '../file.js'
import {
  extractResultText,
  prepareForkedCommandContext,
} from '../forkedAgent.js'
import { getFsImplementation } from '../fsOperations.js'
import { isFullscreenEnvEnabled } from '../fullscreen.js'
import { toArray } from '../generators.js'
import { registerSkillHooks } from '../hooks/registerSkillHooks.js'
import { logError } from '../log.js'
import { enqueuePendingNotification } from '../messageQueueManager.js'
import {
  createCommandInputMessage,
  createSyntheticUserCaveatMessage,
  createSystemMessage,
  createUserInterruptionMessage,
  createUserMessage,
  formatCommandInputTags,
  isCompactBoundaryMessage,
  isSystemLocalCommandMessage,
  normalizeMessages,
  prepareUserContent,
} from '../messages.js'
import type { ModelAlias } from '../model/aliases.js'
import { parseToolListFromCLI } from '../permissions/permissionSetup.js'
import { hasPermissionsToUseTool } from '../permissions/permissions.js'
import {
  isOfficialMarketplaceName,
  parsePluginIdentifier,
} from '../plugins/pluginIdentifier.js'
import {
  isRestrictedToPluginOnly,
  isSourceAdminTrusted,
} from '../settings/pluginOnlyPolicy.js'
import { parseSlashCommand } from '../slashCommandParsing.js'
import { sleep } from '../sleep.js'
import { recordSkillUsage } from '../suggestions/skillUsageTracking.js'
import { logOTelEvent, redactIfDisabled } from '../telemetry/events.js'
import { buildPluginCommandTelemetryFields } from '../telemetry/pluginTelemetry.js'
import { getAssistantMessageContentLength } from '../tokens.js'
import { createAgentId } from '../uuid.js'
import { getWorkload } from '../workloadContext.js'
import type {
  ProcessUserInputBaseResult,
  ProcessUserInputContext,
} from './processUserInput.js'

type SlashCommandResult = ProcessUserInputBaseResult & {
  command: Command
}

// Poll interval and deadline for MCP settle before launching a background
// forked subagent. MCP servers typically connect within 1-3s of startup;
// 10s headroom covers slow SSE handshakes.
const MCP_SETTLE_POLL_MS = 200
const MCP_SETTLE_TIMEOUT_MS = 10_000

/**
 * Executes a slash command with context: fork in a sub-agent.
 */
async function executeForkedSlashCommand(
  command: CommandBase & PromptCommand,
  args: string,
  context: ProcessUserInputContext,
  precedingInputBlocks: ContentBlockParam[],
  setToolJSX: SetToolJSXFn,
  canUseTool: CanUseToolFn,
): Promise<SlashCommandResult> {
  const agentId = createAgentId()

  const pluginMarketplace = command.pluginInfo
    ? parsePluginIdentifier(command.pluginInfo.repository).marketplace
    : undefined
  logEvent('tengu_slash_command_forked', {
    command_name:
      command.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
    invocation_trigger:
      'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
    ...(command.pluginInfo && {
      _PROTO_plugin_name: command.pluginInfo.pluginManifest
        .name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
      ...(pluginMarketplace && {
        _PROTO_marketplace_name:
          pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
      }),
      ...buildPluginCommandTelemetryFields(command.pluginInfo),
    }),
  })

  const { skillContent, modifiedGetAppState, baseAgent, promptMessages } =
    await prepareForkedCommandContext(command, args, context)

  // Merge skill's effort into the agent definition so runAgent applies it
  const agentDefinition =
    command.effort !== undefined
      ? { ...baseAgent, effort: command.effort }
      : baseAgent

  logForDebugging(
    `Executing forked slash command /${command.name} with agent ${agentDefinition.agentType}`,
  )

  // Assistant mode: fire-and-forget. Launch subagent in background, return
  // immediately, re-enqueue the result as an isMeta prompt when done.
  // Without this, N scheduled tasks on startup = N serial (subagent + main
  // agent turn) cycles blocking user input. With this, N subagents run in
  // parallel and results trickle into the queue as they finish.
  //
  // Gated on kairosEnabled (not CLAUDE_CODE_BRIEF) because the closed loop
  // depends on assistant-mode invariants: scheduled_tasks.json exists,
  // the main agent knows to pipe results through SendUserMessage, and
  // isMeta prompts are hidden. Outside assistant mode, context:fork commands
  // are user-invoked skills (/commit etc.) that should run synchronously
  // with the progress UI.
  if (feature('KAIROS') && (await context.getAppState()).kairosEnabled) {
    // Standalone abortController — background subagents survive main-thread
    // ESC (same policy as AgentTool's async path). They're cron-driven; if
    // killed mid-run they just re-fire on the next schedule.
    const bgAbortController = createAbortController()
    const commandName = getCommandName(command)

    // Workload: handlePromptSubmit wraps the entire turn in runWithWorkload
    // (AsyncLocalStorage). ALS context is captured when this `void` fires
    // and survives every await inside — isolated from the parent's
    // continuation. The detached closure's runAgent calls see the cron tag
    // automatically. We still capture the value here ONLY for the
    // re-enqueued result prompt below: that second turn runs in a fresh
    // handlePromptSubmit → fresh runWithWorkload boundary (which always
    // establishes a new context, even for `undefined`) → so it needs its
    // own QueuedCommand.workload tag to preserve attribution.
    const spawnTimeWorkload = getWorkload()

    // Re-enter the queue as a hidden prompt. isMeta: hides from queue
    // preview + placeholder + transcript. skipSlashCommands: prevents
    // re-parsing if the result text happens to start with '/'. When
    // drained, this triggers a main-agent turn that sees the result and
    // decides whether to SendUserMessage. Propagate workload so that
    // second turn is also tagged.
    const enqueueResult = (value: string): void =>
      enqueuePendingNotification({
        value,
        mode: 'prompt',
        priority: 'later',
        isMeta: true,
        skipSlashCommands: true,
        workload: spawnTimeWorkload,
      })

    void (async () => {
      // Wait for MCP servers to settle. Scheduled tasks fire at startup and
      // all N drain within ~1ms (since we return immediately), capturing
      // context.options.tools before MCP connects. The sync path
      // accidentally avoided this — tasks serialized, so task N's drain
      // happened after task N-1's 30s run, by which time MCP was up.
      // Poll until no 'pending' clients remain, then refresh.
      const deadline = Date.now() + MCP_SETTLE_TIMEOUT_MS
      while (Date.now() < deadline) {
        const s = context.getAppState()
        if (!s.mcp.clients.some(c => c.type === 'pending')) break
        await sleep(MCP_SETTLE_POLL_MS)
      }
      const freshTools =
        context.options.refreshTools?.() ?? context.options.tools

      const agentMessages: Message[] = []
      for await (const message of runAgent({
        agentDefinition,
        promptMessages,
        toolUseContext: {
          ...context,
          getAppState: modifiedGetAppState,
          abortController: bgAbortController,
        },
        canUseTool,
        isAsync: true,
        querySource: 'agent:custom',
        model: command.model as ModelAlias | undefined,
        availableTools: freshTools,
        override: { agentId },
      })) {
        agentMessages.push(message)
      }
      const resultText = extractResultText(agentMessages, 'Command completed')
      logForDebugging(
        `Background forked command /${commandName} completed (agent ${agentId})`,
      )
      enqueueResult(
        `<scheduled-task-result command="/${commandName}">\n${resultText}\n</scheduled-task-result>`,
      )
    })().catch(err => {
      logError(err)
      enqueueResult(
        `<scheduled-task-result command="/${commandName}" status="failed">\n${err instanceof Error ? err.message : String(err)}\n</scheduled-task-result>`,
      )
    })

    // Nothing to render, nothing to query — the background runner re-enters
    // the queue on its own schedule.
    return { messages: [], shouldQuery: false, command }
  }

  // Collect messages from the forked agent
  const agentMessages: Message[] = []

  // Build progress messages for the agent progress UI
  const progressMessages: ProgressMessage<AgentProgress>[] = []
  const parentToolUseID = `forked-command-${command.name}`
  let toolUseCounter = 0

  // Helper to create a progress message from an agent message
  const createProgressMessage = (
    message: AssistantMessage | NormalizedUserMessage,
  ): ProgressMessage<AgentProgress> => {
    toolUseCounter++
    return {
      type: 'progress',
      data: {
        message,
        type: 'agent_progress',
        prompt: skillContent,
        agentId,
      },
      parentToolUseID,
      toolUseID: `${parentToolUseID}-${toolUseCounter}`,
      timestamp: new Date().toISOString(),
      uuid: randomUUID(),
    }
  }

  // Helper to update progress display using agent progress UI
  const updateProgress = (): void => {
    setToolJSX({
      jsx: renderToolUseProgressMessage(progressMessages, {
        tools: context.options.tools,
        verbose: false,
      }),
      shouldHidePromptInput: false,
      shouldContinueAnimation: true,
      showSpinner: true,
    })
  }

  // Show initial "Initializing…" state
  updateProgress()

  // Run the sub-agent
  try {
    for await (const message of runAgent({
      agentDefinition,
      promptMessages,
      toolUseContext: {
        ...context,
        getAppState: modifiedGetAppState,
      },
      canUseTool,
      isAsync: false,
      querySource: 'agent:custom',
      model: command.model as ModelAlias | undefined,
      availableTools: context.options.tools,
    })) {
      agentMessages.push(message)
      const normalizedNew = normalizeMessages([message])

      // Add progress message for assistant messages (which contain tool uses)
      if (message.type === 'assistant') {
        // Increment token count in spinner for assistant messages
        const contentLength = getAssistantMessageContentLength(message as AssistantMessage)
        if (contentLength > 0) {
          context.setResponseLength(len => len + contentLength)
        }

        const normalizedMsg = normalizedNew[0]
        if (normalizedMsg && normalizedMsg.type === 'assistant') {
          progressMessages.push(createProgressMessage(message as AssistantMessage))
          updateProgress()
        }
      }

      // Add progress message for user messages (which contain tool results)
      if (message.type === 'user') {
        const normalizedMsg = normalizedNew[0]
        if (normalizedMsg && normalizedMsg.type === 'user') {
          progressMessages.push(createProgressMessage(normalizedMsg as AssistantMessage))
          updateProgress()
        }
      }
    }
  } finally {
    // Clear the progress display
    setToolJSX(null)
  }

  let resultText = extractResultText(agentMessages, 'Command completed')

  logForDebugging(
    `Forked slash command /${command.name} completed with agent ${agentId}`,
  )

  // Prepend debug log for ant users so it appears inside the command output
  if (process.env.USER_TYPE === 'ant') {
    resultText = `[ANT-ONLY] API calls: ${getDisplayPath(getDumpPromptsPath(agentId))}\n${resultText}`
  }

  // Return the result as a user message (simulates the agent's output)
  const messages: UserMessage[] = [
    createUserMessage({
      content: prepareUserContent({
        inputString: `/${getCommandName(command)} ${args}`.trim(),
        precedingInputBlocks,
      }),
    }),
    createUserMessage({
      content: `<local-command-stdout>\n${resultText}\n</local-command-stdout>`,
    }),
  ]

  return {
    messages,
    shouldQuery: false,
    command,
    resultText,
  }
}

/**
 * Determines if a string looks like a valid command name.
 * Valid command names only contain letters, numbers, colons, hyphens, and underscores.
 *
 * @param commandName - The potential command name to check
 * @returns true if it looks like a command name, false if it contains non-command characters
 */
export function looksLikeCommand(commandName: string): boolean {
  // Command names should only contain [a-zA-Z0-9:_-]
  // If it contains other characters, it's probably a file path or other input
  return !/[^a-zA-Z0-9:\-_]/.test(commandName)
}

export async function processSlashCommand(
  inputString: string,
  precedingInputBlocks: ContentBlockParam[],
  imageContentBlocks: ContentBlockParam[],
  attachmentMessages: AttachmentMessage[],
  context: ProcessUserInputContext,
  setToolJSX: SetToolJSXFn,
  uuid?: string,
  isAlreadyProcessing?: boolean,
  canUseTool?: CanUseToolFn,
): Promise<ProcessUserInputBaseResult> {
  const parsed = parseSlashCommand(inputString)
  if (!parsed) {
    logEvent('tengu_input_slash_missing', {})
    const errorMessage = 'Commands are in the form `/command [args]`'
    return {
      messages: [
        createSyntheticUserCaveatMessage(),
        ...attachmentMessages,
        createUserMessage({
          content: prepareUserContent({
            inputString: errorMessage,
            precedingInputBlocks,
          }),
        }),
      ],
      shouldQuery: false,
      resultText: errorMessage,
    }
  }

  const { commandName, args: parsedArgs, isMcp } = parsed

  const sanitizedCommandName = isMcp
    ? 'mcp'
    : !builtInCommandNames().has(commandName)
      ? 'custom'
      : commandName

  // Check if it's a real command before processing
  if (!hasCommand(commandName, context.options.commands)) {
    // Check if this looks like a command name vs a file path or other input
    // Also check if it's an actual file path that exists
    let isFilePath = false
    try {
      await getFsImplementation().stat(`/${commandName}`)
      isFilePath = true
    } catch {
      // Not a file path — treat as command name
    }
    if (looksLikeCommand(commandName) && !isFilePath) {
      logEvent('tengu_input_slash_invalid', {
        input:
          commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
      })

      const unknownMessage = `Unknown skill: ${commandName}`
      return {
        messages: [
          createSyntheticUserCaveatMessage(),
          ...attachmentMessages,
          createUserMessage({
            content: prepareUserContent({
              inputString: unknownMessage,
              precedingInputBlocks,
            }),
          }),
          // gh-32591: preserve args so the user can copy/resubmit without
          // retyping. System warning is UI-only (filtered before API).
          ...(parsedArgs
            ? [
                createSystemMessage(
                  `Args from unknown skill: ${parsedArgs}`,
                  'warning',
                ),
              ]
            : []),
        ],
        shouldQuery: false,
        resultText: unknownMessage,
      }
    }

    const promptId = randomUUID()
    setPromptId(promptId)
    logEvent('tengu_input_prompt', {})
    // Log user prompt event for OTLP
    void logOTelEvent('user_prompt', {
      prompt_length: String(inputString.length),
      prompt: redactIfDisabled(inputString),
      'prompt.id': promptId,
    })
    return {
      messages: [
        createUserMessage({
          content: prepareUserContent({ inputString, precedingInputBlocks }),
          uuid: uuid,
        }),
        ...attachmentMessages,
      ],
      shouldQuery: true,
    }
  }

  // Track slash command usage for feature discovery

  const {
    messages: newMessages,
    shouldQuery: messageShouldQuery,
    allowedTools,
    model,
    effort,
    command: returnedCommand,
    resultText,
    nextInput,
    submitNextInput,
  } = await getMessagesForSlashCommand(
    commandName,
    parsedArgs,
    setToolJSX,
    context,
    precedingInputBlocks,
    imageContentBlocks,
    isAlreadyProcessing,
    canUseTool,
    uuid,
  )

  // Local slash commands that skip messages
  if (newMessages.length === 0) {
    const eventData: Record<string, boolean | number | undefined> = {
      input:
        sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
    }

    // Add plugin metadata if this is a plugin command
    if (returnedCommand.type === 'prompt' && returnedCommand.pluginInfo) {
      const { pluginManifest, repository } = returnedCommand.pluginInfo
      const { marketplace } = parsePluginIdentifier(repository)
      const isOfficial = isOfficialMarketplaceName(marketplace)
      // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns
      // (unredacted, all users); plugin_name/plugin_repository stay in
      // additional_metadata as redacted variants for general-access dashboards.
      eventData._PROTO_plugin_name =
        pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED
      if (marketplace) {
        eventData._PROTO_marketplace_name =
          marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED
      }
      eventData.plugin_repository = (
        isOfficial ? repository : 'third-party'
      ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
      eventData.plugin_name = (
        isOfficial ? pluginManifest.name : 'third-party'
      ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
      if (isOfficial && pluginManifest.version) {
        eventData.plugin_version =
          pluginManifest.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
      }
      Object.assign(
        eventData,
        buildPluginCommandTelemetryFields(returnedCommand.pluginInfo),
      )
    }

    logEvent('tengu_input_command', {
      ...eventData,
      invocation_trigger:
        'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
      ...(process.env.USER_TYPE === 'ant' && {
        skill_name:
          commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
        ...(returnedCommand.type === 'prompt' && {
          skill_source:
            returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
        }),
        ...(returnedCommand.loadedFrom && {
          skill_loaded_from:
            returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
        }),
        ...(returnedCommand.kind && {
          skill_kind:
            returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
        }),
      }),
    })
    return {
      messages: [],
      shouldQuery: false,

      model,
      nextInput,
      submitNextInput,
    }
  }

  // For invalid commands, preserve both the user message and error
  if (
    newMessages.length === 2 &&
    newMessages[1]!.type === 'user' &&
    typeof newMessages[1]!.message.content === 'string' &&
    newMessages[1]!.message.content.startsWith('Unknown command:')
  ) {
    // Don't log as invalid if it looks like a common file path
    const looksLikeFilePath =
      inputString.startsWith('/var') ||
      inputString.startsWith('/tmp') ||
      inputString.startsWith('/private')

    if (!looksLikeFilePath) {
      logEvent('tengu_input_slash_invalid', {
        input:
          commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
      })
    }

    return {
      messages: [createSyntheticUserCaveatMessage(), ...newMessages],
      shouldQuery: messageShouldQuery,
      allowedTools,

      model,
    }
  }

  // A valid command
  const eventData: Record<string, boolean | number | undefined> = {
    input:
      sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  }

  // Add plugin metadata if this is a plugin command
  if (returnedCommand.type === 'prompt' && returnedCommand.pluginInfo) {
    const { pluginManifest, repository } = returnedCommand.pluginInfo
    const { marketplace } = parsePluginIdentifier(repository)
    const isOfficial = isOfficialMarketplaceName(marketplace)
    eventData._PROTO_plugin_name =
      pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED
    if (marketplace) {
      eventData._PROTO_marketplace_name =
        marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED
    }
    eventData.plugin_repository = (
      isOfficial ? repository : 'third-party'
    ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
    eventData.plugin_name = (
      isOfficial ? pluginManifest.name : 'third-party'
    ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
    if (isOfficial && pluginManifest.version) {
      eventData.plugin_version =
        pluginManifest.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
    }
    Object.assign(
      eventData,
      buildPluginCommandTelemetryFields(returnedCommand.pluginInfo),
    )
  }

  logEvent('tengu_input_command', {
    ...eventData,
    invocation_trigger:
      'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
    ...(process.env.USER_TYPE === 'ant' && {
      skill_name:
        commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
      ...(returnedCommand.type === 'prompt' && {
        skill_source:
          returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
      }),
      ...(returnedCommand.loadedFrom && {
        skill_loaded_from:
          returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
      }),
      ...(returnedCommand.kind && {
        skill_kind:
          returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
      }),
    }),
  })

  // Check if this is a compact result which handle their own synthetic caveat message ordering
  const isCompactResult =
    newMessages.length > 0 &&
    newMessages[0] &&
    isCompactBoundaryMessage(newMessages[0])

  return {
    messages:
      messageShouldQuery ||
      newMessages.every(isSystemLocalCommandMessage) ||
      isCompactResult
        ? newMessages
        : [createSyntheticUserCaveatMessage(), ...newMessages],
    shouldQuery: messageShouldQuery,
    allowedTools,
    model,
    effort,
    resultText,
    nextInput,
    submitNextInput,
  }
}

async function getMessagesForSlashCommand(
  commandName: string,
  args: string,
  setToolJSX: SetToolJSXFn,
  context: ProcessUserInputContext,
  precedingInputBlocks: ContentBlockParam[],
  imageContentBlocks: ContentBlockParam[],
  _isAlreadyProcessing?: boolean,
  canUseTool?: CanUseToolFn,
  uuid?: string,
): Promise<SlashCommandResult> {
  const command = getCommand(commandName, context.options.commands)

  // Track skill usage for ranking (only for prompt commands that are user-invocable)
  if (command.type === 'prompt' && command.userInvocable !== false) {
    recordSkillUsage(commandName)
  }

  // Check if the command is user-invocable
  // Skills with userInvocable === false can only be invoked by the model via SkillTool
  if (command.userInvocable === false) {
    return {
      messages: [
        createUserMessage({
          content: prepareUserContent({
            inputString: `/${commandName}`,
            precedingInputBlocks,
          }),
        }),
        createUserMessage({
          content: `This skill can only be invoked by Claude, not directly by users. Ask Claude to use the "${commandName}" skill for you.`,
        }),
      ],
      shouldQuery: false,
      command,
    }
  }

  try {
    switch (command.type) {
      case 'local-jsx': {
        return new Promise<SlashCommandResult>(resolve => {
          let doneWasCalled = false
          const onDone = (
            result?: string,
            options?: {
              display?: CommandResultDisplay
              shouldQuery?: boolean
              metaMessages?: string[]
              nextInput?: string
              submitNextInput?: boolean
            },
          ) => {
            doneWasCalled = true
            // If display is 'skip', don't add any messages to the conversation
            if (options?.display === 'skip') {
              void resolve({
                messages: [],
                shouldQuery: false,
                command,
                nextInput: options?.nextInput,
                submitNextInput: options?.submitNextInput,
              })
              return
            }

            // Meta messages are model-visible but hidden from the user
            const metaMessages = (options?.metaMessages ?? []).map(
              (content: string) => createUserMessage({ content, isMeta: true }),
            )

            // In fullscreen the command just showed as a centered modal
            // pane — the transient notification is enough feedback. The
            // "❯ /config" + "⎿ dismissed" transcript entries are
            // type:system subtype:local_command (user-visible but NOT sent
            // to the model), so skipping them doesn't affect model context.
            // Outside fullscreen keep them so scrollback shows what ran.
            // Only skip "<Name> dismissed" modal-close notifications —
            // commands that early-exit before showing a modal (/ultraplan
            // usage, /rename, /proactive) use display:system for actual
            // output that must reach the transcript.
            const skipTranscript =
              isFullscreenEnvEnabled() &&
              typeof result === 'string' &&
              result.endsWith(' dismissed')

            void resolve({
              messages:
                options?.display === 'system'
                  ? skipTranscript
                    ? metaMessages
                    : [
                        createCommandInputMessage(
                          formatCommandInput(command, args),
                        ),
                        createCommandInputMessage(
                          `<local-command-stdout>${result}</local-command-stdout>`,
                        ),
                        ...metaMessages,
                      ]
                  : [
                      createUserMessage({
                        content: prepareUserContent({
                          inputString: formatCommandInput(command, args),
                          precedingInputBlocks,
                        }),
                      }),
                      result
                        ? createUserMessage({
                            content: `<local-command-stdout>${result}</local-command-stdout>`,
                          })
                        : createUserMessage({
                            content: `<local-command-stdout>${NO_CONTENT_MESSAGE}</local-command-stdout>`,
                          }),
                      ...metaMessages,
                    ],
              shouldQuery: options?.shouldQuery ?? false,
              command,
              nextInput: options?.nextInput,
              submitNextInput: options?.submitNextInput,
            })
          }

          void command
            .load()
            .then(mod => mod.call(onDone, { ...context, canUseTool }, args))
            .then(jsx => {
              if (jsx == null) return
              if (context.options.isNonInteractiveSession) {
                void resolve({
                  messages: [],
                  shouldQuery: false,
                  command,
                })
                return
              }
              // Guard: if onDone fired during mod.call() (early-exit path
              // that calls onDone then returns JSX), skip setToolJSX. This
              // chain is fire-and-forget — the outer Promise resolves when
              // onDone is called, so executeUserInput may have already run
              // its setToolJSX({clearLocalJSX: true}) before we get here.
              // Setting isLocalJSXCommand after clear leaves it stuck true,
              // blocking useQueueProcessor and TextInput focus.
              if (doneWasCalled) return
              setToolJSX({
                jsx,
                shouldHidePromptInput: true,
                showSpinner: false,
                isLocalJSXCommand: true,
                isImmediate: command.immediate === true,
              })
            })
            .catch(e => {
              // If load()/call() throws and onDone never fired, the outer
              // Promise hangs forever, leaving queryGuard stuck in
              // 'dispatching' and deadlocking the queue processor.
              logError(e)
              if (doneWasCalled) return
              doneWasCalled = true
              setToolJSX({
                jsx: null,
                shouldHidePromptInput: false,
                clearLocalJSX: true,
              })
              void resolve({ messages: [], shouldQuery: false, command })
            })
        })
      }
      case 'local': {
        const displayArgs = command.isSensitive && args.trim() ? '***' : args
        const userMessage = createUserMessage({
          content: prepareUserContent({
            inputString: formatCommandInput(command, displayArgs),
            precedingInputBlocks,
          }),
        })

        try {
          const syntheticCaveatMessage = createSyntheticUserCaveatMessage()
          const mod = await command.load()
          const result = await mod.call(args, context)

          if (result.type === 'skip') {
            return {
              messages: [],
              shouldQuery: false,
              command,
            }
          }

          // Use discriminated union to handle different result types
          if (result.type === 'compact') {
            // Append slash command messages to messagesToKeep so that
            // attachments and hookResults come after user messages
            const slashCommandMessages = [
              syntheticCaveatMessage,
              userMessage,
              ...(result.displayText
                ? [
                    createUserMessage({
                      content: `<local-command-stdout>${result.displayText}</local-command-stdout>`,
                      // --resume looks at latest timestamp message to determine which message to resume from
                      // This is a perf optimization to avoid having to recaculcate the leaf node every time
                      // Since we're creating a bunch of synthetic messages for compact, it's important to set
                      // the timestamp of the last message to be slightly after the current time
                      // This is mostly important for sdk / -p mode
                      timestamp: new Date(Date.now() + 100).toISOString(),
                    }),
                  ]
                : []),
            ]
            const compactionResultWithSlashMessages = {
              ...result.compactionResult,
              messagesToKeep: [
                ...(result.compactionResult.messagesToKeep ?? []),
                ...slashCommandMessages,
              ],
            }
            // Reset microcompact state since full compact replaces all
            // messages — old tool IDs are no longer relevant. Budget state
            // (on toolUseContext) needs no reset: stale entries are inert
            // (UUIDs never repeat, so they're never looked up).
            resetMicrocompactState()
            return {
              messages: buildPostCompactMessages(
                compactionResultWithSlashMessages,
              ) as AssistantMessage[],
              shouldQuery: false,
              command,
            }
          }

          // Text result — use system message so it doesn't render as a user bubble
          return {
            messages: [
              userMessage,
              createCommandInputMessage(
                `<local-command-stdout>${result.value}</local-command-stdout>`,
              ),
            ],
            shouldQuery: false,
            command,
            resultText: result.value,
          }
        } catch (e) {
          logError(e)
          return {
            messages: [
              userMessage,
              createCommandInputMessage(
                `<local-command-stderr>${String(e)}</local-command-stderr>`,
              ),
            ],
            shouldQuery: false,
            command,
          }
        }
      }
      case 'prompt': {
        try {
          // Check if command should run as forked sub-agent
          if (command.context === 'fork') {
            return await executeForkedSlashCommand(
              command,
              args,
              context,
              precedingInputBlocks,
              setToolJSX,
              canUseTool ?? hasPermissionsToUseTool,
            )
          }

          return await getMessagesForPromptSlashCommand(
            command,
            args,
            context,
            precedingInputBlocks,
            imageContentBlocks,
            uuid,
          )
        } catch (e) {
          // Handle abort errors specially to show proper "Interrupted" message
          if (e instanceof AbortError) {
            return {
              messages: [
                createUserMessage({
                  content: prepareUserContent({
                    inputString: formatCommandInput(command, args),
                    precedingInputBlocks,
                  }),
                }),
                createUserInterruptionMessage({ toolUse: false }),
              ],
              shouldQuery: false,
              command,
            }
          }
          return {
            messages: [
              createUserMessage({
                content: prepareUserContent({
                  inputString: formatCommandInput(command, args),
                  precedingInputBlocks,
                }),
              }),
              createUserMessage({
                content: `<local-command-stderr>${String(e)}</local-command-stderr>`,
              }),
            ],
            shouldQuery: false,
            command,
          }
        }
      }
    }
  } catch (e) {
    if (e instanceof MalformedCommandError) {
      return {
        messages: [
          createUserMessage({
            content: prepareUserContent({
              inputString: e.message,
              precedingInputBlocks,
            }),
          }),
        ],
        shouldQuery: false,
        command,
      }
    }
    throw e
  }
}

function formatCommandInput(command: CommandBase, args: string): string {
  return formatCommandInputTags(getCommandName(command), args)
}

/**
 * Formats the metadata for a skill loading message.
 * Used by the Skill tool and for subagent skill preloading.
 */
export function formatSkillLoadingMetadata(
  skillName: string,
  _progressMessage: string = 'loading',
): string {
  // Use skill name only - UserCommandMessage renders as "Skill(name)"
  return [
    `<${COMMAND_MESSAGE_TAG}>${skillName}</${COMMAND_MESSAGE_TAG}>`,
    `<${COMMAND_NAME_TAG}>${skillName}</${COMMAND_NAME_TAG}>`,
    `<skill-format>true</skill-format>`,
  ].join('\n')
}

/**
 * Formats the metadata for a slash command loading message.
 */
function formatSlashCommandLoadingMetadata(
  commandName: string,
  args?: string,
): string {
  return [
    `<${COMMAND_MESSAGE_TAG}>${commandName}</${COMMAND_MESSAGE_TAG}>`,
    `<${COMMAND_NAME_TAG}>/${commandName}</${COMMAND_NAME_TAG}>`,
    args ? `<command-args>${args}</command-args>` : null,
  ]
    .filter(Boolean)
    .join('\n')
}

/**
 * Formats the loading metadata for a command (skill or slash command).
 * User-invocable skills use slash command format (/name), while model-only
 * skills use the skill format ("The X skill is running").
 */
function formatCommandLoadingMetadata(
  command: CommandBase & PromptCommand,
  args?: string,
): string {
  // Use command.name (the qualified name including plugin prefix, e.g.
  // "product-management:feature-spec") instead of userFacingName() which may
  // strip the plugin prefix via displayName fallback.
  // User-invocable skills should show as /command-name like regular slash commands
  if (command.userInvocable !== false) {
    return formatSlashCommandLoadingMetadata(command.name, args)
  }
  // Model-only skills (userInvocable: false) show as "The X skill is running"
  if (
    command.loadedFrom === 'skills' ||
    command.loadedFrom === 'plugin' ||
    command.loadedFrom === 'mcp'
  ) {
    return formatSkillLoadingMetadata(command.name, command.progressMessage)
  }
  return formatSlashCommandLoadingMetadata(command.name, args)
}

export async function processPromptSlashCommand(
  commandName: string,
  args: string,
  commands: Command[],
  context: ToolUseContext,
  imageContentBlocks: ContentBlockParam[] = [],
): Promise<SlashCommandResult> {
  const command = findCommand(commandName, commands)
  if (!command) {
    throw new MalformedCommandError(`Unknown command: ${commandName}`)
  }
  if (command.type !== 'prompt') {
    throw new Error(
      `Unexpected ${command.type} command. Expected 'prompt' command. Use /${commandName} directly in the main conversation.`,
    )
  }
  return getMessagesForPromptSlashCommand(
    command,
    args,
    context,
    [],
    imageContentBlocks,
  )
}

async function getMessagesForPromptSlashCommand(
  command: CommandBase & PromptCommand,
  args: string,
  context: ToolUseContext,
  precedingInputBlocks: ContentBlockParam[] = [],
  imageContentBlocks: ContentBlockParam[] = [],
  uuid?: string,
): Promise<SlashCommandResult> {
  // In coordinator mode (main thread only), skip loading the full skill content
  // and permissions. The coordinator only has Agent + TaskStop tools, so the
  // skill content and allowedTools are useless. Instead, send a brief summary
  // telling the coordinator how to delegate this skill to a worker.
  //
  // Workers run in-process and inherit CLAUDE_CODE_COORDINATOR_MODE from the
  // parent env, so we also check !context.agentId: agentId is only set for
  // subagents, letting workers fall through to getPromptForCommand and receive
  // the real skill content when they invoke the Skill tool.
  if (
    feature('COORDINATOR_MODE') &&
    isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) &&
    !context.agentId
  ) {
    const metadata = formatCommandLoadingMetadata(command, args)
    const parts: string[] = [
      `Skill "/${command.name}" is available for workers.`,
    ]
    if (command.description) {
      parts.push(`Description: ${command.description}`)
    }
    if (command.whenToUse) {
      parts.push(`When to use: ${command.whenToUse}`)
    }
    const skillAllowedTools = command.allowedTools ?? []
    if (skillAllowedTools.length > 0) {
      parts.push(
        `This skill grants workers additional tool permissions: ${skillAllowedTools.join(', ')}`,
      )
    }
    parts.push(
      `\nInstruct a worker to use this skill by including "Use the /${command.name} skill" in your Agent prompt. The worker has access to the Skill tool and will receive the skill's content and permissions when it invokes it.`,
    )
    const summaryContent: ContentBlockParam[] = [
      { type: 'text', text: parts.join('\n') },
    ]
    return {
      messages: [
        createUserMessage({ content: metadata, uuid }),
        createUserMessage({ content: summaryContent, isMeta: true }),
      ],
      shouldQuery: true,
      model: command.model,
      effort: command.effort,
      command,
    }
  }

  const result = await command.getPromptForCommand(args, context)

  // Register skill hooks if defined. Under ["hooks"]-only (skills not locked),
  // user skills still load and reach this point — block hook REGISTRATION here
  // where source is known. Mirrors the agent frontmatter gate in runAgent.ts.
  const hooksAllowedForThisSkill =
    !isRestrictedToPluginOnly('hooks') || isSourceAdminTrusted(command.source)
  if (command.hooks && hooksAllowedForThisSkill) {
    const sessionId = getSessionId()
    registerSkillHooks(
      context.setAppState,
      sessionId,
      command.hooks,
      command.name,
      command.type === 'prompt' ? command.skillRoot : undefined,
    )
  }

  // Record skill invocation for compaction preservation, scoped by agent context.
  // Skills are tagged with their agentId so only skills belonging to the current
  // agent are restored during compaction (preventing cross-agent leaks).
  const skillPath = command.source
    ? `${command.source}:${command.name}`
    : command.name
  const skillContent = result
    .filter((b): b is TextBlockParam => b.type === 'text')
    .map(b => b.text)
    .join('\n\n')
  addInvokedSkill(
    command.name,
    skillPath,
    skillContent,
    getAgentContext()?.agentId ?? null,
  )

  const metadata = formatCommandLoadingMetadata(command, args)

  const additionalAllowedTools = parseToolListFromCLI(
    command.allowedTools ?? [],
  )

  // Create content for the main message, including any pasted images
  const mainMessageContent: ContentBlockParam[] =
    imageContentBlocks.length > 0 || precedingInputBlocks.length > 0
      ? [...imageContentBlocks, ...precedingInputBlocks, ...result]
      : result

  // Extract attachments from command arguments (@-mentions, MCP resources,
  // agent mentions in SKILL.md). skipSkillDiscovery prevents the SKILL.md
  // content itself from triggering discovery — it's meta-content, not user
  // intent, and a large SKILL.md (e.g. 110KB) would fire chunked AKI queries
  // adding seconds of latency to every skill invocation.
  const attachmentMessages = await toArray(
    getAttachmentMessages(
      result
        .filter((block): block is TextBlockParam => block.type === 'text')
        .map(block => block.text)
        .join(' '),
      context,
      null,
      [], // queuedCommands - handled by query.ts for mid-turn attachments
      context.messages,
      'repl_main_thread',
      { skipSkillDiscovery: true },
    ),
  )

  const messages = [
    createUserMessage({
      content: metadata,
      uuid,
    }),
    createUserMessage({
      content: mainMessageContent,
      isMeta: true,
    }),
    ...attachmentMessages,
    createAttachmentMessage({
      type: 'command_permissions',
      allowedTools: additionalAllowedTools,
      model: command.model,
    }),
  ]

  return {
    messages,
    shouldQuery: true,
    allowedTools: additionalAllowedTools,
    model: command.model,
    effort: command.effort,
    command,
  }
}
