import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import axios from 'axios'
import { execa } from 'execa'
import capitalize from 'lodash-es/capitalize.js'
import memoize from 'lodash-es/memoize.js'
import { createConnection } from 'net'
import * as os from 'os'
import { basename, join, sep as pathSeparator, resolve } from 'path'
import { logEvent } from 'src/services/analytics/index.js'
import { getIsScrollDraining, getOriginalCwd } from '../bootstrap/state.js'
import { callIdeRpc } from '../services/mcp/client.js'
import type {
  ConnectedMCPServer,
  MCPServerConnection,
} from '../services/mcp/types.js'
import { getGlobalConfig, saveGlobalConfig } from './config.js'
import { env } from './env.js'
import { getSandcodeConfigHomeDir, isEnvTruthy } from './envUtils.js'
import {
  execFileNoThrow,
  execFileNoThrowWithCwd,
  execSyncWithDefaults_DEPRECATED,
} from './execFileNoThrow.js'
import { getFsImplementation } from './fsOperations.js'
import { getAncestorPidsAsync } from './genericProcessUtils.js'
import { isJetBrainsPluginInstalledCached } from './jetbrains.js'
import { logError } from './log.js'
import { getPlatform } from './platform.js'
import { lt } from './semver.js'

// Lazy: IdeOnboardingDialog.tsx pulls React/ink; only needed in interactive onboarding path
/* eslint-disable @typescript-eslint/no-require-imports */
const ideOnboardingDialog =
  (): typeof import('src/components/IdeOnboardingDialog.js') =>
    require('src/components/IdeOnboardingDialog.js')

import { createAbortController } from './abortController.js'
import { logForDebugging } from './debug.js'
import { envDynamic } from './envDynamic.js'
import { errorMessage, isFsInaccessible } from './errors.js'
/* eslint-enable @typescript-eslint/no-require-imports */
import {
  checkWSLDistroMatch,
  WindowsToWSLConverter,
} from './idePathConversion.js'
import { sleep } from './sleep.js'
import { jsonParse } from './slowOperations.js'

function isProcessRunning(pid: number): boolean {
  try {
    process.kill(pid, 0)
    return true
  } catch {
    return false
  }
}

// Returns a function that lazily fetches our process's ancestor PID chain,
// caching within the closure's lifetime. Callers should scope this to a
// single detection pass — PIDs recycle and process trees change over time.
function makeAncestorPidLookup(): () => Promise<Set<number>> {
  let promise: Promise<Set<number>> | null = null
  return () => {
    if (!promise) {
      promise = getAncestorPidsAsync(process.ppid, 10).then(
        pids => new Set(pids),
      )
    }
    return promise
  }
}

type LockfileJsonContent = {
  workspaceFolders?: string[]
  pid?: number
  ideName?: string
  transport?: 'ws' | 'sse'
  runningInWindows?: boolean
  authToken?: string
}

type IdeLockfileInfo = {
  workspaceFolders: string[]
  port: number
  pid?: number
  ideName?: string
  useWebSocket: boolean
  runningInWindows: boolean
  authToken?: string
}

export type DetectedIDEInfo = {
  name: string
  port: number
  workspaceFolders: string[]
  url: string
  isValid: boolean
  authToken?: string
  ideRunningInWindows?: boolean
}

export type IdeType =
  | 'cursor'
  | 'windsurf'
  | 'vscode'
  | 'pycharm'
  | 'intellij'
  | 'webstorm'
  | 'phpstorm'
  | 'rubymine'
  | 'clion'
  | 'goland'
  | 'rider'
  | 'datagrip'
  | 'appcode'
  | 'dataspell'
  | 'aqua'
  | 'gateway'
  | 'fleet'
  | 'androidstudio'

type IdeConfig = {
  ideKind: 'vscode' | 'jetbrains'
  displayName: string
  processKeywordsMac: string[]
  processKeywordsWindows: string[]
  processKeywordsLinux: string[]
}

