import {serializeBbox, type Bbox} from '@crumbs/core/geo'
import type {MapPlacePoint} from '@crumbs/core/schemas'
import {create} from 'zustand'

import {
  bboxContains,
  padBbox,
  regionToBbox,
  zoomBand,
  type ClusterRegion,
} from '@/lib/bbox'
import {cleanError, unwrap} from '@/lib/errors'
import {encodeFilter, type MapFilter} from '@/lib/map'
import {rpc} from '@/lib/rpc'
import {staleGuard} from '@/lib/stale-guard'
import {registerReset} from '@/lib/store-reset'

// fetch a bbox wider than the viewport so pins exist just off-screen and
// small pans never trigger a refetch
const OVERSCAN = 0.4

type MapPlacesState = {
  places: MapPlacePoint[]
  truncated: boolean
  loading: boolean
  error: string | null
  // the last fetched (overscanned) bbox + the view it was fetched for
  lastBbox: Bbox | null
  lastZoom: number | null
  lastFilter: string | null
  load: (region: ClusterRegion, filter: MapFilter) => Promise<void>
  // drop the cached viewport so the next load refetches
  invalidate: () => void
}

// stale-response guard: only the newest in-flight request may commit
const guard = staleGuard()

const initialState = {
  places: [],
  truncated: false,
  loading: false,
  error: null,
  lastBbox: null,
  lastZoom: null,
  lastFilter: null,
} satisfies Omit<MapPlacesState, 'load' | 'invalidate'>

const filterParams = (filter: MapFilter) =>
  filter.kind === 'tag'
    ? {tag: filter.tag}
    : filter.kind === 'status'
      ? {status: filter.status}
      : {}

const useMapPlacesStore = create<MapPlacesState>((set, get) => ({
  ...initialState,
  load: async (region, filter) => {
    const visible = regionToBbox(region)
    const band = zoomBand(region.zoom)
    const filterKey = encodeFilter(filter)
    const {lastBbox, lastZoom, lastFilter, truncated} = get()
    const covered =
      lastBbox != null &&
      lastFilter === filterKey &&
      bboxContains(lastBbox, visible) &&
      // a capped result may hide pins a tighter view would reveal
      !(truncated && lastZoom != null && band > lastZoom)
    if (covered) return

    const fetchBbox = padBbox(visible, OVERSCAN)
    const token = guard.claim()
    set({loading: true, error: null})
    try {
      const {places, truncated: capped} = await unwrap(
        rpc.crumbs.map.$get({
          query: {
            bbox: serializeBbox(fetchBbox),
            zoom: String(band),
            ...filterParams(filter),
          },
        }),
      )
      if (!guard.isCurrent(token)) return
      set({
        places,
        truncated: capped,
        loading: false,
        lastBbox: fetchBbox,
        lastZoom: band,
        lastFilter: filterKey,
      })
    } catch (err) {
      if (!guard.isCurrent(token)) return
      set({loading: false, error: cleanError(err)})
    }
  },
  invalidate: () =>
    set({lastBbox: null, lastZoom: null, lastFilter: null}),
}))

registerReset(() => {
  guard.invalidate()
  useMapPlacesStore.setState(initialState)
})

export const useMapPlaces = () => useMapPlacesStore((s) => s.places)
export const useMapPlacesTruncated = () =>
  useMapPlacesStore((s) => s.truncated)
export const useLoadMapPlaces = () => useMapPlacesStore((s) => s.load)

// crumb edits change which pins belong in a viewport; call after a mutation
// so the next map load refetches instead of trusting the cached bbox
export const invalidateMapPlaces = () =>
  useMapPlacesStore.getState().invalidate()
