import { statSync } from 'fs'
import { spawn } from 'cross-spawn'
import ignore from 'ignore'
import * as path from 'path'
import {
  CLAUDE_CONFIG_DIRECTORIES,
  loadMarkdownFilesForSubdir,
} from 'src/utils/markdownConfigLoader.js'
import type { SuggestionItem } from '../components/PromptInput/PromptInputFooterSuggestions.js'
import {
  CHUNK_MS,
  FileIndex,
  yieldToEventLoop,
} from '../native-ts/file-index/index.js'
import { logEvent } from '../services/analytics/index.js'
import type { FileSuggestionCommandInput } from '../types/fileSuggestion.js'
import { getGlobalConfig } from '../utils/config.js'
import { createCombinedAbortSignal } from '../utils/combinedAbortSignal.js'
import { getCwd } from '../utils/cwd.js'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { getFsImplementation } from '../utils/fsOperations.js'
import { findGitRoot, gitExe } from '../utils/git.js'
import {
  createBaseHookInput,
  executeFileSuggestionCommand,
} from '../utils/hooks.js'
import { logError } from '../utils/log.js'
import { expandPath } from '../utils/path.js'
import { ripGrepStream } from '../utils/ripgrep.js'
import { getInitialSettings } from '../utils/settings/settings.js'
import { createSignal } from '../utils/signal.js'

// Lazily constructed singleton
let fileIndex: FileIndex | null = null

function getFileIndex(): FileIndex {
  if (!fileIndex) {
    fileIndex = new FileIndex()
  }
  return fileIndex
}

let fileListRefreshPromise: Promise<FileIndex> | null = null
// Signal fired when an in-progress index build completes. Lets the
// typeahead UI re-run its last search so partial results upgrade to full.
const indexBuildComplete = createSignal()
export const onIndexBuildComplete = indexBuildComplete.subscribe
let cacheGeneration = 0

// Background fetch for untracked files
let untrackedFetchPromise: Promise<void> | null = null

// Store tracked files so we can rebuild index with untracked
let cachedTrackedFiles: string[] = []
// Store config files so mergeUntrackedIntoNormalizedCache preserves them
let cachedConfigFiles: string[] = []
// Store tracked directories so mergeUntrackedIntoNormalizedCache doesn't
// recompute ~270k path.dirname() calls on each merge
let cachedTrackedDirs: string[] = []

// Cache for .ignore/.rgignore matchers (keyed by repoRoot:cwd)
let ignorePatternsCache: FileSuggestionIgnoreMatcher | null = null
let ignorePatternsCacheKey: string | null = null

// Throttle state for background refresh. .git/index mtime triggers an
// immediate refresh when tracked files change (add/checkout/commit/rm).
// The time floor still refreshes every 5s to pick up untracked files,
// which don't bump the index.
let lastRefreshMs = 0
let lastGitIndexMtime: number | null = null

// Signatures of the path lists loaded into the Rust index. Two separate
// signatures because the two loadFromFileList call sites use differently
// structured arrays — a shared signature would ping-pong and never match.
// Skips nucleo.restart() when git ls-files returns an unchanged list
// (e.g. `git add` of an already-tracked file bumps index mtime but not the list).
let loadedTrackedSignature: string | null = null
let loadedMergedSignature: string | null = null

type FileSuggestionIgnoreMatcher = {
  ignores(filePath: string): boolean
}

type FileSuggestionIgnoreSource = {
  root: string
  patterns: string
}

const MAX_FILE_SUGGESTION_FILES = 200_000
const FILE_SUGGESTION_EXCLUDED_DIRS = new Set([
  '.git',
  '.hg',
  '.svn',
  'node_modules',
  '.pnpm-store',
  '.yarn',
  '.next',
  '.nuxt',
  '.svelte-kit',
  '.turbo',
  'dist',
  'build',
  'out',
  'output',
  'coverage',
  '.cache',
  '__pycache__',
  '.pytest_cache',
  '.mypy_cache',
  '.ruff_cache',
  '.tox',
  'venv',
  '.venv',
  '__pypackages__',
  'site-packages',
  'wandb',
  'lightning_logs',
  'mlruns',
])

export function normalizeFileSuggestionPath(filePath: string): string {
  if (filePath.startsWith('./') || filePath.startsWith('.\\')) {
    return filePath.slice(2)
  }
  return filePath
}

export function shouldExcludeFileSuggestionPath(filePath: string): boolean {
  const normalizedPath = normalizeFileSuggestionPath(filePath)
  const isDirectory = /[\\/]$/.test(normalizedPath)
  const normalized = normalizedPath
    .replaceAll('\\', '/')
    .replace(/\/$/, '')
    .toLowerCase()
  const parts = normalized.split('/').filter(Boolean)
  const lastDirIndex = isDirectory ? parts.length : parts.length - 1
  for (let i = 0; i < lastDirIndex; i++) {
    if (FILE_SUGGESTION_EXCLUDED_DIRS.has(parts[i]!)) {
      return true
    }
  }
  return false
}

