import { useMappingHelper } from '@shopify/flash-list'
import { Image } from 'expo-image'
import { type ComponentType, memo, useMemo } from 'react'
import { Text as RNText } from 'react-native'
import type { CustomEmoji, Mention } from 'src/lib/api-types'
import {
  emojiRegex,
  hashtagRegex,
  linkRegex,
  mentionRegex,
  trimUrlTail,
} from 'src/lib/text/fediverse-patterns'
import { formatLink } from 'src/lib/url-format'
import { openBrowserAsync } from 'src/utils'
import { Paragraph, useTheme } from 'tamagui'

// `Favicon` pulls `react-native-mmkv` -> `react-native-nitro-modules` into
// the resolution graph via `useFavicon`. jest-expo can't load nitro, and the
// parser-level autolinker tests transitively import this module. Mirroring
// the `expo-linking` / `src/state/storage` lazy-require idiom in `utils.ts`,
// resolve the component on first render and cache the symbol. Test files
// that only exercise `parseText` / `getTextParts` never touch the render
// path, so the require never fires there.
let _Favicon: ComponentType<{ variant?: 'inline'; host: string }> | null = null
function getFavicon(): ComponentType<{ variant?: 'inline'; host: string }> {
  if (_Favicon) return _Favicon
  _Favicon = (
    require('src/components/common/Favicon') as typeof import('src/components/common/Favicon')
  ).Favicon
  return _Favicon
}

// URL / mention / hashtag / emoji patterns + `trimUrlTail` live in the pure
// leaf module `src/lib/text/fediverse-patterns.ts` — the single source of
// truth mirroring Pixelfed's server-side `app/Util/Lexer/Regex.php`. They are
// shared with `extractTrailingHashtags` in `src/utils.ts` so inline
// autolinking and trailing-pill extraction never disagree on tag boundaries.

export interface Part {
  type: 'text' | 'mention' | 'hashtag' | 'link' | 'emoji'
  value: string
  emoji?: CustomEmoji
  // Populated at parse time on `link` Parts only. `display` is the visible
  // Mastodon-shortened form (host + trimmed path + ellipsis); `host` is the
  // bare hostname used to key the inline favicon cache. `value` keeps the
  // full URL so the tap target (`openBrowserAsync(part.value)`) stays
  // unchanged.
  display?: string
  host?: string
}

export interface Match extends Part {
  type: 'mention' | 'hashtag' | 'link' | 'emoji'
  index: number
}

// Every captioned post pays for four regex scans + a sort + a node walk.
// With FlashList v2 recycling, the same caption can run this pipeline
// multiple times as cells bind/unbind. Memoize by (text, emojis-ref) so the
// second and later renders are a map lookup. The emojis array identity is
// stable per post (it comes from the Status object), so `(text, emojis)`
// works as a composite key.
const partsCache = new WeakMap<object, Map<string, Part[]>>()
// For callers without emojis we key the inner map on a sentinel object so we
// can still reuse the outer WeakMap structure.
const NO_EMOJIS: CustomEmoji[] = []

function getCachedParts(text: string, emojis: CustomEmoji[] | undefined): Part[] {
  const key = emojis && emojis.length > 0 ? emojis : NO_EMOJIS
  let byText = partsCache.get(key)
  if (!byText) {
    byText = new Map()
    partsCache.set(key, byText)
  }
  const hit = byText.get(text)
  if (hit) return hit
  const matches = parseText(text, emojis)
  const parts = getTextParts(text, matches)
  byText.set(text, parts)
  return parts
}

export function parseText(text: string, emojis?: Array<CustomEmoji>) {
  const matches: Match[] = []
  const linkRangeIndices: { start: number; end: number }[] = []

  // Pass order: links first so subsequent passes can skip tokens that
  // fall inside a URL range (the `@user` or `#frag` portions of a URL
  // path/fragment). Each regex uses `matchAll` to avoid the `lastIndex`
  // carry-over hazard `exec` introduces when a regex literal is reused.
  for (const m of text.matchAll(linkRegex)) {
    const idx = m.index ?? 0
    const value = trimUrlTail(m[0])
    const formatted = formatLink(value)
    matches.push({
      type: 'link',
      value,
      index: idx,
      display: formatted?.display ?? value,
      host: formatted?.host ?? '',
    })
    linkRangeIndices.push({ start: idx, end: idx + value.length })
  }

  const inLinkRange = (idx: number) =>
    linkRangeIndices.some(({ start, end }) => idx > start && idx < end)

  for (const m of text.matchAll(mentionRegex)) {
    const idx = m.index ?? 0
    if (inLinkRange(idx)) continue
    matches.push({ type: 'mention', value: m[0], index: idx })
  }

  for (const m of text.matchAll(hashtagRegex)) {
    const idx = m.index ?? 0
    if (inLinkRange(idx)) continue
    matches.push({ type: 'hashtag', value: m[0], index: idx })
  }

  if (emojis && emojis.length > 0) {
    const emojiMap = new Map<string, CustomEmoji>()
    for (const e of emojis) {
      if (e?.shortcode) emojiMap.set(e.shortcode, e)
    }
    if (emojiMap.size > 0) {
      for (const m of text.matchAll(emojiRegex)) {
        const idx = m.index ?? 0
        if (inLinkRange(idx)) continue
        const emoji = emojiMap.get(m[1])
        if (emoji) {
          matches.push({ type: 'emoji', value: m[0], index: idx, emoji })
        }
      }
    }
  }

  matches.sort((a, b) => a.index - b.index)

  return matches
}

