/**
 * Render tests for GoogleMaps (100 branches at 0% baseline).
 *
 * Uses module-level mocks for @vis.gl/react-google-maps and
 * @deck.gl/google-maps so the component can be rendered without a real
 * Google Maps SDK or DeckGL context.
 *
 * Coverage focus:
 * - controller normalization: boolean shorthand (true/false) and object
 *   shorthand (partial props with ?? defaults)
 * - mappingGmapsToDeck transformation (with and without maxZoom/minZoom)
 * - handleViewStateChange: with vs without instanceRef, with vs without
 *   deckInstance
 * - handleGmapsChange composition
 * - Map onTilesLoaded handler dispatches both `load` and `deckgl` events
 *   indirectly via DeckGlOverlay's onLoad
 * - basemap, initialViewState defaults (the `?? 0` / `?? 23` branches)
 * - scrollwheel: boolean vs truthy non-boolean branches
 * - gestureHandling: 'none' vs 'greedy' based on dragPan
 * - disableDoubleClickZoom mapping
 * - MapReferences useImperativeHandle wiring
 */
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import { createRef } from 'react'
import { render } from '@testing-library/react'

// ─── Mocks ────────────────────────────────────────────────────────────────
const useMapMock = vi.fn()

// Capture props passed to Map so we can assert the conditional branches in
// GoogleMaps construct them correctly.
const mapPropsCalls: unknown[] = []

vi.mock('@vis.gl/react-google-maps', () => ({
  APIProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
  Map: (props: Record<string, unknown>) => {
    mapPropsCalls.push(props)
    return <div data-testid='gmap'>{props.children as React.ReactNode}</div>
  },
  useMap: (...args: unknown[]) => useMapMock(...args) as unknown,
}))

interface OverlayMock {
  setMap: Mock
  setProps: Mock
  finalize: Mock
  _deck: { viewState: object; width: number; height: number }
}
const overlayInstances: OverlayMock[] = []

vi.mock('@deck.gl/google-maps', () => ({
  GoogleMapsOverlay: class GoogleMapsOverlayMock {
    setMap = vi.fn()
    setProps = vi.fn()
    finalize = vi.fn()
    // Private property accessed in handleViewStateChange via @ts-expect-error
    _deck = {
      viewState: { latitude: 1, longitude: 2 },
      width: 800,
      height: 600,
    }
    constructor() {
      overlayInstances.push(this as unknown as OverlayMock)
    }
  },
}))

// Import AFTER vi.mock to ensure the mocks are registered first.
import { GoogleMaps } from './google-maps'

beforeEach(() => {
  overlayInstances.length = 0
  mapPropsCalls.length = 0
  useMapMock.mockReset()
  useMapMock.mockReturnValue({ __id: 'fake-gmap' })
})

const baseProps = {
  id: 'gmap-1',
  basemap: 'roadmap' as const,
  layers: [],
  onLoad: vi.fn(),
  initialViewState: {
    latitude: 40,
    longitude: -3,
    zoom: 5,
    pitch: 0,
    bearing: 0,
  },
}

function lastMapProps() {
  return mapPropsCalls.at(-1) as Record<string, unknown>
}

describe('GoogleMaps controller normalization', () => {
  it('boolean true sets dragPan/doubleClickZoom/scrollZoom all true', () => {
    render(<GoogleMaps {...baseProps} controller={true} />)
    const props = lastMapProps()
    expect(props.gestureHandling).toBe('greedy')
    expect(props.disableDoubleClickZoom).toBe(false)
    expect(props.scrollwheel).toBe(true)
  })

  it('boolean false sets dragPan/doubleClickZoom/scrollZoom all false', () => {
    render(<GoogleMaps {...baseProps} controller={false} />)
    const props = lastMapProps()
    expect(props.gestureHandling).toBe('none')
    expect(props.disableDoubleClickZoom).toBe(true)
    expect(props.scrollwheel).toBe(false)
  })

  it('object with partial properties applies ?? true defaults', () => {
    render(<GoogleMaps {...baseProps} controller={{ dragPan: false }} />)
    const props = lastMapProps()
    expect(props.gestureHandling).toBe('none') // dragPan=false → 'none'
    expect(props.disableDoubleClickZoom).toBe(false) // default true → not disabled
  })

  it('undefined controller defaults to all-true (object branch with ?? defaults)', () => {
    render(<GoogleMaps {...baseProps} />)
    const props = lastMapProps()
    expect(props.gestureHandling).toBe('greedy')
    expect(props.disableDoubleClickZoom).toBe(false)
    expect(props.scrollwheel).toBe(true)
  })

  it('non-boolean scrollZoom is treated as truthy', () => {
    render(
      <GoogleMaps
        {...baseProps}
        controller={
          { scrollZoom: { something: true } } as unknown as Parameters<
            typeof GoogleMaps
          >[0]['controller']
        }
      />,
    )
    const props = lastMapProps()
    expect(props.scrollwheel).toBe(true)
  })
})