export function filterCandidatePathsForSuggestions(
  filePaths: string[],
  maxFiles = MAX_FILE_SUGGESTION_FILES,
  ignorePatterns?: FileSuggestionIgnoreMatcher | null,
): { files: string[]; truncated: boolean } {
  const files: string[] = []
  for (const rawPath of filePaths) {
    if (
      tryAddFileSuggestionPath(rawPath, files, maxFiles, ignorePatterns)
    ) {
      return { files, truncated: true }
    }
  }
  return { files, truncated: false }
}

function tryAddFileSuggestionPath(
  rawPath: string,
  out: string[],
  maxFiles: number,
  ignorePatterns?: FileSuggestionIgnoreMatcher | null,
): boolean {
  if (!rawPath) return false
  const normalizedPath = normalizeFileSuggestionPath(rawPath)
  if (!normalizedPath) return false
  if (shouldIgnoreFileSuggestionPath(normalizedPath, ignorePatterns)) {
    return false
  }
  if (shouldExcludeFileSuggestionPath(normalizedPath)) return false
  out.push(normalizedPath)
  return out.length >= maxFiles
}

function shouldIgnoreFileSuggestionPath(
  filePath: string,
  ignorePatterns?: FileSuggestionIgnoreMatcher | null,
): boolean {
  if (!ignorePatterns) return false
  try {
    return ignorePatterns.ignores(filePath)
  } catch {
    return false
  }
}

function isPathWithinIgnoreRoot(relativePath: string): boolean {
  return (
    relativePath !== '..' &&
    !relativePath.startsWith(`..${path.sep}`) &&
    !path.isAbsolute(relativePath)
  )
}

function normalizePathForIgnoreMatcher(filePath: string): string {
  return normalizeFileSuggestionPath(filePath).replaceAll('\\', '/')
}

function createFileSuggestionIgnoreMatcherFromSources(
  cwd: string,
  sources: FileSuggestionIgnoreSource[],
): FileSuggestionIgnoreMatcher | null {
  const rootedMatchers = sources.flatMap(source => {
    if (!source.patterns) return []
    const matcher = ignore()
    matcher.add(source.patterns)
    return [{ root: source.root, matcher }]
  })

  if (rootedMatchers.length === 0) {
    return null
  }

  return {
    ignores(filePath: string): boolean {
      const normalizedPath = normalizePathForIgnoreMatcher(filePath)
      if (!normalizedPath) return false

      const absolutePath = path.resolve(cwd, normalizedPath)
      for (const { root, matcher } of rootedMatchers) {
        const relativePath = path.relative(root, absolutePath)
        if (!isPathWithinIgnoreRoot(relativePath)) continue
        if (matcher.ignores(normalizePathForIgnoreMatcher(relativePath))) {
          return true
        }
      }

      return false
    },
  }
}

export function createFileSuggestionIgnoreMatcher(
  cwd: string,
  sources: FileSuggestionIgnoreSource[],
): FileSuggestionIgnoreMatcher | null {
  return createFileSuggestionIgnoreMatcherFromSources(cwd, sources)
}

function normalizeGitPath(
  file: string,
  repoRoot: string,
  originalCwd: string,
): string {
  if (originalCwd === repoRoot) {
    return file
  }
  return path.relative(originalCwd, path.join(repoRoot, file))
}

