// Door search: runs entirely in memory over the preloaded attendee list, so
// typing never touches the network.
//
// The rules exist because of who is standing at the door. People say "Jose" for
// José and "Nunez" for Núñez, so both sides are stripped of diacritics. They give
// a surname first as often as a first name, so every word is matched
// independently. Two people called María are told apart by the last digits of a
// phone number, so a mostly-numeric query searches phone digits instead of names.

import type { DoorAttendee } from '@/lib/api';

/** Lowercase and strip combining marks, so "José" and "jose" are the same string. */
export function normalize(value: string): string {
  return value
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .toLowerCase()
    .trim();
}

export function digitsOnly(value: string): string {
  return value.replace(/\D/g, '');
}

/** Precomputed per attendee once per list load; recomputing per keystroke is what makes search feel slow. */
export interface DoorSearchIndex {
  words: string[];
  full: string;
  phoneDigits: string;
  email: string;
}

export function buildIndex(attendee: DoorAttendee): DoorSearchIndex {
  const first = normalize(attendee.firstName || '');
  const last = normalize(attendee.lastName || '');
  const full = `${first} ${last}`.trim();
  return {
    words: full.split(/\s+/).filter(Boolean),
    full,
    phoneDigits: digitsOnly(attendee.phone || ''),
    email: normalize(attendee.email || ''),
  };
}

/**
 * A query is treated as a phone lookup when it is mostly digits — staff asking
 * "what are the last four of your number?" type exactly that and nothing else.
 */
export function isPhoneQuery(query: string): boolean {
  const compact = query.replace(/\s/g, '');
  if (compact.length < 3) return false;
  const digits = digitsOnly(compact);
  return digits.length >= 3 && digits.length / compact.length >= 0.6;
}

/**
 * Bounded Damerau-Levenshtein: true when one insert, delete, substitution or
 * swap of adjacent letters apart. The swap matters — "Jhon" for John is the
 * single commonest way a name gets mistyped, and plain Levenshtein scores it 2.
 */
function withinOneEdit(a: string, b: string): boolean {
  const la = a.length;
  const lb = b.length;
  if (Math.abs(la - lb) > 1) return false;

  let i = 0;
  let j = 0;
  let edits = 0;
  while (i < la && j < lb) {
    if (a[i] === b[j]) {
      i++;
      j++;
      continue;
    }
    if (++edits > 1) return false;
    if (la > lb) i++;
    else if (lb > la) j++;
    else if (a[i + 1] === b[j] && a[i] === b[j + 1]) {
      // Adjacent letters swapped: consume both and count it as the one edit.
      i += 2;
      j += 2;
    } else {
      i++;
      j++;
    }
  }
  return edits + (la - i) + (lb - j) <= 1;
}

// Lower tier ranks first.
const TIER_PREFIX = 0;
const TIER_SUBSTRING = 1;
const TIER_FUZZY = 2;

/** Best match tier for one attendee, or null when the query does not match at all. */
export function matchTier(index: DoorSearchIndex, query: string, phoneMode: boolean): number | null {
  if (phoneMode) {
    const digits = digitsOnly(query);
    if (!index.phoneDigits || !digits) return null;
    // Substring, so a query of the last four digits matches +595 981 234 567.
    return index.phoneDigits.includes(digits) ? TIER_PREFIX : null;
  }

  if (index.full.startsWith(query)) return TIER_PREFIX;
  if (index.words.some((word) => word.startsWith(query))) return TIER_PREFIX;
  if (index.full.includes(query)) return TIER_SUBSTRING;
  // Email is a fallback, not a way staff normally searches, and a one- or
  // two-letter query would match almost every address — so it needs 3 characters.
  if (query.length >= 3 && index.email && index.email.includes(query)) return TIER_SUBSTRING;
  // Typo tolerance is the last resort: "jhon" still finds John, but only after
  // every real prefix and substring match has been listed.
  if (query.length >= 4 && index.words.some((word) => withinOneEdit(word, query))) return TIER_FUZZY;
  return null;
}

/** Attendees who are still waiting to come in, shown when the input is empty. */
const OPEN_STATUSES = new Set(['confirmed', 'pending', 'on_hold']);

export interface IndexedAttendee {
  attendee: DoorAttendee;
  index: DoorSearchIndex;
}

/**
 * Sort weight for equal match quality: people still to come in first, then those
 * already inside, then cancelled tickets last — a cancelled row must never sit
 * above a valid one that matches just as well.
 */
function stateWeight(attendee: DoorAttendee): number {
  if (attendee.status === 'cancelled') return 2;
  return attendee.checkedIn ? 1 : 0;
}

function byRank(
  a: { attendee: DoorAttendee; tier: number },
  b: { attendee: DoorAttendee; tier: number },
): number {
  if (a.tier !== b.tier) return a.tier - b.tier;
  const stateDiff = stateWeight(a.attendee) - stateWeight(b.attendee);
  if (stateDiff !== 0) return stateDiff;
  return a.attendee.fullName.localeCompare(b.attendee.fullName, undefined, { sensitivity: 'base' });
}

export function searchAttendees(indexed: IndexedAttendee[], rawQuery: string): DoorAttendee[] {
  const query = normalize(rawQuery);

  // Empty input is the small-event case: everyone still to come in, alphabetical,
  // so staff can scroll and tap without typing anything at all.
  if (!query) {
    return indexed
      .filter(({ attendee }) => OPEN_STATUSES.has(attendee.status) && !attendee.checkedIn)
      .map(({ attendee }) => attendee)
      .sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }));
  }

  const phoneMode = isPhoneQuery(rawQuery);
  const scored: { attendee: DoorAttendee; tier: number }[] = [];
  for (const entry of indexed) {
    const tier = matchTier(entry.index, query, phoneMode);
    if (tier !== null) scored.push({ attendee: entry.attendee, tier });
  }
  return scored.sort(byRank).map((s) => s.attendee);
}

/** True when the typed text already names somebody exactly — no walk-in row needed. */
export function hasExactMatch(results: DoorAttendee[], rawQuery: string): boolean {
  const query = normalize(rawQuery);
  if (!query) return true;
  return results.some((a) => normalize(a.fullName) === query || normalize(a.firstName) === query);
}