const supportedIdeConfigs: Record<IdeType, IdeConfig> = {
  cursor: {
    ideKind: 'vscode',
    displayName: 'Cursor',
    processKeywordsMac: ['Cursor Helper', 'Cursor.app'],
    processKeywordsWindows: ['cursor.exe'],
    processKeywordsLinux: ['cursor'],
  },
  windsurf: {
    ideKind: 'vscode',
    displayName: 'Windsurf',
    processKeywordsMac: ['Windsurf Helper', 'Windsurf.app'],
    processKeywordsWindows: ['windsurf.exe'],
    processKeywordsLinux: ['windsurf'],
  },
  vscode: {
    ideKind: 'vscode',
    displayName: 'VS Code',
    processKeywordsMac: ['Visual Studio Code', 'Code Helper'],
    processKeywordsWindows: ['code.exe'],
    processKeywordsLinux: ['code'],
  },
  intellij: {
    ideKind: 'jetbrains',
    displayName: 'IntelliJ IDEA',
    processKeywordsMac: ['IntelliJ IDEA'],
    processKeywordsWindows: ['idea64.exe'],
    processKeywordsLinux: ['idea', 'intellij'],
  },
  pycharm: {
    ideKind: 'jetbrains',
    displayName: 'PyCharm',
    processKeywordsMac: ['PyCharm'],
    processKeywordsWindows: ['pycharm64.exe'],
    processKeywordsLinux: ['pycharm'],
  },
  webstorm: {
    ideKind: 'jetbrains',
    displayName: 'WebStorm',
    processKeywordsMac: ['WebStorm'],
    processKeywordsWindows: ['webstorm64.exe'],
    processKeywordsLinux: ['webstorm'],
  },
  phpstorm: {
    ideKind: 'jetbrains',
    displayName: 'PhpStorm',
    processKeywordsMac: ['PhpStorm'],
    processKeywordsWindows: ['phpstorm64.exe'],
    processKeywordsLinux: ['phpstorm'],
  },
  rubymine: {
    ideKind: 'jetbrains',
    displayName: 'RubyMine',
    processKeywordsMac: ['RubyMine'],
    processKeywordsWindows: ['rubymine64.exe'],
    processKeywordsLinux: ['rubymine'],
  },
  clion: {
    ideKind: 'jetbrains',
    displayName: 'CLion',
    processKeywordsMac: ['CLion'],
    processKeywordsWindows: ['clion64.exe'],
    processKeywordsLinux: ['clion'],
  },
  goland: {
    ideKind: 'jetbrains',
    displayName: 'GoLand',
    processKeywordsMac: ['GoLand'],
    processKeywordsWindows: ['goland64.exe'],
    processKeywordsLinux: ['goland'],
  },
  rider: {
    ideKind: 'jetbrains',
    displayName: 'Rider',
    processKeywordsMac: ['Rider'],
    processKeywordsWindows: ['rider64.exe'],
    processKeywordsLinux: ['rider'],
  },
  datagrip: {
    ideKind: 'jetbrains',
    displayName: 'DataGrip',
    processKeywordsMac: ['DataGrip'],
    processKeywordsWindows: ['datagrip64.exe'],
    processKeywordsLinux: ['datagrip'],
  },
  appcode: {
    ideKind: 'jetbrains',
    displayName: 'AppCode',
    processKeywordsMac: ['AppCode'],
    processKeywordsWindows: ['appcode.exe'],
    processKeywordsLinux: ['appcode'],
  },
  dataspell: {
    ideKind: 'jetbrains',
    displayName: 'DataSpell',
    processKeywordsMac: ['DataSpell'],
    processKeywordsWindows: ['dataspell64.exe'],
    processKeywordsLinux: ['dataspell'],
  },
  aqua: {
    ideKind: 'jetbrains',
    displayName: 'Aqua',
    processKeywordsMac: [], // Do not auto-detect since aqua is too common
    processKeywordsWindows: ['aqua64.exe'],
    processKeywordsLinux: [],
  },
  gateway: {
    ideKind: 'jetbrains',
    displayName: 'Gateway',
    processKeywordsMac: [], // Do not auto-detect since gateway is too common
    processKeywordsWindows: ['gateway64.exe'],
    processKeywordsLinux: [],
  },
  fleet: {
    ideKind: 'jetbrains',
    displayName: 'Fleet',
    processKeywordsMac: [], // Do not auto-detect since fleet is too common
    processKeywordsWindows: ['fleet.exe'],
    processKeywordsLinux: [],
  },
  androidstudio: {
    ideKind: 'jetbrains',
    displayName: 'Android Studio',
    processKeywordsMac: ['Android Studio'],
    processKeywordsWindows: ['studio64.exe'],
    processKeywordsLinux: ['android-studio'],
  },
}

export function isVSCodeIde(ide: IdeType | null): boolean {
  if (!ide) return false
  const config = supportedIdeConfigs[ide]
  return config && config.ideKind === 'vscode'
}

export function isJetBrainsIde(ide: IdeType | null): boolean {
  if (!ide) return false
  const config = supportedIdeConfigs[ide]
  return config && config.ideKind === 'jetbrains'
}

export const isSupportedVSCodeTerminal = memoize(() => {
  return isVSCodeIde(env.terminal as IdeType)
})

export const isSupportedJetBrainsTerminal = memoize(() => {
  return isJetBrainsIde(envDynamic.terminal as IdeType)
})

export const isSupportedTerminal = memoize(() => {
  return (
    isSupportedVSCodeTerminal() ||
    isSupportedJetBrainsTerminal() ||
    Boolean(process.env.FORCE_CODE_TERMINAL)
  )
})

export function getTerminalIdeType(): IdeType | null {
  if (!isSupportedTerminal()) {
    return null
  }
  return env.terminal as IdeType
}

/**
 * Gets sorted IDE lockfiles from ~/.sandcode/ide directory
 * @returns Array of full lockfile paths sorted by modification time (newest first)
 */
export async function getSortedIdeLockfiles(): Promise<string[]> {
  try {
    const ideLockFilePaths = await getIdeLockfilesPaths()

    // Collect all lockfiles from all directories
    const allLockfiles: Array<{ path: string; mtime: Date }>[] =
      await Promise.all(
        ideLockFilePaths.map(async ideLockFilePath => {
          try {
            const entries = await getFsImplementation().readdir(ideLockFilePath)
            const lockEntries = entries.filter(file =>
              file.name.endsWith('.lock'),
            )
            // Stat all lockfiles in parallel; skip ones that fail
            const stats = await Promise.all(
              lockEntries.map(async file => {
                const fullPath = join(ideLockFilePath, file.name)
                try {
                  const fileStat = await getFsImplementation().stat(fullPath)
                  return { path: fullPath, mtime: fileStat.mtime }
                } catch {
                  return null
                }
              }),
            )
            return stats.filter(s => s !== null)
          } catch (error) {
            // Candidate paths are pushed without pre-checking existence, so
            // missing/inaccessible dirs are expected here — skip silently.
            if (!isFsInaccessible(error)) {
              logError(error)
            }
            return []
          }
        }),
      )

    // Flatten and sort all lockfiles by last modified date (newest first)
    return allLockfiles
      .flat()
      .sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
      .map(file => file.path)
  } catch (error) {
    logError(error as Error)
    return []
  }
}

