import { resolve as resolvePath } from 'path'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useRegisterOverlay } from '../context/overlayContext.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { Text } from '@anthropic/ink'
import { logEvent } from '../services/analytics/index.js'
import { getCwd } from '../utils/cwd.js'
import { openFileInExternalEditor } from '../utils/editor.js'
import { truncatePathMiddle, truncateToWidth } from '../utils/format.js'
import { highlightMatch } from '../utils/highlightMatch.js'
import { relativePath } from '../utils/permissions/filesystem.js'
import { readFileInRange } from '../utils/readFileInRange.js'
import { ripGrepStream } from '../utils/ripgrep.js'
import { FuzzyPicker, LoadingState } from '@anthropic/ink'

type Props = {
  onDone: () => void
  onInsert: (text: string) => void
}

type Match = {
  file: string
  line: number
  text: string
}

const VISIBLE_RESULTS = 12
const DEBOUNCE_MS = 100
const PREVIEW_CONTEXT_LINES = 4
// rg -m is per-file; we also cap the parsed array to keep memory bounded.
const MAX_MATCHES_PER_FILE = 10
const MAX_TOTAL_MATCHES = 500

/**
 * Global Search dialog (ctrl+shift+f / cmd+shift+f).
 * Debounced ripgrep search across the workspace.
 */
