<script setup lang="ts">
import { useIntersectionObserver } from '@vueuse/core'
import type { SearchHit } from '~/types'
import { useFavoritesStore } from '~/stores/favorites'

const props = defineProps<{
  activities: SearchHit[]
  query?: string
  hasMore?: boolean
  loading?: boolean
  loadingMore?: boolean
  /** Identificador de la colección a la que pertenecen estos resultados.
   *  Se propaga al sistema de favoritos para distinguir entre tipos de
   *  contenido (actividades, conferencias, etc.). */
  collection?: string
}>()

const favorites = useFavoritesStore()
const toast = useToast()

function onToggleFavorite(hit: SearchHit, ev: Event) {
  // Evita que el click en la estrella abra el detalle.
  ev.stopPropagation()
  if (!props.collection) return
  const wasFav = favorites.isFavorite(props.collection, hit._id)
  favorites.toggle(props.collection, hit)
  toast.add({
    title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista',
    description: hit.title,
    icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
    color: wasFav ? 'neutral' : 'primary',
    duration: 1800
  })
}

const emits = defineEmits<{
  loadMore: []
}>()

const activitiesRefs = ref<Record<string | number, Element | null>>({})

const selectedActivity = defineModel<SearchHit | null>()

watch(selectedActivity, () => {
  if (!selectedActivity.value) return
  const el = activitiesRefs.value[selectedActivity.value._id]
  if (el) el.scrollIntoView({ block: 'nearest' })
})

defineShortcuts({
  arrowdown: () => {
    const list = props.activities
    if (!list.length) return
    const idx = list.findIndex(a => a._id === selectedActivity.value?._id)
    if (idx === -1) selectedActivity.value = list[0]
    else if (idx < list.length - 1) selectedActivity.value = list[idx + 1]
  },
  arrowup: () => {
    const list = props.activities
    if (!list.length) return
    const idx = list.findIndex(a => a._id === selectedActivity.value?._id)
    if (idx === -1) selectedActivity.value = list[list.length - 1]
    else if (idx > 0) selectedActivity.value = list[idx - 1]
  }
})

function safeDate(activity: SearchHit) {
  const d = activity.date ?? activity.isodate
  if (!d) return ''
  const ts = typeof d === 'string' ? new Date(d).getTime() : (d as number) * 1000
  if (!Number.isFinite(ts)) return ''
  return formatDate(ts / 1000)
}

function hasDate(activity: SearchHit) {
  return activity.date != null || activity.isodate != null
}

const HIDDEN_FIELDS = new Set([
  '_id', 'id', 'slug', 'date', 'isodate', 'type', 'thumbnail', 'files',
  'place', 'city', 'state', 'country',
  'title', 'body'
])

const FIELD_LABELS: Record<string, string> = {
  speaker: 'orador',
  speakers: 'oradores',
  preacher: 'predicador',
  author: 'autor',
  authors: 'autores',
  tags: 'etiquetas',
  keywords: 'palabras clave',
  categories: 'categorías',
  category: 'categoría',
  transcript: 'transcripción',
  description: 'descripción',
  excerpt: 'extracto',
  summary: 'resumen',
  notes: 'notas',
  activity: 'actividad',
  conference: 'conferencia'
}

function fieldLabel(key: string) {
  return FIELD_LABELS[key] || key
}

// ---- Text helpers ------------------------------------------------------

function escapeHtml(s: string): string {
  return s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}