async function readIdeLockfile(path: string): Promise<IdeLockfileInfo | null> {
  try {
    const content = await getFsImplementation().readFile(path, {
      encoding: 'utf-8',
    })

    let workspaceFolders: string[] = []
    let pid: number | undefined
    let ideName: string | undefined
    let useWebSocket = false
    let runningInWindows = false
    let authToken: string | undefined

    try {
      const parsedContent = jsonParse(content) as LockfileJsonContent
      if (parsedContent.workspaceFolders) {
        workspaceFolders = parsedContent.workspaceFolders
      }
      pid = parsedContent.pid
      ideName = parsedContent.ideName
      useWebSocket = parsedContent.transport === 'ws'
      runningInWindows = parsedContent.runningInWindows === true
      authToken = parsedContent.authToken
    } catch (_) {
      // Older format- just a list of paths.
      workspaceFolders = content.split('\n').map(line => line.trim())
    }

    // Extract the port from the filename (e.g., 12345.lock -> 12345)
    const filename = path.split(pathSeparator).pop()
    if (!filename) return null

    const port = filename.replace('.lock', '')

    return {
      workspaceFolders,
      port: parseInt(port),
      pid,
      ideName,
      useWebSocket,
      runningInWindows,
      authToken,
    }
  } catch (error) {
    logError(error as Error)
    return null
  }
}

/**
 * Checks if the IDE connection is responding by testing if the port is open
 * @param host Host to connect to
 * @param port Port to connect to
 * @param timeout Optional timeout in milliseconds (defaults to 500ms)
 * @returns true if the port is open, false otherwise
 */
async function checkIdeConnection(
  host: string,
  port: number,
  timeout = 500,
): Promise<boolean> {
  try {
    return new Promise(resolve => {
      const socket = createConnection({
        host: host,
        port: port,
        timeout: timeout,
      })

      socket.on('connect', () => {
        socket.destroy()
        void resolve(true)
      })

      socket.on('error', () => {
        void resolve(false)
      })

      socket.on('timeout', () => {
        socket.destroy()
        void resolve(false)
      })
    })
  } catch (_) {
    // Invalid URL or other errors
    return false
  }
}

/**
 * Resolve the Windows USERPROFILE path. WSL often doesn't pass USERPROFILE
 * through, so fall back to shelling out to powershell.exe. That spawn is
 * ~500ms–2s cold; the value is static per session.
 */
const getWindowsUserProfile = memoize(async (): Promise<string | undefined> => {
  if (process.env.USERPROFILE) return process.env.USERPROFILE
  const { stdout, code } = await execFileNoThrow('powershell.exe', [
    '-NoProfile',
    '-NonInteractive',
    '-Command',
    '$env:USERPROFILE',
  ])
  if (code === 0 && stdout.trim()) return stdout.trim()
  logForDebugging(
    'Unable to get Windows USERPROFILE via PowerShell - IDE detection may be incomplete',
  )
  return undefined
})

/**
 * Gets the potential IDE lockfiles directories path based on platform.
 * Paths are not pre-checked for existence — the consumer readdirs each
 * and handles ENOENT. Pre-checking with stat() would double syscalls,
 * and on WSL (where /mnt/c access is 2-10x slower) the per-user-dir
 * stat loop compounded startup latency.
 */
export async function getIdeLockfilesPaths(): Promise<string[]> {
  const paths: string[] = [join(getSandcodeConfigHomeDir(), 'ide')]

  if (getPlatform() !== 'wsl') {
    return paths
  }

  // For Windows, use heuristics to find the potential paths.
  // See https://learn.microsoft.com/en-us/windows/wsl/filesystems

  const windowsHome = await getWindowsUserProfile()

  if (windowsHome) {
    const converter = new WindowsToWSLConverter(process.env.WSL_DISTRO_NAME)
    const wslPath = converter.toLocalPath(windowsHome)
    paths.push(resolve(wslPath, '.sandcode', 'ide'))
  }

  // Construct the path based on the standard Windows WSL locations
  // This can fail if the current user does not have "List folder contents" permission on C:\Users
  try {
    const usersDir = '/mnt/c/Users'
    const userDirs = await getFsImplementation().readdir(usersDir)

    for (const user of userDirs) {
      // Skip files (e.g. desktop.ini) — readdir on a file path throws ENOTDIR.
      // isFsInaccessible covers ENOTDIR, but pre-filtering here avoids the
      // cost of attempting to readdir non-directories. Symlinks are kept since
      // Windows creates junction points for user profiles.
      if (!user.isDirectory() && !user.isSymbolicLink()) {
        continue
      }
      if (
        user.name === 'Public' ||
        user.name === 'Default' ||
        user.name === 'Default User' ||
        user.name === 'All Users'
      ) {
        continue // Skip system directories
      }
      paths.push(join(usersDir, user.name, '.sandcode', 'ide'))
    }
  } catch (error: unknown) {
    if (isFsInaccessible(error)) {
      // Expected on WSL when C: drive is not mounted or user lacks permissions
      logForDebugging(
        `WSL IDE lockfile path detection failed (${error.code}): ${errorMessage(error)}`,
      )
    } else {
      logError(error)
    }
  }
  return paths
}

/**
 * Cleans up stale IDE lockfiles
 * - Removes lockfiles for processes that are no longer running
 * - Removes lockfiles for ports that are not responding
 */
export async function cleanupStaleIdeLockfiles(): Promise<void> {
  try {
    const lockfiles = await getSortedIdeLockfiles()

    for (const lockfilePath of lockfiles) {
      const lockfileInfo = await readIdeLockfile(lockfilePath)

      if (!lockfileInfo) {
        // If we can't read the lockfile, delete it
        try {
          await getFsImplementation().unlink(lockfilePath)
        } catch (error) {
          logError(error as Error)
        }
        continue
      }

      const host = await detectHostIP(
        lockfileInfo.runningInWindows,
        lockfileInfo.port,
      )

      let shouldDelete = false

      if (lockfileInfo.pid) {
        // Check if the process is still running
        if (!isProcessRunning(lockfileInfo.pid)) {
          if (getPlatform() !== 'wsl') {
            shouldDelete = true
          } else {
            // The process id may not be reliable in wsl, so also check the connection
            const isResponding = await checkIdeConnection(
              host,
              lockfileInfo.port,
            )
            if (!isResponding) {
              shouldDelete = true
            }
          }
        }
      } else {
        // No PID, check if the URL is responding
        const isResponding = await checkIdeConnection(host, lockfileInfo.port)
        if (!isResponding) {
          shouldDelete = true
        }
      }

      if (shouldDelete) {
        try {
          await getFsImplementation().unlink(lockfilePath)
        } catch (error) {
          logError(error as Error)
        }
      }
    }
  } catch (error) {
    logError(error as Error)
  }
}

