/**
 * Client-side re-implementation of the backend list-endpoint text search,
 * used to explain on each thumbnail *which* metadata field actually
 * matched the user's query. The backend search at
 * `backend/app/routers/photos.py` walks filename, user_title,
 * user_notes, exif_json, and tag names with case-insensitive ILIKE; we
 * mirror that here over the PhotoResponse fields that the list endpoint
 * already returns on the wire.
 *
 * Pure, deterministic, no I/O. Returns `null` when the query is empty
 * or no field matches (the latter shouldn't happen when the backend
 * and frontend logic agree, but we'd rather render nothing than lie).
 */
import type { Photo } from '../types/photo'

export type SearchMatchField =
  | 'filename'
  | 'title'
  | 'notes'
  | 'tag'
  | 'exif'

export interface SearchMatch {
  field: SearchMatchField
  /** Human-friendly label shown before the excerpt, e.g. "Tag" or
   *  "EXIF: Make". The backend doesn't store per-field breakdowns for
   *  exif so we parse the blob and name the key whose value matched. */
  label: string
  /** Short excerpt centred on the matched substring — up to ~40 chars
   *  of context on either side, ellipsed when trimmed. */
  excerpt: string
  /** Char offset of the match WITHIN ``excerpt`` (not the original
   *  value), so the UI can wrap the matched run in a highlight span
   *  without redoing the search. */
  matchStart: number
  /** Length of the matched run inside ``excerpt``. Together with
   *  ``matchStart`` this fully describes where to highlight. */
  matchLength: number
}

const MAX_EXCERPT = 48

interface Excerpt {
  text: string
  /** Offset of the matched substring inside ``text``. */
  matchStart: number
  matchLength: number
}

/** Build a compact excerpt of ``value`` centred on the first occurrence
 *  of ``needle`` (case-insensitive) and return it alongside the exact
 *  offset of the match inside the excerpt, so the caller can highlight
 *  it without re-searching. Preserves the original casing so the user
 *  reads what the metadata actually contains. */
function excerpt(value: string, needleLower: string): Excerpt {
  const lower = value.toLowerCase()
  const idx = lower.indexOf(needleLower)
  if (idx === -1) {
    return {
      text: value.slice(0, MAX_EXCERPT),
      matchStart: 0,
      matchLength: 0,
    }
  }
  const half = Math.floor((MAX_EXCERPT - needleLower.length) / 2)
  const start = Math.max(0, idx - half)
  const end = Math.min(value.length, idx + needleLower.length + half)
  const prefix = start > 0 ? '…' : ''
  const suffix = end < value.length ? '…' : ''
  const text = prefix + value.slice(start, end) + suffix
  return {
    text,
    matchStart: prefix.length + (idx - start),
    matchLength: needleLower.length,
  }
}

function wrap(
  field: SearchMatchField,
  label: string,
  ex: Excerpt,
): SearchMatch {
  return {
    field,
    label,
    excerpt: ex.text,
    matchStart: ex.matchStart,
    matchLength: ex.matchLength,
  }
}

/** Walk a parsed EXIF object looking for the first (key, value) pair
 *  whose stringified value contains the needle. Keys are stripped of
 *  their `Group:` prefix for display (EXIF output from exiftool looks
 *  like `EXIF:Make` / `IFD0:Model`). Returns null when nothing inside
 *  the blob matches. */
function findExifMatch(
  exifJson: string | null | undefined,
  needleLower: string,
): { key: string; value: string } | null {
  if (!exifJson) return null
  let parsed: unknown
  try {
    parsed = JSON.parse(exifJson)
  } catch {
    // Not JSON? Fall back to substring — the whole blob still
    // counts as "EXIF" in the chip.
    const lower = exifJson.toLowerCase()
    if (lower.includes(needleLower)) {
      return { key: 'metadata', value: exifJson }
    }
    return null
  }
  if (!parsed || typeof parsed !== 'object') return null
  for (const [rawKey, rawVal] of Object.entries(parsed as Record<string, unknown>)) {
    if (rawVal == null) continue
    const str = typeof rawVal === 'string' ? rawVal : JSON.stringify(rawVal)
    if (str.toLowerCase().includes(needleLower)) {
      // Drop the `Group:` prefix some exiftool outputs carry.
      const label = rawKey.includes(':') ? rawKey.split(':').slice(-1)[0] : rawKey
      return { key: label, value: str }
    }
  }
  return null
}

/**
 * Figure out which field of ``photo`` the user's ``query`` matches and
 * return a compact chip-ready description. Field priority mirrors what
 * a human would look at first: filename → title → notes → tag → EXIF.
 * Returns `null` for an empty query or (rarely) a photo that somehow
 * came back from the backend without any client-visible match.
 */
export function findSearchMatch(
  photo: Photo,
  query: string,
): SearchMatch | null {
  const trimmed = query.trim()
  if (!trimmed) return null
  const needle = trimmed.toLowerCase()

  if (photo.filename && photo.filename.toLowerCase().includes(needle)) {
    return wrap('filename', 'Filename', excerpt(photo.filename, needle))
  }

  // PhotoResponse's user_title/user_notes aren't on the Photo type used
  // by the timeline (that's the lighter list shape), but any extra
  // props are still carried through React Query's cache — cast through
  // `unknown` so we can read them opportunistically without widening
  // the Photo type for every consumer.
  const anyPhoto = photo as unknown as Record<string, unknown>
  const title = typeof anyPhoto.user_title === 'string' ? anyPhoto.user_title : null
  if (title && title.toLowerCase().includes(needle)) {
    return wrap('title', 'Title', excerpt(title, needle))
  }
  const notes = typeof anyPhoto.user_notes === 'string' ? anyPhoto.user_notes : null
  if (notes && notes.toLowerCase().includes(needle)) {
    return wrap('notes', 'Note', excerpt(notes, needle))
  }

  if (photo.tags && photo.tags.length > 0) {
    const hit = photo.tags.find((t) => t.name.toLowerCase().includes(needle))
    if (hit) {
      return wrap('tag', 'Tag', excerpt(hit.name, needle))
    }
  }

  const exifJson = typeof anyPhoto.exif_json === 'string' ? anyPhoto.exif_json : null
  const exifHit = findExifMatch(exifJson, needle)
  if (exifHit) {
    return wrap('exif', `EXIF: ${exifHit.key}`, excerpt(exifHit.value, needle))
  }

  return null
}