export function getTextParts(text: string, matches: Match[]) {
  let lastIndex = 0
  const parts: Part[] = []

  matches.forEach((match) => {
    if (lastIndex < match.index) {
      parts.push({ type: 'text', value: text.slice(lastIndex, match.index) })
    }
    const part: Part = { type: match.type, value: match.value }
    if (match.emoji) part.emoji = match.emoji
    if (match.display !== undefined) part.display = match.display
    if (match.host !== undefined) part.host = match.host
    parts.push(part)
    lastIndex = match.index + match.value.length
  })

  if (lastIndex < text?.length) {
    parts.push({ type: 'text', value: text.slice(lastIndex) })
  }

  return parts
}

interface AutolinkTextProps {
  text: string
  emojis?: Array<CustomEmoji>
  onMentionPress: (mention: string, is_local_mention: boolean) => void
  onHashtagPress: (hashtag: string) => void
}

interface AutolinkTextPropsWithUsername extends AutolinkTextProps {
  username: string
  onUsernamePress: () => void
}

// Emoji images render inline inside `<Text>` via RN's native text-composition.
// Size is fixed slightly above the $body font size (14) so they cap-align with
// the surrounding glyphs; the negative marginBottom nudges them back onto the
// baseline rather than floating above it.
const EMOJI_SIZE = 19

// Inline-pressable spans (mention / hashtag / URL / username) MUST use React
// Native's native `Text`, NOT Tamagui's `Text`. Tamagui v2's `Text` is
// `styled(TamaguiText)`; setting `onPress` flows to the base `TamaguiText`,
// which routes through `react-native-gesture-handler`
// (@tamagui/web/src/eventHandling.native.ts:106). RNGH attaches gestures to
// native view tags via `findNodeHandle`, but iOS collapses nested inline
// `<Text>` children into the parent `RCTText`'s text-tree — the inner span has
// no separate native view tag for RNGH to attach to. Styling (color) still
// applies because that's text-tree level, but the press never fires.
// Result: tokens look like links but don't tap. RN's responder system handles
// inline-Text `onPress` correctly via the text-tree hit-test — that's the
// canonical "tappable spans inside a paragraph" pattern. We bypass Tamagui for
// the leaves only. The outer `<Paragraph>` has no `onPress`, so Tamagui's
// RNGH path never engages for it; it renders as a plain `RCTText` and the
// inner native-Text children inherit its typography / color tokens for free.
// Android already has Tamagui's RNGH press path disabled globally (see
// `setupGestureHandler({ pressEvents: false })` in `src/app/_layout.tsx`), so
// this matters most for iOS — but bypassing on both keeps the renderer
// symmetric and avoids future drift if the Android switch ever changes.