export interface IDEExtensionInstallationStatus {
  installed: boolean
  error: string | null
  installedVersion: string | null
  ideType: IdeType | null
}

export async function maybeInstallIDEExtension(
  ideType: IdeType,
): Promise<IDEExtensionInstallationStatus | null> {
  try {
    // Install/update the extension
    const installedVersion = await installIDEExtension(ideType)
    // Only track successful installations
    logEvent('tengu_ext_installed', {})

    // Set diff tool config to auto if it has not been set already
    const globalConfig = getGlobalConfig()
    if (!globalConfig.diffTool) {
      saveGlobalConfig(current => ({ ...current, diffTool: 'auto' }))
    }
    return {
      installed: true,
      error: null,
      installedVersion,
      ideType: ideType,
    }
  } catch (error) {
    logEvent('tengu_ext_install_error', {})
    // Handle installation errors
    const errorMessage = error instanceof Error ? error.message : String(error)
    logError(error as Error)
    return {
      installed: false,
      error: errorMessage,
      installedVersion: null,
      ideType: ideType,
    }
  }
}

let currentIDESearch: AbortController | null = null

export async function findAvailableIDE(): Promise<DetectedIDEInfo | null> {
  if (currentIDESearch) {
    currentIDESearch.abort()
  }
  currentIDESearch = createAbortController()
  const signal = currentIDESearch.signal

  // Clean up stale IDE lockfiles first so we don't check them at all.
  await cleanupStaleIdeLockfiles()
  const startTime = Date.now()
  while (Date.now() - startTime < 30_000 && !signal.aborted) {
    // Skip iteration during scroll drain — detectIDEs reads lockfiles +
    // shells out to ps, competing for the event loop with scroll frames.
    // Next tick after scroll settles resumes the search.
    if (getIsScrollDraining()) {
      await sleep(1000, signal)
      continue
    }
    const ides = await detectIDEs(false)
    if (signal.aborted) {
      return null
    }
    // Return the IDE if and only if there is exactly one match, otherwise the user must
    // use /ide to select an IDE. When running from a supported built-in terminal, detectIDEs()
    // should return at most one IDE.
    if (ides.length === 1) {
      return ides[0]!
    }
    await sleep(1000, signal)
  }
  return null
}

/**
 * Detects IDEs that have a running extension/plugin.
 * @param includeInvalid If true, also return IDEs that are invalid (ie. where
 * the workspace directory does not match the cwd)
 */
export async function detectIDEs(
  includeInvalid: boolean,
): Promise<DetectedIDEInfo[]> {
  const detectedIDEs: DetectedIDEInfo[] = []

  try {
    // Get the SANDCODE_SSE_PORT if set
    const ssePort = process.env.SANDCODE_SSE_PORT
    const envPort = ssePort ? parseInt(ssePort) : null

    // Get the current working directory, normalized to NFC for consistent
    // comparison. macOS returns NFD paths (decomposed Unicode), while IDEs
    // like VS Code report NFC paths (composed Unicode). Without normalization,
    // paths containing accented/CJK characters fail to match.
    const cwd = getOriginalCwd().normalize('NFC')

    // Get sorted lockfiles (full paths) and read them all in parallel.
    // findAvailableIDE() polls this every 1s for up to 30s; serial I/O here was
    // showing up as ~500ms self-time in CPU profiles.
    const lockfiles = await getSortedIdeLockfiles()
    const lockfileInfos = await Promise.all(lockfiles.map(readIdeLockfile))

    // Ancestor PID walk shells out (ps in a loop, up to 10x). Make it lazy and
    // single-shot per detectIDEs() call; with the workspace-check-first ordering
    // below, this often never fires at all.
    const getAncestors = makeAncestorPidLookup()
    const needsAncestryCheck = getPlatform() !== 'wsl' && isSupportedTerminal()

    // Try to find a lockfile that contains our current working directory
    for (const lockfileInfo of lockfileInfos) {
      if (!lockfileInfo) continue

      let isValid = false
      if (isEnvTruthy(process.env.SANDCODE_IDE_SKIP_VALID_CHECK)) {
        isValid = true
      } else if (lockfileInfo.port === envPort) {
        // If the port matches the environment variable, mark as valid regardless of directory
        isValid = true
      } else {
        // Otherwise, check if the current working directory is within the workspace folders
        isValid = lockfileInfo.workspaceFolders.some(idePath => {
          if (!idePath) return false

          let localPath = idePath

          // Handle WSL-specific path conversion and distro matching
          if (
            getPlatform() === 'wsl' &&
            lockfileInfo.runningInWindows &&
            process.env.WSL_DISTRO_NAME
          ) {
            // Check for WSL distro mismatch
            if (!checkWSLDistroMatch(idePath, process.env.WSL_DISTRO_NAME)) {
              return false
            }

            // Try both the original path and the converted path
            // This handles cases where the IDE might report either format
            const resolvedOriginal = resolve(localPath).normalize('NFC')
            if (
              cwd === resolvedOriginal ||
              cwd.startsWith(resolvedOriginal + pathSeparator)
            ) {
              return true
            }

            // Convert Windows IDE path to WSL local path and check that too
            const converter = new WindowsToWSLConverter(
              process.env.WSL_DISTRO_NAME,
            )
            localPath = converter.toLocalPath(idePath)
          }

          const resolvedPath = resolve(localPath).normalize('NFC')

          // On Windows, normalize paths for case-insensitive drive letter comparison
          if (getPlatform() === 'windows') {
            const normalizedCwd = cwd.replace(/^[a-zA-Z]:/, match =>
              match.toUpperCase(),
            )
            const normalizedResolvedPath = resolvedPath.replace(
              /^[a-zA-Z]:/,
              match => match.toUpperCase(),
            )
            return (
              normalizedCwd === normalizedResolvedPath ||
              normalizedCwd.startsWith(normalizedResolvedPath + pathSeparator)
            )
          }

          return (
            cwd === resolvedPath || cwd.startsWith(resolvedPath + pathSeparator)
          )
        })
      }

      if (!isValid && !includeInvalid) {
        continue
      }

      // PID ancestry check: when running in a supported IDE's built-in terminal,
      // ensure this lockfile's IDE is actually our parent process. This
      // disambiguates when multiple IDE windows have overlapping workspace folders.
      // Runs AFTER the workspace check so non-matching lockfiles skip it entirely —
      // previously this shelled out once per lockfile and dominated CPU profiles
      // during findAvailableIDE() polling.
      if (needsAncestryCheck) {
        const portMatchesEnv = envPort !== null && lockfileInfo.port === envPort
        if (!portMatchesEnv) {
          if (!lockfileInfo.pid || !isProcessRunning(lockfileInfo.pid)) {
            continue
          }
          if (process.ppid !== lockfileInfo.pid) {
            const ancestors = await getAncestors()
            if (!ancestors.has(lockfileInfo.pid)) {
              continue
            }
          }
        }
      }

      const ideName =
        lockfileInfo.ideName ??
        (isSupportedTerminal() ? toIDEDisplayName(envDynamic.terminal) : 'IDE')

      const host = await detectHostIP(
        lockfileInfo.runningInWindows,
        lockfileInfo.port,
      )
      let url
      if (lockfileInfo.useWebSocket) {
        url = `ws://${host}:${lockfileInfo.port}`
      } else {
        url = `http://${host}:${lockfileInfo.port}/sse`
      }

      detectedIDEs.push({
        url: url,
        name: ideName,
        workspaceFolders: lockfileInfo.workspaceFolders,
        port: lockfileInfo.port,
        isValid: isValid,
        authToken: lockfileInfo.authToken,
        ideRunningInWindows: lockfileInfo.runningInWindows,
      })
    }

    // The envPort should be defined for supported IDE terminals. If there is
    // an extension with a matching envPort, then we will single that one out
    // and return it, otherwise we return all the valid ones.
    if (!includeInvalid && envPort) {
      const envPortMatch = detectedIDEs.filter(
        ide => ide.isValid && ide.port === envPort,
      )
      if (envPortMatch.length === 1) {
        return envPortMatch
      }
    }
  } catch (error) {
    logError(error as Error)
  }

  return detectedIDEs
}