describe('GoogleMaps initialViewState defaulting', () => {
  it('applies ?? 0 / ?? 23 defaults when initialViewState is empty', () => {
    render(
      <GoogleMaps
        {...baseProps}
        initialViewState={
          {} as Parameters<typeof GoogleMaps>[0]['initialViewState']
        }
      />,
    )
    const props = lastMapProps()
    expect(props.center).toEqual({ lat: 0, lng: 0 })
    expect(props.zoom).toBe(1) // (0 ?? 0) + 1
    expect(props.maxZoom).toBe(24) // (23 ?? 23) + 1
    expect(props.minZoom).toBe(1) // (0 ?? 0) + 1
  })

  it('threads provided initialViewState through to Map props (+1 for zoom)', () => {
    render(<GoogleMaps {...baseProps} />)
    const props = lastMapProps()
    expect(props.center).toEqual({ lat: 40, lng: -3 })
    expect(props.zoom).toBe(6) // 5 + 1
  })
})

describe('GoogleMaps event handlers', () => {
  it('onTilesLoaded fires onLoad with type=load and invokes handleViewStateChange', () => {
    const onLoad = vi.fn()
    render(<GoogleMaps {...baseProps} onLoad={onLoad} />)
    const onTilesLoaded = lastMapProps().onTilesLoaded as () => void
    onTilesLoaded()
    expect(onLoad).toHaveBeenCalledWith({ type: 'load', value: true })
  })

  it('onBoundsChanged passes through mappingGmapsToDeck → handleViewStateChange chain', () => {
    const onViewStateChange = vi.fn()
    const instanceRef = createRef<unknown>()
    render(
      <GoogleMaps
        {...baseProps}
        onViewStateChange={onViewStateChange}
        instanceRef={
          instanceRef as unknown as Parameters<
            typeof GoogleMaps
          >[0]['instanceRef']
        }
      />,
    )
    // After render, instanceRef should be wired through the inner DeckGlOverlay
    // (instanceRef.current === overlayInstances[0]). Now trigger onBoundsChanged.
    const onBoundsChanged = lastMapProps().onBoundsChanged as (
      e: unknown,
    ) => void
    const event = {
      detail: { center: { lat: 50, lng: 4 }, zoom: 7, heading: 10, tilt: 5 },
    }
    onBoundsChanged(event)
    expect(onViewStateChange).toHaveBeenCalled()
    const arg = onViewStateChange.mock.calls[0]![0] as {
      viewState: { latitude: number; longitude: number; zoom: number }
    }
    expect(arg.viewState.latitude).toBe(50)
    expect(arg.viewState.longitude).toBe(4)
    expect(arg.viewState.zoom).toBe(6) // gmaps zoom 7 → deck zoom 6
  })

  it('handleViewStateChange handles a missing instanceRef without throwing', () => {
    const onViewStateChange = vi.fn()
    render(<GoogleMaps {...baseProps} onViewStateChange={onViewStateChange} />)
    const onBoundsChanged = lastMapProps().onBoundsChanged as (
      e: unknown,
    ) => void
    expect(() =>
      onBoundsChanged({
        detail: { center: { lat: 1, lng: 2 }, zoom: 3, heading: 0, tilt: 0 },
      }),
    ).not.toThrow()
    expect(onViewStateChange).toHaveBeenCalled()
  })

  it('handleViewStateChange tolerates missing onViewStateChange callback', () => {
    render(<GoogleMaps {...baseProps} />)
    const onBoundsChanged = lastMapProps().onBoundsChanged as (
      e: unknown,
    ) => void
    expect(() =>
      onBoundsChanged({
        detail: { center: { lat: 0, lng: 0 }, zoom: 0, heading: 0, tilt: 0 },
      }),
    ).not.toThrow()
  })
})

describe('GoogleMaps gmapsProps passthrough', () => {
  it('threads gmapsProps.apiKey and extra props into the Map component', () => {
    render(
      <GoogleMaps
        {...baseProps}
        gmapsProps={{ apiKey: 'test-key', mapId: 'mid-1' }}
      />,
    )
    const props = lastMapProps()
    // mapId is part of _gmapsProps (apiKey + APIProviderProps are destructured
    // out, everything else spreads into Map)
    expect(props.mapId).toBe('mid-1')
    expect(props.mapTypeId).toBe('roadmap')
  })

  it('renders MapReferences which calls useMap(id) and writes to overlayRef', () => {
    const overlayRef = createRef<unknown>()
    const fakeMap = { __id: 'gmap-instance' }
    useMapMock.mockReturnValue(fakeMap)
    render(
      <GoogleMaps
        {...baseProps}
        overlayRef={
          overlayRef as unknown as Parameters<
            typeof GoogleMaps
          >[0]['overlayRef']
        }
      />,
    )
    expect(useMapMock).toHaveBeenCalledWith('gmap-1')
    expect(overlayRef.current).toBe(fakeMap)
  })
})

