import { describe, it, expect, vi, beforeEach } from 'vitest'

vi.mock('../../../server/utils/mongoose.js', () => ({
  connectDB: vi.fn()
}))

vi.mock('../../../server/models/member.js', () => ({
  default: {
    find: vi.fn(),
    countDocuments: vi.fn()
  }
}))

vi.mock('../../../server/models/event.js', () => ({
  default: {
    find: vi.fn()
  }
}))

vi.mock('../../../server/models/preRegistration.js', () => ({
  default: {
    find: vi.fn()
  }
}))

vi.mock('../../../server/models/tagSuggestion.js', () => ({
  default: {
    find: vi.fn()
  }
}))

vi.mock('../../../server/models/adminAlertDismissal.js', () => ({
  default: {
    find: vi.fn()
  },
  ADMIN_ALERT_TYPES: [
    'slack_invite_failed',
    'no_slack_handle_week',
    'stuck_pending_payment',
    'member_suspended',
    'preregistrant_selected_not_invited',
    'preregistrant_expired',
    'event_draft_imminent',
    'event_near_capacity',
    'tag_suggestions_pending'
  ]
}))

import {
  ALERT_THRESHOLDS,
  computeSignature,
  detectSlackInviteFailed,
  detectNoSlackHandleAfterWeek,
  detectStuckPendingPayment,
  detectSuspendedMembers,
  detectPreRegistrantSelectedNotInvited,
  detectPreRegistrantExpired,
  detectDraftEventsImminent,
  detectEventsNearCapacity
} from '../../../server/utils/adminAlerts.js'
import Member from '../../../server/models/member.js'
import PreRegistration from '../../../server/models/preRegistration.js'
import Event from '../../../server/models/event.js'
import { detectPendingTagSuggestions } from '../../../server/utils/adminAlerts.js'
import TagSuggestion from '../../../server/models/tagSuggestion.js'

