import { createInjector } from '@furystack/inject'
import { deserializeQueryString, serializeToQueryString, serializeValue } from '@furystack/rest'
import { usingAsync } from '@furystack/utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LocationService, useCustomSearchStateSerializer } from './location-service.js'

describe('LocationService', () => {
  beforeEach(() => {
    document.body.innerHTML = '<div id="root"></div>'
  })
  afterEach(() => {
    document.body.innerHTML = ''
  })

  it('Shuld be constructed', async () => {
    await usingAsync(createInjector(), async (i) => {
      const s = i.get(LocationService)
      expect(s).toBeDefined()
      expect(typeof s.navigate).toBe('function')
    })
  })

  it('Shuld update state on events', async () => {
    await usingAsync(createInjector(), async (i) => {
      const onLocaionChanged = vi.fn()
      const s = i.get(LocationService)
      s.onLocationPathChanged.subscribe(onLocaionChanged)
      expect(onLocaionChanged).toBeCalledTimes(0)
      history.pushState(null, '', '/loc1')
      expect(onLocaionChanged).toBeCalledTimes(1)
      history.replaceState(null, '', '/loc2')
      expect(onLocaionChanged).toBeCalledTimes(2)

      // TODO: Figure out testing hashchange and popstate subscriptions
      // window.dispatchEvent(new HashChangeEvent('hashchange', { newURL: '/loc3' }))
      // expect(onLocaionChanged).toBeCalledTimes(3)
      // window.dispatchEvent(new PopStateEvent('popstate', {}))
      // expect(onLocaionChanged).toBeCalledTimes(4)
    })
  })

  it('Should update location path when navigate is called', async () => {
    await usingAsync(createInjector(), async (i) => {
      const onLocationChanged = vi.fn()
      const s = i.get(LocationService)
      s.onLocationPathChanged.subscribe(onLocationChanged)
      s.navigate('/dashboard')
      expect(s.onLocationPathChanged.getValue()).toBe('/dashboard')
      expect(onLocationChanged).toHaveBeenCalledWith('/dashboard')
    })
  })

  describe('replace', () => {
    it('Should update the observable path without pushing a new history entry', async () => {
      await usingAsync(createInjector(), async (i) => {
        const s = i.get(LocationService)
        const lengthBefore = history.length
        s.replace('/replaced')
        expect(s.onLocationPathChanged.getValue()).toBe('/replaced')
        expect(history.length).toBe(lengthBefore)
      })
    })

    it('Should call history.replaceState rather than pushState', async () => {
      await usingAsync(createInjector(), async (i) => {
        const s = i.get(LocationService)
        const pushSpy = vi.spyOn(history, 'pushState')
        const replaceSpy = vi.spyOn(history, 'replaceState')

        s.replace('/replaced-2')

        expect(replaceSpy).toHaveBeenCalledTimes(1)
        expect(replaceSpy).toHaveBeenCalledWith(null, '', '/replaced-2')
        expect(pushSpy).not.toHaveBeenCalled()

        pushSpy.mockRestore()
        replaceSpy.mockRestore()
      })
    })

    it('Should notify path subscribers after replace', async () => {
      await usingAsync(createInjector(), async (i) => {
        const s = i.get(LocationService)
        const onLocationChanged = vi.fn()
        s.onLocationPathChanged.subscribe(onLocationChanged)
        s.replace('/notify')
        expect(onLocationChanged).toHaveBeenCalledWith('/notify')
      })
    })
  })

  describe('useSearchParam', () => {
    it('Should create observables lazily', async () => {
      await usingAsync(createInjector(), async (i) => {
        const service = i.get(LocationService)
        const observables = service.searchParamObservables

        const testSearchParam = service.useSearchParam('test', null)
        expect(observables.size).toBe(1)

        const testSearchParam2 = service.useSearchParam('test', null)
        expect(observables.size).toBe(1)

        expect(testSearchParam).toBe(testSearchParam2)

        const testSearchParam3 = service.useSearchParam('test2', undefined)
        expect(observables.size).toBe(2)

        expect(testSearchParam3).not.toBe(testSearchParam2)
      })
    })

    it('Should return the default value, if not present in the query string', async () => {
      await usingAsync(createInjector(), async (i) => {
        const service = i.get(LocationService)
        const testSearchParam = service.useSearchParam('test', { value: 'foo' })
        expect(testSearchParam.getValue()).toEqual({ value: 'foo' })
      })
    })

    it('Should return the value from the query string', async () => {
      await usingAsync(createInjector(), async (i) => {
        const service = i.get(LocationService)
        history.pushState(null, '', `/loc1?test=${serializeValue(1)}`)
        const testSearchParam = service.useSearchParam('test', 123)
        expect(testSearchParam.getValue()).toBe(1)
      })
    })

    it('should update the observable value on push / replace states', async () => {
      await usingAsync(createInjector(), async (i) => {
        const service = i.get(LocationService)
        history.pushState(null, '', `/loc1?test=${serializeValue(1)}`)
        const testSearchParam = service.useSearchParam('test', 234)
        expect(testSearchParam.getValue()).toBe(1)
        history.replaceState(null, '', `/loc1?test=${serializeValue('2')}`)
        expect(testSearchParam.getValue()).toBe('2')
      })
    })

    it('Should update the URL based on search value change', async () => {
      await usingAsync(createInjector(), async (i) => {
        const service = i.get(LocationService)
        history.pushState(null, '', `/loc1?test=${serializeValue('2')}`)
        const testSearchParam = service.useSearchParam('test', '')
        testSearchParam.setValue('2')
        expect(location.search).toBe('?test=IjIi')
      })
    })

    it('Should throw when called after LocationService has been resolved', async () => {
      await usingAsync(createInjector(), async (i) => {
        const customSerializer = vi.fn((value: any) => serializeToQueryString(value))
        const customDeserializer = vi.fn((value: string) => deserializeQueryString(value))

        // Eagerly resolve once so the service patches history / adds listeners.
        i.get(LocationService)

        expect(() => useCustomSearchStateSerializer(i, customSerializer, customDeserializer)).toThrow(
          /before LocationService is resolved/,
        )
      })
    })

    it('Should use custom serializer and deserializer', async () => {
      await usingAsync(createInjector(), async (i) => {
        const customSerializer = vi.fn((value: any) => serializeToQueryString(value))
        const customDeserializer = vi.fn((value: string) => deserializeQueryString(value))

        useCustomSearchStateSerializer(i, customSerializer, customDeserializer)

        const locationService = i.get(LocationService)
        const testSearchParam = locationService.useSearchParam('test', { value: 'foo' })

        testSearchParam.setValue({ value: 'bar' })
        expect(customSerializer).toBeCalledWith({ test: { value: 'bar' } })
        expect(customDeserializer).toBeCalledWith('?test=eyJ2YWx1ZSI6ImJhciJ9')
      })
    })
  })
})