export async function maybeNotifyIDEConnected(client: Client) {
  await client.notification({
    method: 'ide_connected',
    params: {
      pid: process.pid,
    },
  })
}

export function hasAccessToIDEExtensionDiffFeature(
  mcpClients: MCPServerConnection[],
): boolean {
  // Check if there's a connected IDE client in the provided MCP clients list
  return mcpClients.some(
    client => client.type === 'connected' && client.name === 'ide',
  )
}

const EXTENSION_ID =
  process.env.USER_TYPE === 'ant'
    ? 'anthropic.sandcode-internal'
    : 'anthropic.sandcode'

export async function isIDEExtensionInstalled(
  ideType: IdeType,
): Promise<boolean> {
  if (isVSCodeIde(ideType)) {
    const command = await getVSCodeIDECommand(ideType)
    if (command) {
      try {
        const result = await execFileNoThrowWithCwd(
          command,
          ['--list-extensions'],
          {
            env: getInstallationEnv(),
          },
        )
        if (result.stdout?.includes(EXTENSION_ID)) {
          return true
        }
      } catch {
        // eat the error
      }
    }
  } else if (isJetBrainsIde(ideType)) {
    return await isJetBrainsPluginInstalledCached(ideType)
  }
  return false
}

async function installIDEExtension(ideType: IdeType): Promise<string | null> {
  if (isVSCodeIde(ideType)) {
    const command = await getVSCodeIDECommand(ideType)

    if (command) {
      if (process.env.USER_TYPE === 'ant') {
        return await installFromArtifactory(command)
      }
      let version = await getInstalledVSCodeExtensionVersion(command)
      // If it's not installed or the version is older than the one we have bundled,
      if (!version || lt(version, getClaudeCodeVersion())) {
        // `code` may crash when invoked too quickly in succession
        await sleep(500)
        const result = await execFileNoThrowWithCwd(
          command,
          ['--force', '--install-extension', 'anthropic.sandcode'],
          {
            env: getInstallationEnv(),
          },
        )
        if (result.code !== 0) {
          throw new Error(`${result.code}: ${result.error} ${result.stderr}`)
        }
        version = getClaudeCodeVersion()
      }
      return version
    }
  }
  // No automatic installation for JetBrains IDEs as it is not supported in native
  // builds. We show a prominent notice for them to download from the marketplace
  // instead.
  return null
}

function getInstallationEnv(): NodeJS.ProcessEnv | undefined {
  // Cursor on Linux may incorrectly implement
  // the `code` command and actually launch the UI.
  // Make this error out if this happens by clearing the DISPLAY
  // environment variable.
  if (getPlatform() === 'linux') {
    return {
      ...process.env,
      DISPLAY: '',
    }
  }
  return undefined
}

function getClaudeCodeVersion() {
  return MACRO.VERSION
}

async function getInstalledVSCodeExtensionVersion(
  command: string,
): Promise<string | null> {
  const { stdout } = await execFileNoThrow(
    command,
    ['--list-extensions', '--show-versions'],
    {
      env: getInstallationEnv(),
    },
  )
  const lines = stdout?.split('\n') || []
  for (const line of lines) {
    const [extensionId, version] = line.split('@')
    if (extensionId === 'anthropic.sandcode' && version) {
      return version
    }
  }
  return null
}

