import type { Injector } from '@furystack/inject'
import { createInjector } from '@furystack/inject'
import { usingAsync } from '@furystack/utils'
import { describe, expect, it, vi } from 'vitest'
import { NotFoundError } from './errors/not-found-error.js'
import type { PhysicalStore } from './models/physical-store.js'

export class TestClass {
  declare id: number
  declare stringValue1: string
  declare stringValue2: string
  declare numberValue1: number
  declare numberValue2: number
  declare booleanValue: boolean
  declare dateValue: Date
}

let idIndex = 0
export const createMockEntity = (part?: Partial<TestClass>): TestClass => ({
  id: idIndex++,
  stringValue1: 'foo',
  stringValue2: 'bar',
  numberValue1: Math.round(Math.random() * 1000),
  numberValue2: Math.round(Math.random() * 10000) / 100,
  booleanValue: true,
  dateValue: new Date(),
  ...part,
})

export interface StoreTestOptions<T, TPrimaryKey extends keyof T> {
  typeName: string
  createStore: (i: Injector) => PhysicalStore<T, TPrimaryKey>
  skipRegexTests?: boolean
  skipStringTests?: boolean
}

export const createStoreTest = (options: StoreTestOptions<TestClass, 'id'>) => {
  describe(`Standard Physical Store tests for '${options.typeName}'`, () => {
    describe('General CRUD', () => {
      it('Should be created with empty by default', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          const count = await store.count()
          expect(count).toBe(0)
        })
      })

      it('Should be able to store an entity', async () => {
        await usingAsync(createInjector(), async (i) => {
          const onAddListener = vi.fn()

          const store = options.createStore(i)

          store.addListener('onEntityAdded', onAddListener)

          const entity = createMockEntity()
          await store.add(entity)
          const count = await store.count()
          expect(count).toBe(1)

          expect(onAddListener).toHaveBeenCalledTimes(1)
          expect(onAddListener).toHaveBeenCalledWith({ entity })
        })
      })

      it('Should be able to store an entity without providing an unique Id', async () => {
        await usingAsync(createInjector(), async (i) => {
          const onAddListener = vi.fn()
          const store = options.createStore(i)

          store.addListener('onEntityAdded', onAddListener)

          const { id, ...entityWithoutId } = createMockEntity()

          const { created } = await store.add(entityWithoutId)
          expect(created.length).toBe(1)
          const count = await store.count()
          expect(count).toBe(1)
          const retrieved = await store.get(created[0].id)
          expect(retrieved).toEqual(created[0])

          expect(onAddListener).toHaveBeenCalledTimes(1)
          expect(onAddListener).toHaveBeenCalledWith({ entity: created[0] })
        })
      })

      it('Should be able to store multiple entities', async () => {
        await usingAsync(createInjector(), async (i) => {
          const onAddListener = vi.fn()

          const store = options.createStore(i)
          store.addListener('onEntityAdded', onAddListener)
          const entity1 = createMockEntity()
          const entity2 = createMockEntity()
          await store.add(entity1, entity2)
          const count = await store.count()
          expect(count).toBe(2)

          expect(onAddListener).toHaveBeenCalledTimes(2)
          expect(onAddListener).toHaveBeenCalledWith({ entity: entity1 })
          expect(onAddListener).toHaveBeenCalledWith({ entity: entity2 })
        })
      })

      it('Add should throw and skip adding on duplicate IDs', async () => {
        await usingAsync(createInjector(), async (i) => {
          const onAddListener = vi.fn()

          const store = options.createStore(i)

          store.addListener('onEntityAdded', onAddListener)

          const entity = createMockEntity()
          await store.add(entity)
          await expect(store.add(entity)).rejects.toThrow()
          const count = await store.count()
          expect(count).toBe(1)

          expect(onAddListener).toHaveBeenCalledTimes(1)
          expect(onAddListener).toHaveBeenCalledWith({ entity })
        })
      })

      it('Should return undefined if no entry has been found', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          const entity = await store.get(1)
          expect(entity).toBeUndefined()
        })
      })

      it('Should be able to retrieve an added entity', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          const entity = createMockEntity()
          await store.add(entity)
          const retrieved = await store.get(entity.id)
          expect(retrieved).toEqual(entity)
        })
      })

      it('Should be able to retrieve an added entity with projection', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          const entity = createMockEntity()
          await store.add(entity)
          const retrieved = await store.get(entity.id, ['id', 'stringValue1'])
          expect(retrieved).not.toEqual(entity)
          expect(retrieved).to.have.all.keys('id', 'stringValue1')
          expect(retrieved?.id).toBe(entity.id)
          expect(retrieved?.stringValue1).toBe(entity.stringValue1)
        })
      })

      it('Should be able to update an added entity', async () => {
        await usingAsync(createInjector(), async (i) => {
          const updateListener = vi.fn()

          const store = options.createStore(i)

          store.addListener('onEntityUpdated', updateListener)

          const entity = createMockEntity()
          await store.add(entity)
          await store.update(entity.id, { stringValue1: 'modified' })
          const retrieved = await store.get(entity.id)
          expect(retrieved?.stringValue1).toEqual('modified')

          expect(updateListener).toHaveBeenCalledTimes(1)
          expect(updateListener).toHaveBeenCalledWith({ id: entity.id, change: { stringValue1: 'modified' } })
        })
      })

      it('Update should throw an error if the entity does not exists', async () => {
        await usingAsync(createInjector(), async (i) => {
          const updateListener = vi.fn()

          const store = options.createStore(i)

          store.addListener('onEntityUpdated', updateListener)

          const entity = createMockEntity()
          await expect(store.update(entity.id, entity)).rejects.toBeInstanceOf(NotFoundError)
          await expect(store.update(entity.id, entity)).rejects.toThrow('Entity not found')

          expect(updateListener).not.toHaveBeenCalled()
        })
      })

      it('Should remove an entity', async () => {
        await usingAsync(createInjector(), async (i) => {
          const removeListener = vi.fn()

          const store = options.createStore(i)
          store.addListener('onEntityRemoved', removeListener)

          const entity = createMockEntity()
          await store.add(entity)
          const count = await store.count()
          expect(count).toBe(1)
          await store.remove(entity.id)
          const countAferDelete = await store.count()
          expect(countAferDelete).toBe(0)

          expect(removeListener).toHaveBeenCalledTimes(1)
        })
      })

      it('Should remove multiple entities at once', async () => {
        await usingAsync(createInjector(), async (i) => {
          const removeListener = vi.fn()
          const store = options.createStore(i)
          store.addListener('onEntityRemoved', removeListener)

          const entity1 = createMockEntity()
          const entity2 = createMockEntity()
          const entity3 = createMockEntity()
          await store.add(entity1, entity2, entity3)
          const count = await store.count()
          expect(count).toBe(3)
          await store.remove(entity1.id, entity2.id)
          const countAferDelete = await store.count()
          expect(countAferDelete).toBe(1)

          expect(removeListener).toHaveBeenCalledTimes(2)
          expect(removeListener).toHaveBeenCalledWith({ key: entity1.id })
          expect(removeListener).toHaveBeenCalledWith({ key: entity2.id })

          await store.remove(entity3.id)
          const countAferDeleteAll = await store.count()
          expect(countAferDeleteAll).toBe(0)

          expect(removeListener).toHaveBeenCalledTimes(3)
          expect(removeListener).toHaveBeenCalledWith({ key: entity3.id })
        })
      })
    })

    describe('Top, skip', () => {
      it('Should respect top and skip', async () => {
        await usingAsync(createInjector(), async (injector) => {
          const store = options.createStore(injector)
          for (let i = 0; i < 10; i++) {
            await store.add(createMockEntity({ id: i }))
          }
          const zeroToThree = await store.find({ top: 4, select: ['id'] })
          expect(zeroToThree).toEqual([{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }])

          const fiveToEight = await store.find({ skip: 5, top: 4, select: ['id'] })
          expect(fiveToEight).toEqual([{ id: 5 }, { id: 6 }, { id: 7 }, { id: 8 }])

          const eightNine = await store.find({ skip: 8, select: ['id'] })
          expect(eightNine).toEqual([{ id: 8 }, { id: 9 }])
        })
      })
    })
    describe('Ordering', () => {
      it('Should sort by numeric values', async () => {
        await usingAsync(createInjector(), async (injector) => {
          const store = options.createStore(injector)
          for (let i = 0; i < 10; i++) {
            await store.add(createMockEntity({ id: i, numberValue1: Math.random(), numberValue2: Math.random() }))
          }
          // For equality
          await store.add(createMockEntity({ id: 20, numberValue1: 0, numberValue2: 0 }))
          await store.add(createMockEntity({ id: 21, numberValue1: 0, numberValue2: 0 }))

          const orderByValue1Asc = await store.find({ order: { numberValue1: 'ASC' } })
          let min = 0
          for (const currentValue of orderByValue1Asc) {
            if (min > currentValue.numberValue1) {
              throw Error('Order failed!')
            }
            min = currentValue.numberValue1
          }

          const orderByValue1Desc = await store.find({ order: { numberValue1: 'DESC' } })
          let max = Number.MAX_SAFE_INTEGER
          for (const currentValue of orderByValue1Desc) {
            if (max < currentValue.numberValue1) {
              throw Error('Order failed!')
            }
            max = currentValue.numberValue1
          }
        })
      })
    })
    describe('Filtering', () => {
      it('should filter strings with $eq', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          await store.add(
            createMockEntity({ id: 1, stringValue1: 'asd' }),
            createMockEntity({ id: 2, stringValue1: 'def' }),
            createMockEntity({ id: 3, stringValue1: 'def' }),
          )
          const result = await store.find({ filter: { stringValue1: { $eq: 'def' } } })
          expect(result.length).toBe(2)
        })
      })

      it('should filter numbers with $eq', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          await store.add(
            createMockEntity({ id: 1, numberValue1: 1 }),
            createMockEntity({ id: 2, numberValue1: 2 }),
            createMockEntity({ id: 3, numberValue1: 2 }),
          )
          const result = await store.find({ filter: { numberValue1: { $eq: 2 } } })
          expect(result.length).toBe(2)
        })
      })

      it('filter should return the corresponding entries for multiple props', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          await store.add(
            createMockEntity({ id: 1, stringValue1: 'asd' }),
            createMockEntity({ id: 2, stringValue1: 'def', stringValue2: 'def' }),
            createMockEntity({ id: 3, stringValue1: 'def' }),
          )

          const result = await store.find({ filter: { stringValue1: { $eq: 'def' }, stringValue2: { $eq: 'def' } } })
          expect(result.length).toBe(1)
        })
      })

      it('filter should return the corresponding entries with $in statement', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          await store.add(
            createMockEntity({ stringValue1: 'asd' }),
            createMockEntity({ stringValue1: 'def' }),
            createMockEntity({ stringValue1: 'sdf' }),
          )

          const result = await store.find({ filter: { stringValue1: { $in: ['asd', 'def'] } } })
          expect(result.length).toBe(2)
          expect(result.map((r) => r.stringValue1)).toEqual(['asd', 'def'])
        })
      })

      it('filter should return the corresponding entries with $nin statement', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          await store.add(
            createMockEntity({ id: 1, stringValue1: 'asd' }),
            createMockEntity({ id: 2, stringValue1: 'def' }),
            createMockEntity({ id: 3, stringValue1: 'sdf' }),
          )

          const result = await store.find({ filter: { stringValue1: { $nin: ['asd', 'def'] } } })
          expect(result.length).toBe(1)
          expect(result.map((r) => r.stringValue1)).toEqual(['sdf'])
        })
      })

      it('filter should return the corresponding entries with $ne statement', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          await store.add(
            createMockEntity({ id: 1, stringValue1: 'asd' }),
            createMockEntity({ id: 2, stringValue1: 'def' }),
            createMockEntity({ id: 3, stringValue1: 'sdf' }),
          )

          const result = await store.find({ filter: { stringValue1: { $ne: 'asd' } } })
          expect(result.length).toBe(2)
          expect(result.map((r) => r.stringValue1)).toEqual(['def', 'sdf'])
        })
      })

      it('filter should return the corresponding entries with $lt statement', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          const { created } = await store.add(
            createMockEntity({ id: 1, numberValue1: 1 }),
            createMockEntity({ id: 2, numberValue1: 2 }),
            createMockEntity({ id: 3, numberValue1: 3 }),
          )

          const result = await store.find({ filter: { numberValue1: { $lt: 2 } } })
          expect(result.length).toBe(1)
          expect(result).toEqual([created[0]])
        })
      })

      it('filter should return the corresponding entries with $lte statement', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          const { created } = await store.add(
            createMockEntity({ id: 1, numberValue1: 1 }),
            createMockEntity({ id: 2, numberValue1: 2 }),
            createMockEntity({ id: 3, numberValue1: 3 }),
          )

          const result = await store.find({ filter: { numberValue1: { $lte: 2 } } })
          expect(result.length).toBe(2)
          expect(result).toEqual([created[0], created[1]])
        })
      })

      it('filter should return the corresponding entries with $gt statement', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          const { created } = await store.add(
            createMockEntity({ id: 1, numberValue1: 1 }),
            createMockEntity({ id: 2, numberValue1: 2 }),
            createMockEntity({ id: 3, numberValue1: 3 }),
          )

          const result = await store.find({ filter: { numberValue1: { $gt: 2 } } })
          expect(result.length).toBe(1)
          expect(result).toEqual([created[2]])
        })
      })

      it('filter should return the corresponding entries with $gte statement', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          const { created } = await store.add(
            createMockEntity({ id: 1, numberValue1: 1 }),
            createMockEntity({ id: 2, numberValue1: 2 }),
            createMockEntity({ id: 3, numberValue1: 3 }),
          )

          const result = await store.find({ filter: { numberValue1: { $gte: 2 } } })
          expect(result.length).toBe(2)
          expect(result).toEqual([created[1], created[2]])
        })
      })

      it('filter should return the corresponding entries with $in AND $eq statement', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          await store.add(
            createMockEntity({ id: 1, stringValue1: 'asd' }),
            createMockEntity({ id: 2, stringValue1: 'def' }),
            createMockEntity({ id: 3, stringValue1: 'sdf' }),
          )

          const result = await store.find({ filter: { stringValue1: { $in: ['asd', 'def'], $eq: 'asd' } } })
          expect(result.length).toBe(1)
          expect(result.map((r) => r.stringValue1)).toEqual(['asd'])
        })
      })

      describe('logical $and statements', () => {
        it('should filter $and logical statements with $eq statements', async () => {
          await usingAsync(createInjector(), async (i) => {
            const store = options.createStore(i)
            const { created } = await store.add(
              createMockEntity({ id: 1, numberValue1: 1, numberValue2: 1 }),
              createMockEntity({ id: 2, numberValue1: 2, numberValue2: 1 }),
              createMockEntity({ id: 3, numberValue1: 3, numberValue2: 1 }),
            )
            const result = await store.find({
              filter: { $and: [{ numberValue1: { $eq: 2 } }, { numberValue2: { $eq: 1 } }] },
            })
            expect(result.length).toBe(1)
            expect(result[0]).toEqual(created[1])
          })
        })

        it('should filter $and logical statements with $ne statements', async () => {
          await usingAsync(createInjector(), async (i) => {
            const store = options.createStore(i)
            const { created } = await store.add(
              createMockEntity({ id: 1, numberValue1: 1, numberValue2: 2 }),
              createMockEntity({ id: 2, numberValue1: 2, numberValue2: 3 }),
              createMockEntity({ id: 3, numberValue1: 3, numberValue2: 1 }),
            )
            const result = await store.find({
              filter: { $and: [{ numberValue1: { $ne: 2 } }, { numberValue2: { $ne: 1 } }] },
            })
            expect(result.length).toBe(1)
            expect(result[0]).toEqual(created[0])
          })
        })

        it('should filter $and logical statements with $lt/$gt statements', async () => {
          await usingAsync(createInjector(), async (i) => {
            const store = options.createStore(i)
            const { created } = await store.add(
              createMockEntity({ id: 1, numberValue1: 1, numberValue2: 2 }),
              createMockEntity({ id: 2, numberValue1: 2, numberValue2: 3 }),
              createMockEntity({ id: 3, numberValue1: 3, numberValue2: 1 }),
            )
            const result = await store.find({
              filter: { $and: [{ numberValue1: { $lt: 3 } }, { numberValue2: { $gt: 2 } }] },
            })
            expect(result.length).toBe(1)
            expect(result[0]).toEqual(created[1])
          })
        })

        it('should filter $and logical statements with $lte/$gte statements', async () => {
          await usingAsync(createInjector(), async (i) => {
            const store = options.createStore(i)
            const { created } = await store.add(
              createMockEntity({ id: 1, numberValue1: 1, numberValue2: 1 }),
              createMockEntity({ id: 2, numberValue1: 2, numberValue2: 2 }),
              createMockEntity({ id: 3, numberValue1: 3, numberValue2: 3 }),
            )
            const result = await store.find({
              filter: { $and: [{ numberValue1: { $lte: 2 } }, { numberValue2: { $gte: 2 } }] },
            })
            expect(result.length).toBe(1)
            expect(result[0]).toEqual(created[1])
          })
        })
      })

      describe('logical $or statements', () => {
        it('should filter logical $or statements with $eq statements', async () => {
          await usingAsync(createInjector(), async (i) => {
            const store = options.createStore(i)
            const { created } = await store.add(
              createMockEntity({ id: 1, stringValue1: 'asd' }),
              createMockEntity({ id: 2, stringValue1: 'aaa' }),
              createMockEntity({ id: 3, stringValue1: 'bbb' }),
            )

            const result = await store.find({
              filter: { $or: [{ stringValue1: { $eq: 'aaa' } }, { stringValue1: { $eq: 'bbb' } }] },
            })
            expect(result.length).toBe(2)
            expect(result).toEqual([created[1], created[2]])
          })
        })

        it('should filter logical $or statements with $neq statements', async () => {
          await usingAsync(createInjector(), async (i) => {
            const store = options.createStore(i)
            const { created } = await store.add(
              createMockEntity({ id: 1, stringValue1: 'asd' }),
              createMockEntity({ id: 2, stringValue1: 'aaa' }),
              createMockEntity({ id: 3, stringValue1: 'bbb' }),
            )

            const result = await store.find({
              filter: { $or: [{ stringValue1: { $ne: 'aaa' } }, { stringValue1: { $ne: 'bbb' } }] },
            })
            expect(result.length).toBe(3)
            expect(result).toEqual(created)
          })
        })
      })

      describe('Nested $or and $and logical operators', () => {
        it('should filter $and operators inside $or-s', async () => {
          await usingAsync(createInjector(), async (i) => {
            const store = options.createStore(i)
            const { created } = await store.add(
              createMockEntity({ id: 1, numberValue1: 1, numberValue2: 3, booleanValue: true }),
              createMockEntity({ id: 2, numberValue1: 2, numberValue2: 2, booleanValue: false }),
              createMockEntity({ id: 3, numberValue1: 3, numberValue2: 1, booleanValue: true }),
            )

            const result = await store.find({
              filter: {
                $or: [
                  {
                    $and: [{ numberValue1: { $ne: 2 } }, { numberValue2: { $eq: 1 } }],
                  },
                  {
                    $and: [{ numberValue1: { $ne: 3 } }, { booleanValue: { $ne: true } }],
                  },
                ],
              },
            })
            expect(result.length).toBe(2)
            expect(result).toEqual([created[1], created[2]])
          })
        })
      })

      if (!options.skipRegexTests) {
        it('filter should return the corresponding entries with $regex', async () => {
          await usingAsync(createInjector(), async (i) => {
            const store = options.createStore(i)
            await store.add(
              createMockEntity({ id: 1, stringValue1: 'asd' }),
              createMockEntity({ id: 2, stringValue1: 'aaa' }),
              createMockEntity({ id: 3, stringValue1: 'bbb' }),
            )

            const result = await store.find({ filter: { stringValue1: { $regex: '([a])' } } })
            expect(result.length).toBe(2)
            expect(result.map((r) => r.stringValue1)).toEqual(['asd', 'aaa'])
          })
        })
      }

      if (!options.skipStringTests) {
        it('filter should return the corresponding entries with $startsWith', async () => {
          await usingAsync(createInjector(), async (i) => {
            const store = options.createStore(i)
            await store.add(
              createMockEntity({ id: 1, stringValue1: 'asd' }),
              createMockEntity({ id: 2, stringValue1: 'aaa' }),
              createMockEntity({ id: 3, stringValue1: 'bbb' }),
            )

            const result = await store.find({ filter: { stringValue1: { $startsWith: 'aa' } } })
            expect(result.length).toBe(1)
            expect(result.map((r) => r.stringValue1)).toEqual(['aaa'])
          })
        })
        it('filter should return the corresponding entries with $endsWith', async () => {
          await usingAsync(createInjector(), async (i) => {
            const store = options.createStore(i)
            await store.add(
              createMockEntity({ id: 1, stringValue1: 'asd' }),
              createMockEntity({ id: 2, stringValue1: 'aaa' }),
              createMockEntity({ id: 3, stringValue1: 'bbb' }),
            )

            const result = await store.find({ filter: { stringValue1: { $endsWith: 'bb' } } })
            expect(result.length).toBe(1)
            expect(result.map((r) => r.stringValue1)).toEqual(['bbb'])
          })
        })

        it('filter should return the corresponding entries with $like', async () => {
          await usingAsync(createInjector(), async (i) => {
            const store = options.createStore(i)
            await store.add(
              createMockEntity({ id: 1, stringValue1: 'asd' }),
              createMockEntity({ id: 2, stringValue1: 'aaa' }),
              createMockEntity({ id: 3, stringValue1: 'bbb' }),
            )

            const result = await store.find({ filter: { stringValue1: { $like: '%a%' } } })
            expect(result.length).toBe(2)
            expect(result.map((r) => r.stringValue1)).toEqual(['asd', 'aaa'])

            const endsWithAResult = await store.find({ filter: { stringValue1: { $like: '%a' } } })
            expect(endsWithAResult.length).toBe(1)
            expect(endsWithAResult.map((r) => r.stringValue1)).toEqual(['aaa'])

            const startsWithAResult = await store.find({ filter: { stringValue1: { $like: 'a%' } } })
            expect(startsWithAResult.length).toBe(2)
            expect(startsWithAResult.map((r) => r.stringValue1)).toEqual(['asd', 'aaa'])
          })
        })
      }
    })

    describe('Count', () => {
      it('Should return the count', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          await store.add(createMockEntity(), createMockEntity(), createMockEntity())
          const count = await store.count()
          expect(count).toBe(3)
        })
      })
      it('Should respect filters', async () => {
        await usingAsync(createInjector(), async (i) => {
          const store = options.createStore(i)
          await store.add(
            createMockEntity({ numberValue1: 1 }),
            createMockEntity({ numberValue1: 1 }),
            createMockEntity({ numberValue1: 2 }),
          )

          const count = await store.count({ numberValue1: { $eq: 1 } })
          expect(count).toBe(2)

          const count2 = await store.count({ numberValue1: { $eq: 2 } })
          expect(count2).toBe(1)
        })
      })
    })
  })
}
