import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'

import { loggerService } from '@logger'
import { isMac, isWin } from '@main/constant'
import { removeEnvProxy } from '@main/utils'
import { isUserInChina } from '@main/utils/ipService'
import { findCommandInShellEnv, getBinaryName, getBinaryPath, isBinaryExists } from '@main/utils/process'
import getShellEnv from '@main/utils/shell-env'
import type { TerminalConfig, TerminalConfigWithCommand } from '@shared/config/constant'
import {
  codeTools,
  HOME_CHERRY_DIR,
  MACOS_TERMINALS,
  MACOS_TERMINALS_WITH_COMMANDS,
  terminalApps,
  WINDOWS_TERMINALS,
  WINDOWS_TERMINALS_WITH_COMMANDS
} from '@shared/config/constant'
import type { CodeToolsRunResult } from '@shared/config/types'
import { getFunctionalKeys, parseJSONC, sanitizeEnvForLogging } from '@shared/utils'
import { spawn } from 'child_process'
import semver from 'semver'
import { promisify } from 'util'

const execAsync = promisify(require('child_process').exec)
const logger = loggerService.withContext('CodeToolsService')

interface VersionInfo {
  installed: string | null
  latest: string | null
  needsUpdate: boolean
}

class CodeToolsService {
  // Static properties for cleanup management (avoid listener accumulation)
  private static pendingBatCleanups = new Set<string>()
  private static exitCleanupRegistered = false

  private versionCache: Map<string, { version: string; timestamp: number }> = new Map()
  private terminalsCache: {
    terminals: TerminalConfig[]
    timestamp: number
  } | null = null
  private customTerminalPaths: Map<string, string> = new Map() // Store user-configured terminal paths
  private readonly CACHE_DURATION = 1000 * 60 * 30 // 30 minutes cache
  private readonly TERMINALS_CACHE_DURATION = 1000 * 60 * 5 // 5 minutes cache for terminals
  private openCodeCleanupTimers: Map<string, NodeJS.Timeout> = new Map() // Track cleanup timers by directory for debounce
  private openCodeConfigBackups: Map<string, string | null> = new Map() // Store raw backup content of opencode.json

  constructor() {
    this.getBunPath = this.getBunPath.bind(this)
    this.getPackageName = this.getPackageName.bind(this)
    this.getCliExecutableName = this.getCliExecutableName.bind(this)
    this.isPackageInstalled = this.isPackageInstalled.bind(this)
    this.getVersionInfo = this.getVersionInfo.bind(this)
    this.updatePackage = this.updatePackage.bind(this)
    this.run = this.run.bind(this)

    if (isMac || isWin) {
      void this.preloadTerminals()
    }
  }

  /**
   * Preload available terminals in background
   */
  private async preloadTerminals(): Promise<void> {
    try {
      logger.info('Preloading available terminals...')
      await this.getAvailableTerminals()
      logger.info('Terminal preloading completed')
    } catch (error) {
      logger.warn('Terminal preloading failed:', error as Error)
    }
  }

  public async getBunPath() {
    const dir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
    const bunName = await getBinaryName('bun')
    const bunPath = path.join(dir, bunName)
    return bunPath
  }

  public async getPackageName(cliTool: string) {
    switch (cliTool) {
      case codeTools.claudeCode:
        return '@anthropic-ai/claude-code'
      case codeTools.geminiCli:
        return '@google/gemini-cli'
      case codeTools.openaiCodex:
        return '@openai/codex'
      case codeTools.qwenCode:
        return '@qwen-code/qwen-code'
      case codeTools.iFlowCli:
        return '@iflow-ai/iflow-cli'
      case codeTools.githubCopilotCli:
        return '@github/copilot'
      case codeTools.kimiCli:
        return 'kimi-cli' // Python package
      case codeTools.openCode:
        return 'opencode-ai'
      default:
        throw new Error(`Unsupported CLI tool: ${cliTool}`)
    }
  }

  public async getCliExecutableName(cliTool: string) {
    switch (cliTool) {
      case codeTools.claudeCode:
        return 'claude'
      case codeTools.geminiCli:
        return 'gemini'
      case codeTools.openaiCodex:
        return 'codex'
      case codeTools.qwenCode:
        return 'qwen'
      case codeTools.iFlowCli:
        return 'iflow'
      case codeTools.githubCopilotCli:
        return 'copilot'
      case codeTools.kimiCli:
        return 'kimi'
      case codeTools.openCode:
        return 'opencode'
      default:
        throw new Error(`Unsupported CLI tool: ${cliTool}`)
    }
  }

  /**
   * Get the command to execute claude-code.
   *
   * Since @anthropic-ai/claude-code ships a native binary (bin/claude.exe) instead of
   * a JavaScript file, it cannot be executed via Bun. The official cli-wrapper.cjs is
   * a JS launcher that locates and spawns the correct platform-specific binary.
   * We use Bun to run cli-wrapper.cjs, which works on all platforms.
   */
  private async getClaudeCodeCommand(bunPath: string): Promise<string> {
    const globalInstallDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'install', 'global')
    const cliWrapperPath = path.join(
      globalInstallDir,
      'node_modules',
      '@anthropic-ai',
      'claude-code',
      'cli-wrapper.cjs'
    )

    if (fs.existsSync(cliWrapperPath)) {
      logger.debug(`Using cli-wrapper.cjs for claude-code: ${cliWrapperPath}`)
      return `"${bunPath}" "${cliWrapperPath}"`
    }