function getVSCodeIDECommandByParentProcess(): string | null {
  try {
    const platform = getPlatform()

    // Only supported on OSX, where Cursor has the ability to
    // register itself as the 'code' command.
    if (platform !== 'macos') {
      return null
    }

    let pid = process.ppid

    // Walk up the process tree to find the actual app
    for (let i = 0; i < 10; i++) {
      if (!pid || pid === 0 || pid === 1) break

      // Get the command for this PID
      // this function already returned if not running on macos
      const command = execSyncWithDefaults_DEPRECATED(
        // eslint-disable-next-line custom-rules/no-direct-ps-commands
        `ps -o command= -p ${pid}`,
      )?.trim()

      if (command) {
        // Check for known applications and extract the path up to and including .app
        const appNames = {
          'Visual Studio Code.app': 'code',
          'Cursor.app': 'cursor',
          'Windsurf.app': 'windsurf',
          'Visual Studio Code - Insiders.app': 'code',
          'VSCodium.app': 'codium',
        }
        const pathToExecutable = '/Contents/MacOS/Electron'

        for (const [appName, executableName] of Object.entries(appNames)) {
          const appIndex = command.indexOf(appName + pathToExecutable)
          if (appIndex !== -1) {
            // Extract the path from the beginning to the end of the .app name
            const folderPathEnd = appIndex + appName.length
            // These are all known VSCode variants with the same structure
            return (
              command.substring(0, folderPathEnd) +
              '/Contents/Resources/app/bin/' +
              executableName
            )
          }
        }
      }

      // Get parent PID
      // this function already returned if not running on macos
      const ppidStr = execSyncWithDefaults_DEPRECATED(
        // eslint-disable-next-line custom-rules/no-direct-ps-commands
        `ps -o ppid= -p ${pid}`,
      )?.trim()
      if (!ppidStr) {
        break
      }
      pid = parseInt(ppidStr.trim())
    }

    return null
  } catch {
    return null
  }
}
async function getVSCodeIDECommand(ideType: IdeType): Promise<string | null> {
  const parentExecutable = getVSCodeIDECommandByParentProcess()
  if (parentExecutable) {
    // Verify the parent executable actually exists
    try {
      await getFsImplementation().stat(parentExecutable)
      return parentExecutable
    } catch {
      // Parent executable doesn't exist
    }
  }

  // On Windows, explicitly request the .cmd wrapper. VS Code 1.110.0 began
  // prepending the install root (containing Code.exe, the Electron GUI binary)
  // to the integrated terminal's PATH ahead of bin\ (containing code.cmd, the
  // CLI wrapper) when launched via Start-Menu/Taskbar shortcuts. A bare 'code'
  // then resolves to Code.exe via PATHEXT which opens a new editor window
  // instead of running the CLI. Asking for 'code.cmd' forces cross-spawn/which
  // to skip Code.exe. See microsoft/vscode#299416 (fixed in Insiders) and
  // anthropics/sandcode#30975.
  const ext = getPlatform() === 'windows' ? '.cmd' : ''
  switch (ideType) {
    case 'vscode':
      return 'code' + ext
    case 'cursor':
      return 'cursor' + ext
    case 'windsurf':
      return 'windsurf' + ext
    default:
      break
  }
  return null
}

export async function isCursorInstalled(): Promise<boolean> {
  const result = await execFileNoThrow('cursor', ['--version'])
  return result.code === 0
}

export async function isWindsurfInstalled(): Promise<boolean> {
  const result = await execFileNoThrow('windsurf', ['--version'])
  return result.code === 0
}

export async function isVSCodeInstalled(): Promise<boolean> {
  const result = await execFileNoThrow('code', ['--help'])
  // Check if the output indicates this is actually Visual Studio Code
  return (
    result.code === 0 && Boolean(result.stdout?.includes('Visual Studio Code'))
  )
}

// Cache for IDE detection results
let cachedRunningIDEs: IdeType[] | null = null

/**
 * Internal implementation of IDE detection.
 */
async function detectRunningIDEsImpl(): Promise<IdeType[]> {
  const runningIDEs: IdeType[] = []

  try {
    const platform = getPlatform()
    if (platform === 'macos') {
      // On macOS, use ps with process name matching
      const result = await execa(
        'ps aux | grep -E "Visual Studio Code|Code Helper|Cursor Helper|Windsurf Helper|IntelliJ IDEA|PyCharm|WebStorm|PhpStorm|RubyMine|CLion|GoLand|Rider|DataGrip|AppCode|DataSpell|Aqua|Gateway|Fleet|Android Studio" | grep -v grep',
        { shell: true, reject: false },
      )
      const stdout = result.stdout ?? ''
      for (const [ide, config] of Object.entries(supportedIdeConfigs)) {
        for (const keyword of config.processKeywordsMac) {
          if (stdout.includes(keyword)) {
            runningIDEs.push(ide as IdeType)
            break
          }
        }
      }
    } else if (platform === 'windows') {
      // On Windows, use tasklist with findstr for multiple patterns
      const result = await execa(
        'tasklist | findstr /I "Code.exe Cursor.exe Windsurf.exe idea64.exe pycharm64.exe webstorm64.exe phpstorm64.exe rubymine64.exe clion64.exe goland64.exe rider64.exe datagrip64.exe appcode.exe dataspell64.exe aqua64.exe gateway64.exe fleet.exe studio64.exe"',
        { shell: true, reject: false },
      )
      const stdout = result.stdout ?? ''

      const normalizedStdout = stdout.toLowerCase()

      for (const [ide, config] of Object.entries(supportedIdeConfigs)) {
        for (const keyword of config.processKeywordsWindows) {
          if (normalizedStdout.includes(keyword.toLowerCase())) {
            runningIDEs.push(ide as IdeType)
            break
          }
        }
      }
    } else if (platform === 'linux') {
      // On Linux, use ps with process name matching
      const result = await execa(
        'ps aux | grep -E "code|cursor|windsurf|idea|pycharm|webstorm|phpstorm|rubymine|clion|goland|rider|datagrip|dataspell|aqua|gateway|fleet|android-studio" | grep -v grep',
        { shell: true, reject: false },
      )
      const stdout = result.stdout ?? ''

      const normalizedStdout = stdout.toLowerCase()

      for (const [ide, config] of Object.entries(supportedIdeConfigs)) {
        for (const keyword of config.processKeywordsLinux) {
          if (normalizedStdout.includes(keyword)) {
            if (ide !== 'vscode') {
              runningIDEs.push(ide as IdeType)
              break
            } else if (
              !normalizedStdout.includes('cursor') &&
              !normalizedStdout.includes('appcode')
            ) {
              // Special case conflicting keywords from some of the IDEs.
              runningIDEs.push(ide as IdeType)
              break
            }
          }
        }
      }
    }
  } catch (error) {
    // If process detection fails, return empty array
    logError(error as Error)
  }

  return runningIDEs
}