function escapeRegex(s: string): string {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

/** Strip diacritics + lowercase. Matches Meilisearch's default normalization
 *  so the user can type without accents and still find accented text. */
function normalize(s: string): string {
  return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}

/** Build a position-preserving normalized version of `text` plus a map from
 *  normalized index -> original index. Combining marks decompose into 0-length
 *  contributions, so the original index can drift; the map handles that. */
function normalizeWithMap(text: string): { normalized: string, map: number[] } {
  let normalized = ''
  const map: number[] = []
  for (let i = 0; i < text.length; i++) {
    const norm = text[i].normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
    for (let j = 0; j < norm.length; j++) {
      normalized += norm[j]
      map.push(i)
    }
  }
  return { normalized, map }
}

/** Compile a term into a regex source that matches the same phrase even when
 *  the whitespace differs (single space vs newline, double space, etc.). */
function termToRegexSource(term: string): string {
  const parts = term.split(/\s+/).filter(Boolean)
  if (parts.length === 0) return ''
  if (parts.length === 1) return escapeRegex(parts[0])
  return parts.map(escapeRegex).join('\\s+')
}

/** Find all literal occurrences of any term in `text`, accent-insensitive and
 *  whitespace-flexible. Returns spans of the ORIGINAL text. */
function findMatchesInText(text: string, terms: string[]): Array<{ start: number, end: number }> {
  if (!text || !terms.length) return []
  const sources = terms.map(t => termToRegexSource(normalize(t))).filter(s => s.length > 0)
  if (!sources.length) return []

  const { normalized, map } = normalizeWithMap(text)
  const re = new RegExp(`(${sources.join('|')})`, 'g')
  const out: Array<{ start: number, end: number }> = []

  let m: RegExpExecArray | null
  while ((m = re.exec(normalized)) !== null) {
    if (m[0].length === 0) { re.lastIndex++; continue }
    const start = map[m.index] ?? text.length
    const endNormIdx = m.index + m[0].length
    const end = endNormIdx < map.length ? map[endNormIdx] : text.length
    out.push({ start, end })
  }
  return out
}

function stripHtml(s: string): string {
  return s
    .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
    .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
    .replace(/<[^>]+>/g, ' ')
    .replace(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/\s+/g, ' ')
    .trim()
}

/** Like stripHtml but also drops the *content* of inline citation markers
 *  (superscripts, subscripts, footnote refs). Useful when a phrase like
 *  "los países que" gets interrupted in the source by `<sup>1</sup>` between
 *  words — the standard strip leaves "los 1 países que" and breaks the match. */
function stripHtmlAggressive(s: string): string {
  return s
    .replace(/<sup[^>]*>[\s\S]*?<\/sup>/gi, ' ')
    .replace(/<sub[^>]*>[\s\S]*?<\/sub>/gi, ' ')
    .replace(/<a\b[^>]*class="[^"]*(?:footnote|fn-ref|ref)[^"]*"[^>]*>[\s\S]*?<\/a>/gi, ' ')
    .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
    .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
    .replace(/<[^>]+>/g, ' ')
    .replace(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/\s+/g, ' ')
    .trim()
}

/**
 * Tokenize the user's query, respecting double-quoted phrases.
 *   los paises "que vean" otra
 *     -> ['los', 'paises', 'que vean', 'otra']
 * Quoted phrases are kept intact so they're matched and highlighted as a unit;
 * standalone words must be at least 2 chars to avoid noisy "a", "y" matches.
 */
function buildTerms(query: string): string[] {
  if (!query) return []
  const out: string[] = []
  const re = /"([^"]+)"|(\S+)/g
  let m: RegExpExecArray | null
  while ((m = re.exec(query)) !== null) {
    if (m[1] !== undefined) {
      const phrase = m[1].trim()
      if (phrase.length > 0) out.push(phrase)
    } else if (m[2] !== undefined) {
      const word = m[2].trim()
      if (word.length > 1) out.push(word)
    }
  }
  return out
}

/** Wrap each accent-insensitive occurrence of any term in <mark>.
 *  The marked content keeps the ORIGINAL characters (with accents), so the
 *  display preserves the text the user is reading, not the normalized form. */
function highlightPlain(text: string, terms: string[]): string {
  if (!text) return ''
  if (!terms.length) return escapeHtml(text)
  const matches = findMatchesInText(text, terms)
  if (!matches.length) return escapeHtml(text)

  let out = ''
  let cursor = 0
  for (const { start, end } of matches) {
    if (start > cursor) out += escapeHtml(text.slice(cursor, start))
    out += '<mark class="search-match">' + escapeHtml(text.slice(start, end)) + '</mark>'
    cursor = end
  }
  if (cursor < text.length) out += escapeHtml(text.slice(cursor))
  return out
}

