import { PGlite } from '@electric-sql/pglite'
import { drizzle } from 'drizzle-orm/pglite'
import { beforeEach, describe, expect, it } from 'vitest'
import type { Querier } from '@platform-modules/db'
import { createDbNotificationStore, createInboxChannel, InboxValidationError } from './inbox.js'
import { notificationsSchema, notificationsTableSql, type NotificationsSchema } from './inbox-schema.js'
import type { RenderedMessage } from './index.js'

type Db = Querier<NotificationsSchema>
async function createDb(): Promise<Db> {
  const client = new PGlite()
  const db = drizzle(client, { schema: notificationsSchema }) as unknown as Db
  await client.exec(notificationsTableSql())
  return db
}
let db: Db
beforeEach(async () => { db = await createDb() })

describe('NotificationStore — Gate-3 contract', () => {
  it('put → list round-trip returns the record (readAt null)', async () => {
    const store = createDbNotificationStore(db)
    const r = await store.put({ userId: 'u1', title: 'hi', body: 'b', href: '/x', kind: 'sys' })
    expect(r.id).toBeTruthy()
    expect(r.readAt).toBeNull()
    const page = await store.list('u1')
    expect(page.items.map((i) => i.id)).toContain(r.id)
  })

  it('IDOR: list is user-scoped — never returns another user\'s rows', async () => {
    const store = createDbNotificationStore(db)
    await store.put({ userId: 'A', title: 'a' })
    await store.put({ userId: 'B', title: 'b' })
    const page = await store.list('A')
    expect(page.items.every((i) => i.userId === 'A')).toBe(true)
    expect(page.items.length).toBe(1)
  })

  it('IDOR: a cursor lifted from B\'s page cannot widen A\'s scope', async () => {
    const store = createDbNotificationStore(db)
    for (let i = 0; i < 3; i++) await store.put({ userId: 'B', title: `b${i}` })
    await store.put({ userId: 'A', title: 'a0' })
    const bPage = await store.list('B', { limit: 1 })
    expect(bPage.nextCursor).toBeTruthy()
    const aPage = await store.list('A', { cursor: bPage.nextCursor! })
    expect(aPage.items.every((i) => i.userId === 'A')).toBe(true)
  })

  it('unreadCount + unreadOnly reflect readAt', async () => {
    const store = createDbNotificationStore(db)
    const r1 = await store.put({ userId: 'u1', title: '1' })
    await store.put({ userId: 'u1', title: '2' })
    expect(await store.unreadCount('u1')).toBe(2)
    await store.markRead('u1', [r1.id])
    expect(await store.unreadCount('u1')).toBe(1)
    const unread = await store.list('u1', { unreadOnly: true })
    expect(unread.items.every((i) => i.readAt === null)).toBe(true)
    expect(unread.items.length).toBe(1)
  })

  it('IDOR: markRead another user\'s id is a SILENT no-op (no throw, stays unread)', async () => {
    const store = createDbNotificationStore(db)
    const bRow = await store.put({ userId: 'B', title: 'b' })
    await expect(store.markRead('A', [bRow.id])).resolves.toBeUndefined()
    expect(await store.unreadCount('B')).toBe(1) // still unread
  })

  it('markRead([]) is a no-op; markRead all clears every unread for the user only', async () => {
    const store = createDbNotificationStore(db)
    await store.put({ userId: 'u1', title: '1' })
    await store.put({ userId: 'u2', title: '2' })
    await store.markRead('u1', [])
    expect(await store.unreadCount('u1')).toBe(1)
    await store.markRead('u1', 'all')
    expect(await store.unreadCount('u1')).toBe(0)
    expect(await store.unreadCount('u2')).toBe(1)
  })

  it('DoS: oversized ids array → InboxValidationError', async () => {
    const store = createDbNotificationStore(db)
    const big = Array.from({ length: 1001 }, (_, i) => `id${i}`)
    await expect(store.markRead('u1', big)).rejects.toBeInstanceOf(InboxValidationError)
  })

  it('DoS: list limit is clamped, never rejected', async () => {
    const store = createDbNotificationStore(db)
    await store.put({ userId: 'u1', title: '1' })
    await expect(store.list('u1', { limit: 99999 })).resolves.toBeDefined()
  })
})

describe('createInboxChannel — ChannelAdapter over the notify() pipeline', () => {
  const rendered: RenderedMessage = { subject: 'New reply', body: 'someone replied' }

  it('persists to the store and returns the record id', async () => {
    const store = createDbNotificationStore(await createDb())
    const ch = createInboxChannel(store)
    expect(ch.channel).toBe('inbox')
    const res = await ch.send(rendered, { userId: 'u1', href: '/t/1', kind: 'reply' })
    expect(res.ok).toBe(true)
    if (res.ok) expect(res.id).toBeTruthy()
    expect(await store.unreadCount('u1')).toBe(1)
  })

  it('info-disclosure floor: a store failure returns a GENERIC error, swallowing the raw db error', async () => {
    const boom = { put: async () => { throw new Error('connection to db.internal:5432 failed: password=hunter2') } }
    const ch = createInboxChannel(boom as never)
    const res = await ch.send(rendered, { userId: 'u1' })
    expect(res.ok).toBe(false)
    if (!res.ok) {
      expect(res.error.message).toBe('inbox delivery failed')
      expect(res.error.message).not.toContain('hunter2')
      expect(res.error.message).not.toContain('5432')
    }
  })

  it('missing recipient.userId → non-retryable validation error (no throw)', async () => {
    const store = createDbNotificationStore(await createDb())
    const ch = createInboxChannel(store)
    const res = await ch.send(rendered, {} as never)
    expect(res.ok).toBe(false)
    if (!res.ok) expect(res.error.retryable).toBe(false)
  })
})
