import { describe, expect, it, vi } from 'vitest'
import {
  deriveDedupKey,
  notify,
  render,
  type ChannelAdapter,
  type ChannelResult,
  type DedupStore,
  type NotifyEvent,
  type PreferenceStore,
} from './index.js'

function createMemoryDedupStore(): DedupStore & { keys: Set<string> } {
  const keys = new Set<string>()
  return {
    keys,
    async seen(key) {
      return keys.has(key)
    },
    async mark(key) {
      keys.add(key)
    },
  }
}

function baseEvent(overrides: Partial<NotifyEvent> = {}): NotifyEvent {
  return {
    type: 'order.shipped',
    userId: 'user-1',
    dedupKey: 'evt-1',
    template: { subject: 'Hi {{name}}', body: 'Order {{id}} shipped' },
    data: { name: 'Dana', id: '42' },
    recipients: { email: 'a@example.com', sms: '+15551234' },
    ...overrides,
  }
}

function allEnabledPrefs(channels: string[]): PreferenceStore {
  return {
    getEnabledChannels: vi.fn(async () => channels),
  }
}

describe('render', () => {
  it('substitutes {{key}} and leaves missing keys empty', () => {
    expect(render('Hi {{name}}, order {{id}} / {{missing}}', { name: 'Dana', id: '9' })).toBe(
      'Hi Dana, order 9 / ',
    )
  })
})

describe('deriveDedupKey', () => {
  it('combines event, recipient, and channel', () => {
    const event = baseEvent()
    expect(deriveDedupKey(event, 'email', 'a@example.com')).toBe(
      'evt-1:email:a@example.com',
    )
  })

  it('returns null when neither dedupKey nor id is set (no type fallback)', () => {
    const event = baseEvent({ dedupKey: undefined, id: undefined })
    expect(deriveDedupKey(event, 'email', 'a@example.com')).toBeNull()
  })
})