async function collectGitPaths(
  gitArgs: string[],
  params: {
    repoRoot: string
    cwd: string
    abortSignal: AbortSignal
    maxFiles: number
    ignorePatterns?: FileSuggestionIgnoreMatcher | null
  },
): Promise<{ files: string[]; truncated: boolean; code: number; stderr: string }> {
  const { repoRoot, cwd, abortSignal, maxFiles, ignorePatterns } = params
  const controller = new AbortController()
  let abortedExternally = abortSignal.aborted
  const forwardAbort = () => {
    abortedExternally = true
    controller.abort()
  }
  if (abortSignal.aborted) {
    controller.abort()
  } else {
    abortSignal.addEventListener('abort', forwardAbort, { once: true })
  }

  try {
    return await new Promise(resolve => {
      const child = spawn(gitExe(), gitArgs, {
        cwd: repoRoot,
        shell: false,
        signal: controller.signal,
        stdio: ['ignore', 'pipe', 'pipe'],
        windowsHide: true,
      })

      const files: string[] = []
      let remainder = ''
      let stderr = ''
      let truncated = false
      let settled = false

      const finish = (result: {
        files: string[]
        truncated: boolean
        code: number
        stderr: string
      }) => {
        if (settled) return
        settled = true
        resolve(result)
      }

      const processLine = (line: string) => {
        const normalizedPath = normalizeGitPath(line, repoRoot, cwd)
        if (
          tryAddFileSuggestionPath(
            normalizedPath,
            files,
            maxFiles,
            ignorePatterns,
          )
        ) {
          truncated = true
          controller.abort()
        }
      }

      child.stdout?.on('data', (chunk: Buffer) => {
        const data = remainder + chunk.toString('utf8')
        const lines = data.split('\n')
        remainder = lines.pop() ?? ''
        for (const rawLine of lines) {
          const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
          if (!line) continue
          processLine(line)
          if (truncated) break
        }
      })

      child.stderr?.on('data', (chunk: Buffer) => {
        if (stderr.length >= 8192) return
        stderr += chunk.toString('utf8').slice(0, 8192 - stderr.length)
      })

      child.on('error', error => {
        if (truncated) {
          finish({ files, truncated: true, code: 0, stderr })
          return
        }
        if (abortedExternally) {
          const message = error instanceof Error ? error.message : String(error)
          finish({
            files,
            truncated: false,
            code: 1,
            stderr: stderr || message || 'aborted',
          })
          return
        }
        const message = error instanceof Error ? error.message : String(error)
        finish({ files, truncated, code: 1, stderr: stderr || message })
      })

      child.on('close', code => {
        if (!truncated && !abortedExternally && remainder) {
          const line = remainder.endsWith('\r')
            ? remainder.slice(0, -1)
            : remainder
          if (line) {
            processLine(line)
          }
        }

        if (truncated) {
          finish({ files, truncated: true, code: 0, stderr })
          return
        }

        if (abortedExternally) {
          finish({ files, truncated: false, code: 1, stderr: stderr || 'aborted' })
          return
        }

        finish({ files, truncated: false, code: code ?? 1, stderr })
      })
    })
  } finally {
    abortSignal.removeEventListener('abort', forwardAbort)
  }
}

async function collectRipgrepPaths(
  args: string[],
  target: string,
  abortSignal: AbortSignal,
  maxFiles: number,
): Promise<{ files: string[]; truncated: boolean }> {
  const controller = new AbortController()
  let abortedExternally = abortSignal.aborted
  const forwardAbort = () => {
    abortedExternally = true
    controller.abort()
  }
  if (abortSignal.aborted) {
    controller.abort()
  } else {
    abortSignal.addEventListener('abort', forwardAbort, { once: true })
  }

  const files: string[] = []
  let truncated = false

  try {
    await ripGrepStream(args, target, controller.signal, lines => {
      for (const line of lines) {
        if (tryAddFileSuggestionPath(line, files, maxFiles)) {
          truncated = true
          controller.abort()
          break
        }
      }
    })
  } catch (error) {
    if (!(truncated && !abortedExternally)) {
      throw error
    }
  } finally {
    abortSignal.removeEventListener('abort', forwardAbort)
  }

  return { files, truncated }
}

/**
 * Clear all file suggestion caches.
 * Call this when resuming a session to ensure fresh file discovery.
 */
export function clearFileSuggestionCaches(): void {
  fileIndex = null
  fileListRefreshPromise = null
  cacheGeneration++
  untrackedFetchPromise = null
  cachedTrackedFiles = []
  cachedConfigFiles = []
  cachedTrackedDirs = []
  indexBuildComplete.clear()
  ignorePatternsCache = null
  ignorePatternsCacheKey = null
  lastRefreshMs = 0
  lastGitIndexMtime = null
  loadedTrackedSignature = null
  loadedMergedSignature = null
}

/**
 * Content hash of a path list. A length|first|last sample misses renames of
 * middle files (same length, same endpoints → stale entry stuck in nucleo).
 *
 * Samples every Nth path (plus length). On a 346k-path list this hashes ~700
 * paths instead of 14MB — enough to catch git operations (checkout, rebase,
 * add/rm) while running in <1ms. A single mid-list rename that happens to
 * fall between samples will miss the rebuild, but the 5s refresh floor picks
 * it up on the next cycle.
 */
export function pathListSignature(paths: string[]): string {
  const n = paths.length
  const stride = Math.max(1, Math.floor(n / 500))
  let h = 0x811c9dc5 | 0
  for (let i = 0; i < n; i += stride) {
    const p = paths[i]!
    for (let j = 0; j < p.length; j++) {
      h = ((h ^ p.charCodeAt(j)) * 0x01000193) | 0
    }
    h = (h * 0x01000193) | 0
  }
  // Stride starts at 0 (first path always hashed); explicitly include last
  // so single-file add/rm at the tail is caught
  if (n > 0) {
    const last = paths[n - 1]!
    for (let j = 0; j < last.length; j++) {
      h = ((h ^ last.charCodeAt(j)) * 0x01000193) | 0
    }
  }
  return `${n}:${(h >>> 0).toString(16)}`
}