/**
 * Detects running IDEs and returns an array of IdeType for those that are running.
 * This performs fresh detection (~150ms) and updates the cache for subsequent
 * detectRunningIDEsCached() calls.
 */
export async function detectRunningIDEs(): Promise<IdeType[]> {
  const result = await detectRunningIDEsImpl()
  cachedRunningIDEs = result
  return result
}

/**
 * Returns cached IDE detection results, or performs detection if cache is empty.
 * Use this for performance-sensitive paths like tips where fresh results aren't needed.
 */
export async function detectRunningIDEsCached(): Promise<IdeType[]> {
  if (cachedRunningIDEs === null) {
    return detectRunningIDEs()
  }
  return cachedRunningIDEs
}

/**
 * Resets the cache for detectRunningIDEsCached.
 * Exported for testing - allows resetting state between tests.
 */
export function resetDetectRunningIDEs(): void {
  cachedRunningIDEs = null
}

export function getConnectedIdeName(
  mcpClients: MCPServerConnection[],
): string | null {
  const ideClient = mcpClients.find(
    client => client.type === 'connected' && client.name === 'ide',
  )
  return getIdeClientName(ideClient)
}

export function getIdeClientName(
  ideClient?: MCPServerConnection,
): string | null {
  const config = ideClient?.config
  return config?.type === 'sse-ide' || config?.type === 'ws-ide'
    ? config.ideName
    : isSupportedTerminal()
      ? toIDEDisplayName(envDynamic.terminal)
      : null
}

const EDITOR_DISPLAY_NAMES: Record<string, string> = {
  code: 'VS Code',
  cursor: 'Cursor',
  windsurf: 'Windsurf',
  antigravity: 'Antigravity',
  vi: 'Vim',
  vim: 'Vim',
  nano: 'nano',
  notepad: 'Notepad',
  'start /wait notepad': 'Notepad',
  emacs: 'Emacs',
  subl: 'Sublime Text',
  atom: 'Atom',
}

export function toIDEDisplayName(terminal: string | null): string {
  if (!terminal) return 'IDE'

  const config = supportedIdeConfigs[terminal as IdeType]
  if (config) {
    return config.displayName
  }

  // Check editor command names (exact match first)
  const editorName = EDITOR_DISPLAY_NAMES[terminal.toLowerCase().trim()]
  if (editorName) {
    return editorName
  }

  // Extract command name from path/arguments (e.g., "/usr/bin/code --wait" -> "code")
  const command = terminal.split(' ')[0]
  const commandName = command ? basename(command).toLowerCase() : null
  if (commandName) {
    const mappedName = EDITOR_DISPLAY_NAMES[commandName]
    if (mappedName) {
      return mappedName
    }
    // Fallback: capitalize the command basename
    return capitalize(commandName)
  }

  // Fallback: capitalize first letter
  return capitalize(terminal)
}

export { callIdeRpc }

/**
 * Gets the connected IDE client from a list of MCP clients
 * @param mcpClients - Array of wrapped MCP clients
 * @returns The connected IDE client, or undefined if not found
 */
export function getConnectedIdeClient(
  mcpClients?: MCPServerConnection[],
): ConnectedMCPServer | undefined {
  if (!mcpClients) {
    return undefined
  }

  const ideClient = mcpClients.find(
    client => client.type === 'connected' && client.name === 'ide',
  )

  // Type guard to ensure we return the correct type
  return ideClient?.type === 'connected' ? ideClient : undefined
}

/**
 * Notifies the IDE that a new prompt has been submitted.
 * This triggers IDE-specific actions like closing all diff tabs.
 */
export async function closeOpenDiffs(
  ideClient: ConnectedMCPServer,
): Promise<void> {
  try {
    await callIdeRpc('closeAllDiffTabs', {}, ideClient)
  } catch (_) {
    // Silently ignore errors when closing diff tabs
    // This prevents exceptions if the IDE doesn't support this operation
  }
}

/**
 * Initializes IDE detection and extension installation, then calls the provided callback
 * with the detected IDE information and installation status.
 * @param ideToInstallExtension The ide to install the extension to (if installing from external terminal)
 * @param onIdeDetected Callback to be called when an IDE is detected (including null)
 * @param onInstallationComplete Callback to be called when extension installation is complete
 */