describe('adminAlerts module shell', () => {
  describe('ALERT_THRESHOLDS', () => {
    it('exposes the four documented thresholds', () => {
      expect(ALERT_THRESHOLDS.NO_SLACK_DAYS).toBe(7)
      expect(ALERT_THRESHOLDS.STUCK_PAYMENT_DAYS).toBe(7)
      expect(ALERT_THRESHOLDS.PREREG_SELECTED_DAYS).toBe(3)
      expect(ALERT_THRESHOLDS.DRAFT_IMMINENT_DAYS).toBe(14)
      expect(ALERT_THRESHOLDS.NEAR_CAPACITY_RATIO).toBe(0.8)
    })
  })

  describe('computeSignature', () => {
    it('returns a hex string', () => {
      const sig = computeSignature(['a', 'b', 'c'])
      expect(typeof sig).toBe('string')
      expect(sig).toMatch(/^[a-f0-9]+$/)
    })

    it('is order-independent', () => {
      expect(computeSignature(['a', 'b', 'c'])).toBe(computeSignature(['c', 'a', 'b']))
    })

    it('changes when membership changes', () => {
      expect(computeSignature(['a', 'b'])).not.toBe(computeSignature(['a', 'b', 'c']))
    })

    it('returns a stable empty-set signature', () => {
      expect(computeSignature([])).toBe(computeSignature([]))
    })

    it('coerces non-string ids to strings', () => {
      expect(computeSignature([{ toString: () => 'x' }, 'y']))
        .toBe(computeSignature(['x', 'y']))
    })
  })

  describe('member alerts', () => {
    beforeEach(() => {
      vi.clearAllMocks()
    })

    function mockMemberFind(result) {
      Member.find.mockReturnValue({
        select: vi.fn().mockReturnValue({
          lean: vi.fn().mockResolvedValue(result)
        })
      })
    }

    describe('detectSlackInviteFailed', () => {
      it('returns matching members with critical severity', async () => {
        mockMemberFind([
          { _id: 'm1', name: 'Alex', email: 'alex@example.com' },
          { _id: 'm2', name: 'Bea', email: 'bea@example.com' }
        ])

        const alert = await detectSlackInviteFailed()

        expect(alert.type).toBe('slack_invite_failed')
        expect(alert.severity).toBe('critical')
        expect(alert.items).toHaveLength(2)
        expect(alert.items[0]).toEqual({
          id: 'm1',
          label: 'Alex',
          sublabel: 'alex@example.com',
          href: '/admin/members/m1'
        })
        expect(Member.find).toHaveBeenCalledWith(
          { slackInviteStatus: 'failed' }
        )
      })

      it('returns an empty list when nothing matches', async () => {
        mockMemberFind([])
        const alert = await detectSlackInviteFailed()
        expect(alert.items).toEqual([])
      })
    })

    describe('detectNoSlackHandleAfterWeek', () => {
      it('queries active members joined more than 7 days ago without a slackUserId', async () => {
        mockMemberFind([
          { _id: 'm3', name: 'Cam', email: 'cam@example.com', createdAt: new Date(Date.now() - 9 * 86400000) }
        ])

        const alert = await detectNoSlackHandleAfterWeek()

        expect(alert.type).toBe('no_slack_handle_week')
        expect(alert.severity).toBe('attention')
        expect(alert.items).toHaveLength(1)
        expect(alert.items[0].sublabel).toMatch(/9 days/)

        const [query] = Member.find.mock.calls[0]
        expect(query.status).toBe('active')
        expect(query.createdAt).toEqual({ $lte: expect.any(Date) })
        // slackUserId condition: missing or empty
        expect(query.$or).toEqual([
          { slackUserId: { $exists: false } },
          { slackUserId: null },
          { slackUserId: '' }
        ])
      })
    })

    describe('detectStuckPendingPayment', () => {
      it('returns members in pending_payment older than 7 days', async () => {
        mockMemberFind([
          { _id: 'm4', name: 'Dee', email: 'dee@example.com', createdAt: new Date(Date.now() - 10 * 86400000) }
        ])

        const alert = await detectStuckPendingPayment()

        expect(alert.type).toBe('stuck_pending_payment')
        expect(alert.severity).toBe('attention')
        expect(alert.items[0].sublabel).toMatch(/10 days/)

        const [query] = Member.find.mock.calls[0]
        expect(query.status).toBe('pending_payment')
        expect(query.createdAt).toEqual({ $lte: expect.any(Date) })
      })
    })

    describe('detectSuspendedMembers', () => {
      it('returns suspended members', async () => {
        mockMemberFind([
          { _id: 'm5', name: 'Eli', email: 'eli@example.com' }
        ])

        const alert = await detectSuspendedMembers()

        expect(alert.type).toBe('member_suspended')
        expect(alert.severity).toBe('attention')
        expect(alert.items).toHaveLength(1)
        expect(Member.find).toHaveBeenCalledWith({ status: 'suspended' })
      })
    })
  })

  describe('pre-registrant alerts', () => {
    beforeEach(() => {
      vi.clearAllMocks()
    })

    function mockPreRegFind(result) {
      PreRegistration.find.mockReturnValue({
        select: vi.fn().mockReturnValue({
          lean: vi.fn().mockResolvedValue(result)
        })
      })
    }

    describe('detectPreRegistrantSelectedNotInvited', () => {
      it('returns selected pre-registrants older than 3 days', async () => {
        const fourDaysAgo = new Date(Date.now() - 4 * 86400000)
        mockPreRegFind([
          { _id: 'p1', name: 'Fin', email: 'fin@example.com', updatedAt: fourDaysAgo }
        ])

        const alert = await detectPreRegistrantSelectedNotInvited()

        expect(alert.type).toBe('preregistrant_selected_not_invited')
        expect(alert.severity).toBe('attention')
        expect(alert.items).toHaveLength(1)
        expect(alert.items[0]).toEqual({
          id: 'p1',
          label: 'Fin',
          sublabel: 'fin@example.com — 4 days selected',
          href: '/admin/pre-registrants'
        })

        const [query] = PreRegistration.find.mock.calls[0]
        expect(query.status).toBe('selected')
        expect(query.updatedAt).toEqual({ $lte: expect.any(Date) })
      })
    })

    describe('detectPreRegistrantExpired', () => {
      it('returns expired pre-registrants', async () => {
        const expiredAt = new Date(Date.now() - 2 * 86400000)
        mockPreRegFind([
          { _id: 'p2', name: 'Gus', email: 'gus@example.com', updatedAt: expiredAt }
        ])

        const alert = await detectPreRegistrantExpired()

        expect(alert.type).toBe('preregistrant_expired')
        expect(alert.severity).toBe('attention')
        expect(alert.items[0].label).toBe('Gus')
        expect(PreRegistration.find).toHaveBeenCalledWith({ status: 'expired' })
      })
    })
  })

  describe('event alerts', () => {
    beforeEach(() => {
      vi.clearAllMocks()
    })

    function mockEventFind(result) {
      Event.find.mockReturnValue({
        select: vi.fn().mockReturnValue({
          lean: vi.fn().mockResolvedValue(result)
        })
      })
    }

    describe('detectDraftEventsImminent', () => {
      it('returns hidden, non-cancelled events starting in the next 14 days', async () => {
        const startDate = new Date(Date.now() + 5 * 86400000)
        mockEventFind([
          { _id: 'e1', title: 'Workshop', startDate }
        ])

        const alert = await detectDraftEventsImminent()

        expect(alert.type).toBe('event_draft_imminent')
        expect(alert.severity).toBe('critical')
        expect(alert.items).toHaveLength(1)
        expect(alert.items[0]).toEqual({
          id: 'e1',
          label: 'Workshop',
          sublabel: expect.stringMatching(/starts in 5 days/i),
          href: '/admin/events'
        })

        const [query] = Event.find.mock.calls[0]
        expect(query.isVisible).toBe(false)
        expect(query.isCancelled).toEqual({ $ne: true })
        expect(query.startDate.$gte).toBeInstanceOf(Date)
        expect(query.startDate.$lte).toBeInstanceOf(Date)
      })
    })

    describe('detectEventsNearCapacity', () => {
      it('returns events at >= 80% of capacity', async () => {
        mockEventFind([
          {
            _id: 'e2',
            title: 'Sold Out Soon',
            startDate: new Date(Date.now() + 3 * 86400000),
            tickets: {
              enabled: true,
              capacity: { total: 10, reserved: 1 }
            },
            registrations: [
              { name: 'a' }, { name: 'b' }, { name: 'c' },
              { name: 'd' }, { name: 'e' }, { name: 'f' },
              { name: 'g' }
            ]
          },
          {
            _id: 'e3',
            title: 'Plenty of Room',
            startDate: new Date(Date.now() + 3 * 86400000),
            tickets: {
              enabled: true,
              capacity: { total: 10, reserved: 0 }
            },
            registrations: [{ name: 'a' }]
          }
        ])

        const alert = await detectEventsNearCapacity()

        expect(alert.type).toBe('event_near_capacity')
        expect(alert.severity).toBe('attention')
        expect(alert.items).toHaveLength(1)
        expect(alert.items[0].id).toBe('e2')
        expect(alert.items[0].sublabel).toMatch(/8\s*\/\s*10/)
      })

      it('ignores events with no capacity set', async () => {
        mockEventFind([
          {
            _id: 'e4',
            title: 'Uncapped',
            startDate: new Date(Date.now() + 3 * 86400000),
            tickets: { enabled: true, capacity: {} },
            registrations: []
          }
        ])

        const alert = await detectEventsNearCapacity()
        expect(alert.items).toEqual([])
      })
    })
  })

  describe('tag suggestion alert', () => {
    beforeEach(() => {
      vi.clearAllMocks()
    })

    function mockTagSuggestionFind(result) {
      TagSuggestion.find.mockReturnValue({
        select: vi.fn().mockReturnValue({
          lean: vi.fn().mockResolvedValue(result)
        })
      })
    }

    it('returns a single aggregated item with the count', async () => {
      mockTagSuggestionFind([
        { _id: 't1' }, { _id: 't2' }, { _id: 't3' }
      ])

      const alert = await detectPendingTagSuggestions()

      expect(alert.type).toBe('tag_suggestions_pending')
      expect(alert.severity).toBe('attention')
      expect(alert.items).toHaveLength(1)
      expect(alert.items[0]).toEqual({
        id: 'tag-suggestions',
        label: '3 pending tag suggestions',
        sublabel: 'Review and approve in Tags',
        href: '/admin/tags'
      })
      expect(TagSuggestion.find).toHaveBeenCalledWith({ status: 'pending' })
    })

    it('returns an empty list when nothing is pending', async () => {
      mockTagSuggestionFind([])
      const alert = await detectPendingTagSuggestions()
      expect(alert.items).toEqual([])
    })
  })
})