/**
 * Stat .git/index to detect git state changes without spawning git ls-files.
 * Returns null for worktrees (.git is a file → ENOTDIR), fresh repos with no
 * index yet (ENOENT), and non-git dirs — caller falls back to time throttle.
 */
function getGitIndexMtime(): number | null {
  const repoRoot = findGitRoot(getCwd())
  if (!repoRoot) return null
  try {
    // eslint-disable-next-line custom-rules/no-sync-fs -- mtimeMs is the operation here, not a pre-check. findGitRoot above already stat-walks synchronously; one more stat is marginal vs spawning git ls-files on every keystroke. Async would force startBackgroundCacheRefresh to become async, breaking the synchronous fileListRefreshPromise contract at the cold-start await site.
    return statSync(path.join(repoRoot, '.git', 'index')).mtimeMs
  } catch {
    return null
  }
}

/**
 * Normalize git paths relative to originalCwd
 */
/**
 * Merge already-normalized untracked files into the cache
 */
async function mergeUntrackedIntoNormalizedCache(
  normalizedUntracked: string[],
): Promise<void> {
  if (normalizedUntracked.length === 0) return
  if (!fileIndex || cachedTrackedFiles.length === 0) return

  const untrackedDirs = await getDirectoryNamesAsync(normalizedUntracked)
  const allPaths = [
    ...cachedTrackedFiles,
    ...cachedConfigFiles,
    ...cachedTrackedDirs,
    ...normalizedUntracked,
    ...untrackedDirs,
  ]
  const sig = pathListSignature(allPaths)
  if (sig === loadedMergedSignature) {
    logForDebugging(
      `[FileIndex] skipped index rebuild — merged paths unchanged`,
    )
    return
  }
  await fileIndex.loadFromFileListAsync(allPaths).done
  loadedMergedSignature = sig
  logForDebugging(
    `[FileIndex] rebuilt index with ${cachedTrackedFiles.length} tracked + ${normalizedUntracked.length} untracked files`,
  )
}

/**
 * Load ripgrep-specific ignore patterns from .ignore or .rgignore files
 * Returns an ignore instance if patterns were found, null otherwise
 * Results are cached per repoRoot:cwd combination
 */
async function loadRipgrepIgnorePatterns(
  repoRoot: string,
  cwd: string,
): Promise<FileSuggestionIgnoreMatcher | null> {
  const cacheKey = `${repoRoot}:${cwd}`

  // Return cached result if available
  if (ignorePatternsCacheKey === cacheKey) {
    return ignorePatternsCache
  }

  const fs = getFsImplementation()
  const ignoreFiles = ['.ignore', '.rgignore']
  const directories = [...new Set([repoRoot, cwd])]

  const sources: FileSuggestionIgnoreSource[] = []

  const paths = directories.flatMap(dir =>
    ignoreFiles.map(f => path.join(dir, f)),
  )
  const contents = await Promise.all(
    paths.map(p => fs.readFile(p, { encoding: 'utf8' }).catch(() => null)),
  )
  for (const [i, content] of contents.entries()) {
    if (content === null) continue
    logForDebugging(`[FileIndex] loaded ignore patterns from ${paths[i]}`)
    sources.push({
      root: path.dirname(paths[i]!),
      patterns: content,
    })
  }

  const result = createFileSuggestionIgnoreMatcherFromSources(cwd, sources)
  ignorePatternsCache = result
  ignorePatternsCacheKey = cacheKey

  return result
}

export async function collectGitPathsForTesting(
  gitArgs: string[],
  params: {
    repoRoot: string
    cwd: string
    abortSignal: AbortSignal
    maxFiles: number
    ignorePatterns?: FileSuggestionIgnoreMatcher | null
  },
): Promise<{ files: string[]; truncated: boolean; code: number; stderr: string }> {
  return collectGitPaths(gitArgs, params)
}

export async function collectRipgrepPathsForTesting(
  args: string[],
  target: string,
  abortSignal: AbortSignal,
  maxFiles: number,
): Promise<{ files: string[]; truncated: boolean }> {
  return collectRipgrepPaths(args, target, abortSignal, maxFiles)
}