export async function initializeIdeIntegration(
  onIdeDetected: (ide: DetectedIDEInfo | null) => void,
  ideToInstallExtension: IdeType | null,
  onShowIdeOnboarding: () => void,
  onInstallationComplete: (
    status: IDEExtensionInstallationStatus | null,
  ) => void,
): Promise<void> {
  // Don't await so we don't block startup, but return a promise that resolves with the status
  void findAvailableIDE().then(onIdeDetected)

  const shouldAutoInstall = getGlobalConfig().autoInstallIdeExtension ?? true
  if (
    !isEnvTruthy(process.env.SANDCODE_IDE_SKIP_AUTO_INSTALL) &&
    shouldAutoInstall
  ) {
    const ideType = ideToInstallExtension ?? getTerminalIdeType()
    if (ideType) {
      if (isVSCodeIde(ideType)) {
        void isIDEExtensionInstalled(ideType).then(async isAlreadyInstalled => {
          void maybeInstallIDEExtension(ideType)
            .catch(error => {
              const ideInstallationStatus: IDEExtensionInstallationStatus = {
                installed: false,
                error: error.message || 'Installation failed',
                installedVersion: null,
                ideType: ideType,
              }
              return ideInstallationStatus
            })
            .then(status => {
              onInstallationComplete(status)

              if (status?.installed) {
                // If we installed and don't yet have an IDE, search again.
                void findAvailableIDE().then(onIdeDetected)
              }

              if (
                !isAlreadyInstalled &&
                status?.installed === true &&
                !ideOnboardingDialog().hasIdeOnboardingDialogBeenShown()
              ) {
                onShowIdeOnboarding()
              }
            })
        })
      } else if (isJetBrainsIde(ideType)) {
        // Always check installation to populate the sync cache used by status notices
        void isIDEExtensionInstalled(ideType).then(async installed => {
          if (
            installed &&
            !ideOnboardingDialog().hasIdeOnboardingDialogBeenShown()
          ) {
            onShowIdeOnboarding()
          }
        })
      }
    }
  }
}

/**
 * Detects the host IP to use to connect to the extension.
 */
const detectHostIP = memoize(
  async (isIdeRunningInWindows: boolean, port: number) => {
    if (process.env.SANDCODE_IDE_HOST_OVERRIDE) {
      return process.env.SANDCODE_IDE_HOST_OVERRIDE
    }

    if (getPlatform() !== 'wsl' || !isIdeRunningInWindows) {
      return '127.0.0.1'
    }

    // If we are running under the WSL2 VM but the extension/plugin is running in
    // Windows, then we must use a different IP address to connect to the extension.
    // https://learn.microsoft.com/en-us/windows/wsl/networking
    try {
      const routeResult = await execa('ip route show | grep -i default', {
        shell: true,
        reject: false,
      })
      if (routeResult.exitCode === 0 && routeResult.stdout) {
        const gatewayMatch = routeResult.stdout.match(
          /default via (\d+\.\d+\.\d+\.\d+)/,
        )
        if (gatewayMatch) {
          const gatewayIP = gatewayMatch[1]!
          if (await checkIdeConnection(gatewayIP, port)) {
            return gatewayIP
          }
        }
      }
    } catch (_) {
      // Suppress any errors
    }

    // Fallback to the default if we cannot find anything
    return '127.0.0.1'
  },
  (isIdeRunningInWindows, port) => `${isIdeRunningInWindows}:${port}`,
)

async function installFromArtifactory(command: string): Promise<string> {
  // Read auth token from ~/.npmrc
  const npmrcPath = join(os.homedir(), '.npmrc')
  let authToken: string | null = null
  const fs = getFsImplementation()

  try {
    const npmrcContent = await fs.readFile(npmrcPath, {
      encoding: 'utf8',
    })
    const lines = npmrcContent.split('\n')
    for (const line of lines) {
      // Look for the artifactory auth token line
      const match = line.match(
        /\/\/artifactory\.infra\.ant\.dev\/artifactory\/api\/npm\/npm-all\/:_authToken=(.+)/,
      )
      if (match && match[1]) {
        authToken = match[1].trim()
        break
      }
    }
  } catch (error) {
    logError(error as Error)
    throw new Error(`Failed to read npm authentication: ${error}`)
  }

  if (!authToken) {
    throw new Error('No artifactory auth token found in ~/.npmrc')
  }

  // Fetch the version from artifactory
  const versionUrl =
    'https://artifactory.infra.ant.dev/artifactory/armorcode-sandcode-internal/claude-vscode-releases/stable'

  try {
    const versionResponse = await axios.get(versionUrl, {
      headers: {
        Authorization: `Bearer ${authToken}`,
      },
    })

    const version = versionResponse.data.trim()
    if (!version) {
      throw new Error('No version found in artifactory response')
    }

    // Download the .vsix file from artifactory
    const vsixUrl = `https://artifactory.infra.ant.dev/artifactory/armorcode-sandcode-internal/claude-vscode-releases/${version}/sandcode.vsix`
    const tempVsixPath = join(
      os.tmpdir(),
      `sandcode-${version}-${Date.now()}.vsix`,
    )

    try {
      const vsixResponse = await axios.get(vsixUrl, {
        headers: {
          Authorization: `Bearer ${authToken}`,
        },
        responseType: 'stream',
      })

      // Write the downloaded file to disk
      const writeStream = getFsImplementation().createWriteStream(tempVsixPath)
      await new Promise<void>((resolve, reject) => {
        vsixResponse.data.pipe(writeStream)
        writeStream.on('finish', resolve)
        writeStream.on('error', reject)
      })

      // Install the .vsix file
      // Add delay to prevent code command crashes
      await sleep(500)

      const result = await execFileNoThrowWithCwd(
        command,
        ['--force', '--install-extension', tempVsixPath],
        {
          env: getInstallationEnv(),
        },
      )

      if (result.code !== 0) {
        throw new Error(`${result.code}: ${result.error} ${result.stderr}`)
      }

      return version
    } finally {
      // Clean up the temporary file
      try {
        await fs.unlink(tempVsixPath)
      } catch {
        // Ignore cleanup errors
      }
    }
  } catch (error) {
    if (axios.isAxiosError(error)) {
      throw new Error(
        `Failed to fetch extension version from artifactory: ${error.message}`,
      )
    }
    throw error
  }
}