import { computeAllAlerts } from '../../../server/utils/adminAlerts.js'
import AdminAlertDismissal from '../../../server/models/adminAlertDismissal.js'

describe('computeAllAlerts aggregator', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    // Default: every detector returns empty
    Member.find.mockReturnValue({
      select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) })
    })
    Event.find.mockReturnValue({
      select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) })
    })
    PreRegistration.find.mockReturnValue({
      select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) })
    })
    TagSuggestion.find.mockReturnValue({
      select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) })
    })
    AdminAlertDismissal.find.mockReturnValue({
      lean: vi.fn().mockResolvedValue([])
    })
  })

  it('returns an empty list when nothing matches', async () => {
    const alerts = await computeAllAlerts('admin-1')
    expect(alerts).toEqual([])
  })

  it('returns alerts that have items and have not been dismissed', async () => {
    Member.find.mockImplementation((query) => {
      if (query.slackInviteStatus === 'failed') {
        return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([
          { _id: 'm1', name: 'Alex', email: 'alex@example.com' }
        ]) }) }
      }
      return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) }
    })

    const alerts = await computeAllAlerts('admin-1')
    expect(alerts).toHaveLength(1)
    expect(alerts[0].type).toBe('slack_invite_failed')
    expect(alerts[0].signature).toMatch(/^[a-f0-9]+$/)
  })

  it('hides alerts whose signature matches an existing dismissal', async () => {
    Member.find.mockImplementation((query) => {
      if (query.slackInviteStatus === 'failed') {
        return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([
          { _id: 'm1', name: 'Alex', email: 'alex@example.com' }
        ]) }) }
      }
      return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) }
    })

    const sig = computeSignature(['m1'])
    AdminAlertDismissal.find.mockReturnValue({
      lean: vi.fn().mockResolvedValue([
        { adminId: 'admin-1', alertType: 'slack_invite_failed', signature: sig }
      ])
    })

    const alerts = await computeAllAlerts('admin-1')
    expect(alerts).toEqual([])
  })

  it('shows an alert again when the underlying set changes', async () => {
    Member.find.mockImplementation((query) => {
      if (query.slackInviteStatus === 'failed') {
        return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([
          { _id: 'm1', name: 'Alex', email: 'alex@example.com' },
          { _id: 'm2', name: 'Bea', email: 'bea@example.com' }
        ]) }) }
      }
      return { select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }) }
    })

    const staleSig = computeSignature(['m1']) // dismissal was for the old single-member state
    AdminAlertDismissal.find.mockReturnValue({
      lean: vi.fn().mockResolvedValue([
        { adminId: 'admin-1', alertType: 'slack_invite_failed', signature: staleSig }
      ])
    })

    const alerts = await computeAllAlerts('admin-1')
    expect(alerts).toHaveLength(1)
    expect(alerts[0].count).toBe(2)
  })

  it('uses signatureIds for tag_suggestions_pending', async () => {
    TagSuggestion.find.mockReturnValue({
      select: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue([
        { _id: 't1' }, { _id: 't2' }
      ]) })
    })

    const alerts = await computeAllAlerts('admin-1')
    const tagAlert = alerts.find((a) => a.type === 'tag_suggestions_pending')
    expect(tagAlert.signature).toBe(computeSignature(['t1', 't2']))
    // The synthetic item id should not appear in the signature
    expect(tagAlert.signature).not.toBe(computeSignature(['tag-suggestions']))
  })
})
