// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { renderHook, waitFor, act } from '@testing-library/react'
import { vi, describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'

// ── Worker mock infrastructure ─────────────────────────────────────────────
//
// We stub Worker BEFORE importing use-syntax-highlight so that initWorkers()
// (called from useEffect on first render) picks up our mock.
// Module-level state (workers, pending, workersInitialized) is shared across
// all tests in this file — we control behaviour through the mock instances.

type WorkerHandler = (event: { data: unknown }) => void

class MockWorker {
  static instances: MockWorker[] = []

  messageHandler: WorkerHandler | null = null
  postMessage = vi.fn()

  constructor() {
    MockWorker.instances.push(this)
  }

  addEventListener(type: string, handler: WorkerHandler) {
    if (type === 'message') this.messageHandler = handler
  }

  /** Simulate a message arriving from the worker thread */
  simulateResponse(data: unknown) {
    this.messageHandler?.({ data })
  }
}

// Stub Worker before the hook module is loaded.
// initWorkers() is lazy (called inside useEffect), so the stub is in place
// by the time any test renders a hook.
beforeAll(() => {
  vi.stubGlobal('Worker', MockWorker)
})

afterAll(() => {
  vi.unstubAllGlobals()
})

beforeEach(() => {
  // Reset call history between tests; keep instances (pool is created once)
  MockWorker.instances.forEach((w) => w.postMessage.mockClear())
})

// Import the hook AFTER the beforeAll stub is registered (hoisted evaluation
// of the module will not call initWorkers() — that happens in useEffect).
import { useHighlightedFile } from './use-syntax-highlight'

// ── Helpers ────────────────────────────────────────────────────────────────

const MOCK_TOKEN_A = { content: 'const', color: '#569cd6', offset: 0 }
const MOCK_TOKEN_B = { content: 'x', color: '#9cdcfe', offset: 0 }

function makeLine(
  content: string,
  newLineNumber: number,
  type: 'added' | 'context' | 'removed' = 'added',
) {
  return { content, newLineNumber, type } as const
}

// ── Tests ──────────────────────────────────────────────────────────────────

describe('useHighlightedFile — worker path', () => {
  // ── Test 1: Correct message format ───────────────────────────────────────

  it('posts a message to a worker with filePath, language, code, and lineNumbers', async () => {
    const lines = [
      makeLine('const x = 1', 1, 'added'),
      makeLine('const y = 2', 2, 'context'),
    ]

    renderHook(() => useHighlightedFile('src/index.ts', lines))

    // Wait for initWorkers() to fire and postMessage to be called
    await waitFor(() => {
      const totalCalls = MockWorker.instances.reduce(
        (n, w) => n + w.postMessage.mock.calls.length,
        0,
      )
      expect(totalCalls).toBeGreaterThan(0)
    })

    // Find which worker received the message
    const calledWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
    expect(calledWorker).toBeDefined()
    expect(calledWorker!.postMessage).toHaveBeenCalledWith(
      expect.objectContaining({
        filePath: 'src/index.ts',
        language: 'typescript',
        code: 'const x = 1\nconst y = 2',
        lineNumbers: [1, 2],
      }),
    )
  })

  // ── Test 2: Response builds token map ─────────────────────────────────────

  it('returns null initially and a LineTokenMap after worker responds', async () => {
    const lines = [makeLine('const x = 1', 10, 'added')]

    const { result } = renderHook(() => useHighlightedFile('component.ts', lines))

    // Immediately null while worker is pending
    expect(result.current).toBeNull()

    // Capture the request id from whichever worker received it
    let requestId = ''
    let respondingWorker: MockWorker | undefined

    await waitFor(() => {
      respondingWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
      expect(respondingWorker).toBeDefined()
      requestId = respondingWorker!.postMessage.mock.calls[0][0].id as string
      expect(requestId).not.toBe('')
    })

    // Simulate the worker responding
    act(() => {
      respondingWorker!.simulateResponse({
        id: requestId,
        tokens: [{ lineNumber: 10, tokens: [MOCK_TOKEN_A] }],
      })
    })

    await waitFor(() => {
      expect(result.current).not.toBeNull()
      expect(result.current?.get(10)).toEqual([MOCK_TOKEN_A])
    })
  })

  // ── Test 3: Worker error response → null ──────────────────────────────────

  it('returns null when worker responds with an error field', async () => {
    const lines = [makeLine('code here', 1, 'added')]

    const { result } = renderHook(() => useHighlightedFile('bad.ts', lines))

    let requestId = ''
    let respondingWorker: MockWorker | undefined

    await waitFor(() => {
      respondingWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
      expect(respondingWorker).toBeDefined()
      requestId = respondingWorker!.postMessage.mock.calls[0][0].id as string
    })

    act(() => {
      respondingWorker!.simulateResponse({
        id: requestId,
        tokens: [],
        error: 'Worker crashed',
      })
    })

    // Error → stays null (plain text fallback in the UI)
    await new Promise<void>((r) => setTimeout(r, 20))
    expect(result.current).toBeNull()
  })

  // ── Test 4: Unmount before response — no state update ────────────────────

  it('silently discards a late worker response after unmount', async () => {
    const lines = [makeLine('const z = 3', 5, 'added')]

    const { result, unmount } = renderHook(() => useHighlightedFile('late.ts', lines))

    let requestId = ''
    let respondingWorker: MockWorker | undefined

    await waitFor(() => {
      respondingWorker = MockWorker.instances.find((w) => w.postMessage.mock.calls.length > 0)
      expect(respondingWorker).toBeDefined()
      requestId = respondingWorker!.postMessage.mock.calls[0][0].id as string
    })

    // Unmount before the response arrives
    unmount()

    // Simulate the late response — should be silently dropped
    act(() => {
      respondingWorker!.simulateResponse({
        id: requestId,
        tokens: [{ lineNumber: 5, tokens: [MOCK_TOKEN_B] }],
      })
    })

    // result.current is frozen at last rendered value (null) — no update fired
    expect(result.current).toBeNull()
  })

  // ── Test 5: Round-robin — two simultaneous requests go to different workers

  it('distributes two simultaneous requests across both pool workers', async () => {
    // Ensure the pool has been initialised (first test may have done this)
    // and reset call counts for clean measurement.
    MockWorker.instances.forEach((w) => w.postMessage.mockClear())

    const lines1 = [makeLine('alpha', 1, 'added')]
    const lines2 = [makeLine('beta', 1, 'added')]

    // Render two hook instances at the same time
    renderHook(() => useHighlightedFile('file1.ts', lines1))
    renderHook(() => useHighlightedFile('file2.ts', lines2))

    await waitFor(() => {
      const total = MockWorker.instances.reduce((n, w) => n + w.postMessage.mock.calls.length, 0)
      expect(total).toBe(2)
    })

    // Both pool workers should each have received exactly one request
    // (round-robin: even requestCount → workers[0], odd → workers[1])
    const counts = MockWorker.instances.map((w) => w.postMessage.mock.calls.length)
    // Pool has 2 workers; each should have received 1 of the 2 requests
    expect(counts[0]).toBe(1)
    expect(counts[1]).toBe(1)
  })

  // ── Test 6: Unknown language → no request ────────────────────────────────

  it('returns null immediately for files with no detectable language', async () => {
    MockWorker.instances.forEach((w) => w.postMessage.mockClear())

    const lines = [makeLine('raw data', 1, 'added')]

    const { result } = renderHook(() => useHighlightedFile('data.xyz', lines))

    await new Promise<void>((r) => setTimeout(r, 50))

    expect(result.current).toBeNull()
    const total = MockWorker.instances.reduce((n, w) => n + w.postMessage.mock.calls.length, 0)
    expect(total).toBe(0)
  })
})