function findInPlain(text: string, terms: string[]): boolean {
  if (!text || !terms.length) return false
  if (findMatchesInText(stripHtml(text), terms).length > 0) return true
  // Try once more with aggressive stripping (drops <sup>/<sub> content) so
  // phrases interrupted by footnote/verse markers still register as matches.
  return findMatchesInText(stripHtmlAggressive(text), terms).length > 0
}

/** Strip *block-level* tags so the snippet renders cleanly inline (with line-clamp).
 *  Inline tags (b, strong, em, i, u, sup, sub, a, span, mark, br, etc.) are kept. */
function flattenBlocksToInline(html: string): string {
  const blockTags = 'p|div|li|ul|ol|h[1-6]|blockquote|tr|td|th|article|section|aside|header|footer|nav|main|figure|figcaption|pre|table|thead|tbody|tfoot'
  const re = new RegExp(`</?(?:${blockTags})\\b[^>]*>`, 'gi')
  return html.replace(re, ' ').replace(/\s+/g, ' ').trim()
}

/** Walk all text nodes, build the FLAT text content of `root`, find matches in
 *  that flat text, then for each text node mark whichever portion of the match
 *  falls inside it. This handles phrases split across HTML tags (e.g.
 *  "los <em>países</em> que está") — they become several adjacent <mark>s. */
function applyHighlightsToContainer(root: Element, terms: string[]): Element[] {
  if (!terms.length) return []

  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null)
  const infos: { node: Text, start: number, end: number }[] = []
  let flat = ''
  let n: Node | null
  while ((n = walker.nextNode())) {
    const node = n as Text
    const text = node.nodeValue || ''
    infos.push({ node, start: flat.length, end: flat.length + text.length })
    flat += text
  }
  if (!infos.length) return []

  const matches = findMatchesInText(flat, terms)
  if (!matches.length) return []

  const marks: Element[] = []

  // Process text nodes from last to first so DOM modifications (which only
  // affect the node currently being replaced) don't invalidate references to
  // earlier nodes. Each node gets replaced once with a fragment containing
  // its 0..N match segments wrapped in <mark>.
  for (let i = infos.length - 1; i >= 0; i--) {
    const info = infos[i]
    const nodeText = info.node.nodeValue || ''

    const segments: { start: number, end: number }[] = []
    for (const match of matches) {
      const segStart = Math.max(match.start, info.start) - info.start
      const segEnd = Math.min(match.end, info.end) - info.start
      if (segStart < segEnd) segments.push({ start: segStart, end: segEnd })
    }
    if (!segments.length) continue
    segments.sort((a, b) => a.start - b.start)

    const frag = document.createDocumentFragment()
    let cursor = 0
    for (const { start, end } of segments) {
      if (start > cursor) frag.appendChild(document.createTextNode(nodeText.slice(cursor, start)))
      const mark = document.createElement('mark')
      mark.className = 'search-match'
      mark.textContent = nodeText.slice(start, end)
      frag.appendChild(mark)
      marks.push(mark)
      cursor = end
    }
    if (cursor < nodeText.length) frag.appendChild(document.createTextNode(nodeText.slice(cursor)))
    info.node.parentNode?.replaceChild(frag, info.node)
  }

  marks.reverse() // restore document order
  return marks
}

/** Build an HTML snippet centered on the first match, preserving inline
 *  formatting from the source (bold, italic, sup, links, etc.). Uses Range API
 *  so any tag opened inside the cropped region is automatically closed. */