describe('GoogleMaps handleViewStateChange with instanceRef + deckInstance', () => {
  it('spreads deckInstance.viewState into the emitted viewState', () => {
    const onViewStateChange = vi.fn()
    const instanceRef = createRef<unknown>()
    render(
      <GoogleMaps
        {...baseProps}
        instanceRef={
          instanceRef as unknown as Parameters<
            typeof GoogleMaps
          >[0]['instanceRef']
        }
        onViewStateChange={onViewStateChange}
      />,
    )
    // The DeckGlOverlay child has constructed an overlay; instanceRef.current
    // points to it (mocked GoogleMapsOverlayMock with _deck attached).
    const onBoundsChanged = lastMapProps().onBoundsChanged as (
      e: unknown,
    ) => void
    onBoundsChanged({
      detail: { center: { lat: 9, lng: 9 }, zoom: 4, heading: 1, tilt: 2 },
    })
    expect(onViewStateChange).toHaveBeenCalled()
    const arg = onViewStateChange.mock.calls[0]![0] as {
      viewState: { width: number; height: number; latitude: number }
    }
    // Width/height come from the mocked deckInstance._deck
    expect(arg.viewState.width).toBe(800)
    expect(arg.viewState.height).toBe(600)
    // New view state overrides deck-instance viewState
    expect(arg.viewState.latitude).toBe(9)
  })

  it('onTilesLoaded triggers handleViewStateChange with initialViewState', () => {
    const onViewStateChange = vi.fn()
    const instanceRef = createRef<unknown>()
    render(
      <GoogleMaps
        {...baseProps}
        instanceRef={
          instanceRef as unknown as Parameters<
            typeof GoogleMaps
          >[0]['instanceRef']
        }
        onViewStateChange={onViewStateChange}
      />,
    )
    const onTilesLoaded = lastMapProps().onTilesLoaded as () => void
    onTilesLoaded()
    // onTilesLoaded calls handleViewStateChange(initialViewState) at the end
    expect(onViewStateChange).toHaveBeenCalled()
  })

  it('mappingGmapsToDeck threads maxZoom/minZoom from initialViewState when provided', () => {
    const onViewStateChange = vi.fn()
    render(
      <GoogleMaps
        {...baseProps}
        initialViewState={{
          latitude: 0,
          longitude: 0,
          zoom: 0,
          maxZoom: 15,
          minZoom: 2,
        }}
        onViewStateChange={onViewStateChange}
      />,
    )
    const onZoomChanged = lastMapProps().onZoomChanged as (e: unknown) => void
    onZoomChanged({
      detail: { center: { lat: 1, lng: 1 }, zoom: 6, heading: 0, tilt: 0 },
    })
    const arg = onViewStateChange.mock.calls[0]![0] as {
      viewState: { maxZoom: number; minZoom: number }
    }
    expect(arg.viewState.maxZoom).toBe(15)
    expect(arg.viewState.minZoom).toBe(2)
  })

  it('mappingGmapsToDeck leaves maxZoom/minZoom undefined when initialViewState omits them', () => {
    const onViewStateChange = vi.fn()
    render(
      <GoogleMaps
        {...baseProps}
        initialViewState={{ latitude: 0, longitude: 0, zoom: 0 }}
        onViewStateChange={onViewStateChange}
      />,
    )
    const onZoomChanged = lastMapProps().onZoomChanged as (e: unknown) => void
    onZoomChanged({
      detail: { center: { lat: 1, lng: 1 }, zoom: 6, heading: 0, tilt: 0 },
    })
    const arg = onViewStateChange.mock.calls[0]![0] as {
      viewState: { maxZoom?: number; minZoom?: number }
    }
    expect(arg.viewState.maxZoom).toBeUndefined()
    expect(arg.viewState.minZoom).toBeUndefined()
  })
})

describe('GoogleMaps APIProviderProps passthrough', () => {
  it('threads APIProviderProps into the APIProvider (destructured out of _gmapsProps)', () => {
    // No direct way to assert on the passthrough APIProvider in our mock
    // (we replaced it with <>{children}</>), but we can at least confirm
    // GoogleMaps doesn't crash when APIProviderProps is provided.
    expect(() =>
      render(
        <GoogleMaps
          {...baseProps}
          gmapsProps={{
            apiKey: 'k',
            APIProviderProps: { libraries: ['places'] } as never,
          }}
        />,
      ),
    ).not.toThrow()
  })

  it('uses an empty gmapsProps default when none is provided', () => {
    expect(() => render(<GoogleMaps {...baseProps} />)).not.toThrow()
  })
})
