import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { useDeviceMercure } from '@/composables/useDeviceMercure'
import { useDevicesStore } from '@/stores/devices'
import type { Device } from '@/types'

// ── EventSource fake ─────────────────────────────────────────────────────────
// happy-dom doesn't ship one, so we install a small fake on `window` for these
// tests. Each instance is captured so individual tests can assert open/close,
// inject onmessage payloads, and trigger reconnect-on-error flows.

interface FakeEventSource {
  url: string
  withCredentials: boolean
  readyState: number
  onmessage: ((ev: MessageEvent) => void) | null
  onerror: ((ev: Event) => void) | null
  close: ReturnType<typeof vi.fn>
}

let instances: FakeEventSource[] = []

class FakeES {
  url: string
  withCredentials: boolean
  readyState = 1 // OPEN
  onmessage: ((ev: MessageEvent) => void) | null = null
  onerror: ((ev: Event) => void) | null = null
  close = vi.fn(() => { this.readyState = 2 /* CLOSED */ })
  constructor(url: string, init?: { withCredentials?: boolean }) {
    this.url = url
    this.withCredentials = init?.withCredentials ?? false
    instances.push(this as unknown as FakeEventSource)
  }
  static readonly CLOSED = 2
}

const makeDevice = (overrides: Partial<Device> = {}): Device => ({
  id: 1,
  mac: 'AA',
  name: 'Den',
  orientation: 'landscape',
  model: 'v1',
  rotationIntervalMinutes: 60,
  wakeTimes: [],
  timezone: 'UTC',
  uniquenessWindow: 30,
  rotationMode: 'oldest_upload',
  prioritizeNeverShown: false,
  linkedAt: '2026-01-01T00:00:00Z',
  lastSeenAt: null,
  nextPollExpectedAt: null,
  lockedImageId: null,
  currentImageId: null,
  ...overrides,
})

// Mounts a tiny test component that calls the composable in setup so onMounted
// hooks fire and onUnmounted cleanup is wired through @vue/test-utils.
function mountWithComposable() {
  const Comp = defineComponent({
    setup() { useDeviceMercure() },
    render() { return h('div') },
  })
  return mount(Comp)
}