export function GlobalSearchDialog({
  onDone,
  onInsert,
}: Props): React.ReactNode {
  useRegisterOverlay('global-search')
  const { columns, rows } = useTerminalSize()
  const previewOnRight = columns >= 140
  // Chrome (title + search + matchLabel + hints + pane border + gaps) eats
  // ~14 rows. Shrink the list on short terminals so the dialog doesn't clip.
  const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14))

  const [matches, setMatches] = useState<Match[]>([])
  const [truncated, setTruncated] = useState(false)
  const [isSearching, setIsSearching] = useState(false)
  const [query, setQuery] = useState('')
  const [focused, setFocused] = useState<Match | undefined>(undefined)
  const [preview, setPreview] = useState<{
    file: string
    line: number
    content: string
  } | null>(null)
  const abortRef = useRef<AbortController | null>(null)
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)

  useEffect(() => {
    return () => {
      if (timeoutRef.current) clearTimeout(timeoutRef.current)
      abortRef.current?.abort()
    }
  }, [])

  // Load context lines around the focused match. AbortController prevents
  // holding ↓ from piling up reads.
  useEffect(() => {
    if (!focused) {
      setPreview(null)
      return
    }
    const controller = new AbortController()
    const absolute = resolvePath(getCwd(), focused.file)
    const start = Math.max(0, focused.line - PREVIEW_CONTEXT_LINES - 1)
    void readFileInRange(
      absolute,
      start,
      PREVIEW_CONTEXT_LINES * 2 + 1,
      undefined,
      controller.signal,
    )
      .then(r => {
        if (controller.signal.aborted) return
        setPreview({
          file: focused.file,
          line: focused.line,
          content: r.content,
        })
      })
      .catch(() => {
        if (controller.signal.aborted) return
        setPreview({
          file: focused.file,
          line: focused.line,
          content: '(preview unavailable)',
        })
      })
    return () => controller.abort()
  }, [focused])

  const handleQueryChange = (q: string) => {
    setQuery(q)
    if (timeoutRef.current) clearTimeout(timeoutRef.current)
    abortRef.current?.abort()

    if (!q.trim()) {
      setMatches(m => (m.length ? [] : m))
      setIsSearching(false)
      setTruncated(false)
      return
    }
    const controller = new AbortController()
    abortRef.current = controller
    setIsSearching(true)
    setTruncated(false)
    // Client-filter existing results while rg walks — keeps something on
    // screen instead of flashing blank. rg results are merged in (deduped by
    // file:line) rather than replaced, so the count is monotonic within a
    // query: it only grows as rg streams, never dips to the first chunk's
    // size. Narrowing (new query extends old): filter is exact — any line
    // that matched the old -F -i literal contains the new one iff its text
    // includes the new query lowered. Non-narrowing (broadening/different):
    // filter is best-effort — may briefly show a subset until rg fills in
    // the rest.
    const queryLower = q.toLowerCase()
    setMatches(m => {
      const filtered = m.filter(match =>
        match.text.toLowerCase().includes(queryLower),
      )
      return filtered.length === m.length ? m : filtered
    })

    timeoutRef.current = setTimeout(
      (query, controller, setMatches, setTruncated, setIsSearching) => {
        // ripgrep outputs absolute paths when given an absolute target, so
        // relativize against cwd to preserve directory context in the truncated
        // display (otherwise the cwd prefix eats the width budget).
        // relativePath() returns POSIX-normalized output so truncatePathMiddle
        // (which uses lastIndexOf('/')) works on Windows too.
        const cwd = getCwd()
        let collected = 0
        void ripGrepStream(
          // -e disambiguates pattern from options when the query starts with '-'
          // (e.g. searching for "--verbose" or "-rf"). See GrepTool.ts for the
          // same precaution.
          [
            '-n',
            '--no-heading',
            '-i',
            '-m',
            String(MAX_MATCHES_PER_FILE),
            '-F',
            '-e',
            query,
          ],
          cwd,
          controller.signal,
          lines => {
            if (controller.signal.aborted) return
            const parsed: Match[] = []
            for (const line of lines) {
              const m = parseRipgrepLine(line)
              if (!m) continue
              const rel = relativePath(cwd, m.file)
              parsed.push({ ...m, file: rel.startsWith('..') ? m.file : rel })
            }
            if (!parsed.length) return
            collected += parsed.length
            setMatches(prev => {
              // Append+dedupe instead of replace: prev may hold client-
              // filtered results that are valid matches for this query.
              // Replacing would drop the count to this chunk's size then
              // grow it back — visible as a flicker.
              const seen = new Set(prev.map(matchKey))
              const fresh = parsed.filter(p => !seen.has(matchKey(p)))
              if (!fresh.length) return prev
              const next = prev.concat(fresh)
              return next.length > MAX_TOTAL_MATCHES
                ? next.slice(0, MAX_TOTAL_MATCHES)
                : next
            })
            if (collected >= MAX_TOTAL_MATCHES) {
              controller.abort()
              setTruncated(true)
              setIsSearching(false)
            }
          },
        )
          .catch(() => {})
          // Stream closed with zero chunks — clear stale results so
          // "No matches" renders instead of the previous query's list.
          .finally(() => {
            if (controller.signal.aborted) return
            if (collected === 0) setMatches(m => (m.length ? [] : m))
            setIsSearching(false)
          })
      },
      DEBOUNCE_MS,
      q,
      controller,
      setMatches,
      setTruncated,
      setIsSearching,
    )
  }

  const listWidth = previewOnRight
    ? Math.floor((columns - 10) * 0.5)
    : columns - 8
  const maxPathWidth = Math.max(20, Math.floor(listWidth * 0.4))
  const maxTextWidth = Math.max(20, listWidth - maxPathWidth - 4)
  const previewWidth = previewOnRight
    ? Math.max(40, columns - listWidth - 14)
    : columns - 6

  const handleOpen = (m: Match) => {
    const opened = openFileInExternalEditor(
      resolvePath(getCwd(), m.file),
      m.line,
    )
    logEvent('tengu_global_search_select', {
      result_count: matches.length,
      opened_editor: opened,
    })
    onDone()
  }

  const handleInsert = (m: Match, mention: boolean) => {
    onInsert(mention ? `@${m.file}#L${m.line} ` : `${m.file}:${m.line} `)
    logEvent('tengu_global_search_insert', {
      result_count: matches.length,
      mention,
    })
    onDone()
  }

  // Always pass a non-empty string so the line is reserved — prevents the
  // searchBox from bouncing when the count appears/disappears.
  const matchLabel =
    matches.length > 0
      ? `${matches.length}${truncated ? '+' : ''} matches${isSearching ? '…' : ''}`
      : ' '

  return (
    <FuzzyPicker
      title="Global Search"
      placeholder="Type to search…"
      items={matches}
      getKey={matchKey}
      visibleCount={visibleResults}
      direction="up"
      previewPosition={previewOnRight ? 'right' : 'bottom'}
      onQueryChange={handleQueryChange}
      onFocus={m => setFocused(m)}
      onSelect={handleOpen}
      onTab={{ action: 'mention', handler: m => handleInsert(m, true) }}
      onShiftTab={{
        action: 'insert path',
        handler: m => handleInsert(m, false),
      }}
      onCancel={onDone}
      emptyMessage={q =>
        isSearching ? 'Searching…' : q ? 'No matches' : 'Type to search…'
      }
      matchLabel={matchLabel}
      selectAction="open in editor"
      renderItem={(m, isFocused) => (
        <Text color={isFocused ? 'suggestion' : undefined}>
          <Text dimColor>
            {truncatePathMiddle(m.file, maxPathWidth)}:{m.line}
          </Text>{' '}
          {highlightMatch(
            truncateToWidth(m.text.trimStart(), maxTextWidth),
            query,
          )}
        </Text>
      )}
      renderPreview={m =>
        preview?.file === m.file && preview.line === m.line ? (
          <>
            <Text dimColor>
              {truncatePathMiddle(m.file, previewWidth)}:{m.line}
            </Text>
            {preview.content.split('\n').map((line, i) => (
              <Text key={i}>
                {highlightMatch(truncateToWidth(line, previewWidth), query)}
              </Text>
            ))}
          </>
        ) : (
          <LoadingState message="Loading…" dimColor />
        )
      }
    />
  )
}

function matchKey(m: Match): string {
  return `${m.file}:${m.line}`
}

/**
 * Parse a ripgrep -n --no-heading output line: "path:line:text".
 * Windows paths may contain a drive letter ("C:\..."), so a simple split on
 * the first colon would mangle the path — use a regex that captures up to
 * the first :<digits>: instead.
 * @internal exported for testing
 */
export function parseRipgrepLine(line: string): Match | null {
  const m = /^(.*?):(\d+):(.*)$/.exec(line)
  if (!m) return null
  const [, file, lineStr, text] = m
  const lineNum = Number(lineStr)
  if (!file || !Number.isFinite(lineNum)) return null
  return { file, line: lineNum, text: text ?? '' }
}