function snippetHtmlAroundMatch(rawHtml: string, terms: string[], ctx = 120): string | null {
  if (!rawHtml) return null
  if (typeof document === 'undefined') return null
  if (!terms.length) return null

  const container = document.createElement('div')
  container.innerHTML = (typeof fixLink === 'function' ? fixLink(rawHtml) || '' : rawHtml)

  const marks = applyHighlightsToContainer(container, terms)
  if (!marks.length) return null

  // Collect all text nodes in document order *after* highlighting.
  const allText: Text[] = []
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null)
  let n: Node | null
  while ((n = walker.nextNode())) allText.push(n as Text)
  if (!allText.length) return null

  // Find text-node boundaries of the first <mark>.
  const firstMark = marks[0]
  const markText: Text[] = []
  const mw = document.createTreeWalker(firstMark, NodeFilter.SHOW_TEXT, null)
  while ((n = mw.nextNode())) markText.push(n as Text)
  if (!markText.length) return null

  const matchStartIdx = allText.indexOf(markText[0])
  const matchEndIdx = allText.indexOf(markText[markText.length - 1])
  if (matchStartIdx < 0 || matchEndIdx < 0) return null

  // Walk back to gather ~ctx chars before the match.
  let startNode: Text = allText[matchStartIdx]
  let startOffset = 0
  let before = ctx
  for (let i = matchStartIdx - 1; i >= 0 && before > 0; i--) {
    const len = allText[i].nodeValue?.length || 0
    if (len >= before) {
      startNode = allText[i]
      startOffset = Math.max(0, len - before)
      before = 0
    } else {
      before -= len
      startNode = allText[i]
      startOffset = 0
    }
  }

  // Walk forward to gather ~ctx chars after the match.
  let endNode: Text = allText[matchEndIdx]
  let endOffset = endNode.nodeValue?.length || 0
  let after = ctx
  for (let i = matchEndIdx + 1; i < allText.length && after > 0; i++) {
    const len = allText[i].nodeValue?.length || 0
    if (len >= after) {
      endNode = allText[i]
      endOffset = after
      after = 0
    } else {
      after -= len
      endNode = allText[i]
      endOffset = len
    }
  }

  // Snap boundaries to nearest word breaks for cleaner cuts.
  const startText = startNode.nodeValue || ''
  if (startOffset > 0 && startOffset < startText.length) {
    const sp = startText.indexOf(' ', startOffset)
    if (sp !== -1 && sp - startOffset < 25) startOffset = sp + 1
  }
  const endText = endNode.nodeValue || ''
  if (endOffset > 0 && endOffset < endText.length) {
    const sp = endText.lastIndexOf(' ', endOffset)
    if (sp !== -1 && endOffset - sp < 25) endOffset = sp
  }

  const range = document.createRange()
  range.setStart(startNode, startOffset)
  range.setEnd(endNode, endOffset)
  const tmp = document.createElement('div')
  tmp.appendChild(range.cloneContents())

  let html = flattenBlocksToInline(tmp.innerHTML)

  // Ellipsis if we cut anything off.
  const truncatedStart = startNode !== allText[0] || startOffset > 0
  const lastNode = allText[allText.length - 1]
  const lastLen = lastNode.nodeValue?.length || 0
  const truncatedEnd = endNode !== lastNode || endOffset < lastLen
  if (truncatedStart) html = '… ' + html
  if (truncatedEnd) html = html + ' …'

  return html
}

/** No-query case: take a short HTML chunk from the start of the body. */
function startHtmlChunk(rawHtml: string, maxChars = 180): string | null {
  if (!rawHtml) return null
  if (typeof document === 'undefined') {
    const plain = stripHtml(rawHtml)
    return plain ? escapeHtml(plain.length > maxChars ? plain.slice(0, maxChars) + '…' : plain) : null
  }

  const container = document.createElement('div')
  container.innerHTML = (typeof fixLink === 'function' ? fixLink(rawHtml) || '' : rawHtml)

  const allText: Text[] = []
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null)
  let n: Node | null
  while ((n = walker.nextNode())) allText.push(n as Text)
  if (!allText.length) return null

  let endNode: Text = allText[0]
  let endOffset = 0
  let remaining = maxChars
  for (let i = 0; i < allText.length && remaining > 0; i++) {
    const len = allText[i].nodeValue?.length || 0
    if (len >= remaining) {
      endNode = allText[i]
      endOffset = remaining
      remaining = 0
    } else {
      remaining -= len
      endNode = allText[i]
      endOffset = len
    }
  }

  const range = document.createRange()
  range.setStart(allText[0], 0)
  range.setEnd(endNode, endOffset)
  const tmp = document.createElement('div')
  tmp.appendChild(range.cloneContents())

  let html = flattenBlocksToInline(tmp.innerHTML)

  const lastNode = allText[allText.length - 1]
  const lastLen = lastNode.nodeValue?.length || 0
  if (endNode !== lastNode || endOffset < lastLen) html = html + ' …'

  return html
}