/**
 * Get files using git ls-files (much faster than ripgrep for git repos)
 * Returns tracked files immediately, fetches untracked in background
 * @param respectGitignore If true, excludes gitignored files from untracked results
 *
 * Note: Unlike ripgrep --follow, git ls-files doesn't follow symlinks.
 * This is intentional as git tracks symlinks as symlinks.
 */
async function getFilesUsingGit(
  abortSignal: AbortSignal,
  respectGitignore: boolean,
): Promise<string[] | null> {
  const startTime = Date.now()
  logForDebugging(`[FileIndex] getFilesUsingGit called`)

  // Check if we're in a git repo. findGitRoot is LRU-memoized per path.
  const repoRoot = findGitRoot(getCwd())
  if (!repoRoot) {
    logForDebugging(`[FileIndex] not a git repo, returning null`)
    return null
  }

  try {
    const cwd = getCwd()
    const ignorePatterns = await loadRipgrepIgnorePatterns(repoRoot, cwd)

    // Get tracked files (fast - reads from git index)
    // Run from repoRoot so paths are relative to repo root, not CWD
    const lsFilesStart = Date.now()
    const trackedResult = await collectGitPaths(
      ['-c', 'core.quotepath=false', 'ls-files', '--recurse-submodules'],
      {
        repoRoot,
        cwd,
        abortSignal,
        maxFiles: MAX_FILE_SUGGESTION_FILES,
        ignorePatterns,
      },
    )
    logForDebugging(
      `[FileIndex] git ls-files (tracked) took ${Date.now() - lsFilesStart}ms`,
    )

    if (trackedResult.code !== 0) {
      logForDebugging(
        `[FileIndex] git ls-files failed (code=${trackedResult.code}, stderr=${trackedResult.stderr}), falling back to ripgrep`,
      )
      return null
    }

    const normalizedTracked = trackedResult.files

    // Cache tracked files for later merge with untracked
    cachedTrackedFiles = normalizedTracked

    const duration = Date.now() - startTime
    logForDebugging(
      `[FileIndex] git ls-files: ${normalizedTracked.length} tracked files in ${duration}ms`,
    )
    if (trackedResult.truncated) {
      logForDebugging(
        `[FileIndex] capped tracked file index at ${normalizedTracked.length} files to avoid excessive memory use`,
      )
    }

    logEvent('tengu_file_suggestions_git_ls_files', {
      file_count: normalizedTracked.length,
      tracked_count: normalizedTracked.length,
      untracked_count: 0,
      duration_ms: duration,
    })

    // Start background fetch for untracked files (don't await)
    if (!untrackedFetchPromise && !trackedResult.truncated) {
      const untrackedArgs = respectGitignore
        ? [
            '-c',
            'core.quotepath=false',
            'ls-files',
            '--others',
            '--exclude-standard',
          ]
        : ['-c', 'core.quotepath=false', 'ls-files', '--others']

      const generation = cacheGeneration
      const remainingCapacity = Math.max(
        0,
        MAX_FILE_SUGGESTION_FILES - normalizedTracked.length,
      )
      if (remainingCapacity === 0) {
        return normalizedTracked
      }
      const { signal: untrackedAbortSignal, cleanup: cleanupUntrackedFetch } =
        createCombinedAbortSignal(undefined, {
          timeoutMs: 10_000,
        })
      untrackedFetchPromise = collectGitPaths(untrackedArgs, {
        repoRoot,
        cwd,
        abortSignal: untrackedAbortSignal,
        maxFiles: remainingCapacity,
        ignorePatterns,
      })
        .then(async untrackedResult => {
          if (generation !== cacheGeneration) {
            return // Cache was cleared; don't merge stale untracked files
          }
          if (untrackedResult.code === 0) {
            const normalizedUntracked = untrackedResult.files

            logForDebugging(
              `[FileIndex] background untracked fetch: ${normalizedUntracked.length} files`,
            )
            if (untrackedResult.truncated) {
              logForDebugging(
                `[FileIndex] capped untracked file index at ${normalizedUntracked.length} files to avoid excessive memory use`,
              )
            }
            // Pass already-normalized files directly to merge function
            void mergeUntrackedIntoNormalizedCache(normalizedUntracked)
          }
        })
        .catch(error => {
          logForDebugging(
            `[FileIndex] background untracked fetch failed: ${error}`,
          )
        })
        .finally(() => {
          cleanupUntrackedFetch()
          untrackedFetchPromise = null
        })
    }

    return normalizedTracked
  } catch (error) {
    logForDebugging(`[FileIndex] git ls-files error: ${errorMessage(error)}`)
    return null
  }
}

/**
 * This function collects all parent directories for each file path
 * and returns a list of unique directory names with a trailing separator.
 * For example, if the input is ['src/index.js', 'src/utils/helpers.js'],
 * the output will be ['src/', 'src/utils/'].
 * @param files An array of file paths
 * @returns An array of unique directory names with a trailing separator
 */