describe('useDeviceMercure', () => {
  beforeEach(() => {
    instances = []
    vi.useFakeTimers()
    vi.stubGlobal('EventSource', FakeES)
    vi.stubGlobal('__PF_MERCURE_URL__' as never, undefined as never)
    Object.defineProperty(window, '__PF_MERCURE_URL__', {
      value: 'https://hub.example/.well-known/mercure',
      configurable: true,
      writable: true,
    })
  })

  afterEach(() => {
    vi.useRealTimers()
    vi.unstubAllGlobals()
  })

  it('opens one EventSource per device, with the device topic in the URL', async () => {
    const store = useDevicesStore()
    store.devices = [makeDevice({ id: 7 }), makeDevice({ id: 9 })]

    mountWithComposable()
    await flushPromises()

    expect(instances).toHaveLength(2)
    const urls = instances.map(i => i.url)
    expect(urls.some(u => u.includes('topic=https%3A%2F%2Fwevisto.com%2Fdevices%2F7'))).toBe(true)
    expect(urls.some(u => u.includes('topic=https%3A%2F%2Fwevisto.com%2Fdevices%2F9'))).toBe(true)
    expect(instances[0].withCredentials).toBe(true)
  })

  it('merges incoming messages into the matching device store entry', async () => {
    const store = useDevicesStore()
    store.devices = [makeDevice({ id: 7, name: 'Old Name' })]

    mountWithComposable()
    await flushPromises()

    expect(instances).toHaveLength(1)
    instances[0].onmessage?.(new MessageEvent('message', {
      data: JSON.stringify(makeDevice({ id: 7, name: 'New Name' })),
    }))
    await flushPromises()

    expect(store.devices[0].name).toBe('New Name')
  })

  it('ignores messages for a device id not in the store (stale subscription)', async () => {
    const store = useDevicesStore()
    store.devices = [makeDevice({ id: 7, name: 'Original' })]

    mountWithComposable()
    await flushPromises()

    instances[0].onmessage?.(new MessageEvent('message', {
      data: JSON.stringify(makeDevice({ id: 999, name: 'Phantom' })),
    }))
    await flushPromises()

    expect(store.devices[0].name).toBe('Original')
    expect(store.devices).toHaveLength(1)
  })

  it('warns instead of throwing on malformed JSON', async () => {
    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
    const store = useDevicesStore()
    store.devices = [makeDevice({ id: 7 })]

    mountWithComposable()
    await flushPromises()

    instances[0].onmessage?.(new MessageEvent('message', { data: 'not json' }))
    await flushPromises()

    expect(warn).toHaveBeenCalled()
  })

  it('reconnects after a CLOSED error event', async () => {
    const store = useDevicesStore()
    store.devices = [makeDevice({ id: 7 })]

    mountWithComposable()
    await flushPromises()
    expect(instances).toHaveLength(1)

    // Simulate the hub closing the stream — onerror fires with readyState=CLOSED.
    instances[0].readyState = FakeES.CLOSED
    instances[0].onerror?.(new Event('error'))
    await flushPromises()

    // Composable schedules a 5s reconnect.
    vi.advanceTimersByTime(5000)
    await flushPromises()

    expect(instances).toHaveLength(2) // a fresh connection was opened
  })

  it('removes the device from the store on a {deleted: true} sentinel', async () => {
    const store = useDevicesStore()
    store.devices = [makeDevice({ id: 7, name: 'Den' }), makeDevice({ id: 9, name: 'Cabin' })]

    mountWithComposable()
    await flushPromises()
    expect(instances).toHaveLength(2)

    // Find the EventSource instance subscribed to device 7.
    const dev7 = instances.find(i => i.url.includes('%2F7'))!
    dev7.onmessage?.(new MessageEvent('message', {
      data: JSON.stringify({ id: 7, deleted: true }),
    }))
    await flushPromises()

    // Device 7 spliced out, 9 still there. The deleted device's EventSource
    // is closed too — no point keeping a connection for a vanished device.
    expect(store.devices.map(d => d.id)).toEqual([9])
    expect(dev7.close).toHaveBeenCalled()
  })

  it('closes EventSources for devices that disappear from the store', async () => {
    const store = useDevicesStore()
    store.devices = [makeDevice({ id: 7 }), makeDevice({ id: 9 })]

    mountWithComposable()
    await flushPromises()
    expect(instances).toHaveLength(2)

    // Remove device 9 — the watch should close that EventSource.
    store.devices = store.devices.filter(d => d.id !== 9)
    await flushPromises()

    const closed = instances.filter(i => i.close.mock.calls.length > 0)
    expect(closed).toHaveLength(1)
  })

  it('opens new EventSources when a device is added to the store', async () => {
    const store = useDevicesStore()
    store.devices = [makeDevice({ id: 7 })]

    mountWithComposable()
    await flushPromises()
    expect(instances).toHaveLength(1)

    store.devices = [...store.devices, makeDevice({ id: 9 })]
    await flushPromises()

    expect(instances).toHaveLength(2)
  })

  it('no-ops when MERCURE_URL is not configured', async () => {
    Object.defineProperty(window, '__PF_MERCURE_URL__', { value: undefined, configurable: true })
    const store = useDevicesStore()
    store.devices = [makeDevice({ id: 7 })]

    mountWithComposable()
    await flushPromises()

    expect(instances).toHaveLength(0)
  })

  it('closes all EventSources on unmount', async () => {
    const store = useDevicesStore()
    store.devices = [makeDevice({ id: 7 }), makeDevice({ id: 9 })]

    const wrapper = mountWithComposable()
    await flushPromises()
    expect(instances).toHaveLength(2)

    wrapper.unmount()
    await flushPromises()

    expect(instances.every(i => i.close.mock.calls.length > 0)).toBe(true)
  })
})
