import { sanitizeTsquery } from '@platform-modules/util/fts/sanitize-tsquery'

export type Hit = {
  entityType: string
  id: string
  rank: number
  title?: string
  snippetHtml?: string
}

export type SearchWindow = {
  limit: number
  offset: number
}

export type SearchProviderResult = {
  hits: Hit[]
  total: number
}

export type SearchProvider<Ctx> = (
  query: string,
  ctx: Ctx,
  window: SearchWindow,
) => Promise<SearchProviderResult>

export type SearchRegistry<Ctx> = Record<string, SearchProvider<Ctx>>

export type SearchParams<Ctx> = {
  query: string
  ctx: Ctx
  entityTypes?: string[]
  limit: number
  cursor?: string | null
}

export type SearchGroup = {
  entityType: string
  hits: Hit[]
  total: number
}

export type SearchResult = {
  groups: SearchGroup[]
  nextCursor: string | null
}

type SearchCursor = {
  entityType: string
  offset: number
}

// Caps attacker-controlled SQL OFFSET deep-scan (Postgres OFFSET is O(n)); 500 pages at limit 20.
const MAX_CURSOR_OFFSET = 10_000

function encodeCursor(cursor: SearchCursor): string {
  return btoa(JSON.stringify(cursor))
}

/**
 * Decode a client-supplied cursor (untrusted trust-boundary input). Returns
 * null on any malformed/forged token — caller treats null as "first page"
 * (lenient reset), never throws.
 */
function decodeCursor(encoded: string): SearchCursor | null {
  let parsed: unknown
  try {
    parsed = JSON.parse(atob(encoded))
  } catch {
    return null
  }
  if (typeof parsed !== 'object' || parsed === null) return null
  const { entityType, offset } = parsed as Record<string, unknown>
  if (typeof entityType !== 'string') return null
  if (
    typeof offset !== 'number' ||
    !Number.isInteger(offset) ||
    offset < 0 ||
    offset > MAX_CURSOR_OFFSET
  ) {
    return null
  }
  return { entityType, offset }
}

function resolveEntityTypes<Ctx>(
  registry: SearchRegistry<Ctx>,
  entityTypes?: string[],
): string[] {
  const keys = Object.keys(registry)
  if (!entityTypes || entityTypes.length === 0) return keys
  return entityTypes.filter((type) => type in registry)
}

export async function searchEntities<Ctx>(
  registry: SearchRegistry<Ctx>,
  params: SearchParams<Ctx>,
): Promise<SearchResult> {
  const sanitizedQuery = sanitizeTsquery(params.query)
  const candidateTypes = resolveEntityTypes(registry, params.entityTypes)
  const isSingleType = candidateTypes.length === 1

  let offset = 0
  let singleEntityType: string | undefined

  if (isSingleType) {
    singleEntityType = candidateTypes[0]
    if (params.cursor) {
      const decoded = decodeCursor(params.cursor)
      if (decoded && decoded.entityType === singleEntityType) {
        offset = decoded.offset
      }
    }
  }

  const providerResults = await Promise.all(
    candidateTypes.map(async (entityType) => {
      const window: SearchWindow = {
        limit: params.limit,
        offset: isSingleType ? offset : 0,
      }
      const provider = registry[entityType]!
      const result = await provider(sanitizedQuery, params.ctx, window)
      return { entityType, ...result }
    }),
  )

  const groups = providerResults
    .filter((result) => result.total > 0 || result.hits.length > 0)
    .map(({ entityType, hits, total }) => ({ entityType, hits, total }))
    .sort((a, b) => b.total - a.total)

  let nextCursor: string | null = null
  if (isSingleType && singleEntityType) {
    const raw =
      providerResults.find((result) => result.entityType === singleEntityType)?.total ?? 0
    const hasMore = offset + params.limit < raw
    if (hasMore) {
      nextCursor = encodeCursor({
        entityType: singleEntityType,
        offset: offset + params.limit,
      })
    }
  }

  return { groups, nextCursor }
}
