import { describe, expect, it } from 'vitest'
import {
  searchEntities,
  type Hit,
  type SearchRegistry,
  type SearchWindow,
} from './index'

type TestCtx = { tenantId: string }

function makeHit(entityType: string, id: string, rank: number): Hit {
  return { entityType, id, rank }
}

describe('searchEntities', () => {
  it('merges cross-entity results grouped by total desc with no cursor', async () => {
    const registry: SearchRegistry<TestCtx> = {
      posts: async () => ({
        hits: [makeHit('posts', 'p1', 0.9)],
        total: 10,
      }),
      users: async () => ({
        hits: [makeHit('users', 'u1', 0.8)],
        total: 5,
      }),
    }

    const result = await searchEntities(registry, {
      query: 'foo',
      ctx: { tenantId: 't1' },
      limit: 10,
    })

    expect(result.nextCursor).toBeNull()
    expect(result.groups.map((g) => g.entityType)).toEqual(['posts', 'users'])
    expect(result.groups[0]?.total).toBe(10)
    expect(result.groups[1]?.total).toBe(5)
  })

  it('round-trips single-type cursor pagination', async () => {
    const allHits = Array.from({ length: 5 }, (_, i) =>
      makeHit('posts', `p${i}`, 1 - i * 0.1),
    )

    const registry: SearchRegistry<TestCtx> = {
      posts: async (_query, _ctx, window) => ({
        hits: allHits.slice(window.offset, window.offset + window.limit),
        total: allHits.length,
      }),
    }

    const page1 = await searchEntities(registry, {
      query: 'foo',
      ctx: { tenantId: 't1' },
      entityTypes: ['posts'],
      limit: 2,
    })

    expect(page1.groups[0]?.hits.map((h) => h.id)).toEqual(['p0', 'p1'])
    expect(page1.nextCursor).not.toBeNull()

    const page2 = await searchEntities(registry, {
      query: 'foo',
      ctx: { tenantId: 't1' },
      entityTypes: ['posts'],
      limit: 2,
      cursor: page1.nextCursor,
    })

    expect(page2.groups[0]?.hits.map((h) => h.id)).toEqual(['p2', 'p3'])
    expect(page2.nextCursor).not.toBeNull()

    const page3 = await searchEntities(registry, {
      query: 'foo',
      ctx: { tenantId: 't1' },
      entityTypes: ['posts'],
      limit: 2,
      cursor: page2.nextCursor,
    })

    expect(page3.groups[0]?.hits.map((h) => h.id)).toEqual(['p4'])
    expect(page3.nextCursor).toBeNull()
  })

  it('forwards ctx and window to providers unread', async () => {
    const ctx = { tenantId: 'secret-tenant' }
    let recordedCtx: TestCtx | undefined
    let recordedWindow: SearchWindow | undefined

    const registry: SearchRegistry<TestCtx> = {
      posts: async (_query, forwardedCtx, window) => {
        recordedCtx = forwardedCtx
        recordedWindow = window
        return { hits: [makeHit('posts', 'p1', 1)], total: 1 }
      },
    }

    await searchEntities(registry, {
      query: 'foo',
      ctx,
      entityTypes: ['posts'],
      limit: 3,
    })

    expect(recordedCtx).toBe(ctx)
    expect(recordedWindow).toEqual({ limit: 3, offset: 0 })
  })

  it('sanitizes tsquery metachars without throwing', async () => {
    let receivedQuery = ''
    const registry: SearchRegistry<TestCtx> = {
      posts: async (query) => {
        receivedQuery = query
        return { hits: [], total: 0 }
      },
    }

    await expect(
      searchEntities(registry, {
        query: "&|!():*<>'; DROP TABLE users;--",
        ctx: { tenantId: 't1' },
        entityTypes: ['posts'],
        limit: 5,
      }),
    ).resolves.toEqual({ groups: [], nextCursor: null })

    expect(receivedQuery).not.toMatch(/['"|!()]/)
  })

  it('treats a malformed/forged cursor as first page without throwing', async () => {
    const allHits = Array.from({ length: 4 }, (_, i) => makeHit('posts', `p${i}`, 1))
    const registry: SearchRegistry<TestCtx> = {
      posts: async (_query, _ctx, window) => ({
        hits: allHits.slice(window.offset, window.offset + window.limit),
        total: allHits.length,
      }),
    }

    // garbage base64 → atob throws internally; must be caught → first page
    const garbage = await searchEntities(registry, {
      query: 'foo',
      ctx: { tenantId: 't1' },
      entityTypes: ['posts'],
      limit: 2,
      cursor: '!!!not-base64',
    })
    expect(garbage.groups[0]?.hits.map((h) => h.id)).toEqual(['p0', 'p1'])

    // negative offset → invalid shape; must NOT flow into the provider window
    let seenOffset = -1
    const guardRegistry: SearchRegistry<TestCtx> = {
      posts: async (_query, _ctx, window) => {
        seenOffset = window.offset
        return { hits: [], total: 0 }
      },
    }
    await searchEntities(guardRegistry, {
      query: 'foo',
      ctx: { tenantId: 't1' },
      entityTypes: ['posts'],
      limit: 2,
      cursor: btoa(JSON.stringify({ entityType: 'posts', offset: -5 })),
    })
    expect(seenOffset).toBe(0)
  })

  it('clamps an oversized-offset cursor to first page (deep-scan rejected)', async () => {
    let seenOffset = -1
    const registry: SearchRegistry<TestCtx> = {
      posts: async (_query, _ctx, window) => {
        seenOffset = window.offset
        return { hits: [makeHit('posts', 'p0', 1)], total: 1 }
      },
    }

    // Forged cursor: matching entityType (so the entityType guard can't fire) +
    // an offset far above MAX_CURSOR_OFFSET → decodeCursor rejects → first-page reset.
    await searchEntities(registry, {
      query: 'foo',
      ctx: { tenantId: 't1' },
      entityTypes: ['posts'],
      limit: 20,
      cursor: btoa(JSON.stringify({ entityType: 'posts', offset: 999_999_999 })),
    })

    expect(seenOffset).toBe(0)
  })

  it('returns empty groups for an empty registry', async () => {
    const registry: SearchRegistry<TestCtx> = {}
    const result = await searchEntities(registry, {
      query: 'foo',
      ctx: { tenantId: 't1' },
      limit: 10,
    })
    expect(result).toEqual({ groups: [], nextCursor: null })
  })
})