export function getDirectoryNames(files: string[]): string[] {
  const directoryNames = new Set<string>()
  collectDirectoryNames(files, 0, files.length, directoryNames)
  return [...directoryNames].map(d => d + path.sep)
}

/**
 * Async variant: yields every ~10k files so 270k+ file lists don't block
 * the main thread for >10ms at a time.
 */
export async function getDirectoryNamesAsync(
  files: string[],
): Promise<string[]> {
  const directoryNames = new Set<string>()
  // Time-based chunking: yield after CHUNK_MS of work so slow machines get
  // smaller chunks and stay responsive.
  let chunkStart = performance.now()
  for (let i = 0; i < files.length; i++) {
    collectDirectoryNames(files, i, i + 1, directoryNames)
    if ((i & 0xff) === 0xff && performance.now() - chunkStart > CHUNK_MS) {
      await yieldToEventLoop()
      chunkStart = performance.now()
    }
  }
  return [...directoryNames].map(d => d + path.sep)
}

function collectDirectoryNames(
  files: string[],
  start: number,
  end: number,
  out: Set<string>,
): void {
  for (let i = start; i < end; i++) {
    let currentDir = path.dirname(files[i]!)
    // Early exit if we've already processed this directory and all its parents.
    // Root detection: path.dirname returns its input at the root (fixed point),
    // so we stop when dirname stops changing. Checking this before add() keeps
    // the root out of the result set (matching the old path.parse().root guard).
    // This avoids path.parse() which allocates a 5-field object per file.
    while (currentDir !== '.' && !out.has(currentDir)) {
      const parent = path.dirname(currentDir)
      if (parent === currentDir) break
      out.add(currentDir)
      currentDir = parent
    }
  }
}

/**
 * Gets additional files from Claude config directories
 */
async function getClaudeConfigFiles(cwd: string): Promise<string[]> {
  const markdownFileArrays = await Promise.all(
    CLAUDE_CONFIG_DIRECTORIES.map(subdir =>
      loadMarkdownFilesForSubdir(subdir, cwd),
    ),
  )
  return markdownFileArrays.flatMap(markdownFiles =>
    markdownFiles.map(f => f.filePath),
  )
}

/**
 * Gets project files using git ls-files (fast) or ripgrep (fallback)
 */
async function getProjectFiles(
  abortSignal: AbortSignal,
  respectGitignore: boolean,
): Promise<string[]> {
  logForDebugging(
    `[FileIndex] getProjectFiles called, respectGitignore=${respectGitignore}`,
  )

  // Try git ls-files first (much faster for git repos)
  const gitFiles = await getFilesUsingGit(abortSignal, respectGitignore)
  if (gitFiles !== null) {
    logForDebugging(
      `[FileIndex] using git ls-files result (${gitFiles.length} files)`,
    )
    return gitFiles
  }

  // Fall back to ripgrep
  logForDebugging(
    `[FileIndex] git ls-files returned null, falling back to ripgrep`,
  )
  const startTime = Date.now()
  const rgArgs = [
    '--files',
    '--follow',
    '--hidden',
    '--glob',
    '!.git/',
    '--glob',
    '!.svn/',
    '--glob',
    '!.hg/',
    '--glob',
    '!.bzr/',
    '--glob',
    '!.jj/',
    '--glob',
    '!.sl/',
  ]
  if (!respectGitignore) {
    rgArgs.push('--no-ignore-vcs')
  }

  const { files: relativePaths, truncated } = await collectRipgrepPaths(
    rgArgs,
    '.',
    abortSignal,
    MAX_FILE_SUGGESTION_FILES,
  )

  const duration = Date.now() - startTime
  logForDebugging(
    `[FileIndex] ripgrep: ${relativePaths.length} files in ${duration}ms`,
  )
  if (truncated) {
    logForDebugging(
      `[FileIndex] capped ripgrep file index at ${relativePaths.length} files to avoid excessive memory use`,
    )
  }

  logEvent('tengu_file_suggestions_ripgrep', {
    file_count: relativePaths.length,
    duration_ms: duration,
  })

  return relativePaths
}

/**
 * Gets both files and their directory paths for providing path suggestions
 * Uses git ls-files for git repos (fast) or ripgrep as fallback
 * Returns a FileIndex populated for fast fuzzy search
 */
