import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
  useMapScreenshot,
  type MapScreenshotCaptureContext,
  type MapScreenshotProcessCapture,
} from './use-map-screenshot'
import { act, renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MapProvider, useMapStore } from './providers/map/provider'
import { CartoBasemaps, GmapsBasemap } from './const'
import { MAP_ID } from '../tests/config'
import html2canvas from 'html2canvas'
import type { ReactNode } from 'react'

// Mock html2canvas — resolves a real HTMLCanvasElement so `drawImage` sees a
// valid CanvasImageSource in environments that type-check the argument.
vi.mock('html2canvas', () => ({
  default: vi.fn(() => {
    const canvas = document.createElement('canvas')
    canvas.width = 200
    canvas.height = 200
    return Promise.resolve(canvas)
  }),
}))

describe('useMapScreenshot', () => {
  let queryClient: QueryClient

  beforeEach(() => {
    queryClient = new QueryClient({
      defaultOptions: { queries: { retry: false } },
    })
    vi.clearAllMocks()
  })

  const wrapper = ({ children }: { children: ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      <MapProvider
        values={[
          {
            id: MAP_ID,
            basemap: CartoBasemaps.positron,
            initialViewState: {
              latitude: 0,
              longitude: 0,
              zoom: 1,
            },
          },
        ]}
      >
        {children}
      </MapProvider>
    </QueryClientProvider>
  )

  it('should initialize with default values', () => {
    const { result } = renderHook(() => useMapScreenshot({ mapId: MAP_ID }), {
      wrapper,
    })

    expect(result.current.screenshot).toBeUndefined()
    expect(result.current.isLoading).toBe(false)
    expect(result.current.error).toBeUndefined()
  })

  it('should have takeScreenshot function', () => {
    const { result } = renderHook(() => useMapScreenshot({ mapId: MAP_ID }), {
      wrapper,
    })

    expect(typeof result.current.takeScreenshot).toBe('function')
  })

  it('should set isLoading when layers are not loaded', async () => {
    const { result } = renderHook(() => useMapScreenshot({ mapId: MAP_ID }), {
      wrapper,
    })

    act(() => {
      result.current.takeScreenshot()
    })

    await waitFor(() => {
      expect(result.current.isLoading).toBe(true)
    })
  })

  it('should handle missing map instance gracefully', () => {
    const { result } = renderHook(() => useMapScreenshot({ mapId: MAP_ID }), {
      wrapper,
    })

    // Call takeScreenshot without proper setup
    result.current.takeScreenshot()

    // Should not throw
    expect(result.current.error).toBeUndefined()
  })

  it('should set isLoading to false initially', () => {
    const { result } = renderHook(() => useMapScreenshot({ mapId: MAP_ID }), {
      wrapper,
    })

    expect(result.current.isLoading).toBe(false)
  })

  it('should check if all layers are loaded', () => {
    const { result } = renderHook(() => useMapScreenshot({ mapId: MAP_ID }), {
      wrapper,
    })

    // isAllLoaded combines isLoaded and areLayersLoaded
    expect(typeof result.current.isLoading).toBe('boolean')
  })

  it('should handle basemap type correctly', () => {
    const { result } = renderHook(() => useMapScreenshot({ mapId: MAP_ID }), {
      wrapper,
    })

    // Should initialize without errors even with basemap checks
    expect(result.current).toBeDefined()
  })

  it('should handle screenshot state', () => {
    const { result } = renderHook(() => useMapScreenshot({ mapId: MAP_ID }), {
      wrapper,
    })

    expect(result.current.screenshot).toBeUndefined()
  })

  it('should handle error state', () => {
    const { result } = renderHook(() => useMapScreenshot({ mapId: MAP_ID }), {
      wrapper,
    })

    expect(result.current.error).toBeUndefined()
  })

  it('should get deck instance function from store', () => {
    const { result } = renderHook(() => useMapScreenshot({ mapId: MAP_ID }), {
      wrapper,
    })

    // Hook should initialize without errors
    expect(result.current).toBeDefined()
  })

  it('should get overlay instance function from store', () => {
    const { result } = renderHook(() => useMapScreenshot({ mapId: MAP_ID }), {
      wrapper,
    })

    // Hook should initialize without errors
    expect(result.current).toBeDefined()
  })

  it('should check layers loaded status', () => {
    const { result } = renderHook(() => useMapScreenshot({ mapId: MAP_ID }), {
      wrapper,
    })

    // Should have access to areLayersLoaded state
    expect(result.current).toBeDefined()
  })

  it('should check map loaded status', () => {
    const { result } = renderHook(() => useMapScreenshot({ mapId: MAP_ID }), {
      wrapper,
    })

    // Should have access to isLoaded state
    expect(result.current).toBeDefined()
  })

  it('should call html2canvas with scale=pixelRatio on the Google Maps branch', async () => {
    const originalDprDescriptor = Object.getOwnPropertyDescriptor(
      window,
      'devicePixelRatio',
    )
    Object.defineProperty(window, 'devicePixelRatio', {
      configurable: true,
      value: 1,
    })

    try {
      const gmapsWrapper = ({ children }: { children: ReactNode }) => (
        <QueryClientProvider client={queryClient}>
          <MapProvider
            values={[
              {
                id: MAP_ID,
                basemap: GmapsBasemap.roadmap,
                initialViewState: { latitude: 0, longitude: 0, zoom: 1 },
              },
            ]}
          >
            {children}
          </MapProvider>
        </QueryClientProvider>
      )

      const { result } = renderHook(
        () => ({
          // Default pixelRatio is 2 — capture should be at 2× regardless
          // of the display's DPR.
          screenshot: useMapScreenshot({ mapId: MAP_ID }),
          setLoad: useMapStore((s) => s.setLoad),
          setLayersLoad: useMapStore((s) => s.setLayersLoad),
          setIdle: useMapStore((s) => s.setIdle),
          maps: useMapStore((s) => s.maps),
        }),
        { wrapper: gmapsWrapper },
      )

      const fakeCanvas = document.createElement('canvas')
      fakeCanvas.width = 600
      fakeCanvas.height = 400
      const fakeDeck = {
        width: 200,
        height: 200,
        getCanvas: () => fakeCanvas,
        redraw: vi.fn(),
        needsRedraw: () => false as const,
        props: { useDevicePixels: true },
        setProps: vi.fn(),
      }
      const fakeGmaps = {
        getDiv: () => document.createElement('div'),
        getRenderingType: () => 'RASTER',
      }

      act(() => {
        // Wire up fake instances on the refs that the hook reads through
        // getDeckInstance / getOverlayInstance.
        const mapState = result.current.maps[MAP_ID]!
        mapState.instanceRef!.current = {
          // @ts-expect-error - minimal fake for test
          deck: fakeDeck,
        }
        mapState.overlayRef!.current = fakeGmaps as unknown as google.maps.Map
        result.current.setLoad(MAP_ID, true)
        result.current.setLayersLoad(MAP_ID, true)
        result.current.setIdle(MAP_ID, true)
      })

      act(() => {
        result.current.screenshot.takeScreenshot()
      })

      await waitFor(() => {
        expect(html2canvas).toHaveBeenCalled()
      })

      expect(html2canvas).toHaveBeenCalledWith(
        expect.any(HTMLElement),
        expect.objectContaining({ scale: 2, useCORS: true }),
      )

      // Deck was bumped to the requested DPR, then restored.
      expect(fakeDeck.setProps).toHaveBeenNthCalledWith(1, {
        useDevicePixels: 2,
      })
      expect(fakeDeck.setProps).toHaveBeenLastCalledWith({
        useDevicePixels: true,
      })
      expect(fakeDeck.redraw).toHaveBeenCalledWith('screenshot-resize')
    } finally {
      if (originalDprDescriptor) {
        Object.defineProperty(window, 'devicePixelRatio', originalDprDescriptor)
      } else {
        // @ts-expect-error - removing a property we added
        delete window.devicePixelRatio
      }
    }
  })

  it('should capture Google Maps even when the basemap-idle flag is false', async () => {
    // Google Maps never settles `areMapsIdle` to true (the deck<->gmaps camera
    // sync fires a trailing setViewState after Google's idle event, and Google
    // doesn't re-fire idle on a static map). So the screenshot hook must NOT
    // gate gmaps on the idle flag — tile load + layer load + waitForDeckIdle
    // already cover readiness.
    const gmapsWrapper = ({ children }: { children: ReactNode }) => (
      <QueryClientProvider client={queryClient}>
        <MapProvider
          values={[
            {
              id: MAP_ID,
              basemap: GmapsBasemap.roadmap,
              initialViewState: { latitude: 0, longitude: 0, zoom: 1 },
            },
          ]}
        >
          {children}
        </MapProvider>
      </QueryClientProvider>
    )

    const { result } = renderHook(
      () => ({
        screenshot: useMapScreenshot({ mapId: MAP_ID }),
        setLoad: useMapStore((s) => s.setLoad),
        setLayersLoad: useMapStore((s) => s.setLayersLoad),
        maps: useMapStore((s) => s.maps),
      }),
      { wrapper: gmapsWrapper },
    )

    const fakeCanvas = document.createElement('canvas')
    fakeCanvas.width = 600
    fakeCanvas.height = 400
    const fakeDeck = {
      width: 200,
      height: 200,
      getCanvas: () => fakeCanvas,
      redraw: vi.fn(),
      needsRedraw: () => false as const,
      props: { useDevicePixels: true },
      setProps: vi.fn(),
    }
    const fakeGmaps = {
      getDiv: () => document.createElement('div'),
      getRenderingType: () => 'RASTER',
    }

    act(() => {
      const mapState = result.current.maps[MAP_ID]!
      mapState.instanceRef!.current = {
        // @ts-expect-error - minimal fake for test
        deck: fakeDeck,
      }
      mapState.overlayRef!.current = fakeGmaps as unknown as google.maps.Map
      result.current.setLoad(MAP_ID, true)
      result.current.setLayersLoad(MAP_ID, true)
      // Intentionally do NOT call setIdle — gmaps must still capture.
    })

    act(() => {
      result.current.screenshot.takeScreenshot()
    })

    // Capture proceeds despite areMapsIdle being false for gmaps.
    await waitFor(() => {
      expect(html2canvas).toHaveBeenCalled()
    })
  })

  it('should wait for deck.needsRedraw() to return false before drawing', async () => {
    const gmapsWrapper = ({ children }: { children: ReactNode }) => (
      <QueryClientProvider client={queryClient}>
        <MapProvider
          values={[
            {
              id: MAP_ID,
              basemap: GmapsBasemap.roadmap,
              initialViewState: { latitude: 0, longitude: 0, zoom: 1 },
            },
          ]}
        >
          {children}
        </MapProvider>
      </QueryClientProvider>
    )

    const { result } = renderHook(
      () => ({
        screenshot: useMapScreenshot({ mapId: MAP_ID }),
        setLoad: useMapStore((s) => s.setLoad),
        setLayersLoad: useMapStore((s) => s.setLayersLoad),
        setIdle: useMapStore((s) => s.setIdle),
        maps: useMapStore((s) => s.maps),
      }),
      { wrapper: gmapsWrapper },
    )

    const fakeCanvas = document.createElement('canvas')
    fakeCanvas.width = 600
    fakeCanvas.height = 400
    // First 3 polls report busy, subsequent polls report idle. We need 2
    // consecutive idle frames to resolve, so html2canvas must fire only after
    // at least call #5.
    const needsRedraw = vi
      .fn<(opts?: { clearRedrawFlags: boolean }) => string | false>()
      .mockReturnValueOnce('view changed')
      .mockReturnValueOnce('view changed')
      .mockReturnValueOnce('view changed')
      .mockReturnValue(false)
    const fakeDeck = {
      width: 200,
      height: 200,
      getCanvas: () => fakeCanvas,
      redraw: vi.fn(),
      needsRedraw,
    }
    const fakeGmaps = {
      getDiv: () => document.createElement('div'),
      getRenderingType: () => 'RASTER',
    }

    act(() => {
      const mapState = result.current.maps[MAP_ID]!
      mapState.instanceRef!.current = {
        // @ts-expect-error - minimal fake for test
        deck: fakeDeck,
      }
      mapState.overlayRef!.current = fakeGmaps as unknown as google.maps.Map
      result.current.setLoad(MAP_ID, true)
      result.current.setLayersLoad(MAP_ID, true)
      result.current.setIdle(MAP_ID, true)
    })

    act(() => {
      result.current.screenshot.takeScreenshot()
    })

    await waitFor(() => {
      expect(html2canvas).toHaveBeenCalled()
    })

    // 3 busy + at least 2 idle calls before draw resolves.
    expect(needsRedraw.mock.calls.length).toBeGreaterThanOrEqual(5)
    // All calls must use the non-mutating peek option.
    for (const call of needsRedraw.mock.calls) {
      expect(call[0]).toEqual({ clearRedrawFlags: false })
    }
  })

  it('should honor an explicit pixelRatio override', async () => {
    const originalDprDescriptor = Object.getOwnPropertyDescriptor(
      window,
      'devicePixelRatio',
    )
    Object.defineProperty(window, 'devicePixelRatio', {
      configurable: true,
      value: 1,
    })

    try {
      const gmapsWrapper = ({ children }: { children: ReactNode }) => (
        <QueryClientProvider client={queryClient}>
          <MapProvider
            values={[
              {
                id: MAP_ID,
                basemap: GmapsBasemap.roadmap,
                initialViewState: { latitude: 0, longitude: 0, zoom: 1 },
              },
            ]}
          >
            {children}
          </MapProvider>
        </QueryClientProvider>
      )

      const { result } = renderHook(
        () => ({
          screenshot: useMapScreenshot({
            mapId: MAP_ID,
            pixelRatio: 3,
          }),
          setLoad: useMapStore((s) => s.setLoad),
          setLayersLoad: useMapStore((s) => s.setLayersLoad),
          setIdle: useMapStore((s) => s.setIdle),
          maps: useMapStore((s) => s.maps),
        }),
        { wrapper: gmapsWrapper },
      )

      const fakeDeck = {
        width: 200,
        height: 200,
        getCanvas: () => document.createElement('canvas'),
        redraw: vi.fn(),
        needsRedraw: () => false as const,
        props: { useDevicePixels: true },
        setProps: vi.fn(),
      }
      const fakeGmaps = {
        getDiv: () => document.createElement('div'),
        getRenderingType: () => 'RASTER',
      }

      act(() => {
        const mapState = result.current.maps[MAP_ID]!
        mapState.instanceRef!.current = {
          // @ts-expect-error - minimal fake for test
          deck: fakeDeck,
        }
        mapState.overlayRef!.current = fakeGmaps as unknown as google.maps.Map
        result.current.setLoad(MAP_ID, true)
        result.current.setLayersLoad(MAP_ID, true)
        result.current.setIdle(MAP_ID, true)
      })

      act(() => {
        result.current.screenshot.takeScreenshot()
      })

      await waitFor(() => {
        expect(html2canvas).toHaveBeenCalled()
      })

      expect(html2canvas).toHaveBeenCalledWith(
        expect.any(HTMLElement),
        expect.objectContaining({ scale: 3 }),
      )
      expect(fakeDeck.setProps).toHaveBeenNthCalledWith(1, {
        useDevicePixels: 3,
      })
      expect(fakeDeck.setProps).toHaveBeenLastCalledWith({
        useDevicePixels: true,
      })
    } finally {
      if (originalDprDescriptor) {
        Object.defineProperty(window, 'devicePixelRatio', originalDprDescriptor)
      } else {
        // @ts-expect-error - removing a property we added
        delete window.devicePixelRatio
      }
    }
  })

  it('should default to the native DPR when it is already above 2', async () => {
    const originalDprDescriptor = Object.getOwnPropertyDescriptor(
      window,
      'devicePixelRatio',
    )
    Object.defineProperty(window, 'devicePixelRatio', {
      configurable: true,
      value: 3,
    })

    try {
      const gmapsWrapper = ({ children }: { children: ReactNode }) => (
        <QueryClientProvider client={queryClient}>
          <MapProvider
            values={[
              {
                id: MAP_ID,
                basemap: GmapsBasemap.roadmap,
                initialViewState: { latitude: 0, longitude: 0, zoom: 1 },
              },
            ]}
          >
            {children}
          </MapProvider>
        </QueryClientProvider>
      )

      const { result } = renderHook(
        () => ({
          // No pixelRatio — default should resolve to max(2, 3) = 3,
          // which already matches the display, so no resize.
          screenshot: useMapScreenshot({ mapId: MAP_ID }),
          setLoad: useMapStore((s) => s.setLoad),
          setLayersLoad: useMapStore((s) => s.setLayersLoad),
          setIdle: useMapStore((s) => s.setIdle),
          maps: useMapStore((s) => s.maps),
        }),
        { wrapper: gmapsWrapper },
      )

      const fakeDeck = {
        width: 200,
        height: 200,
        getCanvas: () => document.createElement('canvas'),
        redraw: vi.fn(),
        needsRedraw: () => false as const,
        props: { useDevicePixels: true },
        setProps: vi.fn(),
      }
      const fakeGmaps = {
        getDiv: () => document.createElement('div'),
        getRenderingType: () => 'RASTER',
      }

      act(() => {
        const mapState = result.current.maps[MAP_ID]!
        mapState.instanceRef!.current = {
          // @ts-expect-error - minimal fake for test
          deck: fakeDeck,
        }
        mapState.overlayRef!.current = fakeGmaps as unknown as google.maps.Map
        result.current.setLoad(MAP_ID, true)
        result.current.setLayersLoad(MAP_ID, true)
        result.current.setIdle(MAP_ID, true)
      })

      act(() => {
        result.current.screenshot.takeScreenshot()
      })

      await waitFor(() => {
        expect(html2canvas).toHaveBeenCalled()
      })

      // Captures at the native 3× — html2canvas scale matches, deck untouched.
      expect(html2canvas).toHaveBeenCalledWith(
        expect.any(HTMLElement),
        expect.objectContaining({ scale: 3 }),
      )
      expect(fakeDeck.setProps).not.toHaveBeenCalled()
    } finally {
      if (originalDprDescriptor) {
        Object.defineProperty(window, 'devicePixelRatio', originalDprDescriptor)
      } else {
        // @ts-expect-error - removing a property we added
        delete window.devicePixelRatio
      }
    }
  })

  it('should not resize deck when pixelRatio already matches window.devicePixelRatio', async () => {
    const originalDprDescriptor = Object.getOwnPropertyDescriptor(
      window,
      'devicePixelRatio',
    )
    Object.defineProperty(window, 'devicePixelRatio', {
      configurable: true,
      value: 2,
    })

    try {
      const gmapsWrapper = ({ children }: { children: ReactNode }) => (
        <QueryClientProvider client={queryClient}>
          <MapProvider
            values={[
              {
                id: MAP_ID,
                basemap: GmapsBasemap.roadmap,
                initialViewState: { latitude: 0, longitude: 0, zoom: 1 },
              },
            ]}
          >
            {children}
          </MapProvider>
        </QueryClientProvider>
      )

      const { result } = renderHook(
        () => ({
          // Default pixelRatio=2, window DPR=2 → no resize required.
          screenshot: useMapScreenshot({ mapId: MAP_ID }),
          setLoad: useMapStore((s) => s.setLoad),
          setLayersLoad: useMapStore((s) => s.setLayersLoad),
          setIdle: useMapStore((s) => s.setIdle),
          maps: useMapStore((s) => s.maps),
        }),
        { wrapper: gmapsWrapper },
      )

      const fakeDeck = {
        width: 200,
        height: 200,
        getCanvas: () => document.createElement('canvas'),
        redraw: vi.fn(),
        needsRedraw: () => false as const,
        props: { useDevicePixels: true },
        setProps: vi.fn(),
      }
      const fakeGmaps = {
        getDiv: () => document.createElement('div'),
        getRenderingType: () => 'RASTER',
      }

      act(() => {
        const mapState = result.current.maps[MAP_ID]!
        mapState.instanceRef!.current = {
          // @ts-expect-error - minimal fake for test
          deck: fakeDeck,
        }
        mapState.overlayRef!.current = fakeGmaps as unknown as google.maps.Map
        result.current.setLoad(MAP_ID, true)
        result.current.setLayersLoad(MAP_ID, true)
        result.current.setIdle(MAP_ID, true)
      })

      act(() => {
        result.current.screenshot.takeScreenshot()
      })

      await waitFor(() => {
        expect(html2canvas).toHaveBeenCalled()
      })

      expect(fakeDeck.setProps).not.toHaveBeenCalled()
      expect(fakeDeck.redraw).not.toHaveBeenCalledWith('screenshot-resize')
    } finally {
      if (originalDprDescriptor) {
        Object.defineProperty(window, 'devicePixelRatio', originalDprDescriptor)
      } else {
        // @ts-expect-error - removing a property we added
        delete window.devicePixelRatio
      }
    }
  })

  it('should resize maplibre via setPixelRatio + triggerRepaint, await idle, then restore on the CARTO basemap branch', async () => {
    const originalDprDescriptor = Object.getOwnPropertyDescriptor(
      window,
      'devicePixelRatio',
    )
    Object.defineProperty(window, 'devicePixelRatio', {
      configurable: true,
      value: 1,
    })

    try {
      const { result } = renderHook(
        () => ({
          // Default pixelRatio resolves to max(2, 1) = 2; window=1 →
          // both deck and maplibre need to resize.
          screenshot: useMapScreenshot({ mapId: MAP_ID }),
          setLoad: useMapStore((s) => s.setLoad),
          setLayersLoad: useMapStore((s) => s.setLayersLoad),
          setIdle: useMapStore((s) => s.setIdle),
          maps: useMapStore((s) => s.maps),
        }),
        { wrapper },
      )

      const fakeDeckCanvas = document.createElement('canvas')
      fakeDeckCanvas.width = 600
      fakeDeckCanvas.height = 400
      const fakeDeck = {
        width: 200,
        height: 200,
        getCanvas: () => fakeDeckCanvas,
        redraw: vi.fn(),
        needsRedraw: () => false as const,
        props: { useDevicePixels: true },
        setProps: vi.fn(),
      }

      const fakeMaplibreCanvas = document.createElement('canvas')
      fakeMaplibreCanvas.width = 600
      fakeMaplibreCanvas.height = 400
      const idleHandlers: (() => void)[] = []
      const innerMap = {
        getCanvas: () => fakeMaplibreCanvas,
        getPixelRatio: vi.fn(() => 1),
        setPixelRatio: vi.fn(),
        triggerRepaint: vi.fn(),
        once: vi.fn((event: string, cb: () => void) => {
          if (event === 'idle') idleHandlers.push(cb)
        }),
      }
      const fakeMaplibre = { getMap: () => innerMap, redraw: vi.fn() }

      act(() => {
        const mapState = result.current.maps[MAP_ID]!
        mapState.instanceRef!.current = {
          // @ts-expect-error - minimal fake for test
          deck: fakeDeck,
        }
        // @ts-expect-error - minimal fake for test
        mapState.overlayRef!.current = fakeMaplibre
        result.current.setLoad(MAP_ID, true)
        result.current.setLayersLoad(MAP_ID, true)
        result.current.setIdle(MAP_ID, true)
      })

      act(() => {
        result.current.screenshot.takeScreenshot()
      })

      // The hook must register `once('idle', cb)` after calling
      // setPixelRatio + triggerRepaint, then suspend until cb is invoked.
      await waitFor(() => {
        expect(idleHandlers.length).toBe(1)
      })

      // Verify the setup ran before we resolve idle.
      expect(innerMap.setPixelRatio).toHaveBeenNthCalledWith(1, 2)
      expect(innerMap.triggerRepaint).toHaveBeenCalledTimes(1)
      expect(fakeDeck.setProps).not.toHaveBeenCalled() // deck waits for maplibre

      // Fire idle: capture proceeds, deck resizes, drawImage runs, restore.
      act(() => {
        for (const cb of idleHandlers) cb()
      })

      await waitFor(() => {
        expect(result.current.screenshot.screenshot).toBeDefined()
      })

      // Deck was resized after maplibre idle, then restored.
      expect(fakeDeck.setProps).toHaveBeenNthCalledWith(1, {
        useDevicePixels: 2,
      })
      expect(fakeDeck.setProps).toHaveBeenLastCalledWith({
        useDevicePixels: true,
      })
      expect(fakeDeck.redraw).toHaveBeenCalledWith('screenshot-resize')

      // Maplibre was restored to its original pixel ratio with another
      // triggerRepaint (fire-and-forget, no second idle wait).
      expect(innerMap.setPixelRatio).toHaveBeenCalledTimes(2)
      expect(innerMap.setPixelRatio).toHaveBeenNthCalledWith(2, 1)
      expect(innerMap.triggerRepaint).toHaveBeenCalledTimes(2)
    } finally {
      if (originalDprDescriptor) {
        Object.defineProperty(window, 'devicePixelRatio', originalDprDescriptor)
      } else {
        // @ts-expect-error - removing a property we added
        delete window.devicePixelRatio
      }
    }
  })

  it('should never touch maplibre setPixelRatio on the Google Maps branch', async () => {
    const originalDprDescriptor = Object.getOwnPropertyDescriptor(
      window,
      'devicePixelRatio',
    )
    Object.defineProperty(window, 'devicePixelRatio', {
      configurable: true,
      value: 1,
    })

    try {
      const gmapsWrapper = ({ children }: { children: ReactNode }) => (
        <QueryClientProvider client={queryClient}>
          <MapProvider
            values={[
              {
                id: MAP_ID,
                basemap: GmapsBasemap.roadmap,
                initialViewState: { latitude: 0, longitude: 0, zoom: 1 },
              },
            ]}
          >
            {children}
          </MapProvider>
        </QueryClientProvider>
      )

      const { result } = renderHook(
        () => ({
          screenshot: useMapScreenshot({ mapId: MAP_ID }),
          setLoad: useMapStore((s) => s.setLoad),
          setLayersLoad: useMapStore((s) => s.setLayersLoad),
          setIdle: useMapStore((s) => s.setIdle),
          maps: useMapStore((s) => s.maps),
        }),
        { wrapper: gmapsWrapper },
      )

      const fakeDeck = {
        width: 200,
        height: 200,
        getCanvas: () => document.createElement('canvas'),
        redraw: vi.fn(),
        needsRedraw: () => false as const,
        props: { useDevicePixels: true },
        setProps: vi.fn(),
      }
      // The gmaps overlay does NOT expose `getMap` — the hook must detect
      // that and skip the maplibre branch entirely. We deliberately attach
      // a poisoned getMap that would fail loudly if it ever ran.
      const poisonedGetMap = vi.fn(() => {
        throw new Error('getMap must not be called for gmaps basemaps')
      })
      const fakeGmaps = {
        getDiv: () => document.createElement('div'),
        getRenderingType: () => 'RASTER',
        getMap: poisonedGetMap,
      }

      act(() => {
        const mapState = result.current.maps[MAP_ID]!
        mapState.instanceRef!.current = {
          // @ts-expect-error - minimal fake for test
          deck: fakeDeck,
        }
        mapState.overlayRef!.current = fakeGmaps as unknown as google.maps.Map
        result.current.setLoad(MAP_ID, true)
        result.current.setLayersLoad(MAP_ID, true)
        result.current.setIdle(MAP_ID, true)
      })

      act(() => {
        result.current.screenshot.takeScreenshot()
      })

      await waitFor(() => {
        expect(html2canvas).toHaveBeenCalled()
      })

      expect(poisonedGetMap).not.toHaveBeenCalled()
      // Deck still resizes (the gmaps branch only skips maplibre).
      expect(fakeDeck.setProps).toHaveBeenNthCalledWith(1, {
        useDevicePixels: 2,
      })
    } finally {
      if (originalDprDescriptor) {
        Object.defineProperty(window, 'devicePixelRatio', originalDprDescriptor)
      } else {
        // @ts-expect-error - removing a property we added
        delete window.devicePixelRatio
      }
    }
  })

  it('should skip maplibre setPixelRatio when pixelRatio already matches window.devicePixelRatio', async () => {
    const originalDprDescriptor = Object.getOwnPropertyDescriptor(
      window,
      'devicePixelRatio',
    )
    Object.defineProperty(window, 'devicePixelRatio', {
      configurable: true,
      value: 2,
    })

    try {
      const { result } = renderHook(
        () => ({
          // Default pixelRatio resolves to max(2, 2) = 2 → no resize.
          screenshot: useMapScreenshot({ mapId: MAP_ID }),
          setLoad: useMapStore((s) => s.setLoad),
          setLayersLoad: useMapStore((s) => s.setLayersLoad),
          setIdle: useMapStore((s) => s.setIdle),
          maps: useMapStore((s) => s.maps),
        }),
        { wrapper },
      )

      const fakeDeck = {
        width: 200,
        height: 200,
        getCanvas: () => document.createElement('canvas'),
        redraw: vi.fn(),
        needsRedraw: () => false as const,
        props: { useDevicePixels: true },
        setProps: vi.fn(),
      }
      const innerMap = {
        getCanvas: () => document.createElement('canvas'),
        getPixelRatio: vi.fn(() => 2),
        setPixelRatio: vi.fn(),
        triggerRepaint: vi.fn(),
        once: vi.fn(),
      }
      const fakeMaplibre = { getMap: () => innerMap, redraw: vi.fn() }

      act(() => {
        const mapState = result.current.maps[MAP_ID]!
        mapState.instanceRef!.current = {
          // @ts-expect-error - minimal fake for test
          deck: fakeDeck,
        }
        // @ts-expect-error - minimal fake for test
        mapState.overlayRef!.current = fakeMaplibre
        result.current.setLoad(MAP_ID, true)
        result.current.setLayersLoad(MAP_ID, true)
        result.current.setIdle(MAP_ID, true)
      })

      act(() => {
        result.current.screenshot.takeScreenshot()
      })

      await waitFor(() => {
        expect(result.current.screenshot.screenshot).toBeDefined()
      })

      expect(innerMap.setPixelRatio).not.toHaveBeenCalled()
      expect(innerMap.triggerRepaint).not.toHaveBeenCalled()
      expect(innerMap.once).not.toHaveBeenCalled()
      expect(fakeDeck.setProps).not.toHaveBeenCalled()
    } finally {
      if (originalDprDescriptor) {
        Object.defineProperty(window, 'devicePixelRatio', originalDprDescriptor)
      } else {
        // @ts-expect-error - removing a property we added
        delete window.devicePixelRatio
      }
    }
  })

  // Shared setup for the processCapture tests on the CARTO basemap branch.
  // `pixelRatio: 1` matches happy-dom's devicePixelRatio so the capture takes
  // the no-resize path (the fakes' `once('idle')` never fires a callback).
  function renderProcessCaptureHook<TResult extends string | Blob = string>(
    processCapture: MapScreenshotProcessCapture<TResult>,
  ) {
    const { result } = renderHook(
      () => ({
        screenshot: useMapScreenshot({
          mapId: MAP_ID,
          pixelRatio: 1,
          processCapture,
        }),
        setLoad: useMapStore((s) => s.setLoad),
        setLayersLoad: useMapStore((s) => s.setLayersLoad),
        setIdle: useMapStore((s) => s.setIdle),
        maps: useMapStore((s) => s.maps),
      }),
      { wrapper },
    )

    const fakeDeck = {
      width: 200,
      height: 200,
      getCanvas: () => document.createElement('canvas'),
      redraw: vi.fn(),
      needsRedraw: () => false as const,
      props: { useDevicePixels: true },
      setProps: vi.fn(),
    }
    const innerMap = {
      getCanvas: () => document.createElement('canvas'),
      getPixelRatio: vi.fn(() => 1),
      setPixelRatio: vi.fn(),
      triggerRepaint: vi.fn(),
      once: vi.fn(),
      redraw: vi.fn(),
    }
    const fakeMaplibre = { getMap: () => innerMap, redraw: vi.fn() }

    act(() => {
      const mapState = result.current.maps[MAP_ID]!
      mapState.instanceRef!.current = {
        // @ts-expect-error - minimal fake for test
        deck: fakeDeck,
      }
      // @ts-expect-error - minimal fake for test
      mapState.overlayRef!.current = fakeMaplibre
      result.current.setLoad(MAP_ID, true)
      result.current.setLayersLoad(MAP_ID, true)
      result.current.setIdle(MAP_ID, true)
    })

    act(() => {
      result.current.screenshot.takeScreenshot()
    })

    return result
  }

  it('should invoke processCapture with the composited canvas and dpr, defaulting to toDataURL() when it returns nothing', async () => {
    const processCapture = vi.fn()
    const result = renderProcessCaptureHook(processCapture)

    await waitFor(() => {
      expect(result.current.screenshot.screenshot).toBeDefined()
    })

    expect(processCapture).toHaveBeenCalledTimes(1)
    const [canvas, context] = processCapture.mock.calls[0] as [
      HTMLCanvasElement,
      MapScreenshotCaptureContext,
    ]
    expect(canvas).toBeInstanceOf(HTMLCanvasElement)
    expect(canvas.width).toBe(200)
    expect(context.dpr).toBe(1)
  })

  it('should use the string returned by processCapture as the screenshot', async () => {
    const result = renderProcessCaptureHook(() => 'data:custom-result')

    await waitFor(() => {
      expect(result.current.screenshot.screenshot).toBe('data:custom-result')
    })
  })

  it('should support an async processCapture', async () => {
    const result = renderProcessCaptureHook(() =>
      Promise.resolve('data:async-result'),
    )

    await waitFor(() => {
      expect(result.current.screenshot.screenshot).toBe('data:async-result')
    })
  })

  it('should support processCapture returning a Blob', async () => {
    const blob = new Blob(['png-bytes'])
    const result = renderProcessCaptureHook(() => blob)

    await waitFor(() => {
      expect(result.current.screenshot.screenshot).toBe(blob)
    })
  })

  it('should surface processCapture errors through the error state', async () => {
    const result = renderProcessCaptureHook(() => {
      throw new Error('processCapture boom')
    })

    await waitFor(() => {
      expect(result.current.screenshot.error?.message).toBe(
        'processCapture boom',
      )
    })
    expect(result.current.screenshot.screenshot).toBeUndefined()
  })

  it('should override window.devicePixelRatio when a pixelRatio prop is provided', async () => {
    const originalDprDescriptor = Object.getOwnPropertyDescriptor(
      window,
      'devicePixelRatio',
    )
    Object.defineProperty(window, 'devicePixelRatio', {
      configurable: true,
      value: 3,
    })

    try {
      const gmapsWrapper = ({ children }: { children: ReactNode }) => (
        <QueryClientProvider client={queryClient}>
          <MapProvider
            values={[
              {
                id: MAP_ID,
                basemap: GmapsBasemap.roadmap,
                initialViewState: { latitude: 0, longitude: 0, zoom: 1 },
              },
            ]}
          >
            {children}
          </MapProvider>
        </QueryClientProvider>
      )

      const { result } = renderHook(
        () => ({
          screenshot: useMapScreenshot({
            mapId: MAP_ID,
            pixelRatio: 1,
          }),
          setLoad: useMapStore((s) => s.setLoad),
          setLayersLoad: useMapStore((s) => s.setLayersLoad),
          setIdle: useMapStore((s) => s.setIdle),
          maps: useMapStore((s) => s.maps),
        }),
        { wrapper: gmapsWrapper },
      )

      const fakeCanvas = document.createElement('canvas')
      fakeCanvas.width = 600
      fakeCanvas.height = 400
      const fakeDeck = {
        width: 200,
        height: 200,
        getCanvas: () => fakeCanvas,
        redraw: vi.fn(),
        needsRedraw: () => false as const,
      }
      const fakeGmaps = {
        getDiv: () => document.createElement('div'),
        getRenderingType: () => 'RASTER',
      }

      act(() => {
        const mapState = result.current.maps[MAP_ID]!
        mapState.instanceRef!.current = {
          // @ts-expect-error - minimal fake for test
          deck: fakeDeck,
        }
        mapState.overlayRef!.current = fakeGmaps as unknown as google.maps.Map
        result.current.setLoad(MAP_ID, true)
        result.current.setLayersLoad(MAP_ID, true)
        result.current.setIdle(MAP_ID, true)
      })

      act(() => {
        result.current.screenshot.takeScreenshot()
      })

      await waitFor(() => {
        expect(html2canvas).toHaveBeenCalled()
      })

      expect(html2canvas).toHaveBeenCalledWith(
        expect.any(HTMLElement),
        expect.objectContaining({ scale: 1, useCORS: true }),
      )
    } finally {
      if (originalDprDescriptor) {
        Object.defineProperty(window, 'devicePixelRatio', originalDprDescriptor)
      } else {
        // @ts-expect-error - removing a property we added
        delete window.devicePixelRatio
      }
    }
  })

  it('should capture Google Maps even when the basemap-idle flag is false', async () => {
    // Google Maps never settles `areMapsIdle` to true (the deck<->gmaps camera
    // sync fires a trailing setViewState after Google's idle event, and Google
    // doesn't re-fire idle on a static map). So the screenshot hook must NOT
    // gate gmaps on the idle flag — tile load + layer load + waitForDeckIdle
    // already cover readiness.
    const gmapsWrapper = ({ children }: { children: ReactNode }) => (
      <QueryClientProvider client={queryClient}>
        <MapProvider
          values={[
            {
              id: MAP_ID,
              basemap: GmapsBasemap.roadmap,
              initialViewState: { latitude: 0, longitude: 0, zoom: 1 },
            },
          ]}
        >
          {children}
        </MapProvider>
      </QueryClientProvider>
    )

    const { result } = renderHook(
      () => ({
        screenshot: useMapScreenshot({ mapId: MAP_ID }),
        setLoad: useMapStore((s) => s.setLoad),
        setLayersLoad: useMapStore((s) => s.setLayersLoad),
        maps: useMapStore((s) => s.maps),
      }),
      { wrapper: gmapsWrapper },
    )

    const fakeCanvas = document.createElement('canvas')
    fakeCanvas.width = 600
    fakeCanvas.height = 400
    const fakeDeck = {
      width: 200,
      height: 200,
      getCanvas: () => fakeCanvas,
      redraw: vi.fn(),
      needsRedraw: () => false as const,
      props: { useDevicePixels: true },
      setProps: vi.fn(),
    }
    const fakeGmaps = {
      getDiv: () => document.createElement('div'),
      getRenderingType: () => 'RASTER',
    }

    act(() => {
      const mapState = result.current.maps[MAP_ID]!
      mapState.instanceRef!.current = {
        // @ts-expect-error - minimal fake for test
        deck: fakeDeck,
      }
      mapState.overlayRef!.current = fakeGmaps as unknown as google.maps.Map
      result.current.setLoad(MAP_ID, true)
      result.current.setLayersLoad(MAP_ID, true)
      // Intentionally do NOT call setIdle — gmaps must still capture.
    })

    act(() => {
      result.current.screenshot.takeScreenshot()
    })

    // Capture proceeds despite areMapsIdle being false for gmaps.
    await waitFor(() => {
      expect(html2canvas).toHaveBeenCalled()
    })
  })

  it('should wait for deck.needsRedraw() to return false before drawing', async () => {
    const gmapsWrapper = ({ children }: { children: ReactNode }) => (
      <QueryClientProvider client={queryClient}>
        <MapProvider
          values={[
            {
              id: MAP_ID,
              basemap: GmapsBasemap.roadmap,
              initialViewState: { latitude: 0, longitude: 0, zoom: 1 },
            },
          ]}
        >
          {children}
        </MapProvider>
      </QueryClientProvider>
    )

    const { result } = renderHook(
      () => ({
        screenshot: useMapScreenshot({ mapId: MAP_ID }),
        setLoad: useMapStore((s) => s.setLoad),
        setLayersLoad: useMapStore((s) => s.setLayersLoad),
        setIdle: useMapStore((s) => s.setIdle),
        maps: useMapStore((s) => s.maps),
      }),
      { wrapper: gmapsWrapper },
    )

    const fakeCanvas = document.createElement('canvas')
    fakeCanvas.width = 600
    fakeCanvas.height = 400
    // First 3 polls report busy, subsequent polls report idle. We need 2
    // consecutive idle frames to resolve, so html2canvas must fire only after
    // at least call #5.
    const needsRedraw = vi
      .fn<(opts?: { clearRedrawFlags: boolean }) => string | false>()
      .mockReturnValueOnce('view changed')
      .mockReturnValueOnce('view changed')
      .mockReturnValueOnce('view changed')
      .mockReturnValue(false)
    const fakeDeck = {
      width: 200,
      height: 200,
      getCanvas: () => fakeCanvas,
      redraw: vi.fn(),
      needsRedraw,
    }
    const fakeGmaps = {
      getDiv: () => document.createElement('div'),
      getRenderingType: () => 'RASTER',
    }

    act(() => {
      const mapState = result.current.maps[MAP_ID]!
      mapState.instanceRef!.current = {
        // @ts-expect-error - minimal fake for test
        deck: fakeDeck,
      }
      mapState.overlayRef!.current = fakeGmaps as unknown as google.maps.Map
      result.current.setLoad(MAP_ID, true)
      result.current.setLayersLoad(MAP_ID, true)
      result.current.setIdle(MAP_ID, true)
    })

    act(() => {
      result.current.screenshot.takeScreenshot()
    })

    await waitFor(() => {
      expect(html2canvas).toHaveBeenCalled()
    })

    // 3 busy + at least 2 idle calls before draw resolves.
    expect(needsRedraw.mock.calls.length).toBeGreaterThanOrEqual(5)
    // All calls must use the non-mutating peek option.
    for (const call of needsRedraw.mock.calls) {
      expect(call[0]).toEqual({ clearRedrawFlags: false })
    }
  })

  it('should capture the interleaved canvas directly for vector Google Maps (no html2canvas)', async () => {
    const gmapsWrapper = ({ children }: { children: ReactNode }) => (
      <QueryClientProvider client={queryClient}>
        <MapProvider
          values={[
            {
              id: MAP_ID,
              basemap: GmapsBasemap.roadmap,
              initialViewState: { latitude: 0, longitude: 0, zoom: 1 },
            },
          ]}
        >
          {children}
        </MapProvider>
      </QueryClientProvider>
    )

    const { result } = renderHook(
      () => ({
        screenshot: useMapScreenshot({ mapId: MAP_ID }),
        setLoad: useMapStore((s) => s.setLoad),
        setLayersLoad: useMapStore((s) => s.setLayersLoad),
        setIdle: useMapStore((s) => s.setIdle),
        maps: useMapStore((s) => s.maps),
      }),
      { wrapper: gmapsWrapper },
    )

    // Google's shared WebGL2 canvas — already holds basemap + layers.
    const googleCanvas = document.createElement('canvas')
    googleCanvas.width = 800
    googleCanvas.height = 600
    const getCanvas = vi.fn(() => googleCanvas)
    const fakeDeck = {
      // null in interleaved vector mode — capture must not rely on these.
      width: null,
      height: null,
      getCanvas,
      redraw: vi.fn(),
      needsRedraw: () => false as const,
      props: { useDevicePixels: true },
      setProps: vi.fn(),
    }
    const fakeGmaps = {
      getDiv: () => document.createElement('div'),
      getRenderingType: () => 'VECTOR',
    }

    act(() => {
      const mapState = result.current.maps[MAP_ID]!
      mapState.instanceRef!.current = {
        // @ts-expect-error - minimal fake for test
        deck: fakeDeck,
      }
      mapState.overlayRef!.current = fakeGmaps as unknown as google.maps.Map
      result.current.setLoad(MAP_ID, true)
      result.current.setLayersLoad(MAP_ID, true)
      result.current.setIdle(MAP_ID, true)
    })

    act(() => {
      result.current.screenshot.takeScreenshot()
    })

    await waitFor(() => {
      expect(result.current.screenshot.screenshot).toBeDefined()
    })

    // Vector path: no html2canvas, no deck DPR resize — it reads Google's
    // interleaved canvas directly after a forced redraw.
    expect(html2canvas).not.toHaveBeenCalled()
    expect(fakeDeck.setProps).not.toHaveBeenCalled()
    expect(fakeDeck.redraw).toHaveBeenCalledWith('screenshot')
    expect(getCanvas).toHaveBeenCalled()
  })

  it('should invoke processCapture with the captured canvas and the window dpr on the vector Google Maps path', async () => {
    const gmapsWrapper = ({ children }: { children: ReactNode }) => (
      <QueryClientProvider client={queryClient}>
        <MapProvider
          values={[
            {
              id: MAP_ID,
              basemap: GmapsBasemap.roadmap,
              initialViewState: { latitude: 0, longitude: 0, zoom: 1 },
            },
          ]}
        >
          {children}
        </MapProvider>
      </QueryClientProvider>
    )

    const processCapture = vi.fn()
    const { result } = renderHook(
      () => ({
        screenshot: useMapScreenshot({ mapId: MAP_ID, processCapture }),
        setLoad: useMapStore((s) => s.setLoad),
        setLayersLoad: useMapStore((s) => s.setLayersLoad),
        setIdle: useMapStore((s) => s.setIdle),
        maps: useMapStore((s) => s.maps),
      }),
      { wrapper: gmapsWrapper },
    )

    const googleCanvas = document.createElement('canvas')
    googleCanvas.width = 800
    googleCanvas.height = 600
    const fakeDeck = {
      width: null,
      height: null,
      getCanvas: () => googleCanvas,
      redraw: vi.fn(),
      needsRedraw: () => false as const,
      props: { useDevicePixels: true },
      setProps: vi.fn(),
    }
    const fakeGmaps = {
      getDiv: () => document.createElement('div'),
      getRenderingType: () => 'VECTOR',
    }

    act(() => {
      const mapState = result.current.maps[MAP_ID]!
      mapState.instanceRef!.current = {
        // @ts-expect-error - minimal fake for test
        deck: fakeDeck,
      }
      mapState.overlayRef!.current = fakeGmaps as unknown as google.maps.Map
      result.current.setLoad(MAP_ID, true)
      result.current.setLayersLoad(MAP_ID, true)
      result.current.setIdle(MAP_ID, true)
    })

    act(() => {
      result.current.screenshot.takeScreenshot()
    })

    await waitFor(() => {
      expect(result.current.screenshot.screenshot).toBeDefined()
    })

    // The vector path copies Google's interleaved canvas 1:1 into a fresh
    // canvas sized to the source, and dpr is the window's devicePixelRatio.
    expect(processCapture).toHaveBeenCalledTimes(1)
    const [canvas, context] = processCapture.mock.calls[0] as [
      HTMLCanvasElement,
      MapScreenshotCaptureContext,
    ]
    expect(canvas.width).toBe(800)
    expect(canvas.height).toBe(600)
    expect(context.dpr).toBe(window.devicePixelRatio)
  })

  it('should retry while a vector Google Maps map still reports UNINITIALIZED', async () => {
    const gmapsWrapper = ({ children }: { children: ReactNode }) => (
      <QueryClientProvider client={queryClient}>
        <MapProvider
          values={[
            {
              id: MAP_ID,
              basemap: GmapsBasemap.roadmap,
              initialViewState: { latitude: 0, longitude: 0, zoom: 1 },
            },
          ]}
        >
          {children}
        </MapProvider>
      </QueryClientProvider>
    )

    const { result } = renderHook(
      () => ({
        screenshot: useMapScreenshot({ mapId: MAP_ID }),
        setLoad: useMapStore((s) => s.setLoad),
        setLayersLoad: useMapStore((s) => s.setLayersLoad),
        setIdle: useMapStore((s) => s.setIdle),
        maps: useMapStore((s) => s.maps),
      }),
      { wrapper: gmapsWrapper },
    )

    const fakeCanvas = document.createElement('canvas')
    fakeCanvas.width = 600
    fakeCanvas.height = 400
    const fakeDeck = {
      width: null,
      height: null,
      getCanvas: () => fakeCanvas,
      redraw: vi.fn(),
      needsRedraw: () => false as const,
    }
    const fakeGmaps = {
      getDiv: () => document.createElement('div'),
      getRenderingType: () => 'UNINITIALIZED',
    }

    act(() => {
      const mapState = result.current.maps[MAP_ID]!
      mapState.instanceRef!.current = {
        // @ts-expect-error - minimal fake for test
        deck: fakeDeck,
      }
      mapState.overlayRef!.current = fakeGmaps as unknown as google.maps.Map
      result.current.setLoad(MAP_ID, true)
      result.current.setLayersLoad(MAP_ID, true)
      result.current.setIdle(MAP_ID, true)
    })

    vi.useFakeTimers()
    try {
      act(() => {
        result.current.screenshot.takeScreenshot()
      })

      // UNINITIALIZED is treated as not-ready: the mutation retries (never
      // throwing a hard error) and no capture happens.
      await act(async () => {
        await vi.advanceTimersByTimeAsync(2000)
      })

      expect(html2canvas).not.toHaveBeenCalled()
      expect(result.current.screenshot.screenshot).toBeUndefined()
      expect(result.current.screenshot.isLoading).toBe(true)
    } finally {
      vi.useRealTimers()
    }
  })
})