function AutolinkTextInner(props: AutolinkTextProps | AutolinkTextPropsWithUsername) {
  const { text, emojis, onMentionPress, onHashtagPress } = props

  const { username, onUsernamePress } = props as AutolinkTextPropsWithUsername

  const parts = useMemo(() => getCachedParts(text, emojis), [text, emojis])
  const { getMappingKey } = useMappingHelper()
  const theme = useTheme()
  // Resolve theme tokens once per render: native `<Text>` takes a concrete
  // color, not a `$token` string. Inheritance from the outer `Paragraph` covers
  // font family / size / weight, so we only need to override `color` on the
  // pressable spans and the bold username prefix.
  const linkColor = theme.interactiveActive.val
  const textColor = theme.color.val

  return (
    <Paragraph size="$body" color="$color">
      {username ? (
        <RNText
          style={{ fontWeight: '600', color: textColor }}
          suppressHighlighting
          onPress={onUsernamePress}
        >
          {username}{' '}
        </RNText>
      ) : null}
      {parts?.map((part, index) => {
        // getMappingKey provides stable keys across recycled cells so FlashList
        // v2 can preserve text node identity when a post rebinds. Outside a
        // recycler it returns the itemKey verbatim, so the index suffix is
        // load-bearing — without it, repeated whitespace / identical mentions
        // in the same paragraph collide and React drops/duplicates children.
        const key = getMappingKey(`${part.type}:${part.value}:${index}`, index)
        if (part.type === 'mention') {
          return (
            <RNText
              key={key}
              style={{ color: linkColor }}
              suppressHighlighting
              onPress={() =>
                onMentionPress(part.value, part.value.lastIndexOf('@') === 0)
              }
            >
              {part.value}
            </RNText>
          )
        }

        if (part.type === 'hashtag') {
          return (
            <RNText
              key={key}
              style={{ color: linkColor }}
              suppressHighlighting
              onPress={() => onHashtagPress(part.value.slice(1))}
            >
              {part.value}
            </RNText>
          )
        }

        if (part.type === 'link') {
          const Favicon = part.host ? getFavicon() : null
          return (
            <RNText
              key={key}
              style={{ color: linkColor }}
              suppressHighlighting
              onPress={() => openBrowserAsync(part.value)}
            >
              {Favicon && part.host ? (
                <Favicon variant="inline" host={part.host} />
              ) : null}
              {part.display ?? part.value}
            </RNText>
          )
        }

        if (part.type === 'emoji' && part.emoji) {
          return (
            <RNText key={key}>
              <Image
                source={{ uri: part.emoji.url }}
                style={{
                  width: EMOJI_SIZE,
                  height: EMOJI_SIZE,
                  marginBottom: -Math.round(EMOJI_SIZE * 0.15),
                }}
                contentFit="contain"
                cachePolicy="memory-disk"
                accessibilityLabel={part.value}
              />
            </RNText>
          )
        }

        return <RNText key={key}>{part.value}</RNText>
      })}
    </Paragraph>
  )
}

// Parts derive from (text, emojis). Recycling in FlashList swaps `text` and
// `emojis` in place; unless a consumer also changes one of the callbacks,
// memoizing here skips the whole reconcile.
const AutolinkText = memo(AutolinkTextInner, (prev, next) => {
  return (
    prev.text === next.text &&
    prev.emojis === next.emojis &&
    prev.onMentionPress === next.onMentionPress &&
    prev.onHashtagPress === next.onHashtagPress &&
    (prev as AutolinkTextPropsWithUsername).username ===
      (next as AutolinkTextPropsWithUsername).username &&
    (prev as AutolinkTextPropsWithUsername).onUsernamePress ===
      (next as AutolinkTextPropsWithUsername).onUsernamePress
  )
})

export default AutolinkText

export function onMentionPressMethod(
  gotoUsernameProfile: (username: string) => void,
  authorAccountUrl: string,
  // Optional status context. When the surface rendering this text is a Status,
  // pass its `mentions[]` and a by-id profile nav. Each `mentions[]` entry's
  // `id` is the mentioned account's id on the ACTIVE account's own server, so
  // `/profile/<id>` resolves it with no webfinger/search — and, unlike guessing
  // the host from the post author, it can't point at the wrong server for a
  // cross-instance mention (the common "federated profile fails to load" bug).
  options?: {
    mentions?: Mention[]
    gotoProfileById?: (id: string) => void
  }
) {
  return (mention: string, is_username_local: boolean) => {
    const mentions = options?.mentions
    const gotoProfileById = options?.gotoProfileById

    // 1. Resolve against the server-provided mentions[] when available.
    if (mentions?.length && gotoProfileById) {
      const handle = mention.replace(/^@+/, '').toLowerCase()
      const localpart = handle.split('@')[0]
      // `is_username_local` (a single leading `@`, no host) → match by a
      // UNIQUE username; a host-qualified token → match the full `acct`.
      // Username collisions across hosts (two `@alice` from different servers,
      // both rendered host-less) are left to the handle fallback below.
      const match = is_username_local
        ? findUniqueMentionByUsername(mentions, localpart)
        : mentions.find((m) => (m.acct ?? '').toLowerCase() === handle)
      if (match?.id) {
        gotoProfileById(match.id)
        return
      }
    }

    // 2. Fallback: reconstruct the handle. A bare `@user` is assumed to live
    //    on the post author's homeserver — a best guess, used only when the
    //    mentions[] lookup above didn't resolve.
    let username = mention
    if (is_username_local) {
      const authors_homeserver = new URL(authorAccountUrl).hostname
      username = `${mention}@${authors_homeserver}`
    }
    gotoUsernameProfile(username)
  }
}

function findUniqueMentionByUsername(
  mentions: Mention[],
  username: string
): Mention | undefined {
  let found: Mention | undefined
  for (const m of mentions) {
    if ((m.username ?? '').toLowerCase() === username) {
      if (found) return undefined // ambiguous — defer to the handle fallback
      found = m
    }
  }
  return found
}