export async function getPathsForSuggestions(): Promise<FileIndex> {
  const { signal, cleanup } = createCombinedAbortSignal(undefined, {
    timeoutMs: 10_000,
  })
  const index = getFileIndex()

  try {
    // Check project settings first, then fall back to global config
    const projectSettings = getInitialSettings()
    const globalConfig = getGlobalConfig()
    const respectGitignore =
      projectSettings.respectGitignore ?? globalConfig.respectGitignore ?? true

    const cwd = getCwd()
    const [projectFiles, configFiles] = await Promise.all([
      getProjectFiles(signal, respectGitignore),
      getClaudeConfigFiles(cwd),
    ])

    // Cache for mergeUntrackedIntoNormalizedCache
    cachedConfigFiles = configFiles

    const allFiles = [...projectFiles, ...configFiles]
    const directories = await getDirectoryNamesAsync(allFiles)
    cachedTrackedDirs = directories
    const allPathsList = [...directories, ...allFiles]

    // Skip rebuild when the list is unchanged. This is the common case
    // during a typing session — git ls-files returns the same output.
    const sig = pathListSignature(allPathsList)
    if (sig !== loadedTrackedSignature) {
      // Await the full build so cold-start returns complete results. The
      // build yields every ~4ms so the UI stays responsive — user can keep
      // typing during the ~120ms wait without input lag.
      await index.loadFromFileListAsync(allPathsList).done
      loadedTrackedSignature = sig
      // We just replaced the merged index with tracked-only data. Force
      // the next untracked merge to rebuild even if its own sig matches.
      loadedMergedSignature = null
    } else {
      logForDebugging(
        `[FileIndex] skipped index rebuild — tracked paths unchanged`,
      )
    }
  } catch (error) {
    logError(error)
  } finally {
    cleanup()
  }

  return index
}

/**
 * Finds the common prefix between two strings
 */
function findCommonPrefix(a: string, b: string): string {
  const minLength = Math.min(a.length, b.length)
  let i = 0
  while (i < minLength && a[i] === b[i]) {
    i++
  }
  return a.substring(0, i)
}

/**
 * Finds the longest common prefix among an array of suggestion items
 */
export function findLongestCommonPrefix(suggestions: SuggestionItem[]): string {
  if (suggestions.length === 0) return ''

  const strings = suggestions.map(item => item.displayText)
  let prefix = strings[0]!
  for (let i = 1; i < strings.length; i++) {
    const currentString = strings[i]!
    prefix = findCommonPrefix(prefix, currentString)
    if (prefix === '') return ''
  }
  return prefix
}

/**
 * Creates a file suggestion item
 */
function createFileSuggestionItem(
  filePath: string,
  score?: number,
): SuggestionItem {
  return {
    id: `file-${filePath}`,
    displayText: filePath,
    metadata: score !== undefined ? { score } : undefined,
  }
}

/**
 * Find matching files and folders for a given query using the TS file index
 */
const MAX_SUGGESTIONS = 15
function findMatchingFiles(
  fileIndex: FileIndex,
  partialPath: string,
): SuggestionItem[] {
  const results = fileIndex.search(partialPath, MAX_SUGGESTIONS)
  return results.map(result =>
    createFileSuggestionItem(result.path, result.score),
  )
}

/**
 * Starts a background refresh of the file index cache if not already in progress.
 *
 * Throttled: when a cache already exists, we skip the refresh unless git state
 * has actually changed. This prevents every keystroke from spawning git ls-files
 * and rebuilding the nucleo index.
 */
const REFRESH_THROTTLE_MS = 5_000
export function startBackgroundCacheRefresh(): void {
  if (fileListRefreshPromise) return

  // Throttle only when a cache exists — cold start must always populate.
  // Refresh immediately when .git/index mtime changed (tracked files).
  // Otherwise refresh at most once per 5s — this floor picks up new UNTRACKED
  // files, which don't bump .git/index. The signature checks downstream skip
  // the rebuild when the 5s refresh finds nothing actually changed.
  const indexMtime = getGitIndexMtime()
  if (fileIndex) {
    const gitStateChanged =
      indexMtime !== null && indexMtime !== lastGitIndexMtime
    if (!gitStateChanged && Date.now() - lastRefreshMs < REFRESH_THROTTLE_MS) {
      return
    }
  }

  const generation = cacheGeneration
  const refreshStart = Date.now()
  // Ensure the FileIndex singleton exists — it's progressively queryable
  // via readyCount while the build runs. Callers searching early get partial
  // results; indexBuildComplete fires after .done so they can re-search.
  getFileIndex()
  fileListRefreshPromise = getPathsForSuggestions()
    .then(result => {
      if (generation !== cacheGeneration) {
        return result // Cache was cleared; don't overwrite with stale data
      }
      fileListRefreshPromise = null
      indexBuildComplete.emit()
      // Commit the start-time mtime observation on success. If git state
      // changed mid-refresh, the next call will see the newer mtime and
      // correctly refresh again.
      lastGitIndexMtime = indexMtime
      lastRefreshMs = Date.now()
      logForDebugging(
        `[FileIndex] cache refresh completed in ${Date.now() - refreshStart}ms`,
      )
      return result
    })
    .catch(error => {
      logForDebugging(
        `[FileIndex] Cache refresh failed: ${errorMessage(error)}`,
      )
      logError(error)
      if (generation === cacheGeneration) {
        fileListRefreshPromise = null // Allow retry on next call
      }
      return getFileIndex()
    })
}

