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

const TEST_KEY_B64 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='

async function realImportKey(b64) {
    const raw = Uint8Array.from(globalThis.atob(b64), c => c.codePointAt(0))
    return globalThis.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt'])
}

async function realEncrypt(data, key) {
    const json = new TextEncoder().encode(JSON.stringify(data))
    const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
    const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, json)
    const buf = new Uint8Array(encrypted)
    const combined = new Uint8Array(12 + buf.length)
    combined.set(iv)
    combined.set(buf, 12)
    return globalThis.btoa(String.fromCodePoint(...combined))
}

// The module destructures { fetch } from globalThis at import time,
// so we must set up globalThis.fetch BEFORE each dynamic import.
async function loadModule() {
    vi.resetModules()
    const mod = await import('../../assets/modules/analytics.js')
    return mod
}

describe('analytics.js', () => {
    let fetchMock
    let beaconMock

    beforeEach(() => {
        vi.restoreAllMocks()
        sessionStorage.clear()
        document.body.innerHTML = ''
        document.body.removeAttribute('data-k')
        document.body.removeAttribute('data-e')
        document.body.removeAttribute('data-uid')

        fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 })
        globalThis.fetch = fetchMock

        beaconMock = vi.fn().mockReturnValue(true)
        navigator.sendBeacon = beaconMock
    })

    it('returns early when data-k is missing', async () => {
        document.body.dataset.e = '/t'
        const { initAnalytics } = await loadModule()

        await initAnalytics()

        expect(fetchMock).not.toHaveBeenCalled()
        expect(beaconMock).not.toHaveBeenCalled()
    })

    it('returns early when data-e is missing', async () => {
        document.body.dataset.k = TEST_KEY_B64
        const { initAnalytics } = await loadModule()

        await initAnalytics()

        expect(fetchMock).not.toHaveBeenCalled()
    })

    it('returns early when importKey fails', async () => {
        document.body.dataset.k = '!!invalid!!'
        document.body.dataset.e = '/t'
        const { initAnalytics } = await loadModule()

        await initAnalytics()

        expect(fetchMock).not.toHaveBeenCalled()
    })

    it('creates visitor and tracks page view', async () => {
        const key = await realImportKey(TEST_KEY_B64)
        const visitorResp = await realEncrypt({ uid: 'v1', h: 'hash1' }, key)

        document.body.dataset.k = TEST_KEY_B64
        document.body.dataset.e = '/t'

        let callCount = 0
        fetchMock.mockImplementation(async () => {
            callCount++
            if (callCount === 1) {
                return { ok: true, status: 200, json: async () => ({ d: visitorResp }) }
            }
            return { ok: true, status: 204 }
        })

        // Disable sendBeacon so trackPageView uses fetch
        navigator.sendBeacon = undefined
        const { initAnalytics } = await loadModule()

        await initAnalytics()

        expect(fetchMock).toHaveBeenCalledTimes(2)
        expect(sessionStorage.getItem('_u')).toBe('v1')
        expect(sessionStorage.getItem('_h')).toBe('hash1')
    })

    it('reuses existing visitor from sessionStorage', async () => {
        document.body.dataset.k = TEST_KEY_B64
        document.body.dataset.e = '/t'

        sessionStorage.setItem('_u', 'existing')
        sessionStorage.setItem('_h', 'existinghash')

        const { initAnalytics } = await loadModule()

        await initAnalytics()

        // Only trackPageView via beacon, no fetch for visitor creation
        expect(beaconMock).toHaveBeenCalledTimes(1)
        expect(fetchMock).not.toHaveBeenCalled()
    })

    it('returns early when visitor creation returns null', async () => {
        document.body.dataset.k = TEST_KEY_B64
        document.body.dataset.e = '/t'

        // fetch returns 204 (no body) -> send() returns null -> no visitor
        const { initAnalytics } = await loadModule()

        await initAnalytics()

        expect(sessionStorage.getItem('_u')).toBeNull()
    })

    it('uses sendBeacon when available and not expecting response', async () => {
        const key = await realImportKey(TEST_KEY_B64)
        const visitorResp = await realEncrypt({ uid: 'v2', h: 'hash2' }, key)

        document.body.dataset.k = TEST_KEY_B64
        document.body.dataset.e = '/t'

        fetchMock.mockResolvedValue({
            ok: true, status: 200, json: async () => ({ d: visitorResp }),
        })

        const { initAnalytics } = await loadModule()

        await initAnalytics()

        // First call uses fetch (getOrCreateVisitor expects response), second uses sendBeacon (trackPageView)
        expect(fetchMock).toHaveBeenCalledTimes(1)
        expect(beaconMock).toHaveBeenCalledTimes(1)
    })

    it('handles fetch 403 by clearing session and retrying', async () => {
        const key = await realImportKey(TEST_KEY_B64)
        const visitorResp = await realEncrypt({ uid: 'v3', h: 'hash3' }, key)

        document.body.dataset.k = TEST_KEY_B64
        document.body.dataset.e = '/t'

        navigator.sendBeacon = undefined

        let callCount = 0
        fetchMock.mockImplementation(async () => {
            callCount++
            if (callCount === 1) return { ok: true, status: 200, json: async () => ({ d: visitorResp }) }
            if (callCount === 2) return { ok: false, status: 403 }
            if (callCount === 3) return { ok: true, status: 200, json: async () => ({ d: visitorResp }) }
            return { ok: true, status: 204 }
        })

        const { initAnalytics } = await loadModule()

        await initAnalytics()

        expect(callCount).toBe(4)
    })

    it('handles fetch network error gracefully', async () => {
        document.body.dataset.k = TEST_KEY_B64
        document.body.dataset.e = '/t'

        fetchMock.mockRejectedValue(new Error('Network error'))
        const { initAnalytics } = await loadModule()

        await initAnalytics()

        expect(sessionStorage.getItem('_u')).toBeNull()
    })

    it('handles response without d field', async () => {
        document.body.dataset.k = TEST_KEY_B64
        document.body.dataset.e = '/t'

        fetchMock.mockResolvedValue({
            ok: true, status: 200, json: async () => ({ other: 'data' }),
        })
        const { initAnalytics } = await loadModule()

        await initAnalytics()

        expect(sessionStorage.getItem('_u')).toBeNull()
    })

    it('handles response with invalid encrypted data', async () => {
        document.body.dataset.k = TEST_KEY_B64
        document.body.dataset.e = '/t'

        fetchMock.mockResolvedValue({
            ok: true, status: 200, json: async () => ({ d: 'invalidbase64!!' }),
        })
        const { initAnalytics } = await loadModule()

        await initAnalytics()

        expect(sessionStorage.getItem('_u')).toBeNull()
    })

    it('handles decrypt failure with valid base64 but wrong key data', async () => {
        // Encrypt with a different key so decryption with TEST_KEY fails
        const otherKeyRaw = new Uint8Array(32)
        otherKeyRaw[0] = 1
        const otherKey = await globalThis.crypto.subtle.importKey('raw', otherKeyRaw, 'AES-GCM', false, ['encrypt'])
        const json = new TextEncoder().encode(JSON.stringify({ uid: 'x', h: 'y' }))
        const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
        const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, otherKey, json)
        const buf = new Uint8Array(encrypted)
        const combined = new Uint8Array(12 + buf.length)
        combined.set(iv)
        combined.set(buf, 12)
        const wrongEncrypted = globalThis.btoa(String.fromCodePoint(...combined))

        document.body.dataset.k = TEST_KEY_B64
        document.body.dataset.e = '/t'

        fetchMock.mockResolvedValue({
            ok: true, status: 200, json: async () => ({ d: wrongEncrypted }),
        })
        const { initAnalytics } = await loadModule()

        await initAnalytics()

        expect(sessionStorage.getItem('_u')).toBeNull()
    })

    it('calls setAuth when data-uid is present', async () => {
        const key = await realImportKey(TEST_KEY_B64)
        const visitorResp = await realEncrypt({ uid: 'v4', h: 'hash4' }, key)

        document.body.dataset.k = TEST_KEY_B64
        document.body.dataset.e = '/t'
        document.body.dataset.uid = '42'

        fetchMock.mockResolvedValue({
            ok: true, status: 200, json: async () => ({ d: visitorResp }),
        })

        const { initAnalytics } = await loadModule()

        await initAnalytics()

        // fetch for visitor creation, beacon for pageview + setAuth
        expect(fetchMock).toHaveBeenCalledTimes(1)
        expect(beaconMock).toHaveBeenCalledTimes(2)
    })

    it('setAuth does nothing without session', async () => {
        document.body.dataset.k = TEST_KEY_B64
        document.body.dataset.e = '/t'

        const { initAnalytics, setAuth } = await loadModule()

        // Init to set encKey but visitor creation fails (no response body)
        await initAnalytics()

        fetchMock.mockClear()
        beaconMock.mockClear()

        await setAuth(42)

        expect(fetchMock).not.toHaveBeenCalled()
        expect(beaconMock).not.toHaveBeenCalled()
    })

    it('setAuth does nothing without encKey', async () => {
        sessionStorage.setItem('_u', 'uid')
        sessionStorage.setItem('_h', 'hash')

        // Don't call initAnalytics -> encKey stays null
        const { setAuth } = await loadModule()

        await setAuth(42)

        expect(fetchMock).not.toHaveBeenCalled()
        expect(beaconMock).not.toHaveBeenCalled()
    })

    it('setAuth sends when session and key exist', async () => {
        const key = await realImportKey(TEST_KEY_B64)
        const visitorResp = await realEncrypt({ uid: 'v5', h: 'hash5' }, key)

        document.body.dataset.k = TEST_KEY_B64
        document.body.dataset.e = '/t'

        fetchMock.mockResolvedValue({
            ok: true, status: 200, json: async () => ({ d: visitorResp }),
        })

        const { initAnalytics, setAuth } = await loadModule()

        await initAnalytics()

        beaconMock.mockClear()
        await setAuth(99)

        expect(beaconMock).toHaveBeenCalledTimes(1)
    })
})