/** Plain-text fallback when the HTML-preserving snippet can't find the phrase
 *  (because tags get in the way). We strip HTML, find the phrase in the
 *  resulting text, and build a snippet around it. Loses inline formatting but
 *  guarantees a snippet exists for every result Meilisearch returned. */
function snippetPlainAroundMatch(rawHtml: string, terms: string[], ctx = 120, aggressive = false): string | null {
  const plain = aggressive ? stripHtmlAggressive(rawHtml) : stripHtml(rawHtml)
  if (!plain) return null

  const matches = findMatchesInText(plain, terms)
  if (!matches.length) return null

  const first = matches[0]
  let start = Math.max(0, first.start - ctx)
  let end = Math.min(plain.length, first.end + ctx)

  if (start > 0) {
    const sp = plain.indexOf(' ', start)
    if (sp !== -1 && sp - start < 25) start = sp + 1
  }
  if (end < plain.length) {
    const sp = plain.lastIndexOf(' ', end)
    if (sp !== -1 && end - sp < 25) end = sp
  }

  // Highlight every match that falls inside the snippet window.
  const winMatches = matches.filter(m => m.start >= start && m.end <= end)
  let html = ''
  let cursor = start
  for (const m of winMatches) {
    if (m.start > cursor) html += escapeHtml(plain.slice(cursor, m.start))
    html += '<mark class="search-match">' + escapeHtml(plain.slice(m.start, m.end)) + '</mark>'
    cursor = m.end
  }
  if (cursor < end) html += escapeHtml(plain.slice(cursor, end))

  if (start > 0) html = '… ' + html
  if (end < plain.length) html = html + ' …'
  return html
}

interface BodyPreview {
  html: string
  /** True if we couldn't pinpoint the phrase position in the body and are
   *  just showing the start of the document as fallback context. */
  approximate: boolean
}

function bodyPreview(rawHtml: string, terms: string[], meiliMatchedBody = false): BodyPreview | null {
  if (!rawHtml) return null
  if (terms.length) {
    // 1. HTML-preserving snippet (keeps bold/italic/sup formatting).
    const htmlSnippet = snippetHtmlAroundMatch(rawHtml, terms, 120)
    if (htmlSnippet) return { html: htmlSnippet, approximate: false }
    // 2. Plain-text snippet (handles phrases split across simple inline tags).
    const plainSnippet = snippetPlainAroundMatch(rawHtml, terms, 120, false)
    if (plainSnippet) return { html: plainSnippet, approximate: false }
    // 3. Aggressive plain-text snippet — drops <sup>/<sub> content (footnote
    //    numbers, verse markers) that often interrupt phrases between words.
    const aggressiveSnippet = snippetPlainAroundMatch(rawHtml, terms, 120, true)
    if (aggressiveSnippet) return { html: aggressiveSnippet, approximate: false }
    // 4. Meilisearch insists the body matched but we can't pinpoint where —
    //    show the start of the body as fallback context, and flag the row.
    if (meiliMatchedBody) {
      const start = startHtmlChunk(rawHtml, 180)
      if (start) return { html: start, approximate: true }
    }
    return null
  }
  const start = startHtmlChunk(rawHtml, 200)
  return start ? { html: start, approximate: false } : null
}

// ---- Row view-model ----------------------------------------------------

interface RowVm {
  activity: SearchHit
  title: string
  body: string | null
  bodyApproximate: boolean
  extraSnippets: { field: string, snippet: string }[]
  extraFields: string[]
}