    // Fallback: try to execute the binary directly (works if postinstall ran correctly)
    const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
    const executableName = await this.getCliExecutableName(codeTools.claudeCode)
    const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
    logger.warn(`cli-wrapper.cjs not found at ${cliWrapperPath}, falling back to direct execution: ${executablePath}`)
    return `"${executablePath}"`
  }

  /**
   * Generate opencode.json config file for OpenCode CLI
   * Merge approach:
   * 1. Parse existing config (if any) with JSONC support
   * 2. Merge CherryStudio provider into provider object
   * 3. Preserve other fields like $schema, model, etc.
   */
  private async generateOpenCodeConfig(
    directory: string,
    model: { id: string; name: string },
    baseUrl: string,
    isReasoning: boolean,
    supportsReasoningEffort: boolean,
    budgetTokens: number | undefined,
    providerType: string,
    providerName: string,
    endpointType: string
  ): Promise<string> {
    const configPath = path.join(directory, 'opencode.json')

    // Determine npm package based on endpoint type (model-level) then provider type (fallback)
    let npmPackage = '@ai-sdk/openai-compatible'
    if (endpointType === 'anthropic' || (!endpointType && providerType === 'anthropic')) {
      npmPackage = '@ai-sdk/anthropic'
    } else if (endpointType === 'openai-response' || (!endpointType && providerType === 'openai-response')) {
      npmPackage = '@ai-sdk/openai'
    }

    // Build model config - NO limit field (cannot determine output capacity)
    const modelConfig: Record<string, any> = {
      name: model.name
    }

    // Add reasoning config based on endpoint type and provider type
    if (isReasoning) {
      modelConfig.reasoning = true
      if (endpointType === 'anthropic' || (!endpointType && providerType === 'anthropic')) {
        // Anthropic style: thinking with budgetTokens
        modelConfig.options = {
          thinking: {
            budgetTokens: budgetTokens ?? 10000, // Use passed budget or fallback to default
            type: 'enabled'
          }
        }
      } else if (supportsReasoningEffort) {
        // OpenAI style: only add reasoningEffort if model supports it
        modelConfig.options = {
          reasoningEffort: 'medium'
        }
      }
      // else: model is a reasoning model but doesn't support reasoningEffort - don't add options
    }

    // Dynamic provider key to avoid race conditions between different providers
    const dynamicProviderKey = `Cherry-${providerName}`
    const dynamicProviderName = `Cherry-${providerName}`

    // Parse existing config (if any) with JSONC support
    let existingConfig: Record<string, any> | null = null
    let backupContent: string | null = null
    if (fs.existsSync(configPath)) {
      const rawContent = fs.readFileSync(configPath, 'utf8')
      // Parse and clean backup to only preserve non-Cherry content
      const existingConfigForBackup = parseJSONC(rawContent)
      if (existingConfigForBackup && typeof existingConfigForBackup === 'object') {
        // Remove any existing Cherry-* providers from backup
        if (existingConfigForBackup.provider && typeof existingConfigForBackup.provider === 'object') {
          const providers = existingConfigForBackup.provider as Record<string, any>
          const cherryKeys = Object.keys(providers).filter((key) => key.startsWith('Cherry-'))
          for (const key of cherryKeys) {
            delete providers[key]
          }
          // If provider object becomes empty, remove it
          if (Object.keys(providers).length === 0) {
            delete existingConfigForBackup.provider
          }
          // Check if config is empty after cleaning
          const functionalKeys = getFunctionalKeys(existingConfigForBackup)
          if (functionalKeys.length > 0) {
            backupContent = JSON.stringify(existingConfigForBackup, null, 2)
          } else {
            backupContent = null // Backup was all Cherry content, nothing to preserve
          }
        } else {
          backupContent = rawContent
        }
      } else {
        backupContent = rawContent
      }
      existingConfig = JSON.parse(JSON.stringify(existingConfigForBackup))
      logger.info('Parsed existing opencode.json config')
    }
    this.openCodeConfigBackups.set(configPath, backupContent)

    // config with env variable Build CherryStudio provider reference for security
    const envVarKey = `OPENCODE_API_KEY_${providerName.toUpperCase().replace(/[-.]/g, '_')}`
    const cherryProviderConfig = {
      npm: npmPackage,
      name: dynamicProviderName,
      options: { apiKey: `{env:${envVarKey}}`, baseURL: baseUrl },
      models: { [model.id]: modelConfig }
    }

    // Merge into existing config or create new one
    let finalConfig: Record<string, any>
    if (existingConfig && typeof existingConfig === 'object') {
      // Deep merge: preserve existing fields, add Cherry provider
      finalConfig = { ...existingConfig }
      if (!finalConfig.provider || typeof finalConfig.provider !== 'object') {
        finalConfig.provider = {}
      }
      // Merge Cherry provider into existing providers
      finalConfig.provider = {
        ...finalConfig.provider,
        [dynamicProviderKey]: cherryProviderConfig
      }
    } else {
      // No existing config, create fresh one
      finalConfig = {
        $schema: 'https://opencode.ai/config.json',
        provider: {
          [dynamicProviderKey]: cherryProviderConfig
        }
      }
    }

    fs.writeFileSync(configPath, JSON.stringify(finalConfig, null, 2), 'utf8')
    logger.info(`Wrote opencode.json at: ${configPath} (merged: ${existingConfig !== null})`)

    return configPath
  }

  /**
   * Schedule cleanup of opencode.json config file after 60 seconds (debounce mode)
   * Precise cleanup approach:
   * - Parse current config
   * - Remove only providers starting with "Cherry-"
   * - Keep all other providers and fields
   * - If provider object becomes empty, remove it
   */
  private scheduleOpenCodeConfigCleanup(configPath: string): void {
    // Cancel any existing timer for this directory (debounce)
    const existingTimer = this.openCodeCleanupTimers.get(configPath)
    if (existingTimer) {
      clearTimeout(existingTimer)
      logger.info(`Cancelled previous cleanup timer for: ${configPath}`)
    }

    // Schedule new cleanup
    const timer = setTimeout(
      () => {
        this.openCodeCleanupTimers.delete(configPath)

        try {
          // Check if file still exists
          if (!fs.existsSync(configPath)) {
            logger.info(`opencode.json already deleted: ${configPath}`)
            this.openCodeConfigBackups.delete(configPath)
            return
          }

          // Get backup content
          const backupContent = this.openCodeConfigBackups.get(configPath) ?? null

          // Parse current config
          const currentContent = fs.readFileSync(configPath, 'utf8')
          const currentConfig = parseJSONC(currentContent)

          if (!currentConfig || typeof currentConfig !== 'object') {
            // Invalid config, fall back to backup or deletion
            if (backupContent !== null) {
              fs.writeFileSync(configPath, backupContent, 'utf8')
              logger.info(`Restored original opencode.json (invalid current config): ${configPath}`)
            } else {
              fs.unlinkSync(configPath)
              logger.info(`Deleted opencode.json (invalid config, no backup): ${configPath}`)
            }
            this.openCodeConfigBackups.delete(configPath)
            return
          }

          // Remove Cherry-* providers from current config
          if (currentConfig.provider && typeof currentConfig.provider === 'object') {
            const providers = currentConfig.provider as Record<string, any>
            const keysToDelete = Object.keys(providers).filter((key) => key.startsWith('Cherry-'))

            if (keysToDelete.length > 0) {
              for (const key of keysToDelete) {
                delete providers[key]
              }

              // If provider object becomes empty, remove it
              if (Object.keys(providers).length === 0) {
                delete currentConfig.provider
              }

              // Check if config is now "empty" (only contains non-functional fields like $schema)
              const remainingKeys = getFunctionalKeys(currentConfig)
              if (remainingKeys.length === 0) {
                // Config is essentially empty after cleanup
                // Check if backup also has no functional content
                let backupHasFunctionalContent = false
                if (backupContent !== null) {
                  try {
                    const backupConfig = parseJSONC(backupContent)
                    if (backupConfig && typeof backupConfig === 'object') {
                      const backupKeys = getFunctionalKeys(backupConfig)
                      backupHasFunctionalContent = backupKeys.length > 0
                    }
                  } catch {
                    // Parse failed, treat as no functional content
                  }
                }

                if (backupHasFunctionalContent && backupContent !== null) {
                  // Restore original content (it had functional content)
                  fs.writeFileSync(configPath, backupContent, 'utf8')
                  logger.info(`Restored original opencode.json (config empty after cleanup): ${configPath}`)
                } else {
                  // No backup or backup had no functional content, delete the file
                  fs.unlinkSync(configPath)
                  logger.info(`Deleted opencode.json (config empty after cleanup): ${configPath}`)
                }
              } else {
                // Write back the cleaned config
                fs.writeFileSync(configPath, JSON.stringify(currentConfig, null, 2), 'utf8')
                logger.info(`Removed ${keysToDelete.length} Cherry-* provider(s) from opencode.json: ${configPath}`)
              }
            } else {
              logger.info(`No Cherry-* providers found in opencode.json: ${configPath}`)
            }
          } else {
            logger.info(`No provider object in opencode.json: ${configPath}`)
          }

          // Clean up backup
          this.openCodeConfigBackups.delete(configPath)
        } catch (error) {
          logger.warn(`Failed to cleanup opencode.json: ${error}`)
        }
      },
      5 * 60 * 1000
    ) // 5 minutes timeout

    this.openCodeCleanupTimers.set(configPath, timer)
  }

  /**
   * Check if a single terminal is available
   */
  private async checkTerminalAvailability(terminal: TerminalConfig): Promise<TerminalConfig | null> {
    try {
      if (isMac && terminal.bundleId) {
        // macOS: Check if application is installed via bundle ID with timeout
        const { stdout } = await execAsync(`mdfind "kMDItemCFBundleIdentifier == '${terminal.bundleId}'"`, {
          timeout: 3000
        })
        if (stdout.trim()) {
          return terminal
        }
      } else if (isWin) {
        // Windows: Check terminal availability
        return await this.checkWindowsTerminalAvailability(terminal)
      } else {
        // TODO: Check if terminal is available in linux
        await execAsync(`which ${terminal.id}`, { timeout: 2000 })
        return terminal
      }
    } catch (error) {
      logger.debug(`Terminal ${terminal.id} not available:`, error as Error)
    }
    return null
  }

  /**
   * Check Windows terminal availability (simplified - user configured paths)
   */
  private async checkWindowsTerminalAvailability(terminal: TerminalConfig): Promise<TerminalConfig | null> {
    try {
      switch (terminal.id) {
        case terminalApps.cmd:
          // CMD is always available on Windows
          return terminal

        case terminalApps.powershell:
          // Check for PowerShell in PATH
          try {
            await execAsync('powershell -Command "Get-Host"', {
              timeout: 3000
            })
            return terminal
          } catch {
            try {
              await execAsync('pwsh -Command "Get-Host"', { timeout: 3000 })
              return terminal
            } catch {
              return null
            }
          }

        case terminalApps.windowsTerminal:
          // Check for Windows Terminal via where command (doesn't launch the terminal)
          try {
            await execAsync('where wt', { timeout: 3000 })
            return terminal
          } catch {
            return null
          }

        case terminalApps.wsl:
          // Check for WSL
          try {
            await execAsync('wsl --status', { timeout: 3000 })
            return terminal
          } catch {
            return null
          }

        default:
          // For other terminals (Alacritty, WezTerm), check if user has configured custom path
          return await this.checkCustomTerminalPath(terminal)
      }
    } catch (error) {
      logger.debug(`Windows terminal ${terminal.id} not available:`, error as Error)
      return null
    }
  }

  /**
   * Check if user has configured custom path for terminal
   */
  private async checkCustomTerminalPath(terminal: TerminalConfig): Promise<TerminalConfig | null> {
    // Check if user has configured custom path
    const customPath = this.customTerminalPaths.get(terminal.id)
    if (customPath && fs.existsSync(customPath)) {
      try {
        await execAsync(`"${customPath}" --version`, { timeout: 3000 })
        return { ...terminal, customPath }
      } catch {
        return null
      }
    }

    // Fallback to PATH check
    try {
      const command = terminal.id === terminalApps.alacritty ? 'alacritty' : 'wezterm'
      await execAsync(`${command} --version`, { timeout: 3000 })
      return terminal
    } catch {
      return null
    }
  }

  /**
   * Set custom path for a terminal (called from settings UI)
   */
  public setCustomTerminalPath(terminalId: string, path: string): void {
    logger.info(`Setting custom path for terminal ${terminalId}: ${path}`)
    this.customTerminalPaths.set(terminalId, path)
    // Clear terminals cache to force refresh
    this.terminalsCache = null
  }

  /**
   * Get custom path for a terminal
   */
  public getCustomTerminalPath(terminalId: string): string | undefined {
    return this.customTerminalPaths.get(terminalId)
  }

  /**
   * Remove custom path for a terminal
   */
  public removeCustomTerminalPath(terminalId: string): void {
    logger.info(`Removing custom path for terminal ${terminalId}`)
    this.customTerminalPaths.delete(terminalId)
    // Clear terminals cache to force refresh
    this.terminalsCache = null
  }

  /**
   * Get available terminals (with caching and parallel checking)
   */
  private async getAvailableTerminals(): Promise<TerminalConfig[]> {
    const now = Date.now()

    // Check cache first
    if (this.terminalsCache && now - this.terminalsCache.timestamp < this.TERMINALS_CACHE_DURATION) {
      logger.info(`Using cached terminals list (${this.terminalsCache.terminals.length} terminals)`)
      return this.terminalsCache.terminals
    }

    logger.info('Checking available terminals in parallel...')
    const startTime = Date.now()

    // Get terminal list based on platform
    const terminalList = isWin ? WINDOWS_TERMINALS : MACOS_TERMINALS

    // Check all terminals in parallel
    const terminalPromises = terminalList.map((terminal) => this.checkTerminalAvailability(terminal))

    try {
      // Wait for all checks to complete with a global timeout
      const results = await Promise.allSettled(
        terminalPromises.map((p) =>
          Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))])
        )
      )

      const availableTerminals: TerminalConfig[] = []
      results.forEach((result, index) => {
        if (result.status === 'fulfilled' && result.value) {
          availableTerminals.push(result.value as TerminalConfig)
        } else if (result.status === 'rejected') {
          logger.debug(`Terminal check failed for ${MACOS_TERMINALS[index].id}:`, result.reason)
        }
      })

      const endTime = Date.now()
      logger.info(
        `Terminal availability check completed in ${endTime - startTime}ms, found ${availableTerminals.length} terminals`
      )

      // Cache the results
      this.terminalsCache = {
        terminals: availableTerminals,
        timestamp: now
      }

      return availableTerminals
    } catch (error) {
      logger.error('Error checking terminal availability:', error as Error)
      // Return cached result if available, otherwise empty array
      return this.terminalsCache?.terminals || []
    }
  }

  /**
   * Get terminal config by ID, fallback to system default
   */
  private async getTerminalConfig(terminalId?: string): Promise<TerminalConfigWithCommand> {
    const availableTerminals = await this.getAvailableTerminals()
    const terminalCommands = isWin ? WINDOWS_TERMINALS_WITH_COMMANDS : MACOS_TERMINALS_WITH_COMMANDS
    const defaultTerminal = isWin ? terminalApps.cmd : terminalApps.systemDefault

    if (terminalId) {
      let requestedTerminal = terminalCommands.find(
        (t) => t.id === terminalId && availableTerminals.some((at) => at.id === t.id)
      )

      if (requestedTerminal) {
        // Apply custom path if configured
        const customPath = this.customTerminalPaths.get(terminalId)
        if (customPath && isWin) {
          requestedTerminal = this.applyCustomPath(requestedTerminal, customPath)
        }
        return requestedTerminal
      } else {
        logger.warn(`Requested terminal ${terminalId} not available, falling back to system default`)
      }
    }

    // Fallback to system default Terminal
    const systemTerminal = terminalCommands.find(
      (t) => t.id === defaultTerminal && availableTerminals.some((at) => at.id === t.id)
    )
    if (systemTerminal) {
      return systemTerminal
    }

    // If even system Terminal is not found, return the first available
    const firstAvailable = terminalCommands.find((t) => availableTerminals.some((at) => at.id === t.id))
    if (firstAvailable) {
      return firstAvailable
    }

    // Last resort fallback
    return terminalCommands.find((t) => t.id === defaultTerminal)!
  }

  /**
   * Apply custom path to terminal configuration
   */
  private applyCustomPath(terminal: TerminalConfigWithCommand, customPath: string): TerminalConfigWithCommand {
    return {
      ...terminal,
      customPath,
      command: (directory: string, fullCommand: string) => {
        const originalCommand = terminal.command(directory, fullCommand)
        return {
          ...originalCommand,
          command: customPath // Replace command with custom path
        }
      }
    }
  }

  private async isPackageInstalled(cliTool: string): Promise<boolean> {
    const executableName = await this.getCliExecutableName(cliTool)
    const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
    const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))

    // Ensure bin directory exists
    if (!fs.existsSync(binDir)) {
      fs.mkdirSync(binDir, { recursive: true })
    }

    return fs.existsSync(executablePath)
  }

  /**
   * Get version information for a CLI tool
   */
  public async getVersionInfo(cliTool: string): Promise<VersionInfo> {
    logger.info(`Starting version check for ${cliTool}`)
    const packageName = await this.getPackageName(cliTool)
    const isInstalled = await this.isPackageInstalled(cliTool)

    let installedVersion: string | null = null
    let latestVersion: string | null = null

    // Get installed version if package is installed
    if (isInstalled) {
      logger.info(`${cliTool} is installed, getting current version`)
      try {
        let versionCommand: string

        // claude-code ships a native binary that cannot be executed via Bun.
        // Use cli-wrapper.cjs (via Bun) to run --version reliably on all platforms.
        if (cliTool === codeTools.claudeCode) {
          const bunPath = await this.getBunPath()
          versionCommand = await this.getClaudeCodeCommand(bunPath)
        } else {
          const executableName = await this.getCliExecutableName(cliTool)
          const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
          const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))
          versionCommand = `"${executablePath}"`
        }

        const { stdout } = await execAsync(`${versionCommand} --version`, {
          timeout: 10000
        })
        // Extract version number from output (format may vary by tool)
        const versionMatch = stdout.trim().match(/\d+\.\d+\.\d+/)
        installedVersion = versionMatch ? versionMatch[0] : stdout.trim().split(' ')[0]
        logger.info(`${cliTool} current installed version: ${installedVersion}`)
      } catch (error) {
        logger.warn(`Failed to get installed version for ${cliTool}:`, error as Error)
      }
    } else {
      logger.info(`${cliTool} is not installed`)
    }

    // Get latest version from npm (with cache)
    const cacheKey = `${packageName}-latest`
    const cached = this.versionCache.get(cacheKey)
    const now = Date.now()

    if (cached && now - cached.timestamp < this.CACHE_DURATION) {
      logger.info(`Using cached latest version for ${packageName}: ${cached.version}`)
      latestVersion = cached.version
    } else {
      logger.info(`Fetching latest version for ${packageName} from npm`)
      try {
        // Get registry URL
        const registryUrl = await this.getNpmRegistryUrl()

        // Fetch package info directly from npm registry API
        const packageUrl = `${registryUrl}/${packageName}/latest`
        const response = await fetch(packageUrl, {
          signal: AbortSignal.timeout(15000)
        })

        if (!response.ok) {
          throw new Error(`Failed to fetch package info: ${response.statusText}`)
        }

        const packageInfo = await response.json()
        latestVersion = packageInfo.version
        logger.info(`${packageName} latest version: ${latestVersion}`)

        // Cache the result
        this.versionCache.set(cacheKey, {
          version: latestVersion!,
          timestamp: now
        })
        logger.debug(`Cached latest version for ${packageName}`)
      } catch (error) {
        logger.warn(`Failed to get latest version for ${packageName}:`, error as Error)
        // If we have a cached version, use it even if expired
        if (cached) {
          logger.info(`Using expired cached version for ${packageName}: ${cached.version}`)
          latestVersion = cached.version
        }
      }
    }

    const needsUpdate = !!(latestVersion && isInstalled && (!installedVersion || installedVersion !== latestVersion))
    logger.info(
      `Version check result for ${cliTool}: installed=${installedVersion}, latest=${latestVersion}, needsUpdate=${needsUpdate}`
    )

    return {
      installed: installedVersion,
      latest: latestVersion,
      needsUpdate
    }
  }

  /**
   * Get npm registry URL based on user location
   */
  private async getNpmRegistryUrl(): Promise<string> {
    try {
      const inChina = await isUserInChina()
      if (inChina) {
        logger.info('User in China, using Taobao npm mirror')
        return 'https://registry.npmmirror.com'
      } else {
        logger.info('User not in China, using default npm mirror')
        return 'https://registry.npmjs.org'
      }
    } catch (error) {
      logger.warn('Failed to detect user location, using default npm mirror')
      return 'https://registry.npmjs.org'
    }
  }

  /**
   * Get available terminals for the current platform
   */
  public async getAvailableTerminalsForPlatform(): Promise<TerminalConfig[]> {
    if (isMac || isWin) {
      return this.getAvailableTerminals()
    }
    // For other platforms, return empty array for now
    return []
  }

  /**
   * Update a CLI tool to the latest version
   */
  public async updatePackage(cliTool: string): Promise<{ success: boolean; message: string }> {
    logger.info(`Starting update process for ${cliTool}`)
    try {
      const packageName = await this.getPackageName(cliTool)
      const bunPath = await this.getBunPath()
      const bunInstallPath = path.join(os.homedir(), HOME_CHERRY_DIR)
      const registryUrl = await this.getNpmRegistryUrl()

      // Get logs directory for update output redirection
      const logsDir = loggerService.getLogsDir()
      const updateLogPath = path.join(logsDir, 'cli-tools-update.log').replace(/\\/g, '/')

      const installEnvPrefix = isWin
        ? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
        : `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`

      // Use > to truncate log file on each update
      const updateCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName} > "${updateLogPath}" 2>&1`
      logger.info(`Executing update command: ${updateCommand}`)

      await execAsync(updateCommand, { timeout: 60000 })
      logger.info(`Successfully executed update command for ${cliTool}`)

      // Clear version cache for this package
      const cacheKey = `${packageName}-latest`
      this.versionCache.delete(cacheKey)
      logger.debug(`Cleared version cache for ${packageName}`)

      const successMessage = `Successfully updated ${cliTool} to the latest version`
      logger.info(successMessage)
      return {
        success: true,
        message: successMessage
      }
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error)
      const failureMessage = `Failed to update ${cliTool}: ${errorMessage}`
      logger.error(failureMessage, error as Error)
      return {
        success: false,
        message: failureMessage
      }
    }
  }

  async run(
    _: Electron.IpcMainInvokeEvent,
    cliTool: string,
    _model: string,
    directory: string,
    env: Record<string, string>,
    options: { autoUpdateToLatest?: boolean; terminal?: string } = {}
  ): Promise<CodeToolsRunResult> {
    logger.info(`Starting CLI tool launch: ${cliTool} in directory: ${directory}`)
    logger.debug(`Environment variables:`, Object.keys(env))
    logger.debug(`Options:`, options)

    // Validate directory exists before proceeding
    if (!directory || !fs.existsSync(directory)) {
      const errorMessage = `Directory does not exist: ${directory}`
      logger.error(errorMessage)
      return {
        success: false,
        message: errorMessage,
        command: ''
      }
    }

    const packageName = await this.getPackageName(cliTool)
    const bunPath = await this.getBunPath()
    const executableName = await this.getCliExecutableName(cliTool)
    const binDir = path.join(os.homedir(), HOME_CHERRY_DIR, 'bin')
    const executablePath = path.join(binDir, executableName + (isWin ? '.exe' : ''))

    logger.debug(`Package name: ${packageName}`)
    logger.debug(`Bun path: ${bunPath}`)
    logger.debug(`Executable name: ${executableName}`)
    logger.debug(`Executable path: ${executablePath}`)

    // Check if package is already installed
    const isInstalled = await this.isPackageInstalled(cliTool)

    // Check for updates and auto-update if requested
    let updateMessage = ''
    let installedVersion: string | null = null

    // Get installed version if package is installed (needed for qwen-code auth-type check)
    if (isInstalled) {
      try {
        const versionInfo = await this.getVersionInfo(cliTool)
        installedVersion = versionInfo.installed

        // Handle auto-update if enabled
        if (options.autoUpdateToLatest) {
          logger.info(`Auto update to latest enabled for ${cliTool}`)
          if (versionInfo.needsUpdate) {
            logger.info(`Update available for ${cliTool}: ${versionInfo.installed} -> ${versionInfo.latest}`)
            logger.info(`Auto-updating ${cliTool} to latest version`)
            updateMessage = ` && echo "Updating ${escapeBatchText(cliTool)} from ${escapeBatchText(versionInfo.installed || '')} to ${escapeBatchText(versionInfo.latest || '')}..."`
            const updateResult = await this.updatePackage(cliTool)
            if (updateResult.success) {
              logger.info(`Update completed successfully for ${cliTool}`)
              updateMessage += ` && echo "Update completed successfully"`
            } else {
              logger.error(`Update failed for ${cliTool}: ${updateResult.message}`)
              updateMessage += ` && echo "Update failed: ${escapeBatchText(updateResult.message)}"`
            }
          }
        }
      } catch (error) {
        logger.warn(`Failed to check version for ${cliTool}:`, error as Error)
      }
    }

    // Select different terminal based on operating system
    const platform = process.platform
    let terminalCommand: string
    let terminalArgs: string[]

    // Build environment variable prefix (based on platform)
    const buildEnvPrefix = (isWindows: boolean) => {
      if (Object.keys(env).length === 0) {
        logger.info('No environment variables to set')
        return ''
      }

      logger.info('Setting environment variables:', Object.keys(env))
      logger.debug('Environment variable values:', sanitizeEnvForLogging(env))

      if (isWindows) {
        // Windows uses set command
        // Escape all cmd.exe metacharacters in env values to prevent command injection
        return Object.entries(env)
          .map(([key, value]) => `set "${key}=${escapeBatchText(value)}"`)
          .join(' && ')
      } else {
        // Unix-like systems use export command
        const validEntries = Object.entries(env).filter(([key, value]) => {
          if (!key || key.trim() === '') {
            return false
          }
          if (value === undefined || value === null) {
            return false
          }
          return true
        })

        const envCommands = validEntries
          .map(([key, value]) => {
            const sanitizedValue = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
            const exportCmd = `export ${key}="${sanitizedValue}"`
            logger.debug(`Setting env var: ${key}=<redacted>`)
            return exportCmd
          })
          .join(' && ')
        return envCommands
      }
    }

    let baseCommand: string

    // claude-code ships a native binary that cannot be executed via Bun.
    // Use cli-wrapper.cjs (via Bun) on all platforms for reliable execution.
    if (cliTool === codeTools.claudeCode) {
      baseCommand = await this.getClaudeCodeCommand(bunPath)
    } else if (isWin) {
      baseCommand = `"${executablePath}"`
    } else {
      baseCommand = `"${bunPath}" "${executablePath}"`
    }

    // Special handling for kimi-cli: use uvx instead of bun
    if (cliTool === codeTools.kimiCli) {
      const shellEnv = await getShellEnv()
      let uvPath = await findCommandInShellEnv('uv', shellEnv)

      if (!uvPath) {
        if (await isBinaryExists('uv')) {
          uvPath = await getBinaryPath('uv')
          logger.info('Using bundled uv as fallback (not found in PATH)', { command: uvPath })
        } else {
          throw new Error(
            'uv not found in PATH and bundled version is not available.\n' +
              'Please either:\n' +
              '1. Install uv from https://github.com/astral-sh/uv\n' +
              '2. Run the MCP dependencies installer from Settings\n' +
              '3. Restart the application if you recently installed uv'
          )
        }
      }

      baseCommand = `${uvPath} tool run ${packageName}`
    }

    // Special handling for qwen-code: add --auth-type openai for version >= 0.12.3
    if (cliTool === codeTools.qwenCode) {
      // Use semver for proper version comparison (handles v-prefix, prereleases, etc.)
      const coerced = semver.coerce(installedVersion)
      const needsAuthType = installedVersion && coerced && semver.gte(coerced, '0.12.3')
      if (needsAuthType) {
        baseCommand = `${baseCommand} --auth-type openai`
        logger.info(`qwen-code version ${installedVersion} >= 0.12.3, using --auth-type openai`)
      } else {
        logger.info(`qwen-code version ${installedVersion || 'unknown'} < 0.12.3, not using --auth-type`)
      }
    }

    // Add configuration parameters for OpenAI Codex using command line args
    if (cliTool === codeTools.openaiCodex && env.OPENAI_MODEL_PROVIDER) {
      const providerId = env.OPENAI_MODEL_PROVIDER
      const providerName = env.OPENAI_MODEL_PROVIDER_NAME || providerId
      const normalizedBaseUrl = env.OPENAI_BASE_URL.replace(/\/$/, '')
      const model = _model

      const configParams = [
        `--config model_provider="${providerId}"`,
        `--config model_providers.${providerId}.name="${providerName}"`,
        `--config model_providers.${providerId}.base_url="${normalizedBaseUrl}"`,
        `--config model_providers.${providerId}.env_key="OPENAI_API_KEY"`,
        `--config model_providers.${providerId}.wire_api="responses"`,
        `--config model="${model}"`
      ].join(' ')
      baseCommand = `${baseCommand} ${configParams}`
    }

    // Special handling for OpenCode: generate config file and add --model flag
    if (cliTool === codeTools.openCode) {
      const baseUrl = env.OPENCODE_BASE_URL
      const modelId = _model
      const modelName = env.OPENCODE_MODEL_NAME || modelId
      const isReasoning = env.OPENCODE_MODEL_IS_REASONING === 'true'
      const supportsReasoningEffort = env.OPENCODE_MODEL_SUPPORTS_REASONING_EFFORT === 'true'
      const budgetTokens = env.OPENCODE_MODEL_BUDGET_TOKENS ? Number(env.OPENCODE_MODEL_BUDGET_TOKENS) : undefined
      const providerType = env.OPENCODE_PROVIDER_TYPE || 'openai-compatible'
      const providerName = env.OPENCODE_PROVIDER_NAME || 'Studio'
      const endpointType = env.OPENCODE_MODEL_ENDPOINT_TYPE || ''

      const configPath = await this.generateOpenCodeConfig(
        directory,
        { id: modelId, name: modelName },
        baseUrl,
        isReasoning,
        supportsReasoningEffort,
        budgetTokens,
        providerType,
        providerName,
        endpointType
      )
      this.scheduleOpenCodeConfigCleanup(configPath)

      // Add --model flag with dynamic provider prefix to avoid race conditions
      baseCommand = `${baseCommand} --model Cherry-${providerName}/${modelId}`
    }

    const bunInstallPath = path.join(os.homedir(), HOME_CHERRY_DIR)

    // Special handling for kimi-cli: uvx handles installation automatically
    if (cliTool === codeTools.kimiCli) {
      // uvx will automatically download and run kimi-cli, no need to install
      // Just use the base command directly
    } else if (isInstalled) {
      // If already installed, run executable directly (with optional update message)
      if (updateMessage) {
        // updateMessage already has escaped dynamic content, && connectors are intentional
        baseCommand = `echo "Checking ${cliTool} version..."${updateMessage} && ${baseCommand}`
      }
    } else {
      // If not installed, install first then run
      const registryUrl = await this.getNpmRegistryUrl()
      const installEnvPrefix =
        platform === 'win32'
          ? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
          : `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`

      // Windows: Redirect bun output to log file to prevent cmd.exe from
      // misinterpreting multiline output as separate commands
      // macOS/Linux: Keep output visible in terminal (handles multiline correctly)
      let installCommand: string
      if (platform === 'win32') {
        const logsDir = loggerService.getLogsDir()
        // Use forward slashes for cmd.exe compatibility
        const installLogPath = path.join(logsDir, 'cli-tools-install.log').replace(/\\/g, '/')

        // Ensure logs directory exists
        if (!fs.existsSync(logsDir)) {
          fs.mkdirSync(logsDir, { recursive: true })
        }

        installCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName} >> "${installLogPath}" 2>&1`
      } else {
        installCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
      }

      baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && ${baseCommand}`
    }

    switch (platform) {
      case 'darwin': {
        // macOS - Support multiple terminals
        const envPrefix = buildEnvPrefix(false)

        const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand

        // Combine directory change with the main command to ensure they execute in the same shell session
        const fullCommand = `cd "${directory.replace(/"/g, '\\"')}" && clear && ${command}`

        const terminalConfig = await this.getTerminalConfig(options.terminal)
        logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`)

        const { command: cmd, args } = terminalConfig.command(directory, fullCommand)
        terminalCommand = cmd
        terminalArgs = args
        break
      }
      case 'win32': {
        // Windows - Use temp bat file for debugging
        const envPrefix = buildEnvPrefix(true)
        const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand

        // Create temp bat file for debugging and avoid complex command line escaping issues
        const tempDir = path.join(os.tmpdir(), 'cherrystudio')
        const timestamp = Date.now()
        const batFileName = `launch_${cliTool}_${timestamp}.bat`
        const batFilePath = path.join(tempDir, batFileName)

        // Ensure temp directory exists
        if (!fs.existsSync(tempDir)) {
          fs.mkdirSync(tempDir, { recursive: true })
        }

        // Escape special characters in paths for Windows batch scripting
        // Using double quotes for compatibility with CMD

        // Build bat file content, including debug information
        // Use labels and goto to handle errors properly (fixes CMD control-flow issue)
        const batContent = [
          '@echo off',
          'chcp 65001 >nul 2>&1', // Switch to UTF-8 code page for international path support
          `title ${cliTool} - Cherry Studio`,
          'echo ================================================',
          'echo Cherry Studio CLI Tool Launcher',
          `echo Tool: ${CodeToolsService.escapeBatchTextForEcho(cliTool)}`,
          `echo Directory: ${CodeToolsService.escapeBatchTextForEcho(directory)}`,
          `echo Time: ${new Date().toLocaleString()}`,
          'echo ================================================',
          '',
          ':: Verify directory exists',
          `if not exist "${directory.replace(/%/g, '%%')}" goto :dir_missing`,
          '',
          ':: Change to target directory',
          `pushd "${directory.replace(/%/g, '%%')}"`,
          'if errorlevel 1 goto :pushd_failed',
          '',
          ':: Clear screen before running CLI',
          'cls',
          '',
          ':: Execute command',
          command,
          '',
          'goto :end',
          '',
          ':: Error handlers (using labels to ensure entire branch is conditional)',
          ':dir_missing',
          'echo ERROR: Directory does not exist',
          `echo Target: ${CodeToolsService.escapeBatchTextForEcho(directory)}`,
          'pause',
          'exit /b 1',
          '',
          ':pushd_failed',
          'echo ERROR: Failed to change directory',
          'pause',
          'exit /b 1',
          '',
          ':end',
          'pause'
        ].join('\r\n')

        // Write to bat file
        try {
          fs.writeFileSync(batFilePath, batContent, 'utf8')
          // Set restrictive permissions for bat file
          fs.chmodSync(batFilePath, 0o600)
          logger.info(`Created temp bat file: ${batFilePath}`)
        } catch (error) {
          logger.error(`Failed to create bat file: ${error}`)
          throw new Error(`Failed to create launch script: ${error}`)
        }

        // Use selected terminal configuration
        const terminalConfig = await this.getTerminalConfig(options.terminal)
        logger.info(`Using terminal: ${terminalConfig.name} (${terminalConfig.id})`)

        // Get command and args from terminal configuration
        // Pass the bat file path as the command to execute
        const fullCommand = batFilePath
        const { command: cmd, args } = terminalConfig.command(directory, fullCommand)

        // Override if it's a custom terminal with a custom path
        if (terminalConfig.customPath) {
          terminalCommand = terminalConfig.customPath
          terminalArgs = args
        } else {
          terminalCommand = cmd
          terminalArgs = args
        }

        // Add to cleanup set
        CodeToolsService.pendingBatCleanups.add(batFilePath)

        // Register exit handler only once (using process.once to avoid accumulation)
        if (!CodeToolsService.exitCleanupRegistered) {
          process.once('exit', () => {
            // Clean up all remaining bat files on process exit
            for (const filePath of CodeToolsService.pendingBatCleanups) {
              try {
                if (fs.existsSync(filePath)) {
                  fs.unlinkSync(filePath)
                  logger.debug(`Cleaned up temp bat file on exit: ${filePath}`)
                }
              } catch (error) {
                logger.warn(`Failed to cleanup temp bat file: ${error}`)
              }
            }
            CodeToolsService.pendingBatCleanups.clear()
          })
          CodeToolsService.exitCleanupRegistered = true
        }

        // Set timeout for cleanup (normal case - file deleted after 60 seconds)
        const cleanup = () => {
          try {
            if (fs.existsSync(batFilePath)) {
              fs.unlinkSync(batFilePath)
              logger.debug(`Cleaned up temp bat file: ${batFilePath}`)
            }
            // Remove from pending set
            CodeToolsService.pendingBatCleanups.delete(batFilePath)
          } catch (error) {
            logger.warn(`Failed to cleanup temp bat file: ${error}`)
          }
        }

        setTimeout(cleanup, 60 * 1000)

        break
      }
      case 'linux': {
        // Linux - Try to use common terminal emulators
        const envPrefix = buildEnvPrefix(false)
        const command = envPrefix ? `${envPrefix} && ${baseCommand}` : baseCommand

        const linuxTerminals = ['gnome-terminal', 'konsole', 'deepin-terminal', 'xterm', 'x-terminal-emulator']
        let foundTerminal = 'xterm' // Default to xterm

        for (const terminal of linuxTerminals) {
          try {
            // Check if terminal exists
            const checkResult = spawn('which', [terminal], { stdio: 'pipe' })
            await new Promise((resolve) => {
              checkResult.on('close', (code) => {
                if (code === 0) {
                  foundTerminal = terminal
                }
                resolve(code)
              })
            })
            if (foundTerminal === terminal) break
          } catch (error) {
            // Continue trying next terminal
          }
        }

        if (foundTerminal === 'gnome-terminal') {
          terminalCommand = 'gnome-terminal'
          terminalArgs = ['--working-directory', directory, '--', 'bash', '-c', `clear && ${command}; exec bash`]
        } else if (foundTerminal === 'konsole') {
          terminalCommand = 'konsole'
          terminalArgs = ['--workdir', directory, '-e', 'bash', '-c', `clear && ${command}; exec bash`]
        } else if (foundTerminal === 'deepin-terminal') {
          terminalCommand = 'deepin-terminal'
          terminalArgs = ['-w', directory, '-e', 'bash', '-c', `clear && ${command}; exec bash`]
        } else {
          // Default to xterm
          terminalCommand = 'xterm'
          terminalArgs = ['-e', `cd "${directory}" && clear && ${command} && bash`]
        }
        break
      }
      default:
        throw new Error(`Unsupported operating system: ${platform}`)
    }

    const processEnv = { ...process.env, ...env }
    removeEnvProxy(processEnv as Record<string, string>)

    // Launch terminal process
    try {
      logger.info(`Launching terminal with command: ${terminalCommand}`)
      logger.debug(`Terminal arguments:`, terminalArgs)
      logger.debug(`Working directory: ${directory}`)
      logger.debug(`Process environment keys: ${Object.keys(processEnv)}`)

      spawn(terminalCommand, terminalArgs, {
        detached: true,
        stdio: 'ignore',
        cwd: directory,
        env: processEnv,
        shell: isWin
      })

      const successMessage = `Launched ${cliTool} in new terminal window`
      logger.info(successMessage)

      return {
        success: true,
        message: successMessage,
        command: `${terminalCommand} ${terminalArgs.join(' ')}`
      }
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error)
      const failureMessage = `Failed to launch terminal: ${errorMessage}`
      logger.error(failureMessage, error as Error)
      return {
        success: false,
        message: failureMessage,
        command: `${terminalCommand} ${terminalArgs.join(' ')}`
      }
    }
  }

  /**
   * Escape text for safe use in batch echo statements
   * Only handles critical issues: newlines and % characters
   * Preserves command syntax (e.g., &&) - use for constructed command strings
   * @param text - Raw text from command output or user input
   * @returns Escaped text safe for batch echo statements
   */
  private static escapeBatchTextForEcho(text: string): string {
    if (!text) return ''
    return text
      .replace(/%/g, '%%') // Escape % to avoid variable expansion
      .replace(/\r\n/g, ' ') // Windows newline to space
      .replace(/\n/g, ' ') // Unix newline to space
  }
}

/**
 * Escape text for safe use in Windows batch files
 * Handles ALL cmd.exe metacharacters to prevent command injection
 * Use this for arbitrary untrusted input that may contain any characters
 * @param text - Raw text that may contain user input or error messages
 * @returns Fully escaped text safe for batch files
 */
export function escapeBatchText(text: string): string {
  if (!text) return ''
  return text
    .replace(/\^/g, '^^') // Escape caret first (before other escapes)
    .replace(/%/g, '%%') // Escape % to avoid variable expansion
    .replace(/&/g, '^&') // Escape & command separator
    .replace(/\|/g, '^|') // Escape | pipe
    .replace(/>/g, '^>') // Escape > output redirect
    .replace(/</g, '^<') // Escape < input redirect
    .replace(/"/g, '""') // Escape double quotes to prevent echo injection
    .replace(/\r\n/g, ' ') // Windows newline to space
    .replace(/\n/g, ' ') // Unix newline to space
}

export const codeToolsService = new CodeToolsService()