/**
 * Gets the top-level files and directories in the current working directory
 * @returns Array of file/directory paths in the current directory
 */
async function getTopLevelPaths(): Promise<string[]> {
  const fs = getFsImplementation()
  const cwd = getCwd()

  try {
    const entries = await fs.readdir(cwd)
    return entries
      .map(entry => {
        const fullPath = path.join(cwd, entry.name)
        const relativePath = path.relative(cwd, fullPath)
        // Add trailing separator for directories
        return entry.isDirectory() ? relativePath + path.sep : relativePath
      })
      .filter(p => !shouldExcludeFileSuggestionPath(p))
  } catch (error) {
    logError(error as Error)
    return []
  }
}

/**
 * Generate file suggestions for the current input and cursor position
 * @param partialPath The partial file path to match
 * @param showOnEmpty Whether to show suggestions even if partialPath is empty (used for @ symbol)
 */
export async function generateFileSuggestions(
  partialPath: string,
  showOnEmpty = false,
): Promise<SuggestionItem[]> {
  // If input is empty and we don't want to show suggestions on empty, return nothing
  if (!partialPath && !showOnEmpty) {
    return []
  }

  // Use custom command directly if configured. We don't mix in our config files
  // because the command returns pre-ranked results using its own search logic.
  if (getInitialSettings().fileSuggestion?.type === 'command') {
    const input: FileSuggestionCommandInput = {
      ...createBaseHookInput(),
      query: partialPath,
    }
    const results = await executeFileSuggestionCommand(input)
    return results.slice(0, MAX_SUGGESTIONS).map(createFileSuggestionItem)
  }

  // If the partial path is empty or just a dot, return current directory suggestions
  if (partialPath === '' || partialPath === '.' || partialPath === './') {
    const topLevelPaths = await getTopLevelPaths()
    startBackgroundCacheRefresh()
    return topLevelPaths.slice(0, MAX_SUGGESTIONS).map(createFileSuggestionItem)
  }

  const startTime = Date.now()

  try {
    const hadIndex = fileIndex !== null
    // Kick a background refresh. The index is progressively queryable —
    // searches during build return partial results from ready chunks, and
    // the typeahead callback (setOnIndexBuildComplete) re-fires the search
    // when the build finishes to upgrade partial → full.
    const wasBuilding = fileListRefreshPromise !== null
    startBackgroundCacheRefresh()
    if (!hadIndex && fileListRefreshPromise) {
      await fileListRefreshPromise
    }

    // Handle both './' and '.\'
    let normalizedPath = partialPath
    const currentDirPrefix = '.' + path.sep
    if (partialPath.startsWith(currentDirPrefix)) {
      normalizedPath = partialPath.substring(2)
    }

    // Handle tilde expansion for home directory
    if (normalizedPath.startsWith('~')) {
      normalizedPath = expandPath(normalizedPath)
    }

    const matches = fileIndex
      ? findMatchingFiles(fileIndex, normalizedPath)
      : []

    const duration = Date.now() - startTime
    logForDebugging(
      `[FileIndex] generateFileSuggestions: ${matches.length} results in ${duration}ms (${wasBuilding ? 'partial' : 'full'} index)`,
    )
    logEvent('tengu_file_suggestions_query', {
      duration_ms: duration,
      cache_hit: !wasBuilding,
      result_count: matches.length,
      query_length: partialPath.length,
    })

    return matches
  } catch (error) {
    logError(error)
    return []
  }
}

/**
 * Apply a file suggestion to the input
 */
export function applyFileSuggestion(
  suggestion: string | SuggestionItem,
  input: string,
  partialPath: string,
  startPos: number,
  onInputChange: (value: string) => void,
  setCursorOffset: (offset: number) => void,
): void {
  // Extract suggestion text from string or SuggestionItem
  const suggestionText =
    typeof suggestion === 'string' ? suggestion : suggestion.displayText

  // Replace the partial path with the selected file path
  const newInput =
    input.substring(0, startPos) +
    suggestionText +
    input.substring(startPos + partialPath.length)
  onInputChange(newInput)

  // Move cursor to end of the file path
  const newCursorPos = startPos + suggestionText.length
  setCursorOffset(newCursorPos)
}