const rows = computed<RowVm[]>(() => {
  const terms = buildTerms(props.query || '')
  const hasQuery = terms.length > 0

  const result = props.activities.map((activity) => {
    const titleText = activity.title || ''
    const titleHtml = highlightPlain(titleText, terms)

    const meiliMatchedBody = !!activity._matchesPosition?.body
    let bodyResult = bodyPreview(activity.body || '', terms, meiliMatchedBody)

    const titleMatched = hasQuery && findInPlain(titleText, terms)
    const bodyMatched = hasQuery && !!bodyResult && !bodyResult.approximate
      && bodyResult.html.includes('<mark')

    const showFallback = hasQuery && !titleMatched && !bodyMatched

    const extraSnippets: RowVm['extraSnippets'] = []
    const extraFields: string[] = []

    if (showFallback) {
      for (const key of Object.keys(activity)) {
        if (HIDDEN_FIELDS.has(key) || key.startsWith('_')) continue
        const val = (activity as Record<string, unknown>)[key]
        if (typeof val !== 'string') continue
        if (!findInPlain(val, terms)) continue
        if (!extraFields.includes(key)) extraFields.push(key)
        if (extraSnippets.length < 2) {
          const snip = snippetHtmlAroundMatch(val, terms, 90)
            || snippetPlainAroundMatch(val, terms, 90, false)
            || snippetPlainAroundMatch(val, terms, 90, true)
          if (snip) extraSnippets.push({ field: key, snippet: snip })
        }
      }

      if (!extraFields.length && activity._matchesPosition) {
        for (const key of Object.keys(activity._matchesPosition)) {
          if (!HIDDEN_FIELDS.has(key)) extraFields.push(key)
        }
      }
    }

    // Universal safety net: if there's a query but we still have NOTHING to
    // show below the title (no body snippet, no extras, no "matched in" hint),
    // surface at least the start of the body so the user knows what document
    // they're looking at and that Meilisearch saw a match somewhere.
    if (
      hasQuery
      && !bodyResult
      && !extraSnippets.length
      && !extraFields.length
      && activity.body
    ) {
      const start = startHtmlChunk(activity.body, 180)
      if (start) bodyResult = { html: start, approximate: true }
    }

    return {
      activity,
      title: titleHtml,
      body: bodyResult?.html ?? null,
      bodyApproximate: bodyResult?.approximate ?? false,
      extraSnippets,
      extraFields
    }
  })

  if (hasQuery) {
    return result.filter(row => {
      const titleHasMatch = row.title.includes('<mark')
      const hasBodyMatch = row.body && !row.bodyApproximate
      const hasExtraSnippets = row.extraSnippets.length > 0
      return titleHasMatch || hasBodyMatch || hasExtraSnippets
    })
  }

  return result
})

// ---- Infinite scroll ---------------------------------------------------

const sentinel = ref<HTMLElement | null>(null)

useIntersectionObserver(
  sentinel,
  ([entry]) => {
    if (!entry?.isIntersecting) return
    if (props.hasMore && !props.loadingMore && !props.loading) {
      emits('loadMore')
    }
  },
  { threshold: 0 }
)
</script>