describe('notify', () => {
  it('fans out to two registered channels — both send called', async () => {
    const emailSend = vi.fn(async (): Promise<ChannelResult> => ({ ok: true, id: 'e1' }))
    const smsSend = vi.fn(async (): Promise<ChannelResult> => ({ ok: true, id: 's1' }))
    const adapters: ChannelAdapter[] = [
      { channel: 'email', send: emailSend },
      { channel: 'sms', send: smsSend },
    ]

    const results = await notify(baseEvent(), {
      adapters,
      preferences: allEnabledPrefs(['email', 'sms']),
      dedup: createMemoryDedupStore(),
    })

    expect(emailSend).toHaveBeenCalledOnce()
    expect(smsSend).toHaveBeenCalledOnce()
    expect(results).toEqual([
      { channel: 'email', status: 'sent', id: 'e1' },
      { channel: 'sms', status: 'sent', id: 's1' },
    ])
  })

  it('preference opt-out — disabled channel send not called', async () => {
    const emailSend = vi.fn(async (): Promise<ChannelResult> => ({ ok: true }))
    const smsSend = vi.fn(async (): Promise<ChannelResult> => ({ ok: true }))

    const results = await notify(baseEvent(), {
      adapters: [
        { channel: 'email', send: emailSend },
        { channel: 'sms', send: smsSend },
      ],
      preferences: allEnabledPrefs(['email']),
      dedup: createMemoryDedupStore(),
    })

    expect(emailSend).toHaveBeenCalledOnce()
    expect(smsSend).not.toHaveBeenCalled()
    expect(results.find((r) => r.channel === 'sms')?.status).toBe('skipped')
  })

  it('duplicate event key — second notify deduped (single delivery)', async () => {
    const emailSend = vi.fn(async (): Promise<ChannelResult> => ({ ok: true, id: 'e1' }))
    const dedup = createMemoryDedupStore()

    await notify(baseEvent(), {
      adapters: [{ channel: 'email', send: emailSend }],
      preferences: allEnabledPrefs(['email']),
      dedup,
    })
    const second = await notify(baseEvent(), {
      adapters: [{ channel: 'email', send: emailSend }],
      preferences: allEnabledPrefs(['email']),
      dedup,
    })

    expect(emailSend).toHaveBeenCalledOnce()
    expect(second).toEqual([{ channel: 'email', status: 'deduped' }])
  })

  it('channel throw — other still delivers, failure captured (fail-safe)', async () => {
    const emailSend = vi.fn(async (): Promise<ChannelResult> => ({ ok: true, id: 'e1' }))
    const smsSend = vi.fn(async (): Promise<ChannelResult> => {
      throw new Error('sms down')
    })

    const results = await notify(baseEvent(), {
      adapters: [
        { channel: 'email', send: emailSend },
        { channel: 'sms', send: smsSend },
      ],
      preferences: allEnabledPrefs(['email', 'sms']),
      dedup: createMemoryDedupStore(),
    })

    expect(emailSend).toHaveBeenCalledOnce()
    expect(smsSend).toHaveBeenCalledOnce()
    expect(results).toContainEqual({ channel: 'email', status: 'sent', id: 'e1' })
    expect(results).toContainEqual({
      channel: 'sms',
      status: 'failed',
      error: { message: 'sms down', retryable: true },
    })
  })

  it('exactly-once under retry — success then retry does not call send twice', async () => {
    const emailSend = vi.fn(async (): Promise<ChannelResult> => ({ ok: true, id: 'e1' }))
    const retry = async (attempt: () => Promise<ChannelResult>) => {
      const first = await attempt()
      await attempt()
      return first
    }

    await notify(baseEvent(), {
      adapters: [{ channel: 'email', send: emailSend }],
      preferences: allEnabledPrefs(['email']),
      dedup: createMemoryDedupStore(),
      retry,
    })

    expect(emailSend).toHaveBeenCalledOnce()
  })

  it('within-call idempotence is local — unkeyed event, retry after success sends once', async () => {
    const emailSend = vi.fn(async (): Promise<ChannelResult> => ({ ok: true, id: 'e1' }))
    const retry = async (attempt: () => Promise<ChannelResult>) => {
      const first = await attempt()
      await attempt()
      return first
    }

    const results = await notify(baseEvent({ dedupKey: undefined, id: undefined }), {
      adapters: [{ channel: 'email', send: emailSend }],
      preferences: allEnabledPrefs(['email']),
      dedup: createMemoryDedupStore(),
      retry,
    })

    expect(emailSend).toHaveBeenCalledOnce()
    expect(results).toEqual([{ channel: 'email', status: 'sent', id: 'e1' }])
  })

  it('granularity — two distinct-id events (no dedupKey) to same recipient/channel BOTH deliver', async () => {
    const emailSend = vi.fn(async (): Promise<ChannelResult> => ({ ok: true, id: 'e1' }))
    const dedup = createMemoryDedupStore()
    const adapters: ChannelAdapter[] = [{ channel: 'email', send: emailSend }]
    const prefs = allEnabledPrefs(['email'])

    const first = await notify(
      baseEvent({ dedupKey: undefined, id: 'a' }),
      { adapters, preferences: prefs, dedup },
    )
    const second = await notify(
      baseEvent({ dedupKey: undefined, id: 'b' }),
      { adapters, preferences: prefs, dedup },
    )

    expect(emailSend).toHaveBeenCalledTimes(2)
    expect(first).toEqual([{ channel: 'email', status: 'sent', id: 'e1' }])
    expect(second).toEqual([{ channel: 'email', status: 'sent', id: 'e1' }])
  })

  it('timing — failed first attempt then retry RE-SENDS (record on success only)', async () => {
    let calls = 0
    const emailSend = vi.fn(async (): Promise<ChannelResult> => {
      calls += 1
      return calls === 1
        ? { ok: false, error: { message: 'transient', retryable: true } }
        : { ok: true, id: 'e1' }
    })
    const retry = async (attempt: () => Promise<ChannelResult>) => {
      let result = await attempt()
      for (let i = 0; i < 1 && !result.ok; i += 1) {
        result = await attempt()
      }
      return result
    }

    const results = await notify(baseEvent(), {
      adapters: [{ channel: 'email', send: emailSend }],
      preferences: allEnabledPrefs(['email']),
      dedup: createMemoryDedupStore(),
      retry,
    })

    expect(emailSend).toHaveBeenCalledTimes(2)
    expect(results).toEqual([{ channel: 'email', status: 'sent', id: 'e1' }])
  })
})