<template>
  <div class="overflow-y-auto divide-y divide-default flex-1">
    <div
      v-if="loading && !rows.length"
      class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
    >
      <UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
      Buscando...
    </div>

    <div
      v-else-if="!rows.length"
      class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-sm"
    >
      <UIcon name="i-lucide-inbox" class="size-10" />
      <p>{{ query ? `Sin coincidencias para "${query}"` : 'No hay actividades' }}</p>
    </div>

    <div
      v-for="(row, index) in rows"
      :key="row.activity._id ?? index"
      :ref="(el) => { activitiesRefs[row.activity._id] = el as Element | null }"
    >
      <div
        class="bg-gray-100 p-4 sm:px-4 text-sm cursor-pointer border-l-2 transition-colors"
        :class="[
          row.activity.unread ? 'text-highlighted' : 'text-toned',
          selectedActivity && selectedActivity._id === row.activity._id
            ? 'border-primary bg-primary/10'
            : 'border-transparent hover:border-primary hover:bg-primary/5'
        ]"
        @click="selectedActivity = row.activity"
      >
        <div class="">
          <div class="flex items-start justify-between gap-2 mb-1 ">
            <div class="text-sm font-semibold line-clamp-2" v-html="row.title" />
            <div class="flex items-center gap-1 shrink-0">
              <UChip v-if="row.activity.unread" />
              <UTooltip
                v-if="collection"
                :text="favorites.isFavorite(collection, row.activity._id) ? 'Quitar de mi lista' : 'Guardar en mi lista'"
              >
                <UButton
                  :icon="favorites.isFavorite(collection, row.activity._id) ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
                  :color="favorites.isFavorite(collection, row.activity._id) ? 'primary' : 'neutral'"
                  variant="ghost"
                  size="xs"
                  :aria-label="favorites.isFavorite(collection, row.activity._id) ? 'Quitar de mi lista' : 'Guardar en mi lista'"
                  @click="(ev: MouseEvent) => onToggleFavorite(row.activity, ev)"
                />
              </UTooltip>
            </div>
          </div>

          <p class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-sm sm:text-xs  mb-1 justify-between ">
            <span v-if="hasDate(row.activity)" class="flex items-center gap-1"><UIcon name="ph:calendar" class="size-5 text-green-600" />{{ safeDate(row.activity) }}</span>
            <span class="truncate flex items-center"><UIcon name="ph:map-pin" class="size-5 text-green-600" />{{ formatLocation(row.activity) }}</span>
          </p>

          <div class="mt-2 !text-sm" v-if="row.body">
            <div
              v-if="row.bodyApproximate"
              class="text-[11px] text-warning flex items-center gap-1 mb-0.5 bg-white p-2 rounded-xl"
            >
              <UIcon name="i-lucide-info" class="size-3" />
              <span>Coincidencia en el documento (abre para ubicarla con la búsqueda interna)</span>
            </div>
            <div
              class="mt-1 snippet-html text-dimmed text-sm sm:text-xs bg-white p-3 rounded-lg"
              v-html="row.body"
            />
          </div>

          <div
            v-for="extra in row.extraSnippets"
            :key="extra.field"
            class="mt-1.5 text-xs text-dimmed flex items-start gap-1.5"
          >
            <UBadge
              :label="fieldLabel(extra.field)"
              size="xs"
              color="warning"
              variant="subtle"
              class="mt-0.5 shrink-0 capitalize"
            />
            <span class="snippet-html line-clamp-2" v-html="extra.snippet" />
          </div>

          <div
            v-if="!row.extraSnippets.length && row.extraFields.length"
            class="mt-1.5 text-xs text-muted flex items-center gap-1.5 "
          >
            <UIcon name="i-lucide-info" class="size-3.5" />
            <span>
              Coincide en:
              <span class="font-medium capitalize">{{ row.extraFields.map(fieldLabel).join(', ') }}</span>
            </span>
          </div>
        </div>
      </div>
    </div>

    <div ref="sentinel" class="h-12 -mt-12 pointer-events-none" aria-hidden="true" />

    <div
      v-if="loadingMore"
      class="py-3 flex items-center justify-center gap-2 text-xs text-muted"
    >
      <UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
      Cargando más...
    </div>

    <div
      v-else-if="rows.length && !hasMore && !loading"
      class="py-3 text-center text-xs text-dimmed"
    >
      No hay más resultados
    </div>
  </div>
</template>

<style scoped>
/* Keep snippet HTML readable but compact. We render arbitrary inline formatting
   from the source (bold, italic, sup, links, mark) — strip default link colors
   so the row stays readable, and prevent inline elements from breaking layout. */
.snippet-html :deep(a) {
  color: inherit;
  text-decoration: none;
  pointer-events: none;
  font-weight: inherit;
}
.snippet-html :deep(strong),
.snippet-html :deep(b) {
  font-weight: 600;
  color: var(--ui-color-toned, inherit);
}
.snippet-html :deep(em),
.snippet-html :deep(i) {
  font-style: italic;
}
.snippet-html :deep(sup) {
  font-size: 0.7em;
  vertical-align: super;
}
.snippet-html :deep(br) {
  display: none;
}
</style>
